diff --git a/.agents/skills/firstmate-orca/SKILL.md b/.agents/skills/firstmate-orca/SKILL.md index d8d50b07b4..9e61bd62ef 100644 --- a/.agents/skills/firstmate-orca/SKILL.md +++ b/.agents/skills/firstmate-orca/SKILL.md @@ -24,6 +24,7 @@ Use raw `orca` only when the helper surface cannot answer the inspection questio ## Preflight Work from the current firstmate home or repo root. +In host-root mode, work instead from the physical `FM_HOST_ROOT` and invoke FirstMate helpers by their absolute `FM_ROOT/bin/` paths under the [four-root contract](../../../docs/configuration.md#host-root-mode-fm_host_root). If `FM_HOME` is set, remember that operational state lives under `$FM_HOME` while the helper scripts still run from this repo's `bin/`. Before switching or spawning against Orca: diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 0ae4ee05b5..2faf810d45 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -103,6 +103,25 @@ OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates wit Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions the Pi engine auto-discovers once trusted. When changing any primary watcher adapter, update `docs/supervision-protocols/`, `docs/turnend-guard.md` if a shared idle or turn-end hook changed, and the relevant concise fact below. +## Host-root task integration + +When `FM_HOST_ROOT` is set, only the primary supervisor starts from that physical host root. +Ordinary ship and scout harnesses start from their isolated `FM_TARGET_WORKTREE`, so the target repository's instructions and lifecycle adapters load natively while the host context stays out of focused worker sessions. +FirstMate retains one task completion signal per harness and passes the host and target identities for supervision and recovery without changing the worker cwd: + +| Harness | Task completion signal in host-root mode | +|---|---| +| claude | A state-owned settings file passed with `--settings`; target project settings still load from the worker cwd. | +| codex | The existing per-launch `notify` command. | +| opencode | A state-owned task plugin named through `OPENCODE_CONFIG_CONTENT`; target project plugins load from the worker cwd. | +| pi | The existing explicit state-owned `-e` task extension; target project extensions remain subject to target trust. | +| grok | The guarded global FirstMate Stop hook reads a per-process `FM_GROK_TURNEND_TOKEN`. | +| kimi | The guarded global FirstMate Stop hook reads a per-process `FM_KIMI_TURNEND_TOKEN`. | + +Secondmate launches explicitly clear inherited `FM_HOST_ROOT` and `FM_TARGET_WORKTREE` and retain their isolated-home adapters. + +[`docs/verification/supervision.md`](../../../docs/verification/supervision.md#host-root-task-integration) owns the dated lifecycle evidence and current live-verification limits for these task adapters. + ## Launch profile axes `bin/fm-spawn.sh` accepts concrete `--harness`, `--model`, and `--effort` values chosen by firstmate at intake. @@ -320,7 +339,7 @@ The tmux backend's structural `fm_tmux_composer_state` read sees placeholder-fil The Herdr adapter (`fm_backend_herdr_composer_state`, `bin/backends/herdr.sh`) classifies the composer's own row structurally instead of diffing raw content; see `docs/herdr-backend.md` "Composer and injection safety" for the current boundary and `tests/fm-backend-herdr.test.sh` for regression coverage. Startup dialog: the "Run Grok Build in a project directory?" project picker appears ONLY when grok is launched from a non-project directory (home, Desktop, Downloads, `/tmp`). -`fm-spawn` launches inside the treehouse worktree (a git repo root), so the picker never appears and grok treats the worktree as a trusted project automatically - no post-launch keystroke is needed. +Every ordinary `fm-spawn`, including host-root mode, launches inside the isolated target worktree, which must be a recognized project directory to avoid the picker. Pin `[hints] project_picker_disabled = true` in `~/.grok/config.toml` if a non-project launch ever needs to skip it. **TRUECOLOR placeholder styling: covered (task afk-herdr-false-pending, 2026-07-10).** @@ -339,11 +358,12 @@ Turn-end hook: grok fires a `Stop` hook at every turn boundary, giving firstmate grok loads PROJECT hooks (`/.grok/hooks/`, `/.claude/settings.local.json`) only after the folder is granted hook-trust in `~/.grok/trusted_folders.toml`, which is not automatic and which firstmate will not establish by editing grok's own managed trust store. GLOBAL hooks in `~/.grok/hooks/` are always trusted and load on first launch. So `fm-spawn` installs ONE firstmate-owned global hook, `~/.grok/hooks/fm-turn-end.json`, plus the companion `~/.grok/hooks/fm-turn-end.sh`, guarded as a no-op for every non-firstmate grok session. -Its `Stop` command fires only when the current workspace holds a `.fm-grok-turnend` token pointer that matches the firstmate-owned hook registry under `~/.grok/hooks/fm-turn-end.d/`. -`fm-spawn` writes that per-task pointer (`/.fm-grok-turnend`, gitignored via git info/exclude like the other harnesses' worktree hook files) and a matching registry entry naming this task's `state/.turn-ended`. -The hook reads `$GROK_WORKSPACE_ROOT`, which is always set for hooks and equals the worktree. +Its `Stop` command fires only when a launch-scoped `FM_GROK_TURNEND_TOKEN` or the current workspace's `.fm-grok-turnend` pointer matches the firstmate-owned hook registry under `~/.grok/hooks/fm-turn-end.d/`. +`fm-spawn` always writes the matching registry entry naming this task's `state/.turn-ended`. +Default launches write the gitignored pointer under the task worktree, while host-root launches pass the token in the worker environment and write no pointer into the host. +The default pointer path reads `$GROK_WORKSPACE_ROOT`, which Grok sets to the launch workspace. This keeps the hook outside the worktree, needs no trust grant, and writes only firstmate-owned files. -`fm-teardown` removes the worktree pointer before returning a pooled worktree. +`fm-teardown` removes any default-mode 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-28, Grok 0.2.112 and 0.2.73).** @@ -391,6 +411,7 @@ The spinner match covers the full moon-phase glyph set rather than one frame, bu [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns Kimi's verified global hook surface and captain-approved crew wake integration. `fm-spawn.sh` installs one marker-delimited Firstmate entry in `$HOME/.kimi-code/config.toml`, one silent always-zero hook script, and one private token registry under `$HOME/.kimi-code/fm-turn-end.d/`. -Each Kimi crew worktree receives a gitignored `.fm-kimi-turnend` token pointer, and the global hook touches that task's `state/.turn-ended` only when the Stop payload's `cwd`, pointer, and registry entry all agree. +Default Kimi crew worktrees receive a gitignored `.fm-kimi-turnend` token pointer, while host-root launches pass `FM_KIMI_TURNEND_TOKEN` in the worker environment and write no pointer into the host. +The global hook touches that task's `state/.turn-ended` only when the Stop payload has a cwd and the selected token resolves through the private registry. A guarded silent hook cannot be verified from absence of effect, so prove invocation with an unguarded probe before concluding that the hook did not fire. The guarded turn-end signal supplements the pane busy signature, whose locale- and emoji-font-sensitive limits still apply while a turn is running. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index f9e68937ab..aa182e408a 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -64,6 +64,7 @@ Release happens only on explicit retirement or seed rollback, never on routine r `bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`. It also writes the required `.fm-secondmate-home` identity marker, which is gitignored and must remain in place for home validation. `bin/fm-spawn.sh --secondmate` launches it through the secondmate harness path, resolving `config/secondmate-harness` -> `config/crew-harness` -> the primary's own harness unless an explicit per-spawn harness override is passed. +Secondmates remain outside optional host-root mode: their launch prefix clears any inherited `FM_HOST_ROOT` and `FM_TARGET_WORKTREE`, their metadata has no `host_root=`, and their startup cwd remains the isolated FirstMate home. `config/secondmate-harness` may also pin a concrete model and effort for the secondmate agent, in the SAME file rather than a new one: the format is a single whitespace-separated line ` [] []`, with only the first non-empty, non-comment line parsed. A bare `` (today's format, e.g. `claude`) behaves exactly as before - harness only, no model/effort flag - so this is fully backward-compatible. @@ -149,13 +150,10 @@ Do not hand off `local-only` items. ## Recovery -For `kind=secondmate` meta with no window, treat the secondmate as a dead persistent direct report and respawn it with: - -```sh -bin/fm-spawn.sh --secondmate -``` - -Use the recorded `home=` in meta. +Retained `kind=secondmate` metadata remains recovery authority even when its window or endpoint field is missing. +Do not delete or rewrite that metadata, and do not invoke a direct same-id `fm-spawn`; direct spawn refuses retained metadata so an ambiguous or incomplete cleanup cannot create a duplicate secondmate. +The locked session-start liveness sweep owns relaunch after the recorded endpoint and verified harness produce a recovery-grade `dead` or `missing` result. +An absent endpoint field, ambiguous process, unreadable probe, or unverified harness is not relaunch authority; preserve the recorded `home=`, metadata, and endpoint evidence and report the exact blocker. If meta is missing but `data/secondmates.md` still registers the secondmate, respawn from the registry entry and its persistent on-disk home. Respawn re-resolves the secondmate harness from current config, uses the same guarded pre-launch sync, and re-propagates inherited local material, so recovered secondmates converge inherited config items and shared captain preferences whenever their home validates; tracked-file sync remains guarded separately. If the secondmate is already running and only inherited local material changed, prefer `bin/fm-config-push.sh` over respawning. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index d97ebee302..b371d4d3d2 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -31,6 +31,7 @@ Do not sweep another home's endpoints or infer ownership from a matching window Before relaunch, prove that no live agent still owns the recorded task and that the existing worktree remains available. Preserve its uncommitted changes and commits, keep the same task identity, and resume or relaunch the recorded harness in that existing worktree with the same brief plus a concise progress note. +For a host-root task, relaunch the harness from the existing recorded worktree, pass it as `FM_TARGET_WORKTREE`, and retain the recorded physical `host_root=` only as the supervisor authority; [`docs/configuration.md`](../../../docs/configuration.md#host-root-mode-fm_host_root) owns that four-root contract. Do not use a fresh generic spawn while the recorded worktree is unaccounted for, because allocating another worktree can split one task across two copies. If the worktree or ownership cannot be reconciled safely, leave all state intact and report the task failed or blocked with the conflicting evidence. diff --git a/.pi/extensions/lib/fm-host-activator.ts b/.pi/extensions/lib/fm-host-activator.ts new file mode 100644 index 0000000000..febe46e319 --- /dev/null +++ b/.pi/extensions/lib/fm-host-activator.ts @@ -0,0 +1,150 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +export type FirstmateHostConfig = { + fmRoot: string; + fmHome: string; + hostRoot: string; + backend: string; +}; + +type ExtensionFactory = (pi: ExtensionAPI) => void | Promise; + +const policyMarker = ""; + +function physicalPath(path: string): string | undefined { + try { + return realpathSync(path); + } catch { + return undefined; + } +} + +function refuse(pi: ExtensionAPI, reason: string): void { + const message = `FirstMate host activation refused: ${reason}`; + console.error(message); + pi.on("session_start", (_event, ctx) => { + ctx.ui.setStatus( + "firstmate-host", + ctx.ui.theme.fg("error", "FirstMate inactive"), + ); + ctx.ui.notify(message, "error"); + }); +} + +function validateConfig(config: FirstmateHostConfig): string | undefined { + if (physicalPath(config.fmRoot) !== config.fmRoot) + return `invalid FirstMate root ${config.fmRoot}`; + if (physicalPath(config.fmHome) !== config.fmHome) + return `invalid FirstMate home ${config.fmHome}`; + if (physicalPath(config.hostRoot) !== config.hostRoot) + return `invalid host root ${config.hostRoot}`; + if (config.fmRoot === config.hostRoot) + return "FirstMate root and host root must differ"; + if (!existsSync(resolve(config.hostRoot, "AGENTS.md"))) + return `host root has no AGENTS.md: ${config.hostRoot}`; + if (!existsSync(resolve(config.fmRoot, "AGENTS.md"))) + return `FirstMate root has no AGENTS.md: ${config.fmRoot}`; + + const result = spawnSync( + "bash", + [ + "-c", + '. "$1/bin/fm-host-root-lib.sh" && fm_host_root_assert_operational_roots "$3" "$1" "$4" && . "$1/bin/fm-backend.sh" && fm_backend_validate_spawn "$2"', + "fm-host-activator", + config.fmRoot, + config.backend, + config.hostRoot, + config.fmHome, + ], + { + encoding: "utf8", + env: { + ...process.env, + FM_ROOT_OVERRIDE: config.fmRoot, + FM_HOME: config.fmHome, + FM_HOST_ROOT: config.hostRoot, + FM_BACKEND: config.backend, + }, + }, + ); + if (result.status !== 0) + return result.stderr.trim() || `invalid backend ${config.backend}`; + return undefined; +} + +async function loadExtension(pi: ExtensionAPI, path: string): Promise { + const module = (await import(pathToFileURL(path).href)) as { + default?: ExtensionFactory; + }; + if (typeof module.default !== "function") + throw new Error(`extension has no default factory: ${path}`); + await module.default(pi); +} + +export default async function activateFirstmateHost( + pi: ExtensionAPI, + config: FirstmateHostConfig, +): Promise { + if ( + process.env.FM_TARGET_WORKTREE || + physicalPath(process.cwd()) !== config.hostRoot + ) + return; + + const invalid = validateConfig(config); + if (invalid) { + refuse(pi, invalid); + return; + } + + const expectedEnvironment: Record = { + FM_ROOT_OVERRIDE: config.fmRoot, + FM_HOME: config.fmHome, + FM_HOST_ROOT: config.hostRoot, + FM_BACKEND: config.backend, + }; + for (const [name, expected] of Object.entries(expectedEnvironment)) { + const ambient = process.env[name]; + if (ambient !== undefined && ambient !== "" && ambient !== expected) { + refuse(pi, `${name} is already set to a conflicting value`); + return; + } + } + Object.assign(process.env, expectedEnvironment); + + const supervisorPolicy = readFileSync( + resolve(config.fmRoot, "AGENTS.md"), + "utf8", + ).trimEnd(); + pi.on("resources_discover", () => ({ + skillPaths: [resolve(config.fmRoot, ".agents/skills")], + })); + pi.on("before_agent_start", (event) => { + if (event.systemPrompt.includes(policyMarker)) return; + return { + systemPrompt: `${event.systemPrompt}\n\n${policyMarker}\n# FirstMate host supervisor policy\n\nThe host context above remains authoritative for host identity, lifecycle, and cwd.\nApply the following FirstMate supervisor policy additively.\n\n${supervisorPolicy}`, + }; + }); + pi.on("session_start", (_event, ctx) => { + ctx.ui.setStatus( + "firstmate-host", + ctx.ui.theme.fg("accent", "FirstMate active"), + ); + }); + pi.on("session_shutdown", (_event, ctx) => { + ctx.ui.setStatus("firstmate-host", undefined); + }); + + await loadExtension( + pi, + resolve(config.fmRoot, ".pi/extensions/fm-primary-turnend-guard.ts"), + ); + await loadExtension( + pi, + resolve(config.fmRoot, ".pi/extensions/fm-primary-pi-watch.ts"), + ); +} diff --git a/AGENTS.md b/AGENTS.md index ee23c840dc..b4a60db9b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,8 @@ Never add an agent name as a commit co-author. `docs/configuration.md` is the single owner of the top-level operational-home layout and configuration schemas; each producing script's header and help own exact child fields and mutation mechanics. `FM_HOME` selects an instance's private `data/`, `state/`, `config/`, and `projects/`, while scripts continue to come from their tracked code root. -Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. +Optional `FM_HOST_ROOT` keeps another repository authoritative for the primary supervisor's instructions, lifecycle, and cwd while ordinary workers launch from their isolated `FM_TARGET_WORKTREE`; `docs/configuration.md` owns the four-root contract. +Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock, and secondmate launches clear any inherited host-root variables. `bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate except under hard rule 1's concrete captain-approved project operation exception. @@ -89,7 +90,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=, 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) + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; an ordinary host-root task also records host_root=; 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/README.md b/README.md index 5a03c4b9b1..d80b1275fb 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Launching a supported harness inside it instantiates your first mate - and makes - **One liaison** - you talk only to the first mate; it dispatches, supervises, escalates only real decisions, and reports plain outcomes. - **A visible crew** - every crewmate works in its own tmux window, experimental herdr/zellij tab, cmux workspace, or Orca terminal you can watch or type into; the first mate reconciles. - **Disposable worktrees** - each task runs in a clean [treehouse](https://github.com/kunchenguid/treehouse) git worktree, or an Orca-managed worktree when `backend=orca`, so parallel work on one repo never collides. +- **Optional host-root mode** - an explicitly integrated supervisor can keep an existing command-center repository authoritative for its instructions and lifecycle hooks while FirstMate retains its own code/home and every focused worker launches from its isolated target worktree; see [configuration](docs/configuration.md#host-root-mode-fm_host_root). - **Two task shapes** - ship tasks deliver authorized changes; scout tasks leave standalone investigation reports when the intake contract warrants separate research. - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. @@ -82,7 +83,9 @@ git clone https://github.com/kunchenguid/firstmate cd firstmate ``` -Then launch one of the co-primary harnesses; AGENTS.md takes over from there: +Then launch one of the co-primary harnesses; AGENTS.md takes over from there. +To make plain Pi activate FirstMate from one existing external host root, run `bin/fm-host-setup.sh install --backend herdr` once, then run `pi` from that host. +The [host-root configuration](docs/configuration.md#host-root-mode-fm_host_root) covers status, uninstall, custom homes, trust, and the four-root contract. **Claude Code** diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 12dc7629eb..2ae15482bd 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -350,8 +350,14 @@ fm_backend_cmux_surface_id_for_workspace() { # # workspace/surface/pane create all default focus to false) - no # focus-restore dance is needed, unlike zellij. Echoes " # " on success. +# Creation-state globals are consumed by fm-spawn after direct function calls. +# shellcheck disable=SC2034 fm_backend_cmux_create_task() { #