diff --git a/.agents/skills/decision-hold-lifecycle/SKILL.md b/.agents/skills/decision-hold-lifecycle/SKILL.md index 727c6b6699..b568c688d0 100644 --- a/.agents/skills/decision-hold-lifecycle/SKILL.md +++ b/.agents/skills/decision-hold-lifecycle/SKILL.md @@ -23,6 +23,12 @@ Run the command in the originating work's authoritative `FM_HOME`; main-home wor Do not close a hold merely because the originating investigation completed, its report was archived, its visual review ended, or its task was torn down. When the captain's answer authorizes follow-up work, the hold remains the authoritative Captain's Call item until that answer is durably recorded, dependent work is created in the same backlog and blocked by the hold, and `bin/fm-decision-hold.sh resolve` routes the answer by clearing those dependency edges before closing the hold. When the captain's answer routes no follow-up work at all, such as a declined proposal, `bin/fm-decision-hold.sh decline` records that answer and closes the hold; it never substitutes for routing work the captain did authorize. +When the captain simply answers a hold that has no follow-up work routed behind it yet, `bin/fm-decision-hold.sh answer` records that answer and closes the hold, so answering is closing rather than a separate later act that can be forgotten. +"A keyed answer closes its matching hold" is one capability with one owner, `bin/fm-decision-hold.sh answers`, and every channel that carries a captain answer feeds it the same `` and answer. +A channel never maps a key to a hold, records a decision, or closes anything itself, so no channel is special and a new one needs no new closing logic. +Chat already feeds it: `bin/fm-send.sh --resolve-key` answers a decision in whichever ledger still holds it open, including a decision already transferred to its durable hold. +A captured-answer source feeds it too once bound with `bin/fm-decision-hold.sh bind `; bind before arming the source, and key each structured question by the hold's own decision key. +An unbound source and a question slug that is not a decision key both simply feed nothing: the answer is still captured and firstmate is still woken, and closing falls back to the commands above. A hold closed outside this owner leaves no durable answer, so the completion gate keeps failing until `bin/fm-decision-hold.sh repair` records the decision the captain actually gave; neither unrouted path may stand in for an answer the captain has not given. Resolved findings, recommendations that need no captain choice, and prose that merely sounds decision-like do not create holds. Bearings reads the resulting structured state and must never compensate by scraping historical reports, visual-review artifacts, terminal output, chat, or other prose. @@ -35,7 +41,8 @@ Bearings reads the resulting structured state and must never compensate by scrap 4. Run the script's `complete` command with the full unresolved-key inventory for that review pass. 5. Relay the choices to the captain as decisions from Bearings' Captain's Call section under `AGENTS.md` section 9; do not use the word hold in captain chat. 6. If the captain authorizes dependent work, record it with normal tasks-axi commands and block it by the hold identity. -7. Put the captain's exact durable decision in a file and close the hold with the script's `resolve` command and every routed task, its `decline` command when the answer routes no work, or its `repair` command when the hold was already closed outside the script. +7. Put the captain's exact durable decision in a file and close the hold with the script's `resolve` command and every routed task, its `answer` command when the captain answered a hold with no routed work behind it, its `decline` command when the answer routes no work at all, or its `repair` command when the hold was already closed outside the script. + A hold that a channel already closed by feeding its keyed answer needs none of these; confirm it in step 8 instead. 8. Confirm Bearings no longer shows the closed hold and that any routed work remains in structured backlog state. `bin/fm-decision-hold.sh --help` owns command syntax, identity construction, completion attestation, retry behavior, and close ordering. diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index fd53c0ccc0..4b8e4b0e96 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -51,11 +51,18 @@ How the reply lands depends on whether the work finishes during this turn: - **Work that spawns a real, longer-running job** (dispatching a crewmate, a scout investigation, a ship task) cannot report an outcome yet, so it follows **acknowledge first -> act -> follow up on completion**: 1. **Acknowledge first.** Post an immediate, public-safe reply that you have the captain's order and are on it (the normal answer endpoint, via `bin/fm-x-reply.sh`). This is the legitimate, work-backed version of "aye, will do": it is paired with actually starting the work in the same turn, never a promise left empty. 2. **Act.** Dispatch the work through the normal lifecycle right away. - 3. **Link it for the follow-up, before clearing the inbox.** Associate the spawned task with this mention so completion follow-ups can be posted later: `bin/fm-x-link.sh ` (records the request id, a timestamp, a follow-up counter, and reply platform/budget context). - Do this right after the task is spawned, and always **before** removing the inbox file (step 2f). - Linking before cleanup lets `bin/fm-x-link.sh` copy the context directly from the inbox, while the durable per-request context recorded by the poll preserves it independently for delayed and concurrent follow-ups. - The exact resolution and fail-safe posting contract is owned by `docs/configuration.md`. - If a recovery respawns the same relay request onto a successor task, relink with the paired `--carry-count --carry-ts ` flags plus any prior `x_platform=` and `x_reply_max_chars=` as `--carry-platform --carry-max ` so the successor keeps the consumed follow-up count, original 7-day window, and reply split budget. + 3. **Bind the follow-up to wherever the work actually lives, before clearing the inbox.** + **The decision rule: work that stays in this home takes the lightweight link; work routed to a second mate takes a promised-final commitment bound to that second mate's home.** + There is no third option and no fallback between them - each mechanism can only reach the home it was built for, so choosing the wrong one orphans the public promise. + - **Local task (this home spawned it):** `bin/fm-x-link.sh ` (records the request id, a timestamp, a follow-up counter, and reply platform/budget context). + Do this right after the task is spawned, and always **before** removing the inbox file (step 2f). + Linking before cleanup lets `bin/fm-x-link.sh` copy the context directly from the inbox, while the durable per-request context recorded by the poll preserves it independently for delayed and concurrent follow-ups. + The exact resolution and fail-safe posting contract is owned by `docs/configuration.md`. + If a recovery respawns the same relay request onto a successor task, relink with the paired `--carry-count --carry-ts ` flags plus any prior `x_platform=` and `x_reply_max_chars=` as `--carry-platform --carry-max ` so the successor keeps the consumed follow-up count, original 7-day window, and reply split budget. + - **Second-mate-routed work (the request's project or domain belongs to a registered second mate, so the work is or will be routed there):** the link cannot be used at all. + It writes into this home's own `state/.meta`, and a routed task's record lives in the second mate's home, so `bin/fm-x-link.sh` refuses and points you back here. + Register a **typed promised-final commitment bound to that home** up front instead - see "Promised final replies" below for the exact commands - and put its `bin/fm-public-followup.sh brief ` output into the routed worker's instructions so the terminal result comes back as typed data. + Do this in the same turn as the acknowledgement, before routing, so the promise is durable state from the moment it is made. 4. **Follow up on genuine milestones, sparingly.** Firstmate gets up to **three** follow-ups per mention, within a 7-day window, chained in the same thread - spend them only on changes the captain would actually want to hear about (e.g. investigation done and a build started, work shipped or ready, or the task failing), never on routine internal churn. A task without a promised-final commitment posts its final outcome - shipped / reported / merged / failed - with `--final`, which clears the link regardless of how many follow-ups remain. A typed promised-final commitment uses the deterministic consumer instead. That posting happens on the task's milestone and completion wakes (see "Completion follow-up" below), not this turn. @@ -142,9 +149,10 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin When in doubt between an instruction and a question, do the smallest safe lifecycle step the request implies; when in doubt between a question and bare politeness, lean toward skipping - a needless reply is noise on a public bot. c. **Act on an actionable request through the normal lifecycle.** Treat it exactly as a captain prompt typed in session: run ordinary intake (resolve the project), then file the backlog item, dispatch a crewmate, start a scout, or ship through the gate - whatever the request calls for. **Destructive, irreversible, or security-sensitive work is the exception** (Relay is a public, relayed channel and does not carry full in-session trust): do not execute it from the mention. Flag it to the captain through the normal trusted channel first - the same carve-out as `yolo` (AGENTS.md §1, §7) - act only on the captain's word, and in step 2d say only that it has been flagged for the captain. - **If the request spawned a real, longer-running task** (you ran `bin/fm-spawn.sh`), link that task to this mention so milestone and completion follow-ups can be posted: `bin/fm-x-link.sh `. + **If the request spawned a real, longer-running task in THIS home** (you ran `bin/fm-spawn.sh` here), link that task to this mention so milestone and completion follow-ups can be posted: `bin/fm-x-link.sh `. **Link here, in step 2c, before the step 2f inbox cleanup** - `bin/fm-x-link.sh` can copy both the mention's reply platform and explicit budget from the still-present inbox payload without a relay lookup. If that local context is incomplete it uses the durable resolution contract in `docs/configuration.md` and warns loudly, while the follow-up path refuses to post unless both values can be resolved authoritatively. + **If intake routes the work to a second mate instead**, do not reach for the link: register the typed promised-final commitment bound to `secondmate:` and brief the routed worker with its reporting command (step 3 of "acknowledge first, act, then follow up on completion", with the commands in "Promised final replies"). Then step 2d's reply is an **acknowledgement** ("on it, captain"), and genuine milestone updates plus the final outcome come later as follow-ups (see "Completion follow-up" below), with the terminal one posted using `--final` when no typed promised-final commitment exists. If the work completed in this turn (a backlog item filed, a question answered), there is no task to link and step 2d reports the outcome directly. d. **Compose the reply.** For a **question**, answer `.text` from the fleet state gathered in step 1. For an **actionable request that completed now**, report the outcome of step 2c (what was done, or - for escalated work - that it has been flagged for the captain). For an **actionable request that spawned a linked task**, acknowledge that you have the order and are on it - milestone updates and the final outcome follow later as completion follow-ups, so do not promise a result you do not yet have. Either way keep it short, in firstmate's voice, and public-safe. @@ -216,13 +224,18 @@ Never carry one in your head: the moment you promise a specific outcome in a pub This section is the sole owner of that procedure. `tasks-axi public-followup --help` owns the typed obligation, its states, and its file contracts; `bin/fm-public-followup.sh --help` owns firstmate's flags; do not restate either here. -**When you promise a final:** +This is also the **only** mechanism that reaches work outside this home. +The lightweight link of step 3 writes into this home's own task record, so it can never bind a second mate's task; `--work-home secondmate:` here can. +So treat second-mate-routed Relay work as a promised final by construction: the acknowledgement you just posted **is** the promise, and there is no other way to keep it. + +**When you promise a final (including every Relay request whose work is routed to a second mate):** 1. Create the typed obligation with `tasks-axi public-followup add` and bind the work with `bind-work`, keeping the public-safe summary and the opaque thread binding in the obligation and the full request context where the poll already put it. 2. Register it with `bin/fm-public-followup.sh register --relation --work-home > --work-id --generation `. This is what makes the commitment reconcilable without you. 3. Put `bin/fm-public-followup.sh brief ` output straight into the worker's brief. It prints the exact reporting command for that binding. + When the work is routed to a second mate rather than spawned here, the routed item's own note carries that same output, so it survives the routing and reaches whoever ends up doing the work. Never ask a worker to find the thread or post the reply: only this home holds the relay consent and the thread binding. **When work reports back, or on a `public-followup ...` check wake, or when the session-start digest lists a public commitment:** @@ -247,7 +260,7 @@ Treat a commitment as kept only after a validated posted receipt or an explicit ## Notes - The direct author is always your own captain (owner-only routing), and in live mode you answer and act on eligible requests **autonomously**: enabling Relay is the captain's standing authorization, so never ask the captain before posting and never hold a worthwhile reply for a chat-side OK. For reply-worthy mentions, dry-run (`FMX_DRY_RUN`) is the only non-posting path; pure acknowledgments use the relay dismiss path instead. -- An actionable mention is **acted on** through the normal lifecycle (intake, backlog, dispatch, investigate, ship), not merely replied to. Work that finishes now gets one outcome reply; work that spawns a real task gets an **acknowledgement now** plus up to three **completion follow-ups** over time, ending with a `--final` one when no typed promised-final commitment exists (link the task with `bin/fm-x-link.sh` so those follow-ups can post). A reply alone, with no work behind an actionable ask, is the bug to avoid. +- An actionable mention is **acted on** through the normal lifecycle (intake, backlog, dispatch, investigate, ship), not merely replied to. Work that finishes now gets one outcome reply; work that spawns a real task gets an **acknowledgement now** plus up to three **completion follow-ups** over time, ending with a `--final` one when no typed promised-final commitment exists. Bind those follow-ups by where the work lives: a task in this home takes `bin/fm-x-link.sh`, and work routed to a second mate takes a promised-final commitment registered with `--work-home secondmate:`, which is the only mechanism that reaches another home. A reply alone, with no work behind an actionable ask, is the bug to avoid. - Destructive, irreversible, or security-sensitive asks are flagged to the captain through the trusted channel first and never run straight from a mention; the public reply says only that it has been flagged. - One answered mention = one reply (plus up to three completion follow-ups for a spawned task, spent only on genuine milestones); a skipped mention posts no reply but is **dismissed at the relay** (`bin/fm-x-dismiss.sh`) so the relay drops it rather than re-offering it (which would otherwise churn every poll and end in an "offline" auto-reply). A single wake may cover several pending mentions - drain them all. - Conversations: `in_reply_to` carries the parent post and optional `in_reply_to_chain` carries the surrounding transcript for continuity; a pure acknowledgment with nothing to answer is dismissed at the relay and skipped, not replied to. The relay already guards against self-replies and caps replies per conversation, so you only judge "is there something to answer here?". diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index 6374521a33..5251f88e8f 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -31,6 +31,16 @@ For an Atelier review artifact: bin/fm-procevent-atelier.sh arm ``` +When a source carries captain answers to decisions that already have durable holds, bind it to their origin BEFORE arming it, so it can never produce an answer that has nowhere to go: + +```sh +bin/fm-decision-hold.sh bind +``` + +The runner then passes each captured result to that source's own adapter `answers` command and pipes the keyed answers it prints into the one keyed-answer intake, which owns every rule about what they mean. +This is generic: any adapter with an `answers` command works, and the runner still wakes you to act on the result. +`decision-hold-lifecycle` owns when a binding is required and what the keys must be. + A configured remote secondmate reply source is armed and handled through `bin/fm-procevent-remote-reply.sh`. Its header owns exact commands, while the adapter owns cursor continuity, validated deduplicated status ingest, path-confined document fetch, acknowledgement, and re-arming after a good delta. A continuity break is escalated once and stays unarmed until an operator deliberately rebases it. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 5291af0c97..cf26c791c0 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -199,6 +199,8 @@ It refuses a selected item with a single-space or tab-indented continuation rath It accepts in-scope `## Queued` entries only and refuses `## In flight` and historical `## Done` entries. Done records stay with their home for pruning or archiving. It is idempotent; an item already in the secondmate backlog is skipped. +After a successful move it warns for any moved key that still owes a public relay reply bound to `main/`, because that binding no longer names the home owning the work; rebind the commitment to `secondmate:` through the `fmx-respond` promised-final procedure, which owns those commands. +That same rule governs routing generally: a Relay-linked request whose work goes to a secondmate cannot use the home-local mention link at all and needs a promised-final commitment bound to that secondmate's home. It refuses any destination that is not a genuine seeded firstmate home with safe operational directories and a matching `.fm-secondmate-home` marker, so a move can never land in a project. Do not hand off `local-only` items. @@ -214,6 +216,7 @@ Use the recorded `home=` in meta. If meta is missing but `data/secondmates.md` still registers the secondmate, respawn from the registry entry and its persistent home. For a remote route, the same command probes and relaunches only on the configured host. An SSH transport failure or unreadable remote endpoint remains unknown and must be reconciled on that host; never launch a local replacement. +`stuck-crewmate-recovery`'s remote-secondmate note owns why the endpoint-dead and send-failed verdicts that seem to justify this are themselves unreliable. 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. To move a live LOCAL secondmate onto a newly pinned harness, model, or effort without a full recovery, set `config/secondmate-harness` and then relaunch it with `bin/fm-control.sh relaunch`, which re-resolves that pin, stops the agent, and launches the replacement in the same home ([`docs/agent-control.md`](../../../docs/agent-control.md)). diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index 55bd6e52f8..c7d96ce30d 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -1,6 +1,6 @@ --- name: stow -description: Sweep the current session for uncaptured durable knowledge, file it to disk, and curate the home's tiered, decaying startup memory before a context reset. Use when the captain invokes /stow (e.g. "/stow", "stow what you've learned"), before a session reset or context compaction, or periodically to keep operational memory current. +description: Sweep the current session for uncaptured durable knowledge, file it to disk, persist the open work records this session knows are unfiled or now wrong, and curate the home's tiered, decaying startup memory before a context reset. Use when the captain invokes /stow (e.g. "/stow", "stow what you've learned"), before a session reset or context compaction, or periodically to keep operational memory current. user-invocable: true metadata: internal: true @@ -10,7 +10,7 @@ metadata: # stow -Sweep this session for durable knowledge that exists only in conversation, then leave the next session with a compact current operating map rather than an accumulating journal. +Sweep this session for durable knowledge and open-work record state that exist only in conversation, then leave the next session with a compact current operating map rather than an accumulating journal. Memory entries are tiered and decay between passes, and stale material retires to a cold archive instead of being deleted. This skill writes only through the existing Firstmate ownership and write boundaries. @@ -207,6 +207,17 @@ A local skill exists only in this home, so offloading an entry out of `data/capt A stale unique fact is never deleted, only archived. Do not invent another graduation path. +## Open-record persistence + +The sweep above preserves knowledge; this one preserves the state of work. +A reset destroys whatever exists only in this session, and that includes what you have learned about work already under way, not just facts worth remembering. +So before the reset, make sure the important open work you are holding in context is durably recorded: file what was never filed, and correct what you now know is stale. + +Judge for yourself what is important and which record each thing belongs to, and write it through the owner that already governs that record. +One bound holds: this covers the open work you are actually holding in context, not the records at large. +It is not a reconciliation of durable records against repository or forge reality, cannot become one on input this volatile, and must never be reported as one. +Where the right correction is a judgment you cannot make, leave the record alone and raise the question instead of guessing. + ## One-time migration of unmarked entries Legacy entries carry no markers; an unmarked entry is its file's default tier with unknown age, and unknown age is not guilt. @@ -227,8 +238,11 @@ Report the outcome in plain captain-facing language with all of these facts: - each durable finding filed outside memory and its authoritative owner; - each archived entry's reason, each autonomous offload's live destination and actual relief, and, when a pinned candidate was proposed, the `proposed-offload` section with every candidate's fields; - every unresolved exception, including a primary-owned shared-file constraint in a secondmate home, and every concrete captain decision opened for an over-budget result; -- whether the session is safe to reset, only when all durable findings are captured and the post-pass result is within budget with no exception or pending budget decision. +- each open record this pass filed or corrected, and each one it deliberately left alone with the judgment it is waiting on; +- whether the session is safe to reset, only when all durable findings are captured, every open record this session held is filed or explicitly left with its reason, and the post-pass result is within budget with no exception or pending budget decision. +State what reset-safe means in the same breath as the claim: nothing this session knew has been lost. +It is never a claim that the home's durable records are correct, because this pass checks no record the session did not name. Do not hide an over-budget result behind a reset-safe claim. In a primary home the receipt is written after the cascade below, not instead of it. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index db8b6a08d4..cf741b9d95 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -23,6 +23,9 @@ The target window's harness is recorded as `harness=` in `state/.meta`. This procedure covers ordinary `kind=ship` and `kind=scout` direct reports. Load `secondmate-provisioning` instead for `kind=secondmate` recovery. +For a REMOTE secondmate, `fm-crew-state`'s `unknown`/`worktree gone` and `fm-send`'s `remote send failed`/`delivery unconfirmed` verdicts are unreliable and routinely false-negative; do not conclude the mate is dead or the send failed from those alone, confirm against the actual remote pane first. +Recover a genuinely stuck remote mate only through `bin/fm-spawn.sh --secondmate`, never raw herdr pane close/kill surgery, which strands the endpoint binding. + Treat the digest's endpoint result as a presence signal, not proof that the task's work or validation run is gone. Read the targeted current state with `bin/fm-crew-state.sh ` before deciding to relaunch. A no-mistakes run matched to the crew's branch and current code remains authoritative when the endpoint is dead: handle a terminal or parked run through the normal lifecycle, and keep supervising an active run instead of creating a duplicate worker. diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 0230b31f07..36e9a80b93 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -35,7 +35,7 @@ This touches only the firstmate repo and its own worktrees, never anything under 2. **Re-read AGENTS.md if your own instructions changed.** When the updater printed `reread-firstmate: yes`, the tracked instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) just advanced under you. - **Read `AGENTS.md` now** (CLAUDE.md is a symlink to it) to refresh your operating instructions before doing anything else, so you are acting on the new instructions rather than the stale ones you were started with. + **Read `AGENTS.md` now** (CLAUDE.md is a real `@AGENTS.md` pointer to it) to refresh your operating instructions before doing anything else, so you are acting on the new instructions rather than the stale ones you were started with. When it printed `reread-firstmate: no`, nothing changed for you - skip the re-read. 3. **Nudge each updated live secondmate.** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 297d70ceeb..90c5d6e895 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,8 +170,10 @@ jobs: tests-herdr: name: Behavior tests (Herdr) runs-on: ubuntu-latest - # Real Herdr is slower than the portable suite; this is a hang tripwire, - # not the expected healthy end of the lane (estimate 15-40 min first cut). + # Healthy runs finish around 7 minutes. This job cap is a last-resort hang + # tripwire, not the expected end of the lane. The family-run step owns the + # tighter bound so a wedged suite fails fast with always() cleanup and + # timing artifacts still uploaded (docs/fm-test-portable-shards.md). timeout-minutes: 75 steps: - uses: actions/checkout@v6 @@ -252,6 +254,9 @@ jobs: mkdir -p "$RUNNER_TEMP/fm-herdr" bin/fm-herdr-ci-cleanup.sh snapshot "$RUNNER_TEMP/fm-herdr/sessions-before.json" - name: Run real-Herdr family (serial, required) + # Comfortably above the ~7 min healthy wall and far below the 75 min + # job backstop. A hang must fail this step so cleanup still runs. + timeout-minutes: 20 run: | set -eu mkdir -p "$RUNNER_TEMP/fm-test" @@ -368,10 +373,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - name: Symlinks must stay intact + - name: Compatibility pointers must stay intact run: | set -eu - [ "$(readlink CLAUDE.md)" = "AGENTS.md" ] || { echo "::error::CLAUDE.md must be a symlink to AGENTS.md"; exit 1; } + [ ! -L CLAUDE.md ] || { echo "::error::CLAUDE.md must be a real @AGENTS.md pointer file, not a symlink"; exit 1; } + tmp=$(mktemp) + trap 'rm -f "$tmp"' EXIT + printf '%s\n' \ + '' \ + '@AGENTS.md' >"$tmp" + cmp -s CLAUDE.md "$tmp" || { echo "::error::CLAUDE.md must be the canonical @AGENTS.md pointer"; exit 1; } [ "$(readlink .claude/skills)" = "../.agents/skills" ] || { echo "::error::.claude/skills must be a symlink to ../.agents/skills"; exit 1; } - name: Personal fleet paths must not be tracked run: | diff --git a/.gitignore b/.gitignore index ca50660871..18353491f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ projects/ state/ data/ +scratchpad/ .no-mistakes/ .atelier/ .fm-secondmate-home diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index e5e92649eb..1141e6edf1 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -195,6 +195,22 @@ export default function (pi: ExtensionAPI) { registerFirstmateSyntheticPresentation(pi); + // Every on-screen tool row Calm currently presents, keyed by the row-local state Pi + // hands its render slots, so Calm can repaint exactly those rows without touching + // Pi's transcript. Pi can re-render a row at any time - the built-in edit row + // invalidates itself once its diff is ready - so a row can be redrawn during the + // window where /export forces stock rendering and keep that stock content + // afterwards. Rows Pi's exporter renders are excluded: those use throwaway state + // and never appear on screen. Cleared per session lifetime, which rebuilds the rows. + const calmToolRowRepaints = new Map void>(); + const rememberCalmToolRow = (state: object, invalidate: unknown): void => { + if (exportRendering || typeof invalidate !== "function") return; + calmToolRowRepaints.set(state, invalidate as () => void); + }; + const repaintCalmToolRows = (): void => { + for (const invalidate of calmToolRowRepaints.values()) invalidate(); + }; + function wrapBuiltIn( factory: DefinitionFactory, ): ToolDefinition { @@ -262,6 +278,7 @@ export default function (pi: ExtensionAPI) { theme: RenderTheme, context: RenderContext, ) { + rememberCalmToolRow(context.state as object, context.invalidate); if (exportRendering) return originalRenderCall(args, theme, context); if (calmPresentationHides("assistant-tool-call")) return new Container(); if (originalSelfShell) return originalRenderCall(args, theme, context); @@ -280,6 +297,7 @@ export default function (pi: ExtensionAPI) { theme: RenderTheme, context: RenderContext, ) { + rememberCalmToolRow(context.state as object, context.invalidate); if (exportRendering) return originalRenderResult(result, options, theme, context); if (calmPresentationHides("tool-result")) return new Container(); if (originalSelfShell) return originalRenderResult(result, options, theme, context); @@ -392,6 +410,7 @@ export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { reportBuiltInLosses(); + calmToolRowRepaints.clear(); exportRendering = false; setCalmPresentation(loadCalmPreference()); setCalmStockExportRendering(false); @@ -423,9 +442,17 @@ export default function (pi: ExtensionAPI) { exportRendering = false; setCalmStockExportRendering(false); publishPresentationState(); - const expanded = ctx.ui.getToolsExpanded(); - ctx.ui.setToolsExpanded(!expanded); - ctx.ui.setToolsExpanded(expanded); + // Repaint the rows Calm presents, never the whole transcript. Pi's export + // prints "Session exported to: " immediately before this runs, and + // since Pi 0.83.0 setToolsExpanded() emits its own status line; consecutive + // status lines coalesce, so a tools-expanded round-trip here silently + // overwrote the confirmation and left the captain no record of where their + // export landed. Invalidating the rows individually repaints the same + // content with no status line of its own, and setStatus adds the redraw the + // rows that consult Calm live in render(), such as operational user rows, + // need without appending anything to the transcript. + repaintCalmToolRows(); + ctx.ui.setStatus("firstmate-calm", undefined); }, 0); }); }); diff --git a/AGENTS.md b/AGENTS.md index 6a979cf3eb..80e3c0a5b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ Each secondmate has a persistent isolated `FM_HOME`, including its own state, ba Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds 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. ``` -AGENTS.md this file (CLAUDE.md is a symlink to it) +AGENTS.md this file (CLAUDE.md is a real @AGENTS.md pointer to it) CONTRIBUTING.md contributor workflow and repo conventions README.md public overview and development notes .github/workflows/ shared CI and PR enforcement, committed @@ -108,6 +108,7 @@ state/ runtime records and signals; gitignored pending-replies/ parent-owned secondmate pending-reply records (correlation id, delivery vs reply, recovery, escalation); fm-pending-reply-lib.sh procevent/ registered process-to-event sources, one private record per canonical source id; written only by bin/fm-procevent.sh, and their presence alone keeps supervision required (section 13) procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line + decision-bindings/ private bindings from a captured-answer source id to the captain-hold origin its keyed answers close; written only by bin/fm-decision-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/decision-hold-lifecycle.md) when/ private condition->action watch specs, their trust bindings, and single-fire markers; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) x-inbox/ generated Relay pending mention payloads; fmx-respond drains it (section 14) x-context/ generated Relay durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) @@ -248,7 +249,7 @@ Route durable knowledge to its most specific owner: Firstmate never writes a project's `AGENTS.md` directly. A crewmate creates or updates it lazily through the project's selected delivery path, using `bin/fm-ensure-agents-md.sh` and preferring pointers to authoritative sources over copied detail. Keep fleet delivery posture and captain-private strategy out of project memory. -When the captain invokes `/stow`, load the `stow` skill for the complete knowledge-routing and unfinished-work sweep. +When the captain invokes `/stow`, load the `stow` skill for its memory curation, knowledge routing, and persistence of the open work records this session is holding; it files and corrects only the open work that session is holding, and never reconciles the backlog against repository or PR reality. ## 7. Task lifecycle diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d86..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..a9d4d2694a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,2 @@ + +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8fa1f30c56..65305797be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star ## Repo conventions - This repo is a template for running a firstmate orchestrator agent. - `AGENTS.md` is the agent's main job description and names when to load bundled firstmate skills; `CLAUDE.md` is a symlink to it, and `.claude/skills` is a symlink to `.agents/skills`. + `AGENTS.md` is the agent's main job description and names when to load bundled firstmate skills; `CLAUDE.md` is a real `@AGENTS.md` pointer to it, and `.claude/skills` is a symlink to `.agents/skills`. - Only shared material is tracked: `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks.toml`, `.github/workflows/`, `bin/`, `.agents/skills/`, and `skills/`. `.agents/skills/` holds agent-loaded skills that assume a live firstmate home and carry `metadata.internal: true` so installers such as [skills.sh](https://skills.sh) hide them from discovery; `skills/` holds standalone, installer-facing public skills with no firstmate dependency (see the README's "Two-tier skill layout"). Everything personal to one captain's fleet (`.env`, `data/`, `state/`, `config/`, `projects/`, `.no-mistakes/`) is gitignored; never commit it. @@ -83,7 +83,10 @@ bin/fm-test-run.sh --check-coverage # prove portable shards + serial + serial bin/fm-test-run.sh --all # deliberate complete regression (optional local full walk; not no-mistakes Test) bin/fm-test-isolation-proof.sh --list # proven parallel candidate set (Phase 2 owner) bin/fm-test-isolation-proof.sh --jobs 4 --json /tmp/fm-isolation-proof.json # re-run concurrent isolation proof only -[ "$(readlink CLAUDE.md)" = "AGENTS.md" ] +[ ! -L CLAUDE.md ] && cmp -s CLAUDE.md - <<'EOF' + +@AGENTS.md +EOF [ "$(readlink .claude/skills)" = "../.agents/skills" ] tmp=$(mktemp -d) && printf 'done: smoke\n' > "$tmp/smoke.status" && FM_STATE_OVERRIDE="$tmp" FM_SIGNAL_GRACE=1 FM_POLL=1 FM_HEARTBEAT=999999 bin/fm-watch-arm.sh # watcher re-arm smoke test (prints arm status, then an actionable signal) ``` diff --git a/README.md b/README.md index 92fab18637..8ed5226b17 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | `/ahoy` | Recap visible session events since the prior real captain message plus visibly unanswered captain decisions, then guide the captain through any open decisions one at a time in agent-judged impact order; fall back to Bearings when invoked as the session's first real captain message | | `/bearings` | Generate a concise four-section chat digest from bounded local fleet and registered-secondmate state; use `/bearings file` to also replace today's dated report in `data/`, and add `include PRs` when live PR enrichment is wanted | | `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | -| `/stow` | Sweep the session for uncaptured durable knowledge, curate tiered startup memory with decay and cold archival, enforce each home's budget or surface the required decision, cascade to registered second mates, and report what is safe to reset | +| `/stow` | Sweep the session for uncaptured durable knowledge, persist the open work records this session knows are unfiled or now wrong, curate tiered startup memory with decay and cold archival, enforce each home's budget or surface the required decision, cascade to registered second mates, and report what is safe to reset | Bearings invocation examples: diff --git a/bin/fm-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index 3a59f4b132..f773655334 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -24,7 +24,11 @@ # archiving; # - the multi-key classification and idempotent per-key reporting: a key # already present in the secondmate backlog is reported and skipped, and if -# any key matches neither backlog nothing is moved. +# any key matches neither backlog nothing is moved; +# - warning, after a successful move, when a moved key still owes a public +# relay reply bound to main/, because that binding no longer names the +# home that owns the work. The move is not blocked: rebinding the commitment +# to secondmate: is a relay-side decision the caller makes. # # What `tasks-axi mv ... --to ` owns: moving each full item BLOCK # byte-exact (header, body lines, blank separators, and indented pseudo-headings @@ -266,6 +270,28 @@ seed_backlog_scaffold() { # [ -f "$1" ] || printf '## In flight\n\n## Queued\n\n## Done\n' > "$1" } +# A public commitment made through the relay binds its work by home AND id, so an +# item that leaves this home takes that binding out of sync: reconciliation would +# still look for main/ while the work now lives in the secondmate's home. +# The move itself stays safe and is never blocked - rebinding is a relay-side +# decision the caller owns - but this is the one moment the staleness is +# detectable, so report it loudly instead of letting the promise go quiet. +# A home that never opted into the relay pays one presence check per key here. +warn_stale_public_commitments() { # ... + local id=$1 key out rc + shift + for key in "$@"; do + rc=0 + out=$("$SCRIPT_DIR/fm-public-followup.sh" guard-work main "$key" 2>/dev/null) || rc=$? + [ "$rc" -ne 0 ] || continue + [ -z "$out" ] || printf '%s\n' "$out" >&2 + printf 'warning: %s still owes a public reply bound to main/%s; rebind it to secondmate:%s (tasks-axi public-followup bind-work, then bin/fm-public-followup.sh register --relation --work-home secondmate:%s --work-id %s --generation ) or the promised reply will be reconciled against work this home no longer owns.\n' \ + "$key" "$key" "$id" "$id" "$key" >&2 + done + # Reporting never changes the handoff's own success: the move already landed. + return 0 +} + outbox_item_count() { # awk '/^- \[[ x]\] / { count++ } END { print count + 0 }' "$1" } @@ -410,6 +436,7 @@ remote_handoff() { # remote_deliver_outbox "$id" "$outbox" || return 1 echo "handed off ${#requested[@]} item(s) to remote secondmate $id: ${requested[*]}" [ "${#already[@]}" -eq 0 ] || echo " already staged (recovered): ${already[*]}" + warn_stale_public_commitments "$id" "${requested[@]}" } with_remote_route_locks() { # @@ -576,3 +603,4 @@ echo " into $SUB_BACKLOG" if [ "${#ALREADY[@]}" -gt 0 ]; then echo " already present (skipped): ${ALREADY[*]}" fi +warn_stale_public_commitments "$ID" "${TO_MOVE[@]}" diff --git a/bin/fm-decision-hold.sh b/bin/fm-decision-hold.sh index 523fef6084..6f82e0676d 100755 --- a/bin/fm-decision-hold.sh +++ b/bin/fm-decision-hold.sh @@ -24,6 +24,11 @@ # fm-decision-hold.sh verify # fm-decision-hold.sh resolve \ # --decision-file --routed-to [--routed-to ...] +# fm-decision-hold.sh answer --decision-file +# fm-decision-hold.sh answers --source (keyed answers on stdin) +# fm-decision-hold.sh bind +# fm-decision-hold.sh unbind +# fm-decision-hold.sh binding # fm-decision-hold.sh decline --decision-file # fm-decision-hold.sh repair --decision-file # @@ -35,18 +40,62 @@ # `verify` is read-only and is called by scout teardown so teardown cannot erase a # source before this gate has succeeded. # -# `resolve` and `decline` close active holds; `repair` attests a hold already closed -# outside this script. All three paths require a non-empty captain decision file of -# at most 8192 bytes, record the same durable resolution block in the hold body, and -# store the decision digest plus routed identities so an exact retry is idempotent -# while a changed decision or, for `resolve`, routed set is rejected. New records -# include a `Resolution mode:` naming their path; older routed records remain valid. +# `resolve`, `answer`, and `decline` close active holds; `repair` attests a hold +# already closed outside this script. All four paths require a non-empty captain +# decision file of at most 8192 bytes, record the same durable resolution block in +# the hold body, and store the decision digest plus routed identities so an exact +# retry is idempotent while a changed decision or, for `resolve`, routed set is +# rejected. New records include a `Resolution mode:` naming their path; older +# routed records remain valid. # # `resolve` is the routed path. It requires every --routed-to task to exist and to # be blocked by the hold. It writes the captain decision and routed identities into # the hold body, clears those dependency edges, and only then marks the hold Done. # A failure before the final step leaves the captain hold open. # +# `answer` is the answer-time closure path, the hold ledger's counterpart to +# `fm-send.sh --resolve-key`: it exists so the act that carries the captain's +# answer is the act that closes the hold, instead of leaving closure to a +# separate later call nobody is forced to make. It records the captain's answer +# on an actively held hold, records `(none)` as the routed identities because no +# follow-up work has been routed behind the hold yet, and closes it. It shares +# every guard `decline` has, including the refusal while any task is still +# blocked by the hold, so a decision whose follow-up work is already routed still +# goes through `resolve` and the routed-vs-unrouted distinction survives. It says +# only that the captain answered; `decline` still says the captain answered with +# no follow-up work at all. +# +# ONE KEYED-ANSWER INTAKE, FED BY EVERY CHANNEL. +# "A keyed answer closes its matching hold" is a single capability, owned here +# and nowhere else. `answers` is its channel-agnostic entry point: it reads +# `\t\t