diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index a80d3611476..d089ed9dc65 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -2,7 +2,7 @@ name: afk description: >- Enter the away posture when the captain invokes /afk, says they are going afk, `state/.afk-contract` or `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. - It reads the captain's away words back as a mandate, writes the durable away-posture record after their go, announces hold-for-return only at entry, keeps the one supervision session running in the away posture (on Pi the supervision branch takes every safe actionable wake with main parked; the daemon still delivers batched digests on the other harnesses for now), and on the first unmarked message renders the return brief from durable records before ordinary work resumes. + It records the captain's away words verbatim as the whole mandate, reads them back in plain sentences, writes the durable away-posture record after their go, announces hold-for-return only at entry, keeps the one supervision session running in the away posture (on Pi the supervision branch acts on the words by its own judgment and takes every safe actionable wake with main parked; the daemon still delivers batched digests on the other harnesses for now), and on the first unmarked message renders the return brief from durable records before ordinary work resumes. user-invocable: true metadata: internal: true @@ -11,33 +11,26 @@ metadata: # afk Away mode is a POSTURE of the one supervision session, not a second architecture. -Being away changes exactly two things: how the captain is informed, and what happens at a captain-owned decision point (hold for return, or later a pre-answered clause). +Being away changes exactly two things: how the captain is informed, and what happens at a captain-owned decision point (hold for return, or the answer the captain's away words already gave). It never changes the authority set. The posture is a file, `state/.afk-contract`, written only by `bin/fm-afk-contract.sh` after the captain confirms a read-back; nothing infers the posture from chat. Hold-for-return is the default and the only reach profile this release records: there is no phone channel, and the entry announcement says so aloud every time. ## Entering: `/afk [words]` -1. **Translate the captain's words into mandate clauses.** - The words are recorded verbatim; the clauses are your reading of them as explicit fields `bin/fm-afk-contract.sh` records: an action from its fixed verb list, the object in the captain's words, and the stated precondition in the captain's words, plus an optional stop. - Read `bin/fm-afk-contract.sh --help` for the field flags, verb list, and coarse best-effort never-set flag rather than memorizing them. - No static parser reads the object or precondition text, by the captain's mandate: you supply the fields, the script records them verbatim, checks structural presence and the verb list, and may flag obvious never-set concepts without treating that best-effort scan as authoritative. - A flagged clause is still recorded, never refused, and the read-back and return brief show the flag; the flag can miss spellings, including joined compounds such as `oneTimeCode`, never fires on unrelated names such as `ping-service`, and authoritative never-set, forbidden-action, and precondition judgment belongs to the supervision session at execution time in phase 4. - Forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, and no recorded clause is authority by itself. - Write only clauses the words actually support; a wish with no object or no stated precondition is not a clause. - Plain `/afk` with no words has no clauses. +1. **Record the captain's words, verbatim.** + The words are the whole mandate: `bin/fm-afk-contract.sh` records them exactly as given, with no clause fields, verbs, ids, or merge-grant list, and by the captain's mandate no parser, tokenizer, classifier, or grammar reads them anywhere. + Read `bin/fm-afk-contract.sh --help` for the flags rather than memorizing them. + Plain `/afk` with no words is a valid entry with no mandate. 2. **Propose and read back.** - Run `bin/fm-afk-launch.sh propose --words-file [--action --object --when [--stop ]]... [--expected-return ] [--spend ] [--grant ]...` (or `--words `), and relay its read-back to the captain in `AGENTS.md` section 9 language: the accepted clauses as a numbered list, every refused clause with the part it is missing, the expected return, the spend cap, any merge-when-green task ids, and the one-sentence reach announcement. - When the captain names task ids that may merge while green, pass `--grant ` for each named id. - Never infer task ids from clause prose, object text, or the away words. - Red-check exceptions stay in the words or clause `when` text and are not executed. - A refused clause does not fail the proposal; the captain can restate it or leave it refused. - Exit 3 only means a clause was refused; the proposal stands. + Run `bin/fm-afk-launch.sh propose --words-file [--expected-return ] [--spend ]` (or `--words `); it writes the proposal and prints the record's read-back. + Then relay your own plain-sentence restatement of the words to the captain in `AGENTS.md` section 9 language - what you read them as asking for, sentence by sentence, never a numbered field list - beside the expected return, the spend cap, and the one-sentence reach announcement, so the captain can catch a misreading before saying go. + Say plainly which sentence, if any, you could not act on while away (a red merge, a discard, anything on the never-set, local-only landing), so the captain can restate it or accept that it waits for their return. 3. **Confirm on the captain's go.** Run `bin/fm-afk-launch.sh confirm`; it promotes the proposal into the record and prints the entry announcement. - Relay that announcement verbatim in spirit: hold-for-return only, no phone channel, anything that needs the captain waits for their return, N clauses recorded and M refused, recorded clauses are held for the return brief and are not executed by this release, and forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text because no recorded clause is authority by itself. - With no words, run `propose` and `confirm` back to back; the announcement is the same. - Re-invoking `/afk` while already away with no new words is a refresh and leaves the standing record untouched; new words replace the mandate after the same read-back, preserve the original session entry, and archive the superseded mandate for the return brief. + Relay that announcement verbatim in spirit: hold-for-return only, no phone channel, your instructions are recorded and the away session will carry them out where it can, anything it is unsure of, or that needs you, waits for your return, and destructive, irreversible, and security-sensitive actions are never pre-authorizable whatever the words say. + With no words, run `propose` and `confirm` back to back; the announcement says no instructions were recorded. + Re-invoking `/afk` while already away with no new words is a refresh and leaves the standing record untouched; new words replace the mandate after the same read-back, preserve the original session entry, and archive the superseded words for the return brief. 4. **Per harness, after the record exists:** - **Pi and pi-signed**: stop here. The away daemon is no longer launched on Pi; the ordinary supervision session (`docs/pi-supervision-branch.md`) keeps running with the record present, and `bin/fm-afk-launch.sh start` refuses on these harnesses. @@ -58,10 +51,11 @@ Hold-for-return is the default and the only reach profile this release records: - The record exists, so the watcher never rechecks an item held for the captain, in either supervision shape; the return brief lists it instead. Declared external waits keep their condition-aware, hours-long recheck cadence (`bin/fm-watch.sh`, `bin/fm-classify-lib.sh`). -- Recorded clauses are not executed by this release. - Forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, no recorded clause is authority by itself, and merge authority plus ask-user findings keep exactly the rules they have when attended (`AGENTS.md` section 7 and `ask-user-authority`); anything that needs the captain holds for their return. -- On Pi, main is parked and the supervision branch handles every safe actionable wake under main's standing authority plus the record's merge grants, through the same guarded scripts main would use: a granted or `yolo` task merges only green at its live head, already-queued work whose blockers cleared dispatches within the spend cap, and only a finding `ask-user-authority` lets firstmate decide is answered. - Anything else holds for the return, local-only landing always waits for the captain, and only a wake the branch declines (including a broken branch or unsafe scan) or a watcher failure wakes main (`docs/pi-supervision-branch.md` "Postures"). +- The away session acts on the captain's words. + It reads them at the tail of every wake, decides by its own judgment whether the event in front of it is the moment they name, acts on them only through the guarded scripts under standing authority, never by analogy, holds with verdict captain on doubt, and opens every outcome summary for an action taken under the words with "per your away instructions:" (`bin/fm-branch-prompt.sh` "Postures" owns the execution rules). + Destructive, irreversible, and security-sensitive actions are never pre-authorizable whatever the words say, and ask-user findings keep the `ask-user-authority` policy unless the words pre-answer the exact decision; anything else that needs the captain holds for their return. +- On Pi, main is parked and the supervision branch handles every safe actionable wake under main's standing authority, through the same guarded scripts main would use: any pull request green at its live head may merge (which one the words meant is the branch's reading), queued work whose blockers cleared - already queued, or filed by the branch because the words explicitly call for it - dispatches within the spend cap, and a decision is answered with the captain's own pre-stated answer or under `ask-user-authority`. + Anything else holds for the return, a red merge never proceeds while away, local-only landing always waits for the captain, and only a wake the branch declines (including a broken branch or unsafe scan) or a watcher failure wakes main (`docs/pi-supervision-branch.md` "Postures"). - The session-start digest reports the posture under its AFK subsection, so a restart re-enters the posture from the record, not from memory. ## How to exit: the return @@ -71,9 +65,9 @@ No `/back` is needed. The first genuine message is the return signal: - A message **without** the current operational prefix or a legacy bare marker, and **not** starting with `/afk` -> the captain is back. Run `bin/fm-afk-return.sh` before acting on the message that brought the captain back. That script owns the correct-ordered daemon shutdown where a daemon ran, the archive of the posture record, durable wake presentation and post-handling acknowledgement, escalation and wedge evidence, the return brief, and the return-catch-up gate. - Relay the return brief in section 9 language and in its own order: supervisor health across the away window first (any gap leads), then every clause and that it was recorded only, then what is waiting on the captain, then what was tried and failed or could not be fixed, then what was handled, then cost. + Relay the return brief in section 9 language and in its own order: supervisor health across the away window first (any gap leads), then the captain's instructions verbatim with the away session's account of every action it took under them, then what is waiting on the captain, then what was tried and failed or could not be fixed, then what was handled, then cost. The gate keeps every open `blocked:` event until that blocker's own resolution is proven: remediate each immediately through the normal lifecycle, or explicitly reclassify it with a durable reason and close its decision key with `resolved [key=...]`, then run `bin/fm-afk-return.sh check`. - Captain-verdict outcomes are listed under "waiting on you", but do not exempt open blockers because per-blocker provenance is deferred to phase 4. + Captain-verdict outcomes are listed under "waiting on you", but do not exempt open blockers: per-blocker provenance is deferred with no owner, and the gate fails safe by keeping every open blocker. Once the record is archived, resume full per-wake responsiveness through the emitted primary-harness supervision protocol while blocker handling proceeds, so the gate never creates a blind wait. A Bearings request may be answered while the gate is open, and the digest surfaces the catch-up state as a Charted Next `(return-catchup)` warning row naming what still holds it. Acting on the fleet - dispatching, steering, merging, or any other ordinary captain work - still waits until the check exits successfully. @@ -88,14 +82,13 @@ When the captain wants this same token-saving supervision while staying present afk changes how the captain is informed and what happens at a captain-owned decision point, **not who approves what**. "Away" never means "approves more" or "approves less." A PR ready for merge keeps the merge authority from `AGENTS.md` section 7, and a needs-decision finding keeps the `ask-user-authority` policy; anything requiring the captain still waits for the captain's explicit word. -While the away-posture record exists, a merge proceeds only when that task's recorded yolo posture is on or its id is in the record's merge-grant list; otherwise it is held for the captain's return. -A merge grant never releases a captain hold, and it expires when the away record is archived. +While the away-posture record exists, any pull request green at its live head may merge under away authority; which one the captain's words meant is the away session's reading, and a merge the words do not call for holds for the return. +Away authority never releases a captain hold, and it expires when the away record is archived. `--allow-red` remains attended-only and is refused while the record exists. A merge under away authority must be synchronous; `fm-pr-merge.sh` refuses auto-merge and any GitHub queue state that cannot prove an immediate merge while the record exists. The same gates bind whichever actor performs the action: on Pi the parked main's standing authority relocates to the supervision branch, which meets exactly these rules, and the spend cap recorded at entry is enforced by `fm-spawn.sh` for both actors while the record exists. -A mandate clause is the captain's explicit instruction given before leaving, recorded with its named object and condition; a clause is never inferred, never applied by analogy, and expires at return. -Forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, and no recorded clause is authority by itself. -This release records clauses and does not execute them. +The captain's away words are their explicit instruction given before leaving, recorded verbatim and acted on by the away session's judgment at the moment an event makes them relevant; the words cover nothing they do not say, are never applied by analogy, and die at archive. +Destructive, irreversible, and security-sensitive actions are never pre-authorizable whatever the words say. ## The daemon, where it still runs diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index a56d064ca6f..74ccac0be9d 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -188,10 +188,10 @@ const PROVIDER_REPROBE_MAX_MS = 60 * 60 * 1000; // section is what this tail refers back to. const AWAY_POSTURE_TAIL = "POSTURE: AWAY. The away-posture record state/.afk-contract exists, so the captain is not present and MAIN is parked: you take every row, including check rows and decision rows, and no outcome reaches the captain until the return brief. " + - "MAIN's standing authority - never more - is relocated to you for this wake only through the guarded scripts, which enforce it: bin/fm-pr-merge.sh merges only a granted or yolo=on task that is green at its live head, synchronously; bin/fm-spawn.sh dispatches only already-queued work whose blockers cleared and refuses past the spend cap; bin/fm-send.sh --resolve-key answers only a finding the ask-user-authority policy in your prompt lets firstmate decide; bin/fm-merge-local.sh still refuses you. " + - "Hold on doubt: a fork no standing rule covers is reported with verdict captain and left for the return. " + - "Credential entry, legal or financial acceptance, an attended prompt, any discard the captain did not name, and any destructive, irreversible, or security-sensitive action are refused for every actor in every posture, whatever a clause says. " + - "A recorded clause below is a fact for the return brief, not authority: this release records clauses and does not execute them. " + + "The record below is the captain's away words, verbatim, and the whole mandate: act on them by your own judgment where this event is the moment they name, only through the guarded scripts under MAIN's standing authority - never more - which enforce it: bin/fm-pr-merge.sh merges any pull request that is green at its live head, synchronously, and refuses a red one or --allow-red; bin/fm-spawn.sh dispatches queued work (already queued, or filed by you from the words) within the spend cap; bin/fm-send.sh --resolve-key answers a decision the words pre-answer, or one the ask-user-authority policy in your prompt lets firstmate decide; bin/fm-merge-local.sh still refuses you. " + + "Never by analogy, and hold on doubt: a sentence you cannot act on with confidence is reported with verdict captain, naming it, and left for the return. " + + "Credential entry, legal or financial acceptance, an attended prompt, any discard the captain did not name, and any destructive, irreversible, or security-sensitive action are refused for every actor in every posture, whatever the words say. " + + "Log every action taken under the words in its outcome summary, opening with \"per your away instructions:\". " + "A mirrored captain sentence authorizes nothing new once the record exists. " + "The record, verbatim:"; const PROCESSING_INSTRUCTION = @@ -1435,9 +1435,10 @@ ${context.command} } } - // The away posture at the tail of a wake: the record's own read-back (its - // grants, spend cap, words, and clauses, verbatim) plus the standing rule - // for acting under it. Read per wake so the byte-stable prefix never + // The away posture at the tail of a wake: the record's own read-back (the + // captain's words verbatim, the spend cap, expected return, and reach line) + // carried byte-for-byte, trailing blank lines included, plus the standing + // rule for acting under it. Read per wake so the byte-stable prefix never // carries posture; a read-back that cannot be rendered still names the // posture, because the record's presence is the fact the guarded scripts // enforce either way. @@ -1445,11 +1446,11 @@ ${context.command} let readback = ""; try { const rendered = await runCommandAsync("bash", [afkContractScript, "readback"], { cwd: fmRoot, env: scriptEnv }); - if (rendered.status === 0) readback = (rendered.stdout || "").trim(); + if (rendered.status === 0) readback = rendered.stdout || ""; } catch { readback = ""; } - return `\n\n${AWAY_POSTURE_TAIL}\n${readback || "(the record's read-back could not be rendered; treat every grant and clause as unavailable and hold on doubt)"}`; + return `\n\n${AWAY_POSTURE_TAIL}\n${readback || "(the record's read-back could not be rendered; treat the captain's words as unavailable, act on standing authority only, and hold on doubt)"}`; } function enqueueWake(message: string, acceptedGeneration: number, recoveryProbe = false, acceptedAwayOnly = false): Promise { diff --git a/AGENTS.md b/AGENTS.md index 29ced552794..045f87c2b8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ state/ runtime records and signals; gitignored .watcher-down private generation-bound recovery state coupling watcher downtime, durable wake presentation, and post-handling acknowledgement; never touch ..open-decisions-cursor per-task byte cursor and folded open-decision set bounding the OPEN DECISIONS scan's cost to new status-log appends; written only by fm-classify-lib.sh's status_open_decisions_incremental, removed by teardown, safe to delete (forces one full re-fold) .status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity plus independent annotation and outcome-backstop byte offsets, with a serialization lock preventing already-presented lines from replaying while preserving delayed signal annotations; owned by fm-classify-lib.sh, with each task's row retired by teardown - .afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, spend cap, and structured mandate clauses; written only by bin/fm-afk-contract.sh after the captain confirms the read-back, archived under afk-contracts/ at return; its presence IS the away posture in every harness; its sibling .afk-contract.lock serializes actions authorized by the live record (contract: bin/fm-afk-contract.sh) + .afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, and spend cap; written only by bin/fm-afk-contract.sh after the captain confirms the read-back, archived under afk-contracts/ at return; its presence IS the away posture in every harness; its sibling .afk-contract.lock serializes actions authorized by the live record (contract: bin/fm-afk-contract.sh) afk-contracts/ archived away-posture records: one final record per away window keyed by entry time, plus any superseded mandates from that window .afk durable away/quiet-mode daemon flag on the harnesses that still launch the daemon (never on Pi); present = sub-supervisor may inject escalations, first line `away` (default, set by /afk, cleared on user return) or `quiet` (set by /quiet, cleared only on explicit /quiet off) per the single owner fm_afk_mode() in bin/fm-wake-lib.sh .lock-session trusted Claude session-lock sidecar; written only by bin/fm-lock.sh; never touch @@ -461,7 +461,7 @@ Invoke the `/quiet` skill instead when the captain says `/quiet` or asks for qui Each skill owns its own daemon procedure, which is otherwise identical; these safety facts remain inline for both: - Every current daemon injection uses the `away-supervisor` kind from `bin/fm-operational-input.sh` after `FM_OPERATIONAL_PREFIX` (U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), while the `/afk` skill owns legacy bare-marker compatibility. -- `state/.afk-contract` is the away posture, written only after the captain confirms the read-back of their away words; entry announces hold-for-return only, and the record's clauses are recorded, not executed, in this release. +- `state/.afk-contract` is the away posture, written only after the captain confirms the read-back of their away words; entry announces hold-for-return only, and the away session acts on those words by its own judgment through the guarded scripts under standing authority, holding for the return on doubt. - While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. The daemon is never launched on Pi, where the ordinary supervision session continues under the record with main parked: the branch takes every safe actionable wake it can, and only a declined wake (including a broken branch or unsafe scan) or a watcher failure wakes main. - A marked message while away or quiet mode is active is internal escalation and does not exit that mode. diff --git a/bin/fm-afk-contract.sh b/bin/fm-afk-contract.sh index 04f8197f9a6..cfb2bc425f6 100755 --- a/bin/fm-afk-contract.sh +++ b/bin/fm-afk-contract.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # fm-afk-contract.sh - the one owner of the away-posture record: its schema, the -# mandate-clause fields and their structural check, refusal naming the missing -# part, the read-back rendering, the entry announcement, and the archive at return. +# captain's away words recorded verbatim, the read-back rendering, the entry +# announcement, and the archive at return. # # POSTURE. Away mode is a posture of the one supervision session, recorded in # state/.afk-contract and never inferred from chat. While the record exists the @@ -12,120 +12,85 @@ # only reach profile this release records: there is no phone channel, and the # entry announcement says so every time. # +# THE RECORD IS THE WORDS. The captain's away words are the whole mandate: they +# are recorded verbatim, read back as plain sentences by firstmate before the +# captain says go, and acted on by the supervision session's own judgment at the +# moment an event makes them relevant, through the guarded scripts and under the +# standing authority it already has (bin/fm-branch-prompt.sh "Postures" owns the +# execution rules). NO PARSER, TOKENIZER, CLASSIFIER, OR GRAMMAR READS THE WORDS +# HERE, BY THE CAPTAIN'S MANDATE: this script never tokenizes, classifies, or +# semantically validates them, records no clause fields, ids, or verbs, and keeps +# no per-task merge-grant list. What stays mechanical is exactly what a script can +# check without reading words: a merge green at its live head under this record's +# lock, synchronous merges only, the spend cap, and the never-set. +# HARD RULE: destructive, irreversible, and security-sensitive actions are never +# pre-authorizable whatever the words say. +# # RECORD (state/.afk-contract; written only by this script; YAML-shaped so a # human can read it, but parsed only here - consumers use the read subcommands): -# version: 1 +# version: 2 # entered: # entered_epoch: # expected_return: | - # reach_channels: none # reach_announced: # spend_max_concurrent_workers: -# merge_grants: - | task ids that may merge while this record exists -# - (empty is `merge_grants: -`; a missing field on -# ... a pre-field v1 record reads as an empty list) # confirmed: # confirmed_epoch: # words: | or |- the captain's words, verbatim, never edited, # one record line per input line (or `words: -` # ... when /afk carried no words); `|` retains a # final newline and `|-` records its absence -# clauses: accepted clauses, recorded from the fields given -# - id: -# action: -# object: e: -# when: e: -# stop: e: | - -# flag: | - -# refused: clauses missing a part, with the part named -# - id: -# text: e: -# missing: +# The words block runs to the end of a version 2 record; in a version 1 record +# only its legacy clauses:, refused:, and merge_grants: sections end it. Any +# other line after the header that is not a stored line is damage, not a +# boundary, so a truncated mandate can never read as a whole one. +# A version 1 record (the retired clause model) still validates and reads: its +# scalar fields and words are read exactly as above, and its clauses:, refused:, +# and merge_grants: sections are ignored, so an upgrade never breaks a live away +# window. Only version 2 is ever written. # A proposal (state/.afk-contract.proposed) has the same shape without the # confirmed fields; confirmation stamps the first entry time. Archived final # records live under state/afk-contracts/ as .afk-contract, and # replaced mandates use -superseded-.afk-contract. -# A replacement carries the original session entry forward as the phase-1 -# fail-safe. Durable archive-chain identity and same-second session identity are -# deferred to phase 4 (fm-afk-clauses-execute-r1). -# -# CLAUSE FIELDS. A clause is given as explicit fields, one clause per --action: -# --action --object --when [--stop ] -# action one of: merge land prerelease install rerun dispatch abort-run answer -# discard wake-me. A new verb is a code change here, never a prompt change. -# object the thing the clause acts on, in the captain's words, verbatim. -# when the stated precondition, in the captain's words, verbatim. -# stop optional: what ends the clause early, verbatim. -# NO STATIC NATURAL-LANGUAGE PARSER EXISTS HERE, BY THE CAPTAIN'S MANDATE. The -# object and precondition text are recorded exactly as given and are never -# tokenized, classified, or semantically validated by this script; whether a -# precondition holds is the supervision session's judgment at execution time -# in a later phase. The structural check asserts only that the action, object, -# and precondition fields are present, and that the action is a listed verb. -# THE NEVER-SET SCAN is only a coarse best-effort structural FLAG, never a -# refusal and never the authoritative gate: a clause whose fields mention a -# listed never-set concept is still recorded, with `flag:` naming the concept -# so the read-back and the return brief show it. The scan matches a listed term -# exactly or with a plain inflection (s, es, d, ed, ing, er, ers) at -# punctuation-delimited token boundaries, so an unrelated name such as -# ping-service or tokenize-worker is never flagged, and it can miss spellings, -# with joined compounds such as oneTimeCode a known limitation. Authoritative -# never-set and forbidden-action enforcement is the supervision session's -# judgment at execution time in phase 4. -# A clause missing a required field is refused with that field named, recorded -# under refused:, read back beside the accepted list, and never executes. Ids -# are the input ordinals across accepted and refused clauses. -# THIS RELEASE RECORDS CLAUSES AND DOES NOT EXECUTE THEM: the guarded gates learn -# to cite a clause in a later phase, and the announcement and return brief both -# say so, so a recorded clause is never mistaken for a promise. -# HARD RULE: forbidden, destructive, irreversible, and security-sensitive actions -# are never pre-authorizable regardless of clause text, and no recorded clause is -# authority by itself. +# A replacement carries the original session entry forward. Durable +# archive-chain identity and same-second session identity are deferred, with no +# owner: no incident motivates them. # # Usage: # fm-afk-contract.sh propose [--words-file | --words ] -# [--action --object --when [--stop ]]... -# [--expected-return ] [--spend ] [--grant ]... -# Compile and write the proposal, then print the read-back. Exit 0 with every -# clause accepted, 3 when at least one clause was refused (the read-back names -# the missing part), and 2 on a usage error. --words-file keeps the file's -# bytes verbatim, trailing newlines included. A refused clause remains in the -# proposal so the captain can restate it before saying go. Repeatable --grant -# records captain-named task ids that may merge-when-green while the record -# exists; invalid or duplicate ids are a usage error, never a refused clause. +# [--expected-return ] [--spend ] +# Write the proposal, then print the read-back. Exit 0 on success and 2 on a +# usage error. --words-file keeps the file's bytes verbatim, trailing +# newlines included. # fm-afk-contract.sh confirm # Promote the proposal into the record with the confirmed timestamp and # print the entry announcement. A proposal is required when no confirmed # record exists; an existing record with no proposal is a no-op refresh. # A replacement is staged before the prior record is archived and replaced. # fm-afk-contract.sh readback [--proposal] +# The record's content for the captain and for the away session: the words +# verbatim plus the entry time, expected return, spend cap, and reach line. # fm-afk-contract.sh field [--proposal] # fm-afk-contract.sh words [--proposal | --path ] -# fm-afk-contract.sh clauses [--proposal | --path ] TSV: id action object when stop -# fm-afk-contract.sh flags [--proposal | --path ] TSV: id concept (flagged clauses only) # fm-afk-contract.sh validate [--proposal | --path ] exit 0 when the record is readable and, for a record, confirmed -# Backslashes and control whitespace in TSV fields use reversible escapes -# (`\\`, `\t`, `\r`, and `\n`) so every record remains one row per clause; -# a literal `-` is `\x2d` to distinguish it from the empty-stop marker. -# fm-afk-contract.sh refused [--proposal | --path ] TSV: id text missing -# fm-afk-contract.sh grants [--proposal | --path ] one task id per line # fm-afk-contract.sh archive move the record aside; print its path # fm-afk-contract.sh archived print that archived record's path # # CROSS-SUBSYSTEM LOCK (state/.afk-contract.lock; this script is its one owner). # This record is authority another subsystem reads and then ACTS on outside this -# script: bin/fm-pr-merge.sh reads the merge grants and afterwards hands a merge -# to the forge. A publication, replacement, or archive landing between that read -# and the forge handoff would land a merge on authority that no longer holds, so -# the two subsystems share one lock instead of each locking its own records: the -# record-mutating subcommands (confirm, archive) hold it across their mutation, -# and a reader that acts on the record holds it across both its read and that -# action (fm_afk_contract_lock_hold / fm_afk_contract_lock_release). The -# read-only subcommands never take it, so a holder can still read the record it -# locked. Neither side ever proceeds without it: the acquire is bounded, and a -# bound that is hit refuses and names the live holder rather than racing. That -# fixed bound is 120 seconds, sized so only a genuinely wedged holder trips it. -# A lock left by a killed process is reclaimed +# script: bin/fm-pr-merge.sh reads the record's presence as away merge authority +# and afterwards hands a merge to the forge. A publication, replacement, or +# archive landing between that read and the forge handoff would land a merge on +# authority that no longer holds, so the two subsystems share one lock instead of +# each locking its own records: the record-mutating subcommands (confirm, +# archive) hold it across their mutation, and a reader that acts on the record +# holds it across both its read and that action (fm_afk_contract_lock_hold / +# fm_afk_contract_lock_release). The read-only subcommands never take it, so a +# holder can still read the record it locked. Neither side ever proceeds without +# it: the acquire is bounded, and a bound that is hit refuses and names the live +# holder rather than racing. That fixed bound is 120 seconds, sized so only a +# genuinely wedged holder trips it. A lock left by a killed process is reclaimed # by the ordinary stale-owner recovery in bin/fm-wake-lib.sh, which owns the lock # primitive itself. # @@ -143,8 +108,9 @@ FM_AFK_CONTRACT_STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-classify-lib.sh . "$FM_AFK_CONTRACT_DIR/fm-classify-lib.sh" -FM_AFK_CONTRACT_VERSION=1 -FM_AFK_CONTRACT_VERBS="merge land prerelease install rerun dispatch abort-run answer discard wake-me" +FM_AFK_CONTRACT_VERSION=2 +# Older record versions this script still reads (never writes). +FM_AFK_CONTRACT_READABLE_VERSIONS="1 2" FM_AFK_CONTRACT_REACH_ANNOUNCED='No phone channel is configured; anything that needs you waits for your return.' FM_AFK_CONTRACT_SPEND_DEFAULT=4 # Generous against the longest legitimate holder, a merge waiting on the forge, @@ -219,169 +185,23 @@ fm_afk_contract_lock_release() { fm_afk_contract_log() { printf 'fm-afk-contract: %s\n' "$*" >&2; } fm_afk_contract_usage() { - sed -n '/^# Usage:/,/^# Sourceable:/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//' + sed -n '/^# Usage:/,/^# CROSS-SUBSYSTEM LOCK/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//' } fm_afk_contract_now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ } -fm_afk_contract_lower() { # - printf '%s' "$1" | tr '[:upper:]' '[:lower:]' -} - -fm_afk_contract_action() { # - fm_afk_contract_lower "$1" | tr '\t\r\n' ' ' | sed 's/^ *//; s/ *$//; s/ */ /g' -} - -fm_afk_contract_blank() { # - [ -z "$(printf '%s' "$1" | tr -d '[:space:]')" ] -} - -# Same alphabet as fm_pr_task_id_valid / fm_task_id_path_safe in bin/fm-pr-lib.sh. -# Kept local so sourcing this file cannot reset that library's parse globals. -fm_afk_contract_grant_id_valid() { # - local LC_ALL=C id=${1-} - case "$id" in - ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; - esac -} - -fm_afk_contract_escape() { # - local value=$1 - value=${value//\\/\\\\} - value=${value//$'\t'/\\t} - value=${value//$'\r'/\\r} - value=${value//$'\n'/\\n} - [ "$value" != - ] || value='\x2d' - printf '%s' "$value" -} - -fm_afk_contract_unescape() { # - printf '%b' "$1" -} - -# --- clause structural check and never-set scan ------------------------------ - -# fm_afk_contract_never_set_hit : prints the protected concept the -# text mentions, or nothing. This coarse best-effort structural flag lowercases -# and splits punctuation before checking fixed token stems. It is not authoritative, -# can miss joined compounds such as oneTimeCode, and does not understand language; -# phase-4 supervision judgment owns never-set and forbidden-action enforcement. -fm_afk_contract_never_set_hit() { # - local normalized concept matched i j - local -a tokens stems concepts=( - credential password passcode login signin otp totp hotp 2fa mfa token secret - passphrase apikey legal financial payment invoice pin - 'log in' 'sign in' 'attended prompt' 'one time code' 'one time password' - 'one time passcode' 'verification code' 'security code' 'auth code' - 'authentication code' 'recovery code' 'backup code' 'api key' 'access token' - 'secret key' 'private key' - ) - normalized=$(printf '%s ' "$@" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]/ /g; s/ */ /g') - read -r -a tokens <<< "$normalized" - for concept in "${concepts[@]}"; do - read -r -a stems <<< "$concept" - for ((i = 0; i + ${#stems[@]} <= ${#tokens[@]}; i++)); do - matched=1 - for ((j = 0; j < ${#stems[@]}; j++)); do - case "${tokens[$((i + j))]}" in - "${stems[$j]}"|"${stems[$j]}s"|"${stems[$j]}es"|"${stems[$j]}d"|"${stems[$j]}ed"|"${stems[$j]}ing"|"${stems[$j]}er"|"${stems[$j]}ers") ;; - *) matched=0; break ;; - esac - done - if [ "$matched" -eq 1 ]; then - printf '%s' "$concept" - return 0 - fi - done - done - return 1 -} - -# Check one clause's fields. Sets C_ACTION C_OBJECT C_WHEN C_STOP; on refusal -# C_MISSING names the missing field and the reason. The fields are never parsed: -# presence, the listed verb, and the coarse best-effort flag are the whole check. -fm_afk_contract_clause_check() { # - C_ACTION=$(fm_afk_contract_action "$1") - C_OBJECT=$2 - C_WHEN=$3 - C_STOP=$4 - C_MISSING= - C_FLAG=$(fm_afk_contract_never_set_hit "$C_ACTION" "$C_OBJECT" "$C_WHEN" "$C_STOP") || C_FLAG= - if [ -z "$C_ACTION" ]; then - C_MISSING='action - the clause names no action' - return 1 - fi - case " $FM_AFK_CONTRACT_VERBS " in - *" $C_ACTION "*) ;; - *) - C_MISSING="action - '$C_ACTION' is not a mandate verb (one of: ${FM_AFK_CONTRACT_VERBS// /, })" - return 1 ;; - esac - if fm_afk_contract_blank "$C_OBJECT"; then - C_MISSING='object - the clause names no thing to act on' - return 1 - fi - if fm_afk_contract_blank "$C_WHEN"; then - C_MISSING='when - the clause states no precondition' - return 1 - fi - if [ "$5" -eq 1 ] && fm_afk_contract_blank "$C_STOP"; then - C_MISSING='stop - --stop was given with no text' - return 1 - fi - return 0 -} - -# The refused list keeps the fields exactly as given, so the captain sees what -# was refused; an absent field reads as "(none)". -fm_afk_contract_clause_as_given() { # - local text - text="action=${1:-(none)} object=${2:-(none)} when=${3:-(none)}" - [ "$5" -eq 0 ] || text="$text stop=${4:-(none)}" - printf '%s' "$text" -} - # --- record writing --------------------------------------------------------- fm_afk_contract_validate_iso() { # fm_utc_iso_to_epoch "$1" >/dev/null 2>&1 } -# Compile every input into a record body on stdout (everything except the -# confirmed fields). Inputs: WORDS (verbatim), the parallel clause field arrays -# CLAUSE_ACTIONS CLAUSE_OBJECTS CLAUSE_WHENS CLAUSE_STOPS, EXPECTED_RETURN, -# SPEND, MERGE_GRANTS. +# Render a record body on stdout (everything except the confirmed fields). +# Inputs: WORDS (verbatim), EXPECTED_RETURN, SPEND. fm_afk_contract_render_body() { # - local entered=$1 entered_epoch=$2 ordinal=0 i as_given grant - local accepted_block="" refused_block="" - i=0 - while [ "$i" -lt "${#CLAUSE_ACTIONS[@]}" ]; do - ordinal=$((ordinal + 1)) - if fm_afk_contract_clause_check "${CLAUSE_ACTIONS[$i]}" "${CLAUSE_OBJECTS[$i]}" "${CLAUSE_WHENS[$i]}" "${CLAUSE_STOPS[$i]}" "${CLAUSE_STOP_GIVENS[$i]}"; then - accepted_block="$accepted_block$(printf ' - id: %s\n action: %s\n object: e:%s\n when: e:%s\n' \ - "$ordinal" "$C_ACTION" "$(fm_afk_contract_escape "$C_OBJECT")" "$(fm_afk_contract_escape "$C_WHEN")" - if [ -n "$C_STOP" ]; then - printf ' stop: e:%s\n' "$(fm_afk_contract_escape "$C_STOP")" - else - printf ' stop: -\n' - fi - if [ -n "$C_FLAG" ]; then - printf ' flag: %s' "$C_FLAG" - else - printf ' flag: -' - fi) -" - else - as_given=$(fm_afk_contract_clause_as_given "${CLAUSE_ACTIONS[$i]}" "${CLAUSE_OBJECTS[$i]}" "${CLAUSE_WHENS[$i]}" "${CLAUSE_STOPS[$i]}" "${CLAUSE_STOP_GIVENS[$i]}"; printf x) - as_given=${as_given%x} - refused_block="$refused_block$(printf ' - id: %s\n text: e:%s\n missing: %s' \ - "$ordinal" "$(fm_afk_contract_escape "$as_given")" "$C_MISSING") -" - fi - i=$((i + 1)) - done + local entered=$1 entered_epoch=$2 printf 'version: %s\n' "$FM_AFK_CONTRACT_VERSION" printf 'entered: %s\n' "$entered" printf 'entered_epoch: %s\n' "$entered_epoch" @@ -389,14 +209,6 @@ fm_afk_contract_render_body() { # printf 'reach_channels: none\n' printf 'reach_announced: %s\n' "$FM_AFK_CONTRACT_REACH_ANNOUNCED" printf 'spend_max_concurrent_workers: %s\n' "${SPEND:-$FM_AFK_CONTRACT_SPEND_DEFAULT}" - if [ "${#MERGE_GRANTS[@]}" -eq 0 ]; then - printf 'merge_grants: -\n' - else - printf 'merge_grants:\n' - for grant in "${MERGE_GRANTS[@]}"; do - printf ' - %s\n' "$grant" - done - fi if [ -n "$WORDS" ]; then local words_body=$WORDS words_indicator='|-' case "$words_body" in @@ -407,10 +219,6 @@ fm_afk_contract_render_body() { # else printf 'words: -\n' fi - printf 'clauses:\n' - [ -z "$accepted_block" ] || printf '%s' "$accepted_block" - printf 'refused:\n' - [ -z "$refused_block" ] || printf '%s' "$refused_block" } fm_afk_contract_write_atomic() { # (content on stdin) @@ -432,10 +240,15 @@ fm_afk_contract_read_field() { # sed -n "s/^${name}: //p" "$path" | head -1 } +# The words block runs from its header to the end of a version 2 record, and in a +# version 1 record to one of its legacy sections. Every stored line carries the +# two-space record prefix; anything else there is damage, and reading refuses +# rather than returning the mandate truncated at the damage. fm_afk_contract_read_words() { # - local path=$1 + local path=$1 version [ -f "$path" ] || return 1 - awk -v record="$path" ' + version=$(fm_afk_contract_read_field "$path" version) + awk -v record="$path" -v version="$version" ' function die(reason) { printf "fm-afk-contract: record %s has an invalid words block: %s\n", record, reason > "/dev/stderr" bad = 1 @@ -445,17 +258,19 @@ fm_afk_contract_read_words() { # /^words: \|-$/ && !found { found = inwords = 1; keep_final = 0; next } /^words: -$/ && !found { found = scalar = 1; next } !found { next } - $0 == "clauses:" { + /^[^ ]/ { + if (version != "1" || ($0 != "clauses:" && $0 != "refused:" && $0 != "merge_grants:")) { + die("the line after the stored words is neither a stored line nor a section this record version ends the block at: " $0) + } if (inwords && count == 0) die("the block indicator has no stored lines") - done = 1 exit } inwords && /^ / { lines[++count] = substr($0, 3); next } - { die("a stored line lacks its two-space record prefix") } + { die("a line after the words field is not a stored line with its two-space record prefix") } END { if (bad) exit 2 if (!found) die("the words field is missing") - if (!done) die("the clauses section does not follow the words field") + if (inwords && count == 0) die("the block indicator has no stored lines") for (i = 1; i <= count; i++) { printf "%s", lines[i] if (i < count || keep_final) printf "\n" @@ -464,133 +279,19 @@ fm_afk_contract_read_words() { # ' "$path" } -# One granted task id per line. A missing merge_grants field is an empty list -# so a pre-field v1 record fails closed for non-yolo merges instead of skipping -# the grant check. A present but unreadable field fails rather than guessing. -fm_afk_contract_read_grants() { # - local path=$1 - [ -f "$path" ] || return 1 - awk -v record="$path" ' - function die(reason) { - printf "fm-afk-contract: record %s has an invalid merge_grants field: %s\n", record, reason > "/dev/stderr" - bad = 1 - exit 2 - } - function valid_id(value) { - if (value == "" || substr(value, 1, 1) == ".") return 0 - return value ~ /^[A-Za-z0-9._-]+$/ - } - /^merge_grants:/ { - if (found) die("the field is defined more than once") - found = 1 - if ($0 == "merge_grants: -") { empty = 1; next } - if ($0 == "merge_grants:") { inlist = 1; next } - die("the empty form is merge_grants: -") - } - inlist && /^ - / { - id = substr($0, 5) - if (!valid_id(id)) die("task id \"" id "\" is not a valid task id") - if (seen[id]++) die("task id \"" id "\" is listed more than once") - print id - count++ - next - } - inlist && /^[^ ]/ { - if (count == 0) die("the list form has no stored ids") - inlist = 0 - next - } - empty && /^[^ ]/ { empty = 0; next } - inlist || empty { die("a stored grant line is malformed") } - END { - if (bad) exit 2 - if (!found) exit 0 - if (inlist && count == 0) die("the list form has no stored ids") - } - ' "$path" -} - -# TSV rows for a list section:
is clauses or refused. -fm_afk_contract_read_list() { #
- local path=$1 section=$2 - [ -f "$path" ] || return 1 - awk -v want="$section" -v verbs="$FM_AFK_CONTRACT_VERBS" -v record="$path" ' - function row_name() { return (id != "" ? id : ordinal + 1) } - function die(part) { - printf "fm-afk-contract: record %s has malformed %s row %s: missing or invalid %s\n", record, section, row_name(), part > "/dev/stderr" - bad = 1 - exit 2 - } - function valid_action(value, values, count, i) { - count = split(verbs, values, " ") - for (i = 1; i <= count; i++) if (value == values[i]) return 1 - return 0 - } - function flush() { - if (!active) return - if (section == "clauses") { - if (state < 1 || id !~ /^[0-9]+$/) die("id") - if (state < 2 || !valid_action(action)) die("action") - if (state < 3) die("object") - if (state < 4) die("when") - if (state < 5) die("stop") - if (state < 6 || flag == "") die("flag") - if (want == "clauses") printf "%s\t%s\t%s\t%s\t%s\n", id, action, object, when, stop - else if (flag != "-") printf "%s\t%s\n", id, flag - } else { - if (state < 1 || id !~ /^[0-9]+$/) die("id") - if (state < 2) die("text") - if (state < 3 || missing == "") die("missing") - printf "%s\t%s\t%s\n", id, text, missing - } - ordinal++ - active = 0 - state = 0 - id = action = object = when = stop = text = missing = flag = "" - } - BEGIN { section = (want == "flags") ? "clauses" : want } - $0 == section ":" && !found { found = insection = 1; next } - insection && /^[^ ]/ { flush(); done = 1; exit } - !insection { next } - /^ - id: / { - flush() - active = 1 - id = substr($0, 9) - state = 1 - next - } - section == "clauses" && state == 1 && /^ action: / { action = substr($0, 13); state = 2; next } - section == "clauses" && state == 2 && /^ object: e:/ { object = substr($0, 15); state = 3; next } - section == "clauses" && state == 3 && /^ when: e:/ { when = substr($0, 13); state = 4; next } - section == "clauses" && state == 4 && /^ stop: e:/ { stop = substr($0, 13); state = 5; next } - section == "clauses" && state == 4 && /^ stop: -$/ { stop = "-"; state = 5; next } - section == "clauses" && state == 5 && /^ flag: / { flag = substr($0, 11); state = 6; next } - section == "refused" && state == 1 && /^ text: e:/ { text = substr($0, 13); state = 2; next } - section == "refused" && state == 2 && /^ missing: / { missing = substr($0, 14); state = 3; next } - { die(section == "clauses" ? (state == 1 ? "action" : state == 2 ? "object" : state == 3 ? "when" : state == 4 ? "stop" : state == 5 ? "flag" : "row") : (state == 1 ? "text" : state == 2 ? "missing" : "row")) } - END { - if (bad) exit 2 - if (!done) flush() - if (!found) { - printf "fm-afk-contract: record %s lacks its %s section\n", record, section > "/dev/stderr" - exit 2 - } - } - ' "$path" -} - -# A record is valid when its version is the one this script writes and the -# required scalar fields are present. Refuses rather than guessing at a foreign -# schema. +# A record is valid when its version is one this script reads and the required +# scalar fields and words block are present. Refuses rather than guessing at a +# foreign schema. A version 1 record's clause and grant sections are ignored. fm_afk_contract_validate() { # local path=$1 require_confirmed=$2 version entered entered_epoch expected reach announced spend words_header confirmed - local clause_rows refused_rows clause refused id object when stop text decoded [ -f "$path" ] || return 1 version=$(fm_afk_contract_read_field "$path" version) - [ "$version" = "$FM_AFK_CONTRACT_VERSION" ] || { - fm_afk_contract_log "record $path carries version '${version:-none}', expected $FM_AFK_CONTRACT_VERSION; refusing to read it" - return 1 - } + case " $FM_AFK_CONTRACT_READABLE_VERSIONS " in + *" $version "*) ;; + *) + fm_afk_contract_log "record $path carries version '${version:-none}', expected one of ${FM_AFK_CONTRACT_READABLE_VERSIONS// /, }; refusing to read it" + return 1 ;; + esac entered=$(fm_afk_contract_read_field "$path" entered) fm_afk_contract_validate_iso "$entered" || { fm_afk_contract_log "record $path has no valid entered time"; return 1; } entered_epoch=$(fm_afk_contract_read_field "$path" entered_epoch) @@ -606,10 +307,6 @@ fm_afk_contract_validate() { # words_header=$(sed -n '/^words: /{p;q;}' "$path") case "$words_header" in 'words: -'|'words: |'|'words: |-') ;; *) fm_afk_contract_log "record $path has no valid words field"; return 1 ;; esac fm_afk_contract_read_words "$path" >/dev/null || return 1 - fm_afk_contract_read_grants "$path" >/dev/null || { - fm_afk_contract_log "record $path has no valid merge_grants field" - return 1 - } if [ "$require_confirmed" -eq 1 ]; then confirmed=$(fm_afk_contract_read_field "$path" confirmed) fm_afk_contract_validate_iso "$confirmed" || { fm_afk_contract_log "record $path has no valid confirmed time"; return 1; } @@ -617,77 +314,25 @@ fm_afk_contract_validate() { # ''|*[!0-9]*) fm_afk_contract_log "record $path was never confirmed"; return 1 ;; esac fi - if ! clause_rows=$(fm_afk_contract_read_list "$path" clauses); then - return 1 - fi - while IFS= read -r clause; do - [ -n "$clause" ] || continue - id=$(printf '%s' "$clause" | cut -f1) - object=$(printf '%s' "$clause" | cut -f3) - when=$(printf '%s' "$clause" | cut -f4) - stop=$(printf '%s' "$clause" | cut -f5) - decoded=$(fm_afk_contract_unescape "$object"; printf x) - decoded=${decoded%x} - if fm_afk_contract_blank "$decoded"; then - fm_afk_contract_log "record $path has malformed clauses row $id: missing or invalid object" - return 1 - fi - decoded=$(fm_afk_contract_unescape "$when"; printf x) - decoded=${decoded%x} - if fm_afk_contract_blank "$decoded"; then - fm_afk_contract_log "record $path has malformed clauses row $id: missing or invalid when" - return 1 - fi - if [ "$stop" != - ]; then - decoded=$(fm_afk_contract_unescape "$stop"; printf x) - decoded=${decoded%x} - if fm_afk_contract_blank "$decoded"; then - fm_afk_contract_log "record $path has malformed clauses row $id: missing or invalid stop" - return 1 - fi - fi - done < - local path=$1 title=$2 words count id action object when stop text missing expected spend flag grants grant_list + local path=$1 title=$2 words expected spend expected=$(fm_afk_contract_read_field "$path" expected_return) spend=$(fm_afk_contract_read_field "$path" spend_max_concurrent_workers) - grants=$(fm_afk_contract_read_grants "$path") || return 1 - grant_list= - while IFS= read -r id; do - [ -n "$id" ] || continue - grant_list="${grant_list:+$grant_list, }$id" - done <<EOF -$grants -EOF printf '%s\n' "$title" printf ' entered: %s\n' "$(fm_afk_contract_read_field "$path" entered)" printf ' expected return: %s\n' "$( [ "$expected" = - ] && printf 'not given' || printf '%s' "$expected")" printf ' spend cap: %s concurrent workers\n' "$spend" - printf ' merge when green (task ids): %s\n' "${grant_list:-(none)}" printf ' reach: hold-for-return only. %s\n' "$(fm_afk_contract_read_field "$path" reach_announced)" - words=$(fm_afk_contract_read_words "$path"; printf x) + words=$(fm_afk_contract_read_words "$path"; rc=$?; printf x; exit "$rc") || return 1 words=${words%x} if [ -n "$words" ]; then printf ' your words (verbatim):\n' @@ -696,69 +341,31 @@ EOF else printf ' your words: (none)\n' fi - printf ' accepted clauses:\n' - count=0 - while IFS="$(printf '\t')" read -r id action object when stop; do - [ -n "$id" ] || continue - count=$((count + 1)) - printf ' %s. %s ' "$id" "$action" - fm_afk_contract_unescape "$object" - printf ' when ' - fm_afk_contract_unescape "$when" - if [ "$stop" != - ]; then - printf ' stop ' - fm_afk_contract_unescape "$stop" - fi - flag=$(fm_afk_contract_read_list "$path" flags | awk -F '\t' -v id="$id" '$1 == id { print $2 }') - [ -z "$flag" ] || printf " - flagged: names '%s', a never-set concept that is never pre-authorizable; recorded, judged at execution" "$flag" - printf '\n' - done <<EOF -$(fm_afk_contract_read_list "$path" clauses) -EOF - [ "$count" -gt 0 ] || printf ' (none)\n' - printf ' refused clauses:\n' - count=0 - while IFS="$(printf '\t')" read -r id text missing; do - [ -n "$id" ] || continue - count=$((count + 1)) - printf ' %s. "' "$id" - fm_afk_contract_unescape "$text" - printf '" - refused: missing %s\n' "$missing" - done <<EOF -$(fm_afk_contract_read_list "$path" refused) -EOF - [ "$count" -gt 0 ] || printf ' (none)\n' - printf ' everything else waits for your return: no red merge without its named check, no discard without a named object and condition, never credentials, legal, financial, or attended prompts, nothing by analogy, and every clause expires at return.\n' - printf ' hard rule: forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text; no recorded clause is authority by itself.\n' - printf ' recorded clauses are held for the return brief and are not executed by this release.\n' } fm_afk_contract_render_announcement() { # <path> - local path=$1 accepted refused flagged expected clause_text - accepted=$(fm_afk_contract_read_list "$path" clauses | grep -c . || true) - refused=$(fm_afk_contract_read_list "$path" refused | grep -c . || true) - flagged=$(fm_afk_contract_read_list "$path" flags | grep -c . || true) + local path=$1 expected words mandate_text expected=$(fm_afk_contract_read_field "$path" expected_return) - if [ "$accepted" -eq 0 ] && [ "$refused" -eq 0 ]; then - clause_text='No mandate clauses recorded. Forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, and no recorded clause is authority by itself.' + words=$(fm_afk_contract_read_words "$path"; rc=$?; printf x; exit "$rc") || return 1 + words=${words%x} + if [ -n "$words" ]; then + mandate_text='Your away instructions are recorded verbatim; the away session will carry them out where it can, and anything it is unsure of, or that needs you, waits for your return.' else - clause_text="$accepted mandate clause(s) recorded, $refused refused, and $flagged flagged as naming a never-set concept; recorded clauses are held for the return brief and are not executed by this release; forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, and no recorded clause is authority by itself." + mandate_text='No away instructions were recorded; the away session acts on standing authority only, and anything that needs you waits for your return.' fi - printf 'Away posture confirmed at %s: hold-for-return only. %s %s Expected return: %s. Spend cap: %s concurrent workers.\n' \ + printf 'Away posture confirmed at %s: hold-for-return only. %s %s Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: %s. Spend cap: %s concurrent workers.\n' \ "$(fm_afk_contract_read_field "$path" confirmed)" \ "$(fm_afk_contract_read_field "$path" reach_announced)" \ - "$clause_text" \ + "$mandate_text" \ "$( [ "$expected" = - ] && printf 'not given' || printf '%s' "$expected")" \ "$(fm_afk_contract_read_field "$path" spend_max_concurrent_workers)" } # --- subcommands ------------------------------------------------------------ -fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, the CLAUSE_* arrays, EXPECTED_RETURN, SPEND, MERGE_GRANTS - local words_file='' open=-1 grant +fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, EXPECTED_RETURN, SPEND + local words_file='' WORDS=; EXPECTED_RETURN=-; SPEND=$FM_AFK_CONTRACT_SPEND_DEFAULT - CLAUSE_ACTIONS=(); CLAUSE_OBJECTS=(); CLAUSE_WHENS=(); CLAUSE_STOPS=(); CLAUSE_STOP_GIVENS=() - MERGE_GRANTS=() while [ "$#" -gt 0 ]; do case "$1" in --words-file) @@ -769,20 +376,6 @@ fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, the CLAUSE_* arrays, [ "$#" -gt 1 ] || { fm_afk_contract_log '--words requires text'; return 2; } WORDS=$2 shift 2 ;; - --action) - [ "$#" -gt 1 ] || { fm_afk_contract_log '--action requires a verb; it opens a clause for the --object, --when, and --stop that follow it'; return 2; } - CLAUSE_ACTIONS+=("$2"); CLAUSE_OBJECTS+=(''); CLAUSE_WHENS+=(''); CLAUSE_STOPS+=(''); CLAUSE_STOP_GIVENS+=(0) - open=$(( ${#CLAUSE_ACTIONS[@]} - 1 )) - shift 2 ;; - --object|--when|--stop) - [ "$#" -gt 1 ] || { fm_afk_contract_log "$1 requires text"; return 2; } - [ "$open" -ge 0 ] || { fm_afk_contract_log "$1 must follow the --action that opens its clause"; return 2; } - case "$1" in - --object) CLAUSE_OBJECTS[open]=$2 ;; - --when) CLAUSE_WHENS[open]=$2 ;; - --stop) CLAUSE_STOPS[open]=$2; CLAUSE_STOP_GIVENS[open]=1 ;; - esac - shift 2 ;; --expected-return) [ "$#" -gt 1 ] || { fm_afk_contract_log '--expected-return requires a UTC ISO 8601 time'; return 2; } if ! fm_afk_contract_validate_iso "$2"; then @@ -796,22 +389,8 @@ fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, the CLAUSE_* arrays, case "$2" in ''|*[!0-9]*|0) fm_afk_contract_log "--spend must be a positive integer, got '$2'"; return 2 ;; esac SPEND=$2 shift 2 ;; - --grant) - [ "$#" -gt 1 ] || { fm_afk_contract_log '--grant requires a task id'; return 2; } - fm_afk_contract_grant_id_valid "$2" || { - fm_afk_contract_log "--grant must be a valid task id, got '$2'" - return 2 - } - for grant in "${MERGE_GRANTS[@]+"${MERGE_GRANTS[@]}"}"; do - [ "$grant" != "$2" ] || { - fm_afk_contract_log "--grant lists '$2' more than once" - return 2 - } - done - MERGE_GRANTS+=("$2") - shift 2 ;; - --grant=*) - fm_afk_contract_log '--grant takes a separate task-id argument' + --action|--object|--when|--stop|--grant|--grant=*) + fm_afk_contract_log "$1 was retired: the captain's away words are the whole mandate, so pass them with --words or --words-file and nothing else" return 2 ;; *) fm_afk_contract_log "unknown option '$1'" @@ -822,14 +401,14 @@ fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, the CLAUSE_* arrays, [ -f "$words_file" ] || { fm_afk_contract_log "words file not found: $words_file"; return 2; } # Command substitution strips trailing newlines; the sentinel keeps the # file's bytes verbatim, trailing newlines included. - WORDS=$(cat "$words_file"; printf x) || return 1 + WORDS=$(cat "$words_file"; rc=$?; printf x; exit "$rc") || return 1 WORDS=${WORDS%x} fi return 0 } fm_afk_contract_cmd_propose() { - local entered entered_epoch proposal rc=0 refused + local entered entered_epoch proposal fm_afk_contract_parse_inputs "$@" || return 2 entered=$(fm_afk_contract_now_iso) entered_epoch=$(date +%s) @@ -838,11 +417,8 @@ fm_afk_contract_cmd_propose() { fm_afk_contract_log "failed to write the proposal at $proposal" return 1 } - refused=$(fm_afk_contract_read_list "$proposal" refused | grep -c . || true) - [ "$refused" -eq 0 ] || rc=3 - fm_afk_contract_render_readback "$proposal" 'Away posture read-back (proposed, not yet confirmed):' - printf 'Say go to confirm; restate any refused clause first if you want it recorded.\n' - return "$rc" + fm_afk_contract_render_readback "$proposal" 'Away posture read-back (proposed, not yet confirmed):' || return 1 + printf 'Say go to confirm; restate your instructions first if this reading is not what you meant.\n' } fm_afk_contract_archive_target() { # <record> [superseded-stamp] @@ -872,7 +448,7 @@ fm_afk_contract_cmd_confirm() { elif [ -f "$record" ]; then fm_afk_contract_validate "$record" 1 || return 1 fm_afk_contract_log "away posture already recorded at $(fm_afk_contract_read_field "$record" entered); nothing to confirm" - fm_afk_contract_render_announcement "$record" + fm_afk_contract_render_announcement "$record" || return 1 return 0 else fm_afk_contract_log "no away-posture proposal exists; run propose before confirm" @@ -915,7 +491,7 @@ fm_afk_contract_cmd_confirm() { fm_afk_contract_log "replaced the earlier away posture; its record is archived at $archived" fi rm -f "$proposal" - fm_afk_contract_render_announcement "$record" + fm_afk_contract_render_announcement "$record" || return 1 } fm_afk_contract_cmd_archive() { @@ -970,9 +546,9 @@ fm_afk_contract_main() { path=$(fm_afk_contract_select_path "$@") || { fm_afk_contract_usage >&2; return 2; } [ -f "$path" ] || { fm_afk_contract_log "no record at $path"; return 1; } if [ "$path" = "$(fm_afk_contract_proposal_path)" ]; then - fm_afk_contract_render_readback "$path" 'Away posture read-back (proposed, not yet confirmed):' + fm_afk_contract_render_readback "$path" 'Away posture read-back (proposed, not yet confirmed):' || return 1 else - fm_afk_contract_render_readback "$path" 'Away posture (confirmed):' + fm_afk_contract_render_readback "$path" 'Away posture (confirmed):' || return 1 fi ;; field) [ "$#" -ge 1 ] || { fm_afk_contract_usage >&2; return 2; } @@ -982,12 +558,6 @@ fm_afk_contract_main() { words) path=$(fm_afk_contract_select_path "$@") || { fm_afk_contract_usage >&2; return 2; } fm_afk_contract_read_words "$path" ;; - clauses) - path=$(fm_afk_contract_select_path "$@") || { fm_afk_contract_usage >&2; return 2; } - fm_afk_contract_read_list "$path" clauses ;; - flags) - path=$(fm_afk_contract_select_path "$@") || { fm_afk_contract_usage >&2; return 2; } - fm_afk_contract_read_list "$path" flags ;; validate) path=$(fm_afk_contract_select_path "$@") || { fm_afk_contract_usage >&2; return 2; } if [ "$path" = "$(fm_afk_contract_proposal_path)" ]; then @@ -995,13 +565,9 @@ fm_afk_contract_main() { else fm_afk_contract_validate "$path" 1 fi ;; - refused) - path=$(fm_afk_contract_select_path "$@") || { fm_afk_contract_usage >&2; return 2; } - fm_afk_contract_read_list "$path" refused ;; - grants) - path=$(fm_afk_contract_select_path "$@") || { fm_afk_contract_usage >&2; return 2; } - [ -f "$path" ] || { fm_afk_contract_log "no record at $path"; return 1; } - fm_afk_contract_read_grants "$path" ;; + clauses|flags|refused|grants) + fm_afk_contract_log "'$cmd' was retired with the clause and merge-grant apparatus: the record is the captain's words (read them with 'words' or 'readback')" + return 2 ;; archive) fm_afk_contract_locked_cmd fm_afk_contract_cmd_archive ;; archived) [ "$#" -eq 1 ] || { fm_afk_contract_usage >&2; return 2; } diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index a85e37a8a1e..ee8a6e6693f 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -7,11 +7,12 @@ # after a crash. # # ENTRY (the posture record). `/afk [words]` is two steps so the captain hears -# the mandate back before it binds: `propose` compiles the words and clauses -# into a proposal and prints the read-back (bin/fm-afk-contract.sh owns the -# clause fields, the never-set, the refusal wording, and the record schema); `confirm` promotes it -# into state/.afk-contract and prints the entry announcement (hold-for-return -# only: no phone channel exists). The record is the posture in every harness. +# the mandate back before it binds: `propose` records the captain's away words +# verbatim into a proposal and prints the read-back (bin/fm-afk-contract.sh owns +# the record schema; the words are the whole mandate and no script parses them); +# `confirm` promotes it into state/.afk-contract and prints the entry +# announcement (hold-for-return only: no phone channel exists). The record is +# the posture in every harness. # On Pi and pi-signed the entry ENDS there: the away daemon is no longer launched # on Pi, the ordinary supervision session keeps running in both postures, and # `start` refuses on those harnesses. Every other harness still runs the daemon @@ -38,16 +39,9 @@ # # Usage: # fm-afk-launch.sh propose [--words-file <path> | --words <text>] -# [--action <verb> --object <text> --when <text> [--stop <text>]]... # [--expected-return <UTC ISO 8601>] [--spend <n>] -# [--grant <task-id>]... -# Record the captain's away words and mandate -# clause fields into a proposal and print the -# read-back. Exit 3 when a clause was refused (its -# missing part is named in the read-back); the -# proposal still records it as refused. -# Repeatable --grant records captain-named task -# ids that may merge-when-green while away. +# Record the captain's away words verbatim into a +# proposal and print the read-back. # fm-afk-launch.sh confirm Promote the required proposal and print the entry # announcement. On Pi this is the whole entry. # fm-afk-launch.sh start Capture the captain pane, then (unless the daemon diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 3b38defc916..05953b725df 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -15,12 +15,14 @@ # (bin/fm-afk-contract.sh), the supervision outcome store # (bin/fm-branch-outcome.sh), the held set in the backlog (tasks-axi), and the # status logs. Its order is fixed: supervisor health across the away window -# first, then every mandate clause the captain recorded, including superseded -# in-session read-backs (this release records clauses and does not execute them, -# and the brief says so), then what is -# waiting on the captain, then what was tried and failed or could not be fixed, -# then what the away session handled, then cost. The health snapshot is taken -# BEFORE the daemon shutdown so the shutdown itself cannot read as a gap. +# first, then the captain's away instructions - their words verbatim, including +# superseded in-session mandates - followed by the away session's account of +# every action it took under them (each outcome-store row from the window whose +# summary opens with the "per your away instructions:" marker the branch prompt +# in bin/fm-branch-prompt.sh requires), then what is waiting on the captain, +# then what was tried and failed or could not be fixed, then what the away +# session handled, then cost. The health snapshot is taken BEFORE the daemon +# shutdown so the shutdown itself cannot read as a gap. # # THE GATE. `blocked:` is the crewmate protocol's firstmate-actionable verb. A # live task's open blocked event must be remediated and closed with @@ -29,11 +31,12 @@ # `needs-decision:` is deliberately not part of this blocker gate. The gate # keeps every open blocker until that blocker's own resolution is proven. # Captain-verdict outcomes are listed under "waiting on you", but cannot exempt -# a blocker because decision-key provenance is deferred to phase 4 -# (fm-afk-clauses-execute-r1). Away-window attribution uses second-resolution -# epochs; a durable sequence boundary and archive-chain identity are deferred to -# that phase as well. Replacement records carry the original entry boundary and -# superseded mandates are included as the phase-1 fail-safe. +# a blocker: per-blocker decision-key provenance is deferred, with no owner, +# because the gate fails safe by keeping every open blocker. Away-window +# attribution uses second-resolution epochs; a durable sequence boundary and +# archive-chain identity are likewise deferred with no owner. Replacement +# records carry the original entry boundary and superseded mandates are +# included so the brief shows every instruction the window ran under. # # The durable state/.afk-return-catchup file is written BEFORE daemon shutdown, # so a crash between stopping, wake presentation, and blocker handling fails @@ -364,48 +367,41 @@ strip_axi_help() { awk '/^help\[/ { skip = 1; next } skip && /^ / { next } { skip = 0; print }' } +# The branch prompt (bin/fm-branch-prompt.sh "Postures") requires every action +# taken under the captain's words to open its outcome summary with this marker +# exactly; the brief's account is every store row from the window that carries it. +AWAY_ACTION_MARKER='per your away instructions:' + MANDATE_COUNT=0 HELD_READ_FAILED=0 HELD_READ_PATH= -render_mandate_record() { # <record> [superseded-time] - local record=$1 superseded=${2:-} id action object when stop text missing suffix="" words flag - [ -z "$superseded" ] || suffix=" - superseded at $superseded" - while IFS="$(printf '\t')" read -r id action object when stop; do - [ -n "$id" ] || continue - MANDATE_COUNT=$((MANDATE_COUNT + 1)) - printf ' - %s. %s ' "$id" "$action" - fm_afk_contract_unescape "$object" - printf ' when ' - fm_afk_contract_unescape "$when" - if [ "$stop" != - ]; then - printf ' stop ' - fm_afk_contract_unescape "$stop" - fi - flag=$("$CONTRACT" flags --path "$record" | awk -F '\t' -v id="$id" '$1 == id { print $2 }') - [ -z "$flag" ] || printf " - flagged: names '%s', a never-set concept that is never pre-authorizable" "$flag" - printf '%s - recorded, not executed by this release\n' "$suffix" - done <<EOF -$("$CONTRACT" clauses --path "$record") -EOF - while IFS="$(printf '\t')" read -r id text missing; do - [ -n "$id" ] || continue +render_words_record() { # <record> [superseded-time] + local record=$1 superseded=${2:-} words + if ! words=$("$CONTRACT" words --path "$record"; rc=$?; printf x; exit "$rc"); then MANDATE_COUNT=$((MANDATE_COUNT + 1)) - printf ' - %s. "' "$id" - fm_afk_contract_unescape "$text" - printf '"%s - refused at entry: missing %s\n' "$suffix" "$missing" - done <<EOF -$("$CONTRACT" refused --path "$record") -EOF - words=$("$CONTRACT" words --path "$record"; printf x) + printf ' your words are unreadable in %s; catch-up stays gated until the record is restored\n' "$record" + return 1 + fi words=${words%x} - if [ -n "$words" ]; then - if [ -n "$superseded" ]; then - printf ' your words superseded at %s:\n' "$superseded" - else - printf ' your words at entry:\n' - fi - printf '%s' "$words" | sed 's/^/ /' - case "$words" in *$'\n') ;; *) printf '\n' ;; esac + [ -n "$words" ] || return 0 + MANDATE_COUNT=$((MANDATE_COUNT + 1)) + if [ -n "$superseded" ]; then + printf ' your words superseded at %s:\n' "$superseded" + else + printf ' your words at entry:\n' + fi + printf '%s' "$words" | sed 's/^/ /' + case "$words" in *$'\n') ;; *) printf '\n' ;; esac +} + +render_words_account() { # the away session's account of what it did under the words + local rows + rows=$(printf '%s\n' "$STORE_ROWS" | awk -F '\t' -v marker="$AWAY_ACTION_MARKER" ' + substr($5, 1, length(marker)) == marker { printf " - %s: %s\n", $2, $5 }') + if [ -n "$rows" ]; then + printf ' the away session acted on them:\n%s\n' "$rows" + else + printf ' the away session took no action under them.\n' fi } @@ -423,8 +419,8 @@ render_return_brief() { # <evidence-file> <blockers-file> <since-epoch> printf 'Supervisor health:\n' awk -F '\t' '$1 == "evidence" && ($2 == "health" || ($2 == "lifecycle" && ($3 ~ /^outcome store unreadable/ || $3 ~ /^status file unreadable:/ || $3 ~ /^away-posture record (unreadable|missing):/ || $3 ~ /^archived away-posture record/ || $3 ~ /^superseded away-posture record/))) { print " - " $3 }' "$evidence" - # 2. the mandate. - printf 'Mandate clauses:\n' + # 2. the captain's instructions, verbatim, then the session's account. + printf 'Your instructions:\n' record="" MANDATE_COUNT=0 [ -z "$since" ] || record=$("$CONTRACT" archived "$since" 2>/dev/null || true) @@ -436,10 +432,11 @@ render_return_brief() { # <evidence-file> <blockers-file> <since-epoch> stamp=${stamp%%-*} stamp=${stamp%.afk-contract} case "$stamp" in ''|*[!0-9]*) superseded_at=unknown ;; *) superseded_at=$(epoch_to_iso "$stamp") ;; esac - render_mandate_record "$superseded" "$superseded_at" + render_words_record "$superseded" "$superseded_at" done - render_mandate_record "$record" - [ "$MANDATE_COUNT" -gt 0 ] || printf ' (none recorded)\n' + render_words_record "$record" + [ "$MANDATE_COUNT" -gt 0 ] || printf ' (no away instructions recorded)\n' + render_words_account else printf ' (no away-posture record for this window; legacy away flag only)\n' fi diff --git a/bin/fm-branch-prompt.sh b/bin/fm-branch-prompt.sh index acf213ccff2..7808e24c662 100755 --- a/bin/fm-branch-prompt.sh +++ b/bin/fm-branch-prompt.sh @@ -99,15 +99,20 @@ The Postures section below is the one, bounded exception to the first three limi You run in one of two postures, and the posture is a file: the away-posture record `state/.afk-contract`, written only by `bin/fm-afk-contract.sh` after the captain confirmed its read-back and archived by the return path on the captain's first ordinary message. Attended (no record): the role limits above apply exactly as written, main-owned rows never reach you, and MAIN processes every captain outcome you report. Away (the record exists): the wake message ends with a `POSTURE: AWAY` tail carrying the record's read-back verbatim; MAIN is parked, you take every row including check rows, decision rows, and heartbeat rows, and captain outcomes remain unprocessed for the return brief even though their visible transcript entries persist. -Under that tail MAIN's standing authority - never more than MAIN could do attended - is relocated to you, and only through the guarded scripts, which enforce it themselves: -- `bin/fm-pr-merge.sh` merges only a task the record grants or whose recorded yolo posture is on, only green at its live head, only synchronously; a red pull request is never merged while away, whatever the captain's words or a clause say, and `--allow-red` is refused under the record. -- `bin/fm-spawn.sh` dispatches only work already queued in the backlog whose blockers and time gates have cleared, and refuses past the record's spend cap; never invent work. -- `bin/fm-send.sh --resolve-key` answers only a finding the ask-user-authority policy included at the end of this prompt lets firstmate decide; a finding it says to escalate is reported with verdict captain and left for the return. +The record is the captain's away words, recorded verbatim: the explicit instruction the captain gave before leaving, and the whole mandate. +No script parses them; you read them at the tail of every wake, decide by your own judgment whether the event in front of you is the moment they name, and act on them only through the guarded scripts under MAIN's standing authority - never more than MAIN could do attended - which enforce what a script can check without reading words: +- `bin/fm-pr-merge.sh`: a merge the words call for proceeds when the pull request is green at its live head, synchronously, under the record lock; which pull request the words meant is your reading, and any green merge is mechanically permitted while the record exists. + A red pull request is never merged while away, whatever the words say, and `--allow-red` is refused under the record: a merge the words want past a red check holds for the return. +- `bin/fm-spawn.sh`: work the words explicitly call for is dispatched within the record's spend cap, from a queued backlog item - one already queued, or one you file yourself for exactly that step under the `backlog` lease, writing its brief intent from the captain's words and a backlog note citing them; filing the item the captain asked for is not inventing work, and anything the words do not call for is. +- `bin/fm-send.sh` and `bin/fm-control.sh`: a run the words say to abort or a worker the words say to steer is steered, as in any posture. +- `bin/fm-send.sh --resolve-key`: a decision the words pre-answer is answered with the captain's own answer, and every other decision only as the ask-user-authority policy at the end of this prompt lets firstmate decide; a finding it says to escalate is reported with verdict captain and left for the return. - `bin/fm-merge-local.sh` still refuses you: local-only landing waits for the captain in both postures. -Hold on doubt: a fork no standing rule covers is reported with verdict captain and left for the return brief, never improvised. -The never-set is absolute for every actor in every posture: credential entry, legal or financial acceptance, an attended prompt, any discard the captain did not name, and any destructive, irreversible, or security-sensitive action are refused whatever a clause says. -A recorded clause is a fact for the return brief, not authority: this release records clauses and does not execute them, so act only on standing authority and the record's explicit merge grants. -A mirrored captain sentence authorizes nothing new once the record exists; only the record and the standing rules do. +Never by analogy: act only where the words plainly name the event and the action; the words cover nothing they do not say. +Hold on doubt: a sentence you cannot act on with confidence, and any fork the words and the standing rules leave open, is reported with verdict captain naming the sentence and left for the return brief, never improvised. +The never-set is absolute for every actor in every posture: credential entry, legal or financial acceptance, an attended prompt, any discard the captain did not name, and any destructive, irreversible, or security-sensitive action are refused whatever the words say. +Log every action taken under the words in that event's outcome summary, opening with "per your away instructions:" and naming the sentence you acted on, so the return brief can account for each one. +The words die at archive: an archived record authorizes nothing, and the return brief is where the captain hears what was done under them. +A mirrored captain sentence authorizes nothing new once the record exists; only the record's words and the standing rules do. # Discipline diff --git a/bin/fm-contributions.jq b/bin/fm-contributions.jq index 3b1748802b9..fc3f9715ab1 100644 --- a/bin/fm-contributions.jq +++ b/bin/fm-contributions.jq @@ -88,7 +88,7 @@ def projected($input; $saved; $now; $max_age): elif $verdict != null and $verdict.actor == "captain" then {actor:"fleet",reason:"record the unresolved arbitration as a captain hold"} elif $o.review_decision == "REVIEW_REQUIRED" then {actor:"maintainer",reason:"review required"} - elif $o.can_merge == true and ($merge_authority == "yolo" or $merge_authority == "away-grant") then + elif $o.can_merge == true and $merge_authority == "away" then {actor:"fleet",reason:"checks green; merge is authorized by delivery posture"} elif $o.can_merge == true then {actor:"captain",reason:"checks green; merge approval needed"} else {actor:"maintainer",reason:"delivery awaits the maintainer"} end) as $action diff --git a/bin/fm-lease-lib.sh b/bin/fm-lease-lib.sh index 8c42a6042b4..00e311f18e5 100755 --- a/bin/fm-lease-lib.sh +++ b/bin/fm-lease-lib.sh @@ -57,10 +57,10 @@ # record exists (bin/fm-afk-contract.sh validate; docs/pi-supervision- # branch.md "Postures"), main is parked and its STANDING authority # relocates to the branch for exactly the actions whose guarded script -# opts in with --away-relocated: the PR merge (its own grant-or-yolo, -# live-head-green, synchronous gate still decides), a fresh spawn of -# already-queued work (its own spend-cap gate still decides), and a -# decision answer (ask-user-authority's judgment still decides). The +# opts in with --away-relocated: a PR merge, a fresh spawn of queued work, +# and a decision answer. Each guarded script keeps its own mechanical gate; +# bin/fm-branch-prompt.sh "Postures" owns how the branch judges the +# captain's away words before invoking one. The # relocation grants nothing beyond what main could do attended: it only # changes which actor may reach the guarded script's own gate. An action # that has no record-side gate of its own - landing local-only work - is diff --git a/bin/fm-merge-authority-lib.sh b/bin/fm-merge-authority-lib.sh index 9dbbadda2b1..b3af34c4e53 100755 --- a/bin/fm-merge-authority-lib.sh +++ b/bin/fm-merge-authority-lib.sh @@ -1,16 +1,22 @@ #!/usr/bin/env bash # Durable ownership of the authority under which a task's merge was accepted. # -# The away-posture record (state/.afk-contract) and the task's recorded yolo -# posture are resolved only at the merge gate. After a forge accepts the merge, -# bin/fm-pr-merge.sh persists that answer as: +# The away-posture record (state/.afk-contract) is resolved only at the merge +# gate. After a forge accepts the merge, bin/fm-pr-merge.sh persists that answer +# as: # state/<task-id>.merge-authority # fm-merge-authority-v1 # <provider> # <host> # <path> # <number> -# <authority> yolo | away-grant | attended +# <authority> away | attended +# While the away-posture record exists every merge runs under away authority +# (the record's presence is the whole mechanical fact; which merge the captain's +# away words meant is the supervision session's reading); without it the merge +# is attended. The retired values yolo and away-grant are still accepted when an +# existing record is read, so a merge persisted before the words model landed is +# still consumed, but they are never written again. # The identity comes from the merge run's immutable canonical URL parse; # persistence revalidates the task's current pr= metadata under its metadata # and lifecycle locks and refuses a mismatch. The file is atomically published, @@ -45,7 +51,6 @@ FM_MERGE_AUTHORITY_RECORD_IDENTITY= fm_merge_authority_resolve() { # <home> <state> <meta> <task-id> local home=${1-} state=${2-} meta=${3-} id=${4-} - local yolo='' grants grant FM_MERGE_AUTHORITY= FM_MERGE_AUTHORITY_REASON='invalid' [ -n "$home" ] && [ -n "$state" ] && [ -n "$meta" ] && [ -n "$id" ] || return 1 @@ -60,30 +65,10 @@ fm_merge_authority_resolve() { # <home> <state> <meta> <task-id> FM_MERGE_AUTHORITY_REASON='record-unreadable' return 1 fi - if [ -f "$meta" ]; then - yolo=$(grep '^yolo=' "$meta" | tail -1 | cut -d= -f2- || true) - fi - if [ "$yolo" = on ]; then - FM_MERGE_AUTHORITY='yolo' - FM_MERGE_AUTHORITY_REASON='granted' - return 0 - fi - grants=$(FM_HOME="$home" FM_STATE_OVERRIDE="$state" \ - "$_FM_MERGE_AUTHORITY_LIB_DIR/fm-afk-contract.sh" grants 2>/dev/null) || { - FM_MERGE_AUTHORITY_REASON='grants-unreadable' - return 1 - } - while IFS= read -r grant; do - [ "$grant" = "$id" ] || continue - FM_MERGE_AUTHORITY='away-grant' - FM_MERGE_AUTHORITY_REASON='granted' - return 0 - done <<EOF -$grants -EOF + FM_MERGE_AUTHORITY='away' # shellcheck disable=SC2034 # Public results consumed by sourcing callers. - FM_MERGE_AUTHORITY_REASON='not-granted' - return 1 + FM_MERGE_AUTHORITY_REASON='away' + return 0 } fm_merge_authority_record_matches() { # <record> <device> <provider> <host> <path> <number> @@ -102,7 +87,7 @@ fm_merge_authority_record_matches() { # <record> <device> <provider> <host> <pa return 1 fi exec 8<&- - case "$authority" in yolo|away-grant|attended) ;; *) return 1 ;; esac + case "$authority" in away|attended|yolo|away-grant) ;; *) return 1 ;; esac [ "$version" = fm-merge-authority-v1 ] \ && [ "$provider" = "$expected_provider" ] \ && [ "$host" = "$expected_host" ] \ @@ -115,7 +100,7 @@ fm_merge_authority_persist() { # <state> <task-id> <meta> <provider> <host> <pa local state=$1 id=$2 meta=$3 provider=$4 host=$5 path=$6 number=$7 authority=$8 local record tmp='' state_device lock status=0 fm_pr_task_id_valid "$id" || return 1 - case "$authority" in yolo|away-grant|attended) ;; *) return 1 ;; esac + case "$authority" in away|attended) ;; *) return 1 ;; esac [ -d "$state" ] && [ ! -L "$state" ] || return 1 state_device=$(fm_pr_file_device "$state") || return 1 fm_pr_metadata_identity_parse "$meta" || return 1 diff --git a/bin/fm-merge-outcome-lib.sh b/bin/fm-merge-outcome-lib.sh index bf11266213b..bcc524cf16d 100755 --- a/bin/fm-merge-outcome-lib.sh +++ b/bin/fm-merge-outcome-lib.sh @@ -41,11 +41,13 @@ FM_MERGE_OUTCOME_ALREADY_RECORDED=false # self - this home performed the merge. # poll - this home's merge poll detected the merge, so the canonical outcome # also wakes this home after any upward hop needed by a secondmate. -# Optional <authority> is yolo, away-grant, attended, or external. Yolo, -# away-grant, and external are appended to the ledger line; attended remains -# untagged. The merge entrypoint supplies its authority after forge acceptance, -# while the poll supplies the persisted identity-bound value or external when -# no matching record proves that this home authorized the merge. +# Optional <authority> is away, attended, or external (the retired yolo and +# away-grant values are still accepted for a persisted authority written before +# the words model landed). Away, external, and the retired tags are appended to +# the ledger line; attended remains untagged. The merge entrypoint supplies its +# authority after forge acceptance, while the poll supplies the persisted +# identity-bound value or external when no matching record proves that this +# home authorized the merge. # # Returns 0 when the outcome is recorded (or already was), 2 on an invalid # request, 3 when this home's own role or parent binding cannot be read well @@ -62,7 +64,7 @@ fm_merge_outcome_report() { # <home> <state> <task-id> <pr-url> <origin> [autho FM_MERGE_OUTCOME_ALREADY_RECORDED=false case "$origin" in self|poll) ;; *) return 2 ;; esac case "$authority" in - yolo|away-grant|external) suffix=" $authority" ;; + away|external|yolo|away-grant) suffix=" $authority" ;; attended|'') ;; *) return 2 ;; esac diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 7c3e5fc072f..da827f810f9 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -70,11 +70,12 @@ # serializes the captain-hold check through the forge command. A still-held or # unreadable row refuses before that command, so a captain approval must be # recorded as an `answer --release` before this entrypoint is invoked. While -# state/.afk-contract exists, a merge for this task also proceeds only if its -# meta yolo=on or its id is in that record's merge-grant list; otherwise it is -# held for the captain return. An unreadable record refuses rather than being -# skipped. Neither posture releases a captain hold, and the grant lapses when -# the record is archived. +# state/.afk-contract exists any green merge may proceed under away authority: +# the record's presence is the whole mechanical fact, and which merge the +# captain's away words meant is the supervision session's reading +# (bin/fm-branch-prompt.sh "Postures"). An unreadable record refuses rather +# than being skipped, neither posture releases a captain hold, and away +# authority lapses when the record is archived. # The authority read and synchronous forge command share the away record's # cross-subsystem lock, which bin/fm-afk-contract.sh owns, closing the common # live-owner TOCTOU; failure to take it refuses before the forge call. Async and @@ -97,7 +98,7 @@ # --remove-source-branch) are refused by default; --attended-override, parsed # before the optional -- separator, re-enables those forge flags for an # explicit captain instruction and never skips the live green check, the -# away-grant check, or a captain hold. +# away-record read, or a captain hold. # # Usage: fm-pr-merge.sh <task-id> <pr-url> [--attended-override] [--allow-red <check-name>] [-- <extra forge merge args>] # @@ -315,8 +316,8 @@ META="$STATE/$ID.meta" # branch reports the green PR and never merges (contract: bin/fm-lease-lib.sh; # no-op in homes without a branch actor). While the away-posture record exists # main is parked and this one action relocates to the branch, which then meets -# exactly the same gates below as main would: a granted or yolo=on task only, -# green at its live head, synchronous, under the record lock. This precedes +# exactly the same gates below as main would: green at its live head, +# synchronous, under the record lock. This precedes # reading the task record, because the wrong actor is refused for its role # whatever that record says. # shellcheck source=bin/fm-lease-lib.sh @@ -890,27 +891,17 @@ require_released_captain_hold() { } FM_PR_MERGE_AUTHORITY= -# The gate on top of the shared authority read. bin/fm-merge-authority-lib.sh -# owns what the away-posture record and the task's recorded yolo posture say; -# this function owns what a merge run may do about it, so the answer the merge -# poll later tags its ledger row with is the same answer gated here. -require_away_merge_grant() { +# The authority read. bin/fm-merge-authority-lib.sh owns what the away-posture +# record's presence means; this function owns what a merge run may do about it, +# so the answer the merge poll later tags its ledger row with is the same answer +# resolved here. An unreadable record refuses rather than being skipped. +resolve_merge_authority() { FM_PR_MERGE_AUTHORITY= if fm_merge_authority_resolve "$FM_HOME" "$STATE" "$META" "$ID"; then FM_PR_MERGE_AUTHORITY=$FM_MERGE_AUTHORITY return 0 fi - case "$FM_MERGE_AUTHORITY_REASON" in - record-unreadable) - echo "error: PR merge refused - the away-posture record could not be read; nothing was merged" >&2 - ;; - grants-unreadable) - echo "error: PR merge refused - the away-posture record's grants could not be read; nothing was merged" >&2 - ;; - *) - echo "error: task $ID is held for the captain return" >&2 - ;; - esac + echo "error: PR merge refused - the away-posture record could not be read; nothing was merged" >&2 return 1 } @@ -942,7 +933,7 @@ require_current_away_authority() { fi fi fm_lease_forbid_branch "PR merge (fm-pr-merge)" --away-relocated - require_away_merge_grant || return 1 + resolve_merge_authority || return 1 if [ "$FM_PR_AWAY_POSTURE" = true ] && [ "${#ALLOW_RED[@]}" -gt 0 ]; then echo "error: --allow-red is attended-only; while the away-posture record exists the green check is absolute" >&2 return 2 @@ -968,8 +959,7 @@ persist_accepted_merge_authority() { # While away, a merge proceeds only when the base branch's rules prove no # merge queue, because a queued merge can land after its away authority -# lapses; this holds regardless of which away authority (a named merge grant -# or a standing yolo=on posture) let the merge run at all. A repository whose +# lapses with the record's archive. A repository whose # plan does not expose branch rules at all (GitHub's "Upgrade to GitHub Pro or # make this repository public" 403) proves that on its own, since such a # repository cannot have a merge_queue rule either; see @@ -982,7 +972,7 @@ refuse_github_queue_while_away() { [ "$FM_PR_AWAY_POSTURE" = true ] || return 0 # Accepted confused-agent-grade limitation, as in bin/fm-lease-lib.sh, not an # oversight: a queue rule or PR base change after this preflight can still - # enqueue the merge, which can land after its away grant lapses. + # enqueue the merge, which can land after its away authority lapses. github_read_queue_method [ "$FM_PR_GITHUB_QUEUE_STATUS" = none ] && return 0 echo "error: GitHub merge refused while away because the base branch's merge-queue state does not prove an immediate merge; nothing was handed to the forge" >&2 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index b1b8608531d..2d9c43dd865 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1378,9 +1378,12 @@ fi # an existing task is legitimate branch recovery (fm-control drives it through # this same entrypoint), so only a fresh spawn refuses the branch actor # (contract: bin/fm-lease-lib.sh; no-op in homes without a branch actor). While -# the away-posture record exists main is parked and a fresh spawn of -# already-queued work relocates to the branch, under the record's spend cap -# below - the same cap main meets in that posture. +# the away-posture record exists main is parked and a fresh spawn of queued +# work relocates to the branch, under the record's spend cap below - the same +# cap main meets in that posture. Queued means a dispatchable backlog item: +# one already queued at entry, or one the branch filed itself because the +# captain's away words explicitly call for that work (its backlog note cites +# the words); filing the item the captain asked for is not inventing work. # shellcheck source=bin/fm-lease-lib.sh . "$SCRIPT_DIR/fm-lease-lib.sh" if [ "$RELAUNCH" -ne 1 ]; then @@ -1427,7 +1430,7 @@ spawn_require_relocated_queued_work() { fi fm_lease_forbid_branch "new-task spawn (fm-spawn)" --away-relocated if ! fm_backlog_row_probe "$DATA" "$ID" || [ "$FM_BACKLOG_ROW_STATE" != "queued no no" ]; then - echo "error: spawn refused - the supervision branch under the away-posture record may dispatch only already-queued unblocked work; task $ID has no dispatchable backlog item in this home" >&2 + echo "error: spawn refused - the supervision branch under the away-posture record may dispatch only queued unblocked work (already queued, or filed by the branch from the captain's away words); task $ID has no dispatchable backlog item in this home" >&2 exit 1 fi } @@ -3095,7 +3098,7 @@ if fm_backlog_transition_applies "$CONFIG" "$DATA" "$KIND"; then spawn_preflight_actor=$(fm_lease_actor) || exit "$FM_LEASE_REFUSE_EXIT" if [ "$spawn_preflight_actor" = branch ] && fm_lease_away_relocated; then if [ "$BACKLOG_ROW_STATE" != "queued no no" ]; then - echo "error: spawn refused - the supervision branch under the away-posture record may dispatch only already-queued unblocked work; task $ID has no dispatchable backlog item in this home" >&2 + echo "error: spawn refused - the supervision branch under the away-posture record may dispatch only queued unblocked work (already queued, or filed by the branch from the captain's away words); task $ID has no dispatchable backlog item in this home" >&2 exit 1 fi elif ! fm_backlog_row_dispatchable "$BACKLOG_ROW_STATE"; then diff --git a/docs/architecture.md b/docs/architecture.md index 7af90ab2e00..08b6a195f8d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -171,13 +171,12 @@ It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns t On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work, a process-event source, a registered custom check, or Relay polling needs supervision and no supervision owner provably holds this home with a fresh beacon, blocking-capable Stop hooks block and nonblocking turn-end integrations force one bounded follow-up. The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). -Away mode is a posture of the one supervision session, recorded in `state/.afk-contract` by `bin/fm-afk-contract.sh` after the captain confirms a read-back of their away words and mandate clauses, and announced at entry as hold-for-return only because no phone channel exists. -The record owner's header is the single owner of the record schema and clause fields, and by the captain's mandate no static parser reads the clause text: the object and precondition are recorded verbatim, structural presence and the verb list are checked, and the coarse best-effort never-set flag can miss spellings including joined compounds such as `oneTimeCode`. -That scan flags a clause without refusing it and is not authoritative; never-set, forbidden-action, and precondition judgment belongs to the supervision session at execution time in phase 4. -Forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, and no recorded clause is authority by itself. -The record's presence is the posture on every harness, `bin/fm-afk-launch.sh` owns entry and exit, and `bin/fm-afk-return.sh` archives the record and renders the return brief (supervisor health first, then the recorded clauses, what waits on the captain, what could not be fixed, what was handled, and cost) from the outcome store, the held set, and the status logs. +Away mode is a posture of the one supervision session, recorded in `state/.afk-contract` by `bin/fm-afk-contract.sh` after the captain confirms a plain-sentence read-back of their away words, and announced at entry as hold-for-return only because no phone channel exists. +The captain's away words are the whole mandate: the record owner's header is the single owner of the record schema, the words are recorded verbatim, and by the captain's mandate no parser, tokenizer, classifier, or grammar reads them anywhere. +The supervision session reads the words at the tail of every wake and acts on them by its own judgment at the moment an event makes them relevant, only through the guarded scripts under standing authority, never by analogy, holding for the return on doubt; `bin/fm-branch-prompt.sh` "Postures" owns those execution rules. +What stays mechanical is exactly what a script can check without reading words: a merge green at its live head under the record lock, synchronous merges only, the spend cap, and the never-set; destructive, irreversible, and security-sensitive actions are never pre-authorizable whatever the words say. +The record's presence is the posture on every harness, `bin/fm-afk-launch.sh` owns entry and exit, and `bin/fm-afk-return.sh` archives the record and renders the return brief (supervisor health first, then the captain's words verbatim with the session's account of every action taken under them, what waits on the captain, what could not be fixed, what was handled, and cost) from the outcome store, the held set, and the status logs. While the record exists neither supervisor rechecks an item held for the captain, and a declared external wait names when it clears with `until` for a condition-aware recheck in both postures that occurs at the declared time or the hours-long `FM_PAUSE_RESURFACE_SECS` bound, whichever comes first. -This release records clauses and does not execute them. On Pi and pi-signed the away daemon is no longer launched: the ordinary supervision session continues under the record with main parked, so the supervision branch takes every actionable wake, captain outcomes accumulate for the return brief, and main's standing authority relocates to the branch through the guarded scripts, each keeping its own gate ([`pi-supervision-branch.md`](pi-supervision-branch.md#postures)); a wake the branch cannot take and a watcher failure still reach main. A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) still extends this for walk-away supervision on the other harnesses: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh` once the record exists, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-wait vocabulary (a `paused:` external wait and a verified `captain-held` transfer alike, through one combined predicate), and status-scan primitives. @@ -356,13 +355,13 @@ PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and an The helper requires a full canonical URL and rejects malformed URLs or repo override flags before recording merge state. A `https://github.com/<owner>/<repo>/pull/<n>` URL requires `gh` and `jq`, is merged only after one live read confirms the pull request is open, not a draft, mergeable, conflict-free, and every unwaived check is green at the current head, then `gh pr merge` binds that verified head with `--match-head-commit`. A check run is green when its current run is green, because GitHub leaves a cancelled run in the rollup beside the passing re-run it triggered when the base branch advanced; `bin/fm-pr-merge.sh`'s `github_checks_not_green` owns the rule, which uses `startedAt` to clear only an older completed check run that a passing run with the same name provably replaced, while unfinished check runs and non-green status contexts stay red. -`--auto`, `--admin`, and branch-deletion flags are refused unless `--attended-override` is passed for an explicit captain instruction; that override never skips the live green check, the away-grant check, or a captain hold. +`--auto`, `--admin`, and branch-deletion flags are refused unless `--attended-override` is passed for an explicit captain instruction; that override never skips the live green check, the away-record read, or a captain hold. An attended `--allow-red <check-name>` may appear once, waives only GitHub checks with that exact name, and is refused while the away-posture record exists. Because away merge authority is read from that record and then acted on by the forge, the authority read and synchronous forge command share the record's cross-subsystem lock, closing the common live-owner TOCTOU. A lock that cannot be taken refuses the merge. While the record exists, GitHub auto-merge and any base whose rules cannot prove the absence of a merge queue are refused before submission, and GitLab auto-merge flags or scheduled state are refused while an immediate merge is forced with a final `--auto-merge=false`; a branch-rules read that fails only because the repository's plan does not expose branch rules at all (GitHub's plan-upgrade 403) proves the absence of a merge queue on its own and does not refuse, while every other failure to read that state still does. This is deliberately confused-agent-grade, as `bin/fm-lease-lib.sh` defines that grade, rather than fully atomic. -A GitHub queue-rule or PR-base change after the queue-free preflight can still enqueue a merge that lands after its away grant lapses, and killing the lock-owning shell while its forge child survives lets stale-owner recovery admit archive or replacement before that child completes. +A GitHub queue-rule or PR-base change after the queue-free preflight can still enqueue a merge that lands after its away authority lapses, and killing the lock-owning shell while its forge child survives lets stale-owner recovery admit archive or replacement before that child completes. These are accepted limitations, not oversights; durable authority, landing re-verification, and child-lock handoff are outside this boundary. `bin/fm-afk-contract.sh` owns the lock contract, while `tests/fm-afk-contract.test.sh` and `tests/fm-pr-merge.test.sh` pin the serialization and fail-closed merge behavior. A `https://<host>/<path>/-/merge_requests/<n>` URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) invokes `glab mr merge <n> -R https://<host>/<path>`, so the instance comes from the URL, and adds no merge-method flag because the project's own merge method applies. @@ -375,7 +374,7 @@ An auto-merge request is held to the same standard: `--auto` that leaves the pul Every GitHub refusal states what it could not observe as plainly as what it did, so an unreadable branch-rule response, an unrecognised queue method, and a merge queue no available read can see are each named rather than left to look like a base branch with no queue at all. A confirmed merge leaves a durable role-routed outcome instead of living only in the merging agent's memory, and [`bin/fm-merge-outcome-lib.sh`](../bin/fm-merge-outcome-lib.sh)'s header owns its destination, shape, identity, normal-case deduplication, and at-least-once recovery. The same emitter handles a merge firstmate performed and one its poll detected, while the watcher immediately delivers the emitter's local actionable poll row. -After the forge accepts firstmate's merge request, the merge path persists the resolved yolo, away-grant, or attended authority bound to the task's canonical PR identity. +After the forge accepts firstmate's merge request, the merge path persists the resolved away or attended authority bound to the task's canonical PR identity; while the away-posture record exists any green merge runs under away authority, and which merge the captain's words meant is the supervision session's reading. A later merged poll consumes only that matching persisted value; with no match it records the landing as external rather than consulting a live away-posture record that may have been archived or replaced. [`bin/fm-merge-authority-lib.sh`](../bin/fm-merge-authority-lib.sh)'s header owns resolution, private atomic persistence, identity-checked consumption, and retirement, while only the merge path gates on the answer. Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned. diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 97b354a56dc..b45ab7ea493 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -164,24 +164,25 @@ While the record exists: A prompt that claims a check row is not scoped by task, so the branch may report it as `fleet`. - Main is parked, and reachable only for the classes only main can act on: a watcher-failure alarm is delivered to main as always, because `fm_watch_arm_pi` lives there, and a wake the branch declines or cannot take (a broken branch inside its cooldown, an unresolvable or corrupt scan) falls back to main exactly as attended. Parking is a cost and chat-cleanliness measure; supervision continuity is the safety property, and the return brief's health section reads any gap. -- The wake message ends with a fixed `POSTURE: AWAY` tail plus the record's read-back verbatim (`bin/fm-afk-contract.sh readback`), so the branch knows the posture, the merge grants, the spend cap, and the recorded clauses at execution time without any prefix change. +- The wake message ends with a fixed `POSTURE: AWAY` tail plus the record's read-back verbatim (`bin/fm-afk-contract.sh readback`), so the branch has the captain's away words, the spend cap, the expected return, and the reach line in front of it at execution time without any prefix change. - Captain-verdict outcomes accumulate unprocessed in the outcome store. Their visible entries still persist, but no processing turn opens on the parked main: the request is re-checked against the record immediately before it would open and at every run boundary, so a request pending when the record appears is cancelled rather than delivered. The first run boundary after the record is archived, ordinarily the captain's return message, presents the accumulated rows with a fresh triggered budget exactly as after any other gap, and `bin/fm-afk-return.sh` lists them under "waiting on you". - Main's standing authority relocates to the branch, and nothing more. `fm_lease_forbid_branch` passes the branch actor only for the actions whose guarded script opts in, and only while `bin/fm-afk-contract.sh validate` succeeds on a confirmed, readable, live record; an archived, unconfirmed, or invalid record restores the attended refusal byte for byte. - Each relocated script keeps its own gate: `bin/fm-pr-merge.sh` merges only a task the record grants or whose recorded yolo posture is on, only green at its live head, synchronously, under the record lock, and refuses `--allow-red` while away, so the green gate is absolute in this posture; `bin/fm-spawn.sh` dispatches only already-queued work whose blockers cleared and refuses a fresh ordinary spawn for either actor once the home holds as many ordinary task records as the record's spend cap (relaunches and secondmates exempt); `bin/fm-send.sh --resolve-key` answers a decision only under `ask-user-authority`'s judgment, which the branch prompt carries verbatim; `bin/fm-merge-local.sh` is never relocated. - The merge-authority record and the outcome row's summary are the audit trail. + The captain's away words are the whole mandate: the branch reads them at the tail, decides by its own judgment whether the event in front of it is the moment they name, acts on them only through the guarded scripts, never by analogy, and holds with verdict captain on doubt; `bin/fm-branch-prompt.sh` "Postures" owns those execution rules and requires every action taken under the words to open its outcome summary with "per your away instructions:". + Each relocated script keeps its own gate, enforcing exactly what a script can check without reading words: `bin/fm-pr-merge.sh` merges any pull request green at its live head, synchronously, under the record lock, and refuses `--allow-red` while away, so the green gate is absolute in this posture and which pull request the words meant is the branch's reading; `bin/fm-spawn.sh` dispatches only queued work whose blockers cleared - already queued, or filed by the branch because the words explicitly call for it - and refuses a fresh ordinary spawn for either actor once the home holds as many ordinary task records as the record's spend cap (relaunches and secondmates exempt); `bin/fm-send.sh --resolve-key` answers a decision the words pre-answer, or one `ask-user-authority`'s judgment (carried verbatim in the branch prompt) lets firstmate decide; `bin/fm-merge-local.sh` is never relocated. + The merge-authority record and the outcome row's summary are the audit trail, and the return brief renders the words verbatim beside that account. - The branch prompt's fixed "Postures" section states these rules once per firstmate version, so the prefix stays byte-stable; the per-wake tail is the only dynamic content. The authority invariant, pinned by `tests/fm-branch-supervision.test.sh`, `tests/fm-pr-merge.test.sh`, and `tests/fm-send-resolve-key.test.sh`: being away changes how the captain is informed and what happens at a captain-owned decision point, never firstmate's authority set. -The never-set (credential entry, legal or financial acceptance, an attended prompt, an unnamed discard, a security-sensitive action) has no guarded entrypoint that accepts away authority for either actor, a forced teardown stays refused for the branch, a red merge is refused in this posture, a recorded clause is a fact for the return brief rather than authority in this release, and no relocation survives the return, because an archived record validates as absent. +The never-set (credential entry, legal or financial acceptance, an attended prompt, an unnamed discard, a security-sensitive action) has no guarded entrypoint that accepts away authority for either actor, a forced teardown stays refused for the branch, a red merge is refused in this posture whatever the words say, and no relocation survives the return, because an archived record validates as absent and the words die with it. ## Verification Portable regressions: `tests/fm-pi-branch-extension.test.sh` covers dispatch, signal and stale report scoping with unscoped heartbeat reports, the new branch conversation at every main session start with continuation inside one session, the mirror re-anchor that pairs with it, requested-versus-unsolicited delivery, exact visible entry content, no unkeyed model turn, the sequence-keyed processing request and its acknowledgement, re-presentation after an empty reply and after an unrelated prior answer, the triggered-then-next-turn pacing, session-start re-presentation, routine outcomes staying turn-free, the processed-marker migration, idle and busy main state, incident-shaped compaction and unrelated-assistant context, cold-start post-lock recovery, crash-before-cursor reload recovery, repeated-reload idempotency, mirroring, post-construction provider-error and no-report fallback, the consecutive-error latch, cooldown probe, exponential backoff, report-plus-settlement recovery, report-before-error re-latch, cache key, model and effort selection, and (in `test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot`) decision-owned signal and stale rows' exclusion from `eligibleSeqs`, their presence in `needsDecisionKeys`, task alias resolution, reserved-key configuration, status-log race and symlink refusal, non-vetoing behavior for unrelated eligible rows, and decision-only queues reading as ordinary main-only absence. `tests/fm-branch-supervision.test.sh` covers prompt stability, store append-only behavior, the captain cursor barrier, the processed marker's sequence bounds, leases, guards, non-branch-home invariance, and the away relocation (only under a confirmed live record, never for local-only landing, queued-only branch dispatch rather than orphaned in-flight recovery, the spend cap for both actors and its lock-held recheck, and the attended guarded-action behavior restored by archive or an invalid record). -`tests/fm-pr-merge.test.sh` covers the branch actor merging a granted task under the record, being held without a grant, and being refused at the partition while attended; `tests/fm-send-resolve-key.test.sh` covers the decision-answer partition (a needs-decision or captain-held key refuses the attended branch before anything is sent, a `blocked:` key stays ordinary steering, and the record relocates the answer). +`tests/fm-pr-merge.test.sh` covers the branch actor merging a green task under the record, being refused on a red check or `--allow-red` under it, and being refused at the partition while attended; `tests/fm-send-resolve-key.test.sh` covers the decision-answer partition (a needs-decision or captain-held key refuses the attended branch before anything is sent, a `blocked:` key stays ordinary steering, and the record relocates the answer). `tests/fm-pi-watch-extension.test.sh` covers the away eligibility collapse (check-kind and decision-owned triggers offered) with the broken-queue vetoes and the watcher-failure alarm still reaching main, and `tests/fm-pi-branch-extension.test.sh` covers the posture tail with the verbatim read-back, the unscoped claim of check and heartbeat rows, no processing turn under the record, cancellation of a request pending when the record appears, and the re-presentation at the first run boundary after archive. `tests/fm-wake-drain-outcome-backstop.test.sh` covers keyless resurfacing, causal suppression, same-second ordering, one-shot presentation, first-drain index self-healing under the outcome lock, store-fault fail-closed behavior, bounded history cost and output, and the oversized-line limit. `tests/fm-teardown.test.sh` covers removal of the retired task's outcome index and the append-side rule that a post-teardown report does not recreate it. diff --git a/docs/scripts.md b/docs/scripts.md index a03683f16df..208ccbb0564 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -87,7 +87,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-watch-checkpoint.sh` | Run one bounded foreground watcher checkpoint for Codex-style supervision | | `fm-watch.sh` | Singleton-safe watcher: absorb benign wakes, detect stalled local-secondmate wake queues, and exit on actionable ones | | `fm-inactive-reconcile.sh` | Reconcile long-inactive direct crewmate terminal outcomes without forge access | -| `fm-afk-contract.sh` | Own the away-posture record: schema, mandate-clause fields and never-set scan, refusal naming the missing part, read-back, entry announcement, archive, and cross-subsystem authority lock | +| `fm-afk-contract.sh` | Own the away-posture record: schema, the captain's away words verbatim, read-back, entry announcement, archive, and cross-subsystem authority lock | | `fm-afk-start.sh` | Run the common sourceable away-mode daemon entry in the foreground | | `fm-afk-launch.sh` | Own away-mode entry (read-back, confirm, record), exit, rollback, and any backend terminal lifecycle | | `fm-afk-return.sh` | Own deterministic return shutdown, the return brief, catch-up evidence, and the firstmate-actionable blocker gate | diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 2ec6d91f5b7..21c85b74537 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -2033,6 +2033,36 @@ Every record read in those regressions ultimately goes through the real `bin/fm- Against the installed 0.81.1 package the typecheck reports a pre-existing `ModelsRefreshOptions.providers` mismatch in the branch's provider-registration path that this change does not touch; the option exists from the 0.84 line on, which is why the typecheck evidence uses the newer package as the earlier entries do. The real Pi/Herdr return guard (`FM_AFK_PI_HERDR_E2E=1 tests/fm-afk-pi-herdr-return-e2e.test.sh`) remains the owner of the live return-brief proof; it loads no supervision extension into its synthetic primary and does not yet exercise the parked-main scenario, which is a follow-up for a Herdr-lab-guarded task. +### 2026-09-20 the away words execute + +The away-record owner, launch, return, merge, branch-supervision, contributions, merge-poll security, and Pi branch extension suites were run on macOS 26.6.2 arm64 (Darwin 25.6.0), Node v24.14.1, after the away record became the captain's words alone (version 2, with version 1 still readable) and the per-task merge-grant list retired. +No model was selected or prompted, no provider call was made, and the captain's own Pi session was not changed. +The 2026-09-18 entry above records the retired grant model's merge matrix; the lines below supersede it for the merge gate. + +```sh +bin/fm-test-run.sh tests/fm-afk-contract.test.sh tests/fm-afk-launch.test.sh tests/fm-afk-return.test.sh tests/fm-pr-merge.test.sh tests/fm-branch-supervision.test.sh tests/fm-contributions.test.sh tests/fm-pr-check-security.test.sh tests/fm-pi-branch-extension.test.sh +``` + +```text +ok - the read-back renders the words verbatim beside the expected return, spend cap, and reach line +ok - propose then confirm writes a version 2 record, announces hold-for-return only, and every read subcommand reflects it +ok - retired clause fields, --grant, and the clause and grant subcommands are refused by name +ok - a version 1 record validates, reads its words and scalars with the clause and grant sections ignored, refreshes untouched, and archives +ok - new words over a live version 1 record archive it and write version 2 with the same session start +ok - propose: the retired --grant flag is refused by name +ok - the return brief renders health, the words with the session account, waiting, could-not-fix, handled, and cost from durable records, and the gate shrinks to what the away session could not fix +ok - while the away-posture record exists any green merge lands under away authority, yolo or not, and attended merges stay untagged +ok - under the away-posture record the branch merges a green task, is refused on a red check with or without --allow-red, and is refused at the partition while attended +ok - the away record does not bypass red checks, and a recorded pr= must match the URL +ok - no away-record archive or replacement lands between the authority read and the merge +ok - a record made unreadable before the merge's own authority read refuses the merge +ok - queued merges retain their away authority after captain return +ok - branch prompt is byte-stable across homes, cwd, timezone, and time, above the cache floor +ok - under the away-posture record the wake carries the verbatim read-back tail, claims every row, opens no processing turn, cancels a pending request, and presents the accumulated rows after archive +``` + +The runner reported exit 0 with 337 passing lines across the eight scripts; the merge suite (about 227 s) and the security suite dominate the wall time. + ## Native Codex through Pi Verified on 2026-09-08 with Pi 0.85.1 and the installed `pi-codex-native` 0.2.1 adapter. diff --git a/tests/fm-afk-contract.test.sh b/tests/fm-afk-contract.test.sh index 5ccacb6b58e..6598b37862f 100755 --- a/tests/fm-afk-contract.test.sh +++ b/tests/fm-afk-contract.test.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # tests/fm-afk-contract.test.sh - the away-posture record owner -# (bin/fm-afk-contract.sh): the mandate-clause fields, the structural refusal -# naming the missing part, the never-set scan, the read-back rendering, the entry announcement (hold-for- -# return only), the propose/confirm lifecycle with verbatim words, the refresh -# and replace rules, the archive at return, and the read subcommands every -# consumer uses instead of parsing the file. +# (bin/fm-afk-contract.sh): the captain's away words recorded verbatim as the +# whole mandate, the read-back rendering, the entry announcement (hold-for- +# return only), the propose/confirm lifecycle, the refresh and replace rules, +# the archive at return, the version 2 record with version 1 still readable, +# the retired clause and merge-grant apparatus refusing by name, and the read +# subcommands every consumer uses instead of parsing the file. set -u # shellcheck source=tests/lib.sh @@ -25,203 +26,64 @@ contract() { # <home> <args...> FM_HOME="$home" FM_STATE_OVERRIDE="$home/state" "$CONTRACT" "$@" } -# compile_refusal <expected-missing-fragment> <label> <field flags...> -compile_refusal() { - local expected=$1 label=$2 home out rc - shift 2 - home=$(make_home "refuse-$RANDOM-$$") - set +e - out=$(contract "$home" propose "$@" 2>&1) - rc=$? - set -e - [ "$rc" -eq 3 ] || fail "$label: expected exit 3 for a refused clause, got $rc: $out" - assert_contains "$out" "refused: missing $expected" "$label: the refusal did not name the missing part" - assert_contains "$out" ' (none)' "$label: a refused-only proposal should list no accepted clause" -} - -# compile_accept <expected-readback-line> <label> <field flags...> -compile_accept() { - local expected=$1 label=$2 home out rc - shift 2 - home=$(make_home "accept-$RANDOM-$$") - set +e - out=$(contract "$home" propose "$@" 2>&1) - rc=$? - set -e - [ "$rc" -eq 0 ] || fail "$label: expected exit 0 for an accepted clause, got $rc: $out" - assert_contains "$out" "$expected" "$label: the accepted clause was not read back as given" -} - -# compile_flagged <concept> <label> <field flags...>: the clause is recorded -# (exit 0, listed as accepted) and carries the best-effort never-set flag. -compile_flagged() { - local concept=$1 label=$2 home out rc - shift 2 - home=$(make_home "flag-$RANDOM-$$") - set +e - out=$(contract "$home" propose "$@" 2>&1) - rc=$? - set -e - [ "$rc" -eq 0 ] || fail "$label: a flagged clause must still be recorded (exit 0), got $rc: $out" - assert_contains "$out" "flagged: names '$concept', a never-set concept that is never pre-authorizable; recorded, judged at execution" "$label: the read-back did not show the flag" - assert_not_contains "$out" 'refused: missing object' "$label: a never-set match must flag, never refuse" - [ "$(contract "$home" flags --proposal | cut -f2)" = "$concept" ] || fail "$label: flags did not name the concept: $(contract "$home" flags --proposal)" -} - -# compile_unflagged <label> <field flags...>: an ordinary name is neither -# refused nor flagged. -compile_unflagged() { - local label=$1 home out rc - shift - home=$(make_home "plain-$RANDOM-$$") - set +e - out=$(contract "$home" propose "$@" 2>&1) - rc=$? - set -e - [ "$rc" -eq 0 ] || fail "$label: an ordinary clause was refused: $out" - assert_not_contains "$out" 'flagged:' "$label: an ordinary name was flagged" - [ -z "$(contract "$home" flags --proposal)" ] || fail "$label: flags listed an ordinary clause" +# A confirmed record in the retired version 1 shape, exactly as the clause +# model wrote it: scalar fields, a merge-grant list, the words block, then the +# clauses and refused sections. A live away window may still hold one of these +# when this version lands, so it must validate, read, and archive unchanged. +write_v1_record() { # <home> <words-line> + local home=$1 words=$2 + cat > "$home/state/.afk-contract" <<EOF +version: 1 +entered: 2026-09-20T01:00:00Z +entered_epoch: 1789600000 +expected_return: 2026-09-20T09:00:00Z +reach_channels: none +reach_announced: No phone channel is configured; anything that needs you waits for your return. +spend_max_concurrent_workers: 3 +merge_grants: + - task-x1 +confirmed: 2026-09-20T01:00:05Z +confirmed_epoch: 1789600005 +words: |- + $words +clauses: + - id: 1 + action: merge + object: e:task x1 PR + when: e:checks green + stop: - + flag: - +refused: + - id: 2 + text: e:action=merge object=everything when=(none) + missing: when - the clause states no precondition +EOF } -# The structural check refuses only a missing field or an unlisted verb, and -# names the missing part every time. -test_fields_refuse_each_missing_part_by_name() { - compile_refusal "action - 'fix' is not a mandate verb" 'unknown verb' --action fix --object 'whatever breaks' --when 'it breaks' - compile_refusal 'action - the clause names no action' 'empty verb' --action '' --object 'task x PR' --when 'checks green' - compile_refusal 'object - the clause names no thing to act on' 'no object' --action merge --when 'checks green' - compile_refusal 'object - the clause names no thing to act on' 'blank object' --action merge --object ' ' --when 'checks green' - compile_refusal 'when - the clause states no precondition' 'no when' --action merge --object 'task x PR' - compile_refusal 'when - the clause states no precondition' 'blank when' --action merge --object 'task x PR' --when ' ' - compile_refusal 'stop - --stop was given with no text' 'blank explicit stop' --action merge --object 'task x PR' --when 'checks green' --stop ' ' - pass "structural refusals name their missing part" -} - -test_omitted_stop_confirms_as_no_stop() { - local home row out - home=$(make_home omitted-stop) - contract "$home" propose --action merge --object 'task x PR' --when 'checks green' >/dev/null || fail "proposal without stop failed" - row=$(contract "$home" clauses --proposal) - [ "$(printf '%s' "$row" | cut -f5)" = - ] || fail "an omitted stop was not serialized as the no-stop marker" - out=$(contract "$home" confirm 2>&1) || fail "confirmation without stop failed: $out" - assert_contains "$out" '1 mandate clause(s) recorded, 0 refused' "confirmation did not accept the omitted stop" - pass "an omitted stop uses the no-stop marker and confirms" -} - -# The never-set is a coarse best-effort flag: a listed concept, exact or plainly -# inflected, across punctuation boundaries, flags the clause without refusing it; -# an unrelated name never matches; joined compounds are a documented miss. -test_never_set_flags_without_refusing_and_never_over_matches() { - compile_flagged credential 'credentials' --action answer --object 'the credential prompt on task q' --when asked - compile_flagged legal 'legal' --action answer --object 'the legal acceptance on task q' --when asked - compile_flagged 'attended prompt' 'attended prompt' --action answer --object 'the attended prompt on task q' --when asked - compile_flagged credential 'credential compound' --action answer --object 'task q credential-prompt' --when 'prompt starts' - compile_flagged credential 'credential plural with punctuation' --action answer --object 'task q credentials/keys' --when 'prompt starts' - compile_flagged 'attended prompt' 'attended plural compound' --action answer --object 'task q attended-prompts' --when 'it appears' - compile_flagged payment 'payment plural' --action answer --object 'task q payments' --when 'prompt starts' - compile_flagged 'one time code' 'one-time code' --action answer --object 'task q one-time-code prompt' --when 'it appears' - compile_flagged 'one time code' 'one-time codes plural' --action answer --object 'task q one-time-codes prompt' --when 'it appears' - compile_flagged 'api key' 'api keys plural' --action answer --object 'task q api-keys prompt' --when 'it appears' - compile_flagged login 'in the precondition' --action merge --object 'task x PR' --when 'after the Login/2FA prompt clears' - compile_flagged password 'in the stop' --action merge --object 'task x PR' --when 'checks green' --stop 'if a PASSWORD is asked' - compile_unflagged 'ping-service is not pin' --action merge --object 'task ping-service PR' --when 'checks green' - compile_unflagged 'tokenize-worker is not token' --action rerun --object 'task tokenize-worker' --when 'after clause 1' - compile_unflagged 'pinned is not pin' --action merge --object 'task pinned-deps PR' --when 'checks green' - compile_unflagged 'legally is not legal' --action rerun --object 'task legally-named' --when 'after clause 1' - compile_unflagged 'joined compound is a documented miss' --action answer --object 'task q oneTimeCode prompt' --when 'it appears' - pass "the never-set flags listed concepts and their inflections without refusing, and never fires on unrelated names" -} - -# No parser reads the object or precondition: any text the captain gives is -# recorded verbatim, including wording a grammar would have judged. -test_fields_record_the_captain_wording_verbatim() { - compile_accept '1. merge task nm-windows-fix-r1 PR when checks green' 'green merge' \ - --action merge --object 'task nm-windows-fix-r1 PR' --when 'checks green' - compile_accept "1. merge task x's PR when checks green" 'possessive PR role' \ - --action merge --object "task x's PR" --when 'checks green' - compile_accept '1. merge task y PR when red on nm-ci-windows' 'red merge with the failing check named' \ - --action Merge --object 'task y PR' --when 'red on nm-ci-windows' - compile_accept '1. merge task y PR when even if nm-ci-windows is red stop the captain returns' 'stop field' \ - --action merge --object 'task y PR' --when 'even if nm-ci-windows is red' --stop 'the captain returns' - compile_accept '1. abort-run no-mistakes run for task nm-ci-windows-git-shard-split-r1 when install deadlocks' 'named event' \ - --action abort-run --object 'no-mistakes run for task nm-ci-windows-git-shard-split-r1' --when 'install deadlocks' - compile_accept '1. wake-me task fix-windows when at 2026-09-08T08:00Z' 'time precondition' \ - --action wake-me --object 'task fix-windows' --when 'at 2026-09-08T08:00Z' - compile_accept '1. discard the worktree of task w when its rerun fails twice' 'named discard' \ - --action discard --object 'the worktree of task w' --when 'its rerun fails twice' - compile_accept '1. merge task x PR when looks red enough, honestly' 'wording is recorded, never judged' \ - --action merge --object 'task x PR' --when 'looks red enough, honestly' - compile_accept '1. dispatch these queued items when the windows lane is green' 'dispatch' \ - --action dispatch --object 'these queued items' --when 'the windows lane is green' - pass "clause fields are recorded verbatim, and no static parser judges the wording" -} - -test_clause_fields_round_trip_reversible_whitespace() { - local home rows out object when stop expected - home=$(make_home clause-whitespace) - object='task x PR' - when=$'checks\tgreen\nthen done' - stop=$'stop\\literal\n' - contract "$home" propose --action merge --object "$object" --when "$when" --stop "$stop" >/dev/null \ - || fail "proposal with whitespace-bearing clause fields failed" - rows=$(contract "$home" clauses --proposal) - expected=$(printf '1\tmerge\ttask x PR\tchecks\\tgreen\\nthen done\tstop\\\\literal\\n') - [ "$rows" = "$expected" ] || fail "clause TSV did not reversibly preserve whitespace: $rows" - out=$(contract "$home" readback --proposal; printf x) - out=${out%x} - assert_contains "$out" $'1. merge task x PR when checks\tgreen\nthen done stop stop\\literal' \ - "read-back did not render clause fields verbatim" - pass "clause fields preserve repeated spaces, tabs, newlines, and backslashes" -} - -test_clause_ids_are_input_ordinals_across_accepted_and_refused() { - local home out rc - home=$(make_home ordinals) - set +e - out=$(contract "$home" propose \ - --action merge --object 'task a PR' --when 'checks green' \ - --action merge --object regardless \ - --action prerelease --object 'repo r' --when 'after clause 1' \ - --action install --object 'the prerelease on mini' --when 'after clause 3' \ - --action rerun --object 'task t' --when 'after clause 4' 2>&1) - rc=$? - set -e - [ "$rc" -eq 3 ] || fail "a mixed proposal should exit 3 (rc=$rc): $out" - assert_contains "$out" '1. merge task a PR when checks green' 'clause 1 accepted' - assert_contains "$out" '2. "action=merge object=regardless when=(none)" - refused: missing when - the clause states no precondition' 'clause 2 refused for its missing precondition' - assert_contains "$out" '3. prerelease repo r when after clause 1' 'clause 3 keeps its input ordinal' - assert_contains "$out" '4. install the prerelease on mini when after clause 3' 'clause 4 keeps its input ordinal' - assert_contains "$out" '5. rerun task t when after clause 4' 'clause 5 keeps its input ordinal' - [ "$(contract "$home" propose --action merge --object 'task a PR' --when 'checks green' --action merge --object regardless --action rerun --object 'task t' --when 'after clause 1' 2>/dev/null | grep -c '^ [0-9]')" -eq 3 ] \ - || fail "the read-back did not list every clause once" - pass "clause ids are input ordinals across accepted and refused clauses" -} - -test_readback_renders_words_verbatim_and_both_lists() { +# No parser reads the words: any text the captain gives is recorded verbatim, +# including wording a grammar would have judged, and the read-back mirrors it. +test_readback_renders_words_verbatim_with_the_record_scalars() { local home out words home=$(make_home readback) words="$home/words.txt" - printf 'drive the windows fix to green and merge it,\n cut a prerelease; then re-run "nm-ci-windows"\n\tif the install deadlocks abort the competing pipeline\n' > "$words" - out=$(contract "$home" propose --words-file "$words" --expected-return 2026-09-08T08:00Z --spend 3 \ - --action merge --object 'task nm-windows-fix-r1 PR' --when 'checks green' \ - --action merge --object 'regardless of checks' 2>&1) || true + printf 'drive the windows fix to green and merge it,\n cut a prerelease; then re-run "nm-ci-windows"\n\tif the install deadlocks abort the competing pipeline\nmerge task y even if nm-ci-windows looks red enough, honestly\n' > "$words" + out=$(contract "$home" propose --words-file "$words" --expected-return 2026-09-08T08:00Z --spend 3 2>&1) \ + || fail "proposal with words failed: $out" assert_contains "$out" 'Away posture read-back (proposed, not yet confirmed):' 'read-back title' assert_contains "$out" 'expected return: 2026-09-08T08:00Z' 'expected return rendered' assert_contains "$out" 'spend cap: 3 concurrent workers' 'spend cap rendered' assert_contains "$out" 'reach: hold-for-return only. No phone channel is configured; anything that needs you waits for your return.' 'reach rendered' + assert_contains "$out" ' your words (verbatim):' 'words header' assert_contains "$out" ' drive the windows fix to green and merge it,' 'words line 1' assert_contains "$out" ' cut a prerelease; then re-run "nm-ci-windows"' 'words line 2 keeps its own indentation and quotes' assert_contains "$out" "$(printf ' \tif the install deadlocks')" 'words line 3 keeps its tab' - assert_contains "$out" ' accepted clauses:' 'accepted list header' - assert_contains "$out" ' 1. merge task nm-windows-fix-r1 PR when checks green' 'accepted clause' - assert_contains "$out" ' refused clauses:' 'refused list header' - assert_contains "$out" ' 2. "action=merge object=regardless of checks when=(none)" - refused: missing when' 'refused clause' - assert_contains "$out" 'every clause expires at return' 'the never-set reminder' - assert_contains "$out" 'recorded clauses are held for the return brief and are not executed by this release' 'the not-executed notice' - assert_contains "$out" 'forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text; no recorded clause is authority by itself' 'the hard authority invariant' + assert_contains "$out" ' merge task y even if nm-ci-windows looks red enough, honestly' 'wording is recorded, never judged' assert_contains "$out" 'Say go to confirm' 'confirmation prompt' + assert_not_contains "$out" 'clause' 'the read-back must carry no clause apparatus' + assert_not_contains "$out" 'task ids' 'the read-back must carry no merge-grant list' # The verbatim words survive the record byte for byte, trailing newline included. [ "$(contract "$home" words --proposal; printf x)" = "$(cat "$words"; printf x)" ] || fail "the proposal did not keep the words verbatim" - pass "the read-back renders the words verbatim beside the accepted and refused lists" + pass "the read-back renders the words verbatim beside the expected return, spend cap, and reach line" } test_words_preserve_final_newline_shape() { @@ -241,16 +103,17 @@ test_words_preserve_final_newline_shape() { || fail "words with a final newline did not round-trip byte-exact" out=$(contract "$home" propose --words-file "$trailing"; printf x) || fail "proposal with trailing blank lines failed" out=${out%x} - assert_contains "$out" $' first line\n \n accepted clauses:' \ + assert_contains "$out" $' first line\n \nSay go to confirm' \ "read-back dropped a trailing blank line from the captain's words" + [ "$(contract "$home" words --proposal; printf x)" = "$(cat "$trailing"; printf x)" ] \ + || fail "trailing blank lines did not round-trip byte-exact" pass "words preserve their final newline shape in storage and read-back" } -test_propose_confirm_writes_the_record_and_announces_hold_for_return() { +test_propose_confirm_writes_a_v2_record_and_announces_hold_for_return() { local home out record proposed_epoch home=$(make_home lifecycle) - contract "$home" propose --words 'merge it when green' --action merge --object 'task a PR' --when 'checks green' \ - --action merge --object everything >/dev/null 2>&1 || true + contract "$home" propose --words 'merge it when green' >/dev/null || fail "propose failed" [ -f "$home/state/.afk-contract.proposed" ] || fail "propose did not write the proposal" proposed_epoch=$(contract "$home" field entered_epoch --proposal) [ ! -f "$home/state/.afk-contract" ] || fail "a proposal alone must not count as the posture" @@ -259,20 +122,26 @@ test_propose_confirm_writes_the_record_and_announces_hold_for_return() { record="$home/state/.afk-contract" [ -f "$record" ] || fail "confirm did not write the record" [ ! -f "$home/state/.afk-contract.proposed" ] || fail "confirm left the proposal behind" - [ -f "$record" ] || fail "the confirmed posture record is absent" assert_contains "$out" 'Away posture confirmed at ' 'announcement opens with the confirmation time' assert_contains "$out" 'hold-for-return only. No phone channel is configured; anything that needs you waits for your return.' 'announcement says hold-for-return only, aloud' - assert_contains "$out" '1 mandate clause(s) recorded, 1 refused, and 0 flagged as naming a never-set concept; recorded clauses are held for the return brief and are not executed by this release; forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, and no recorded clause is authority by itself.' 'announcement counts clauses and states the hard authority invariant' + assert_contains "$out" 'Your away instructions are recorded verbatim; the away session will carry them out where it can, and anything it is unsure of, or that needs you, waits for your return.' 'announcement says the words will be carried out' + assert_contains "$out" 'Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say.' 'announcement states the never-set' assert_contains "$out" 'Expected return: not given. Spend cap: 4 concurrent workers.' 'announcement carries the defaults' - [ "$(contract "$home" field version)" = 1 ] || fail "record version is not 1" + assert_not_contains "$out" 'not executed' 'the announcement must not call the words inert' + assert_not_contains "$out" 'clause' 'the announcement must carry no clause apparatus' + [ "$(contract "$home" field version)" = 2 ] || fail "record version is not 2: $(contract "$home" field version)" [ "$(contract "$home" field reach_channels)" = none ] || fail "reach channels are not none" case "$(contract "$home" field confirmed_epoch)" in ''|*[!0-9]*) fail "confirmed_epoch is not numeric" ;; esac case "$(contract "$home" field entered_epoch)" in ''|*[!0-9]*) fail "entered_epoch is not numeric" ;; esac [ "$(contract "$home" field entered_epoch)" -gt "$proposed_epoch" ] || fail "entry time was not stamped at confirmation" [ "$(contract "$home" words)" = 'merge it when green' ] || fail "words did not round-trip" - [ "$(contract "$home" clauses)" = "$(printf '1\tmerge\ttask a PR\tchecks green\t-')" ] || fail "clauses TSV is wrong: $(contract "$home" clauses)" - [ "$(contract "$home" refused | cut -f1,2)" = "$(printf '2\taction=merge object=everything when=(none)')" ] || fail "refused TSV is wrong: $(contract "$home" refused)" - pass "propose then confirm writes the record, announces hold-for-return only, and every read subcommand reflects it" + [ -z "$(contract "$home" field merge_grants)" ] || fail "a version 2 record carries a merge_grants field" + [ -z "$(contract "$home" field clauses)" ] || fail "a version 2 record carries a clauses section" + contract "$home" validate || fail "the confirmed record does not validate" + out=$(contract "$home" readback) || fail "readback of the confirmed record failed" + assert_contains "$out" 'Away posture (confirmed):' 'confirmed read-back title' + assert_contains "$out" ' merge it when green' 'confirmed read-back carries the words' + pass "propose then confirm writes a version 2 record, announces hold-for-return only, and every read subcommand reflects it" } test_confirm_requires_readback_and_refresh_is_a_no_op() { @@ -285,9 +154,10 @@ test_confirm_requires_readback_and_refresh_is_a_no_op() { [ "$rc" -ne 0 ] || fail "confirm without a proposal wrote a record" assert_contains "$out" 'run propose before confirm' 'confirm refusal names the required read-back step' [ ! -e "$home/state/.afk-contract" ] || fail "confirm without a proposal created posture state" - contract "$home" propose >/dev/null || fail "plain proposal failed" + out=$(contract "$home" propose) || fail "plain proposal failed" + assert_contains "$out" ' your words: (none)' 'a plain proposal reads back no words' out=$(contract "$home" confirm 2>&1) || fail "plain confirmation failed: $out" - assert_contains "$out" 'No mandate clauses recorded.' 'plain announcement' + assert_contains "$out" 'No away instructions were recorded; the away session acts on standing authority only, and anything that needs you waits for your return.' 'plain announcement' assert_contains "$out" 'hold-for-return only.' 'plain announcement says hold-for-return' first=$(cat "$home/state/.afk-contract") sleep 1 @@ -300,17 +170,18 @@ test_confirm_requires_readback_and_refresh_is_a_no_op() { test_confirming_a_new_proposal_archives_the_standing_record() { local home first_epoch archived home=$(make_home replace) - contract "$home" propose >/dev/null 2>&1 || fail "first propose failed" + contract "$home" propose --words 'first words' >/dev/null 2>&1 || fail "first propose failed" contract "$home" confirm >/dev/null 2>&1 || fail "first confirm failed" first_epoch=$(contract "$home" field entered_epoch) sleep 1 - contract "$home" propose --action merge --object 'task a PR' --when 'checks green' >/dev/null 2>&1 || fail "second propose failed" + contract "$home" propose --words 'replacement words' >/dev/null 2>&1 || fail "second propose failed" contract "$home" confirm >/dev/null 2>&1 || fail "second confirm failed" archived=$(find "$home/state/afk-contracts" -name "$first_epoch-superseded-*.afk-contract" -print -quit) [ -f "$archived" ] || fail "the superseded record was not archived" + [ "$(contract "$home" words --path "$archived")" = 'first words' ] || fail "the archived record lost the superseded words" [ "$(contract "$home" field entered_epoch)" = "$first_epoch" ] || fail "replacement changed the away session start" - [ "$(contract "$home" clauses | cut -f2)" = merge ] || fail "the new record does not carry the new clause" - pass "a replacement archives the old mandate and keeps the session start" + [ "$(contract "$home" words)" = 'replacement words' ] || fail "the new record does not carry the new words" + pass "a replacement archives the old words and keeps the session start" } test_failed_replacement_keeps_the_standing_record() { @@ -359,91 +230,6 @@ SH pass "a failed final replacement publication rolls back its superseded archive" } -test_validation_rejects_incomplete_clause_rows() { - local home record out rc - home=$(make_home malformed-clause-row) - contract "$home" propose --action merge --object 'task a PR' --when 'checks green' >/dev/null || fail "proposal failed" - contract "$home" confirm >/dev/null || fail "confirmation failed" - record="$home/state/.afk-contract" - grep -v '^ object: ' "$record" > "$home/truncated" - mv "$home/truncated" "$record" - set +e - out=$(contract "$home" validate 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "validation accepted a clause row without its object field" - assert_contains "$out" 'malformed clauses row 1: missing or invalid object' "validation did not name the malformed clause row" - set +e - out=$(contract "$home" archive 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "archive accepted a clause row without its object field" - [ -f "$record" ] || fail "archive moved the malformed clause record" - pass "validation and archive refuse incomplete clause rows by name" -} - -test_validation_rejects_blank_decoded_clause_fields() { - local field home record out rc - for field in object when; do - home=$(make_home "blank-$field-row") - contract "$home" propose --action merge --object 'task a PR' --when 'checks green' >/dev/null || fail "$field proposal failed" - contract "$home" confirm >/dev/null || fail "$field confirmation failed" - record="$home/state/.afk-contract" - sed "s/^ $field: e:.*/ $field: e:/" "$record" > "$home/damaged" - mv "$home/damaged" "$record" - set +e - out=$(contract "$home" validate 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "validation accepted a blank decoded $field" - assert_contains "$out" "malformed clauses row 1: missing or invalid $field" "validation did not name the blank $field" - set +e - contract "$home" archive >/dev/null 2>&1 - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "archive accepted a blank decoded $field" - [ -f "$record" ] || fail "archive moved the record with a blank $field" - done - pass "validation and archive refuse blank decoded clause fields" -} - -test_validation_rejects_blank_stop_and_refused_text() { - local kind home record out rc - for kind in stop refused-text; do - home=$(make_home "blank-$kind") - if [ "$kind" = stop ]; then - contract "$home" propose --action merge --object 'task a PR' --when 'checks green' --stop 'captain returns' >/dev/null || fail "stop proposal failed" - else - contract "$home" propose --action merge --object 'task a PR' >/dev/null 2>&1 || true - fi - contract "$home" confirm >/dev/null || fail "$kind confirmation failed" - record="$home/state/.afk-contract" - if [ "$kind" = stop ]; then - sed 's/^ stop: e:.*/ stop: e:/' "$record" > "$home/damaged" - else - sed 's/^ text: e:.*/ text: e:/' "$record" > "$home/damaged" - fi - mv "$home/damaged" "$record" - set +e - out=$(contract "$home" validate 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "validation accepted blank $kind data" - if [ "$kind" = stop ]; then - assert_contains "$out" 'malformed clauses row 1: missing or invalid stop' "validation did not name the blank stop" - else - assert_contains "$out" 'malformed refused row 1: missing or invalid text' "validation did not name the blank refused text" - fi - set +e - contract "$home" archive >/dev/null 2>&1 - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "archive accepted blank $kind data" - [ -f "$record" ] || fail "archive moved the record with blank $kind data" - done - pass "validation and archive refuse blank stop and refused text" -} - test_validation_rejects_damaged_words_blocks() { local mode home record out rc for mode in unindented empty; do @@ -473,10 +259,70 @@ test_validation_rejects_damaged_words_blocks() { pass "validation and archive refuse damaged words blocks" } +# A stored line that lost its two-space prefix is damage, not the end of the +# words: reading must refuse rather than hand back the mandate truncated at the +# damage, because a dropped tail can take a hold or condition with it. Version 2 +# words run to the end of the record; a version 1 record's words end only at one +# of its legacy sections. +test_a_damaged_words_line_never_truncates_the_mandate() { + local home record out rc + + home=$(make_home truncated-v2) + contract "$home" propose --words $'merge A when green\nhold B until I return' >/dev/null \ + || fail "the multi-line v2 proposal failed" + contract "$home" confirm >/dev/null || fail "the multi-line v2 confirmation failed" + record="$home/state/.afk-contract" + [ "$(contract "$home" words)" = $'merge A when green\nhold B until I return' ] \ + || fail "the intact v2 record lost a words line" + sed 's/^ hold B until I return$/hold B until I return/' "$record" > "$home/damaged" + mv "$home/damaged" "$record" + assert_words_read_refuses_the_damage "$home" "$record" 'version 2' + + home=$(make_home truncated-v1) + write_v1_record "$home" $'merge A when green\n hold B until I return' + record="$home/state/.afk-contract" + contract "$home" validate || fail "the intact multi-line v1 record must still validate" + [ "$(contract "$home" words)" = $'merge A when green\nhold B until I return' ] \ + || fail "the intact v1 record lost a words line before its clauses section" + sed 's/^ hold B until I return$/hold B until I return/' "$record" > "$home/damaged" + mv "$home/damaged" "$record" + assert_words_read_refuses_the_damage "$home" "$record" 'version 1' + + pass "a words line that lost its record prefix fails validate, read, read-back, and archive instead of truncating the mandate" +} + +assert_words_read_refuses_the_damage() { # <home> <record> <label> + local home=$1 record=$2 label=$3 out rc + set +e + out=$(contract "$home" validate 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "validation accepted the truncated $label words block" + assert_contains "$out" 'invalid words block:' "the $label truncation was not named as a damaged words block" + set +e + out=$(contract "$home" words 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "words read the truncated $label block" + assert_not_contains "$out" 'merge A when green' "the damaged $label record handed back a truncated mandate" + set +e + out=$(contract "$home" readback 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "readback rendered the truncated $label mandate" + assert_not_contains "$out" 'your words (verbatim)' "the damaged $label record still rendered its words" + set +e + contract "$home" archive >/dev/null 2>&1 + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "archive accepted the truncated $label words block" + [ -f "$record" ] || fail "the refused archive still moved the damaged $label record" +} + test_archive_moves_the_record_aside_and_is_idempotent() { local home epoch path home=$(make_home archive) - contract "$home" propose >/dev/null 2>&1 || fail "propose failed" + contract "$home" propose --words 'archived words' >/dev/null 2>&1 || fail "propose failed" contract "$home" confirm >/dev/null 2>&1 || fail "confirm failed" epoch=$(contract "$home" field entered_epoch) path=$(contract "$home" archive) || fail "archive failed" @@ -485,7 +331,10 @@ test_archive_moves_the_record_aside_and_is_idempotent() { [ ! -f "$home/state/.afk-contract" ] || fail "the record still stands after archive" contract "$home" archive || fail "a second archive with no record must succeed as a no-op" [ "$(contract "$home" archived "$epoch")" = "$path" ] || fail "archived lookup did not find the record" - [ "$(contract "$home" words --path "$path")" = '' ] || fail "reading an archived record by path failed" + [ "$(contract "$home" words --path "$path")" = 'archived words' ] || fail "reading an archived record by path failed" + if contract "$home" words >/dev/null 2>&1; then + fail "words on the live path succeeded after archive" + fi pass "archive keys the record by its entry time, empties the posture, and is idempotent" } @@ -504,128 +353,115 @@ test_inputs_are_validated() { set -e [ "$rc" -eq 2 ] || fail "a zero spend cap should be a usage error (rc=$rc): $out" set +e - out=$(contract "$home" propose --object 'task x PR' 2>&1) + out=$(contract "$home" propose --words-file "$home/absent.txt" 2>&1) rc=$? set -e - [ "$rc" -eq 2 ] || fail "an empty clause should be a usage error, not a silent skip (rc=$rc): $out" - assert_contains "$out" '--object must follow the --action that opens its clause' 'a field with no open clause is a usage error' + [ "$rc" -eq 2 ] || fail "a missing words file should be a usage error (rc=$rc): $out" [ ! -f "$home/state/.afk-contract.proposed" ] || fail "an invalid proposal was written" set +e out=$(contract "$home" validate 2>&1) rc=$? set -e [ "$rc" -ne 0 ] || fail "validate with no record should fail" - printf 'version: 9\nentered_epoch: 1\nclauses:\nrefused:\n' > "$home/state/.afk-contract" + printf 'version: 9\nentered_epoch: 1\nwords: -\n' > "$home/state/.afk-contract" set +e out=$(contract "$home" validate 2>&1) rc=$? set -e [ "$rc" -ne 0 ] || fail "a foreign record version must be refused" - assert_contains "$out" "carries version '9', expected 1" 'version refusal wording' + assert_contains "$out" "carries version '9', expected one of 1, 2" 'version refusal wording' pass "malformed inputs and foreign record versions are refused rather than guessed" } -test_merge_grants_round_trip_and_read_back() { - local home out - home=$(make_home grants-roundtrip) - out=$(contract "$home" propose --grant task-x1 --grant task-y2 --words 'merge those two when green') || fail "grant proposal failed: $out" - assert_contains "$out" 'merge when green (task ids): task-x1, task-y2' 'read-back did not list the granted ids' - [ "$(contract "$home" grants --proposal)" = "$(printf 'task-x1\ntask-y2')" ] \ - || fail "proposal grants subcommand: $(contract "$home" grants --proposal)" - contract "$home" confirm >/dev/null || fail "grant confirm failed" - [ "$(contract "$home" grants)" = "$(printf 'task-x1\ntask-y2')" ] \ - || fail "confirmed grants subcommand: $(contract "$home" grants)" - grep -q '^merge_grants:$' "$home/state/.afk-contract" || fail "confirmed record lacks merge_grants list" - grep -q ' - task-x1' "$home/state/.afk-contract" || fail "confirmed record dropped task-x1" - pass "merge grants round-trip through propose, confirm, read-back, and grants" -} - -test_merge_grants_empty_form_and_usage_errors() { - local home out rc - home=$(make_home grants-empty) - contract "$home" propose >/dev/null || fail "empty grant proposal failed" - grep -qxF 'merge_grants: -' "$home/state/.afk-contract.proposed" \ - || fail "empty grants did not write merge_grants: -" - [ -z "$(contract "$home" grants --proposal)" ] || fail "empty grants subcommand was not empty" - set +e - out=$(contract "$home" propose --grant 'bad id' 2>&1) - rc=$? - set -e - [ "$rc" -eq 2 ] || fail "invalid grant id should be usage error (rc=$rc): $out" +# The clause fields and the merge-grant list are retired with the words model. +# A stale caller that still passes them is told so by name, and no proposal is +# written from a refused command line. +test_retired_clause_and_grant_inputs_are_usage_errors_by_name() { + local home flag out rc + home=$(make_home retired-inputs) + for flag in --action --object --when --stop --grant; do + set +e + out=$(contract "$home" propose --words 'merge it when green' "$flag" merge 2>&1) + rc=$? + set -e + [ "$rc" -eq 2 ] || fail "$flag should be a usage error (rc=$rc): $out" + assert_contains "$out" "$flag was retired" "$flag refusal did not name the retirement" + assert_contains "$out" "away words are the whole mandate" "$flag refusal did not point at the words" + [ ! -f "$home/state/.afk-contract.proposed" ] || fail "$flag wrote a proposal despite the refusal" + done set +e - out=$(contract "$home" propose --grant task-x1 --grant task-x1 2>&1) + out=$(contract "$home" propose --grant=task-x1 2>&1) rc=$? set -e - [ "$rc" -eq 2 ] || fail "duplicate grant id should be usage error (rc=$rc): $out" - pass "empty grants write the scalar form, and invalid or duplicate ids are usage errors" + [ "$rc" -eq 2 ] || fail "--grant= should be a usage error (rc=$rc): $out" + contract "$home" propose --words 'merge it when green' >/dev/null || fail "a words-only proposal failed" + contract "$home" confirm >/dev/null || fail "confirm failed" + for cmd in clauses flags refused grants; do + set +e + out=$(contract "$home" "$cmd" 2>&1) + rc=$? + set -e + [ "$rc" -eq 2 ] || fail "$cmd should be a usage error (rc=$rc): $out" + assert_contains "$out" "'$cmd' was retired" "$cmd refusal did not name the retirement" + done + pass "retired clause fields, --grant, and the clause and grant subcommands are refused by name" } -test_legacy_record_without_merge_grants_reads_empty() { - local home record - home=$(make_home grants-legacy) - contract "$home" propose >/dev/null || fail "legacy proposal failed" - contract "$home" confirm >/dev/null || fail "legacy confirm failed" - record="$home/state/.afk-contract" - awk '!/^merge_grants/' "$record" > "$home/legacy" || fail "could not strip merge_grants" - mv "$home/legacy" "$record" - contract "$home" validate >/dev/null || fail "a pre-field v1 record must still validate" - [ -z "$(contract "$home" grants)" ] || fail "a missing merge_grants field must read as an empty list" - pass "a pre-field v1 record reads as empty grants rather than skipping the field" +# A live away window may still hold a version 1 record when this version lands. +# It validates, every read subcommand reads it, the read-back shows the words +# (and nothing of the ignored clause and grant sections), and it archives. +test_version_1_record_still_validates_reads_and_archives() { + local home out path + home=$(make_home v1-live) + write_v1_record "$home" 'merge the windows fix when green' + contract "$home" validate || fail "a version 1 record must still validate" + [ "$(contract "$home" field version)" = 1 ] || fail "field did not read the version 1 record" + [ "$(contract "$home" field spend_max_concurrent_workers)" = 3 ] || fail "field did not read the v1 spend cap" + [ "$(contract "$home" field expected_return)" = 2026-09-20T09:00:00Z ] || fail "field did not read the v1 expected return" + [ "$(contract "$home" words; printf x)" = 'merge the windows fix when greenx' ] \ + || fail "words did not read the v1 words block bounded by its clauses section: $(contract "$home" words)" + out=$(contract "$home" readback) || fail "readback of a version 1 record failed" + assert_contains "$out" 'Away posture (confirmed):' 'v1 read-back title' + assert_contains "$out" 'spend cap: 3 concurrent workers' 'v1 read-back spend cap' + assert_contains "$out" 'expected return: 2026-09-20T09:00:00Z' 'v1 read-back expected return' + assert_contains "$out" ' merge the windows fix when green' 'v1 read-back words' + assert_not_contains "$out" 'task x1 PR' 'the ignored v1 clauses leaked into the read-back' + assert_not_contains "$out" 'task-x1' 'the ignored v1 merge grants leaked into the read-back' + assert_not_contains "$out" 'refused' 'the ignored v1 refused section leaked into the read-back' + out=$(contract "$home" confirm 2>&1) || fail "refresh of a version 1 record failed: $out" + assert_contains "$out" 'already recorded at 2026-09-20T01:00:00Z' 'refresh did not keep the v1 record' + [ "$(contract "$home" field version)" = 1 ] || fail "a refresh rewrote the version 1 record" + path=$(contract "$home" archive) || fail "archive of a version 1 record failed" + [ "$path" = "$home/state/afk-contracts/1789600000.afk-contract" ] || fail "v1 archive path is wrong: $path" + [ "$(contract "$home" words --path "$path")" = 'merge the windows fix when green' ] || fail "the archived v1 record lost its words" + pass "a version 1 record validates, reads its words and scalars with the clause and grant sections ignored, refreshes untouched, and archives" } -test_malformed_merge_grants_refuse_validation() { - local home record out rc - home=$(make_home grants-malformed-scalar) - contract "$home" propose >/dev/null || fail "malformed scalar proposal failed" - contract "$home" confirm >/dev/null || fail "malformed scalar confirm failed" - record="$home/state/.afk-contract" - awk '{ print; if ($0 == "merge_grants: -") print " - task-x1" }' "$record" > "$home/malformed" - mv "$home/malformed" "$record" - set +e - out=$(contract "$home" validate 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "indented data attached to scalar merge_grants validated" - assert_contains "$out" 'invalid merge_grants field' 'attached scalar data refusal wording' - - home=$(make_home grants-malformed-duplicate) - contract "$home" propose --grant task-x1 >/dev/null || fail "duplicate field proposal failed" - contract "$home" confirm >/dev/null || fail "duplicate field confirm failed" - record="$home/state/.afk-contract" - printf 'merge_grants: -\n' >> "$record" - set +e - out=$(contract "$home" validate 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "duplicate merge_grants fields validated" - assert_contains "$out" 'invalid merge_grants field' 'duplicate field refusal wording' - pass "malformed and duplicate merge-grant fields fail record validation" -} - -test_archive_drops_live_grants() { - local home rc - home=$(make_home grants-archive) - contract "$home" propose --grant task-x1 >/dev/null || fail "archive grant proposal failed" - contract "$home" confirm >/dev/null || fail "archive grant confirm failed" - contract "$home" archive >/dev/null || fail "archive failed" - [ ! -f "$home/state/.afk-contract" ] || fail "archive left the live record" - set +e - contract "$home" grants >/dev/null 2>&1 - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "grants on the live path succeeded after archive" - pass "archive removes live grants so archived copies are not consulted" +test_version_1_record_is_replaced_by_a_version_2_record() { + local home archived + home=$(make_home v1-replace) + write_v1_record "$home" 'first words, version 1' + contract "$home" propose --words 'new words after the upgrade' >/dev/null || fail "replacement propose over a v1 record failed" + contract "$home" confirm >/dev/null 2>&1 || fail "replacement confirm over a v1 record failed" + [ "$(contract "$home" field version)" = 2 ] || fail "the replacement did not write a version 2 record" + [ "$(contract "$home" field entered_epoch)" = 1789600000 ] || fail "the replacement changed the v1 session start" + [ "$(contract "$home" words)" = 'new words after the upgrade' ] || fail "the replacement lost the new words" + archived=$(find "$home/state/afk-contracts" -name '1789600000-superseded-*.afk-contract' -print -quit) + [ -f "$archived" ] || fail "the superseded v1 record was not archived" + contract "$home" validate --path "$archived" >/dev/null 2>&1 || fail "the archived v1 record no longer validates" + [ "$(contract "$home" words --path "$archived")" = 'first words, version 1' ] || fail "the archived v1 record lost its words" + pass "new words over a live version 1 record archive it and write version 2 with the same session start" } # The record-mutating commands share one lock with the subsystems that read this -# record's authority and then act on it (bin/fm-pr-merge.sh reads the grants and -# merges). While a reader holds that lock, confirm and archive must refuse and -# change nothing, so no publication, replacement, or archive can land inside the -# window between that read and the action it authorized. +# record's authority and then act on it (bin/fm-pr-merge.sh reads the record +# and merges). While a reader holds that lock, confirm and archive must refuse +# and change nothing, so no publication, replacement, or archive can land inside +# the window between that read and the action it authorized. test_record_changes_refuse_while_a_reader_holds_the_lock() { local home lock holder_pid i rc out before home=$(make_home lock-contended) - contract "$home" propose --grant task-x1 >/dev/null || fail "lock-contended: proposal failed" + contract "$home" propose --words 'standing words' >/dev/null || fail "lock-contended: proposal failed" contract "$home" confirm >/dev/null || fail "lock-contended: confirm failed" before=$(cat "$home/state/.afk-contract") lock="$home/state/.afk-contract.lock" @@ -655,7 +491,7 @@ test_record_changes_refuse_while_a_reader_holds_the_lock() { [ -f "$home/state/.afk-contract" ] \ || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: the refused archive still moved the record"; } - contract "$home" propose --grant task-other >/dev/null || fail "lock-contended: replacement proposal failed" + contract "$home" propose --words 'replacement words' >/dev/null || fail "lock-contended: replacement proposal failed" set +e out=$(FM_TEST_AFK_CONTRACT_LOCK_TIMEOUT=1 contract "$home" confirm 2>&1) rc=$? @@ -664,41 +500,30 @@ test_record_changes_refuse_while_a_reader_holds_the_lock() { assert_contains "$out" 'locked by live process' "lock-contended: the confirm refusal did not name the live holder" [ "$(cat "$home/state/.afk-contract")" = "$before" ] \ || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: the refused confirm changed the standing record"; } - [ "$(contract "$home" grants)" = task-x1 ] \ - || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: a read subcommand did not see the unchanged grants"; } + [ "$(contract "$home" words)" = 'standing words' ] \ + || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: a read subcommand did not see the unchanged words"; } : > "$home/release" wait "$holder_pid" || fail "lock-contended: the fixture holder did not release cleanly" contract "$home" confirm >/dev/null 2>&1 || fail "lock-contended: confirm failed once the lock cleared" - [ "$(contract "$home" grants)" = task-other ] \ + [ "$(contract "$home" words)" = 'replacement words' ] \ || fail "lock-contended: the released replacement did not take effect" contract "$home" archive >/dev/null || fail "lock-contended: archive failed once the lock cleared" pass "confirm and archive refuse while the record is locked, and proceed once it clears" } -test_fields_refuse_each_missing_part_by_name -test_omitted_stop_confirms_as_no_stop -test_never_set_flags_without_refusing_and_never_over_matches -test_fields_record_the_captain_wording_verbatim -test_clause_fields_round_trip_reversible_whitespace -test_clause_ids_are_input_ordinals_across_accepted_and_refused -test_readback_renders_words_verbatim_and_both_lists +test_readback_renders_words_verbatim_with_the_record_scalars test_words_preserve_final_newline_shape -test_propose_confirm_writes_the_record_and_announces_hold_for_return +test_propose_confirm_writes_a_v2_record_and_announces_hold_for_return test_confirm_requires_readback_and_refresh_is_a_no_op test_confirming_a_new_proposal_archives_the_standing_record test_failed_replacement_keeps_the_standing_record test_failed_final_replacement_rolls_back_the_superseded_archive -test_validation_rejects_incomplete_clause_rows -test_validation_rejects_blank_decoded_clause_fields -test_validation_rejects_blank_stop_and_refused_text test_validation_rejects_damaged_words_blocks +test_a_damaged_words_line_never_truncates_the_mandate test_archive_moves_the_record_aside_and_is_idempotent test_inputs_are_validated -test_merge_grants_round_trip_and_read_back -test_merge_grants_empty_form_and_usage_errors -test_legacy_record_without_merge_grants_reads_empty -test_malformed_merge_grants_refuse_validation -test_archive_drops_live_grants +test_retired_clause_and_grant_inputs_are_usage_errors_by_name +test_version_1_record_still_validates_reads_and_archives +test_version_1_record_is_replaced_by_a_version_2_record test_record_changes_refuse_while_a_reader_holds_the_lock - diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index a8ca8e71033..577393e2cd4 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -60,17 +60,24 @@ unit_propose_confirm_records_the_posture_without_a_daemon() { st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-propose.XXXXXX") mkdir -p "$st/state" out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" propose \ - --words 'merge the windows fix when green' --action merge --object 'task fix-windows PR' --when 'checks green' \ - --action merge --object regardless 2>&1) + --words 'merge the windows fix when green' --expected-return 2026-09-08T08:00Z --spend 2 2>&1) rc=$? - if [ "$rc" -eq 3 ] && [ -f "$st/state/.afk-contract.proposed" ] \ - && printf '%s' "$out" | grep -F '1. merge task fix-windows PR when checks green' >/dev/null \ - && printf '%s' "$out" | grep -F '2. "action=merge object=regardless when=(none)" - refused: missing when' >/dev/null \ + if [ "$rc" -eq 0 ] && [ -f "$st/state/.afk-contract.proposed" ] \ + && printf '%s' "$out" | grep -F ' merge the windows fix when green' >/dev/null \ + && printf '%s' "$out" | grep -F 'expected return: 2026-09-08T08:00Z' >/dev/null \ + && printf '%s' "$out" | grep -F 'spend cap: 2 concurrent workers' >/dev/null \ && [ ! -e "$st/state/.afk-contract" ]; then - pass "propose: the read-back lists accepted and refused clauses and writes only a proposal" + pass "propose: the read-back carries the words verbatim with the expected return and spend cap, and writes only a proposal" else fail "propose: read-back or proposal wrong (rc=$rc): $out" fi + out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" propose --words 'merge it' --grant fix-windows 2>&1) + rc=$? + if [ "$rc" -eq 2 ] && printf '%s' "$out" | grep -F -- '--grant was retired' >/dev/null; then + pass "propose: the retired --grant flag is refused by name" + else + fail "propose: --grant was not refused by name (rc=$rc): $out" + fi out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" confirm 2>&1) rc=$? if [ "$rc" -eq 0 ] && [ -f "$st/state/.afk-contract" ] && [ ! -e "$st/state/.afk-contract.proposed" ] \ @@ -81,7 +88,7 @@ unit_propose_confirm_records_the_posture_without_a_daemon() { fail "confirm: record, announcement, or daemon state wrong (rc=$rc): $out" fi printf 'schema\tfm-afk-return.v1\nphase\tblocked\n' > "$st/state/.afk-return-catchup" - if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" propose --action merge --object 'task a PR' --when 'checks green' >/dev/null 2>&1; then + if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" propose --words 'merge task a PR when green' >/dev/null 2>&1; then fail "propose: accepted a new mandate while the prior return catch-up was pending" else pass "propose: refuses while the prior return catch-up is pending" @@ -120,7 +127,7 @@ unit_daemon_entry_requires_confirmation() { local st out rc st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-entry-record.XXXXXX") mkdir -p "$st/state" - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" propose --action merge --object 'task a PR' --when 'checks green' >/dev/null 2>&1 + FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" propose --words 'merge task a PR when green' >/dev/null 2>&1 out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native 2>&1) rc=$? if [ "$rc" -ne 0 ] && [ -f "$st/state/.afk-contract.proposed" ] && [ ! -e "$st/state/.afk-contract" ] \ diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index 2322687d68a..6434cb021e6 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -371,16 +371,14 @@ line_of() { # <haystack> <needle> -> 1-based line number of the first match, or } test_return_brief_composes_from_record_store_and_held_set() { - local dir out rc gate health_line clauses_line waiting_line failed_line second + local dir out rc gate health_line words_line waiting_line failed_line second dir="$TMP_ROOT/brief" install_runner "$dir" (cd "$dir/home" && tasks-axi add fix-windows 'Fix the windows lane' --file data/backlog.md >/dev/null \ && tasks-axi hold fix-windows --reason 'awaiting the captain on the merge' --kind captain --file data/backlog.md >/dev/null) \ || fail "could not seed the held backlog" - contract_in "$dir" propose --words 'merge the windows fix when green, then cut a prerelease' \ - --action merge --object 'task fix-windows PR' --when 'checks green' \ - --action prerelease --object 'repo no-mistakes' --when 'after clause 1' \ - --action merge --object everything >/dev/null 2>&1 || true + contract_in "$dir" propose --words $'merge the windows fix when green, then cut a prerelease\nif the install deadlocks abort the competing run' >/dev/null 2>&1 \ + || fail "could not propose the away-posture record" contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the away-posture record" # Two live blockers, one on a task with a captain-verdict outcome and one on a # task with a routine outcome. A third task failed outright. @@ -396,6 +394,20 @@ test_return_brief_composes_from_record_store_and_held_set() { outcome_in "$dir" append --task other --verdict routine \ --summary 'resent the steer; worker resumed' --wake 'stale: synthetic:fm-other' >/dev/null \ || fail "could not seed the routine outcome row" + # A near miss recorded first: it opens with the marker's words but not the + # marker, so it is no action taken under them and the account must skip it. + outcome_in "$dir" append --task held-note --verdict routine \ + --summary 'per your away instructions were unclear, so I held for your return' --wake 'signal: held-note.status' >/dev/null \ + || fail "could not seed the near-miss outcome row" + # Two actions taken under the words, one routine and one escalated, each + # opening its summary with the marker the branch prompt requires; the account + # lists both and nothing else. + outcome_in "$dir" append --task fix-windows --verdict routine \ + --summary 'per your away instructions: merged the windows fix PR once checks went green' --wake 'check: fix-windows merge poll' >/dev/null \ + || fail "could not seed the words-action outcome row" + outcome_in "$dir" append --task prerelease --verdict captain \ + --summary 'per your away instructions: filed and dispatched the prerelease cut; it needs your review' --wake 'signal: prerelease.status' >/dev/null \ + || fail "could not seed the escalated words-action outcome row" touch "$dir/home/state/.last-watcher-beat" : > "$dir/home/state/.fake-drain" @@ -410,17 +422,19 @@ test_return_brief_composes_from_record_store_and_held_set() { assert_contains "$out" '=== Return brief (away ' "the brief did not open with the away window" assert_contains "$out" 'supervision ran through the away window with no detected gap' "health did not report the clean window" health_line=$(line_of "$out" 'Supervisor health:') - clauses_line=$(line_of "$out" 'Mandate clauses:') + words_line=$(line_of "$out" 'Your instructions:') waiting_line=$(line_of "$out" 'Waiting on you:') failed_line=$(line_of "$out" 'Tried and failed, or could not be fixed:') - [ -n "$health_line" ] && [ -n "$clauses_line" ] && [ -n "$waiting_line" ] && [ -n "$failed_line" ] \ + [ -n "$health_line" ] && [ -n "$words_line" ] && [ -n "$waiting_line" ] && [ -n "$failed_line" ] \ || fail "the brief is missing a section: $out" - [ "$health_line" -lt "$clauses_line" ] && [ "$clauses_line" -lt "$waiting_line" ] && [ "$waiting_line" -lt "$failed_line" ] \ - || fail "the brief sections are out of order (health $health_line, clauses $clauses_line, waiting $waiting_line, failed $failed_line)" - assert_contains "$out" '1. merge task fix-windows PR when checks green - recorded, not executed by this release' "the accepted clause was not listed as recorded-only" - assert_contains "$out" '2. prerelease repo no-mistakes when after clause 1 - recorded, not executed by this release' "the second clause was not listed" - assert_contains "$out" '3. "action=merge object=everything when=(none)" - refused at entry: missing when' "the refused clause was not listed with its missing part" - assert_contains "$out" 'merge the windows fix when green, then cut a prerelease' "the captain's verbatim words were not carried into the brief" + [ "$health_line" -lt "$words_line" ] && [ "$words_line" -lt "$waiting_line" ] && [ "$waiting_line" -lt "$failed_line" ] \ + || fail "the brief sections are out of order (health $health_line, instructions $words_line, waiting $waiting_line, failed $failed_line)" + assert_contains "$out" $' your words at entry:\n merge the windows fix when green, then cut a prerelease\n if the install deadlocks abort the competing run\n' "the captain's verbatim words were not carried into the brief" + assert_contains "$out" $' the away session acted on them:\n - fix-windows: per your away instructions: merged the windows fix PR once checks went green\n - prerelease: per your away instructions: filed and dispatched the prerelease cut; it needs your review\nWaiting on you:\n' "the session's account listed something other than exactly the two actions taken under the words" + assert_not_contains "$out" $'acted on them:\n - other:' "an outcome that did not cite the words was listed as an action under them" + assert_not_contains "$out" $'acted on them:\n - held-note:' "a summary opening with the marker's words but no colon was listed as an action under them" + assert_not_contains "$out" 'not executed' "the brief still calls the words inert" + assert_not_contains "$out" 'clause' "the brief still speaks of clauses" assert_contains "$out" 'fix-windows,queued,task' "the held backlog item was not listed under waiting on you" assert_contains "$out" 'awaiting the captain on the merge' "the hold reason was not listed" assert_contains "$out" 'other [key=pick] needs your decision: choose the target' "the open decision was not listed under waiting on you" @@ -428,9 +442,9 @@ test_return_brief_composes_from_record_store_and_held_set() { assert_contains "$out" 'fix-windows [key=token] still blocked, firstmate remediates before ordinary work' "the blocker sharing a task with a captain outcome was exempted" assert_contains "$out" 'other [key=dep] still blocked, firstmate remediates before ordinary work' "the unreached blocker was not listed as could-not-fix" assert_contains "$out" 'dead: failed: the reproduction never compiled' "the failed task was not listed" - assert_contains "$out" '1 routine outcome(s) recorded' "the routine outcome count was not reported" + assert_contains "$out" '3 routine outcome(s) recorded' "the routine outcome count was not reported" assert_contains "$out" 'other: resent the steer; worker resumed' "the routine outcome was not listed" - assert_contains "$out" 'Cost: 2 supervision outcome(s) recorded (1 routine, 1 captain); 3 task(s) live at return.' "the cost line is wrong" + assert_contains "$out" 'Cost: 5 supervision outcome(s) recorded (3 routine, 2 captain); 3 task(s) live at return.' "the cost line is wrong" assert_contains "$out" 'firstmate-actionable blocker: other [key=dep]' "the unreached blocker did not gate" assert_contains "$out" 'firstmate-actionable blocker: fix-windows [key=token]' "a captain outcome incorrectly exempted an open blocker" grep -F "$(printf 'contract\t')" "$gate" >/dev/null || fail "the gate did not retain the posture-record window" @@ -441,37 +455,37 @@ test_return_brief_composes_from_record_store_and_held_set() { printf 'resolved [key=dep]: the upstream dependency landed\n' >> "$dir/home/state/other.status" printf 'resolved [key=token]: the token was refreshed\n' >> "$dir/home/state/fix-windows.status" second=$(run_return "$dir" check) || fail "the remediated return did not clear: $second" - assert_contains "$second" '1. merge task fix-windows PR when checks green - recorded, not executed by this release' "check did not re-render the mandate from the archived record" + assert_contains "$second" $' your words at entry:\n merge the windows fix when green, then cut a prerelease' "check did not re-render the words from the archived record" + assert_contains "$second" 'fix-windows: per your away instructions: merged the windows fix PR' "check did not re-render the session account" assert_contains "$second" 'supervision ran through the away window with no detected gap' "check lost the health snapshot taken at begin" assert_contains "$second" 'catch-up clear' "check did not clear the gate" [ ! -e "$gate" ] || fail "the cleared check left the gate behind" FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$dir/bin/fm-afk-return.sh" guard \ || fail "guard still refused after the record was archived and the gate cleared" - pass "the return brief renders health, mandate, waiting, could-not-fix, handled, and cost from durable records, and the gate shrinks to what the away session could not fix" + pass "the return brief renders health, the words with the session account, waiting, could-not-fix, handled, and cost from durable records, and the gate shrinks to what the away session could not fix" } test_return_brief_keeps_refresh_history() { local dir out first_epoch dir="$TMP_ROOT/brief-refresh" install_runner "$dir" - contract_in "$dir" propose --words 'first mandate' \ - --action merge --object 'task first PR' --when 'checks green' >/dev/null 2>&1 || fail "could not propose the first mandate" + contract_in "$dir" propose --words 'first mandate: merge task first PR when green' >/dev/null 2>&1 || fail "could not propose the first mandate" contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the first mandate" first_epoch=$(contract_in "$dir" field entered_epoch) outcome_in "$dir" append --task first --verdict routine \ --summary 'completed before the mandate refresh' --wake 'signal: first.status' >/dev/null \ || fail "could not seed the pre-refresh outcome" - contract_in "$dir" propose --words $'replacement mandate\n\n' \ - --action wake-me --object 'task second' --when 'at 2026-09-08T08:00Z' >/dev/null 2>&1 || fail "could not propose the replacement mandate" + contract_in "$dir" propose --words $'replacement mandate\n\n' >/dev/null 2>&1 || fail "could not propose the replacement mandate" contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the replacement mandate" [ "$(contract_in "$dir" field entered_epoch)" = "$first_epoch" ] || fail "refresh changed the away-window boundary" touch "$dir/home/state/.last-watcher-beat" : > "$dir/home/state/.fake-drain" out=$(run_return "$dir" begin) || fail "refreshed posture return did not clear: $out" - assert_contains "$out" 'merge task first PR when checks green - superseded at ' "the superseded mandate was omitted" - assert_contains "$out" 'wake-me task second when at 2026-09-08T08:00Z - recorded' "the final mandate was omitted" + assert_contains "$out" $' your words superseded at ' "the superseded words were omitted" + assert_contains "$out" ' first mandate: merge task first PR when green' "the superseded words were not rendered verbatim" + assert_contains "$out" $' your words at entry:\n replacement mandate' "the final words were omitted" assert_contains "$out" 'first: completed before the mandate refresh' "the pre-refresh outcome was omitted" - assert_contains "$out" $' replacement mandate\n \nWaiting on you:' "the return brief dropped a trailing blank line from the final words" + assert_contains "$out" $' replacement mandate\n \n the away session took no action under them.\nWaiting on you:' "the return brief dropped a trailing blank line from the final words or lost the empty account" [ -f "$dir/home/state/afk-contracts/$first_epoch.afk-contract" ] || fail "return did not archive the final session record at the canonical path" pass "a refreshed posture keeps its original window, superseded mandate, and earlier outcomes" } @@ -506,8 +520,7 @@ test_missing_epoch_record_stays_required_after_disappearing() { gate="$dir/home/state/.afk-return-catchup" record="$dir/home/state/.afk-contract" backup="$dir/valid-record.backup" - contract_in "$dir" propose --words 'captain words survive' \ - --action merge --object 'task restored PR' --when 'checks green' >/dev/null || fail "could not propose the posture record" + contract_in "$dir" propose --words 'captain words survive' >/dev/null || fail "could not propose the posture record" contract_in "$dir" confirm >/dev/null || fail "could not confirm the posture record" epoch=$(contract_in "$dir" field entered_epoch) entered=$(contract_in "$dir" field entered) @@ -534,7 +547,7 @@ test_missing_epoch_record_stays_required_after_disappearing() { cp "$backup" "$record" out=$(run_return "$dir" check) || fail "check did not clear after the retained record was restored valid: $out" assert_contains "$out" "=== Return brief (away $entered ->" "the restored record did not recover its away window" - assert_contains "$out" 'merge task restored PR when checks green - recorded' "the restored clause was omitted from the brief" + assert_contains "$out" $' your words at entry:\n captain words survive' "the restored words were omitted from the brief" assert_contains "$out" 'captain words survive' "the restored captain words were omitted from the brief" [ -f "$dir/home/state/afk-contracts/$epoch.afk-contract" ] || fail "the restored record was not archived under its recovered epoch" assert_contains "$out" 'catch-up clear' "the restored valid record did not clear catch-up" @@ -672,8 +685,8 @@ test_return_brief_health_leads_with_a_gap() { assert_contains "$out" 'GAP: the watcher beat was ' "the stale beacon was not reported as a gap" assert_not_contains "$out" 'no detected gap' "a gap window was reported as clean" gap_line=$(line_of "$out" 'GAP: watcher downtime') - clean_line=$(line_of "$out" 'Mandate clauses:') - [ "$gap_line" -lt "$clean_line" ] || fail "the gap was not reported before the mandate" + clean_line=$(line_of "$out" 'Your instructions:') + [ "$gap_line" -lt "$clean_line" ] || fail "the gap was not reported before the instructions" pass "the return brief leads with supervisor health and names every detected gap" } @@ -714,12 +727,10 @@ test_unreadable_superseded_archive_keeps_return_gated() { local dir out rc epoch archive backup dir="$TMP_ROOT/superseded-unreadable" install_runner "$dir" - contract_in "$dir" propose --words 'first mandate' \ - --action merge --object 'task first PR' --when 'checks green' >/dev/null 2>&1 || fail "could not propose the first mandate" + contract_in "$dir" propose --words 'first mandate' >/dev/null 2>&1 || fail "could not propose the first mandate" contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the first mandate" epoch=$(contract_in "$dir" field entered_epoch) - contract_in "$dir" propose --words 'replacement mandate' \ - --action wake-me --object 'task second' --when 'at 2026-09-08T08:00Z' >/dev/null 2>&1 || fail "could not propose the replacement mandate" + contract_in "$dir" propose --words 'replacement mandate' >/dev/null 2>&1 || fail "could not propose the replacement mandate" contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the replacement mandate" archive="" for archive in "$dir/home/state/afk-contracts/$epoch-superseded-"*.afk-contract; do break; done @@ -753,8 +764,7 @@ test_missing_final_archive_keeps_retained_contract_gated() { local dir out rc epoch archive backup dir="$TMP_ROOT/final-archive-missing" install_runner "$dir" - contract_in "$dir" propose --words 'durable mandate' \ - --action merge --object 'task final PR' --when 'checks green' >/dev/null 2>&1 || fail "could not propose the mandate" + contract_in "$dir" propose --words 'durable mandate' >/dev/null 2>&1 || fail "could not propose the mandate" contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the mandate" epoch=$(contract_in "$dir" field entered_epoch) seed_live_blocker "$dir" tmux repair-final diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index 3a19310026e..7c70a92e846 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -893,7 +893,7 @@ test_away_record_relocates_main_owned_actions_to_the_branch() { status=$? [ "$status" -ne 6 ] || fail "branch fm-spawn still hit the partition under the record: $out" assert_contains "$out" "main is parked" "the spawn relocation did not announce itself" - assert_contains "$out" "already-queued unblocked work" "an arbitrary branch spawn was not held to queued work" + assert_contains "$out" "queued unblocked work" "an arbitrary branch spawn was not held to queued work" assert_not_contains "$out" "caps concurrent workers" "one ordinary task under a cap of 2 was refused" fm_write_meta "$home/state/task-b.meta" "window=fm-task-b" "kind=ship" out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ @@ -981,12 +981,12 @@ EOF "$ROOT/bin/fm-spawn.sh" task-arbitrary --mode no-mistakes --yolo off 2>&1) status=$? [ "$status" -eq 1 ] || fail "an arbitrary branch spawn exited $status, not 1: $out" - assert_contains "$out" "already-queued unblocked work" "an arbitrary id was dispatched under the record" + assert_contains "$out" "queued unblocked work" "an arbitrary id was dispatched under the record" out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ "$ROOT/bin/fm-spawn.sh" task-queued --mode no-mistakes --yolo off 2>&1) status=$? - assert_not_contains "$out" "already-queued unblocked work" "a queued item was refused as if it were arbitrary: $out" + assert_not_contains "$out" "queued unblocked work" "a queued item was refused as if it were arbitrary: $out" [ "$status" -ne 6 ] || fail "a queued branch spawn hit the partition: $out" assert_contains "$out" "main is parked" "the queued spawn lost its relocation note" @@ -994,7 +994,7 @@ EOF "$ROOT/bin/fm-spawn.sh" task-inflight --mode no-mistakes --yolo off 2>&1) status=$? [ "$status" -eq 1 ] || fail "an in-flight branch spawn exited $status, not 1: $out" - assert_contains "$out" "already-queued unblocked work" "an in-flight row was dispatched by the away branch" + assert_contains "$out" "queued unblocked work" "an in-flight row was dispatched by the away branch" out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ "$ROOT/bin/fm-spawn.sh" mate-new --secondmate 2>&1) @@ -1034,7 +1034,7 @@ WRAPPER out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ "$ROOT/bin/fm-spawn.sh" task-arbitrary --mode no-mistakes --yolo off 2>&1) - assert_not_contains "$out" "already-queued unblocked work" "main's attended spawn was held to the branch queued-work gate" + assert_not_contains "$out" "queued unblocked work" "main's attended spawn was held to the branch queued-work gate" pass "relocated branch spawn admits only already-queued dispatchable work, including on a manual-backend home" } diff --git a/tests/fm-contributions.test.sh b/tests/fm-contributions.test.sh index d4c5abf0f1e..e31d398b375 100755 --- a/tests/fm-contributions.test.sh +++ b/tests/fm-contributions.test.sh @@ -339,7 +339,7 @@ test_away_yolo_is_fleet_work() { with_home "$home" "$ROOT/bin/fm-pr-check.sh" delivery https://github.com/o/r/pull/8 >/dev/null \ || fail 'could not register away delivery' printf 'yolo=on\n' >> "$home/state/delivery.meta" - with_home "$home" "$ROOT/bin/fm-afk-contract.sh" propose --grant delivery >/dev/null \ + with_home "$home" "$ROOT/bin/fm-afk-contract.sh" propose --words 'merge the delivery PR when green' >/dev/null \ || fail 'could not propose away posture' with_home "$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null \ || fail 'could not confirm away posture' @@ -364,7 +364,7 @@ test_away_yolo_cross_home_is_fleet_work() { with_home "$child" "$ROOT/bin/fm-pr-check.sh" delivery https://github.com/o/r/pull/8 >/dev/null \ || fail 'could not register child away delivery' printf 'yolo=on\n' >> "$child/state/delivery.meta" - with_home "$child" "$ROOT/bin/fm-afk-contract.sh" propose --grant delivery >/dev/null \ + with_home "$child" "$ROOT/bin/fm-afk-contract.sh" propose --words 'merge the delivery PR when green' >/dev/null \ || fail 'could not propose child away posture' with_home "$child" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null \ || fail 'could not confirm child away posture' diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 06ccf8eb93c..7b60f33ef12 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1663,7 +1663,7 @@ const contract = (args) => { env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state` }, }); if (result.status !== 0) throw new Error(`fm-afk-contract.sh ${args.join(" ")} failed: ${result.stderr}`); - return (result.stdout || "").trim(); + return result.stdout || ""; }; const requests = () => sentToMain.filter((sent) => sent.message.customType === "fm-branch-process"); const unprocessedSeqs = () => outcomeScript(["unprocessed"]).split("\n").filter(Boolean).map((line) => JSON.parse(line).seq); @@ -1714,7 +1714,7 @@ if (pending.options.triggerTurn !== true || pending.options.deliverAs !== "follo if (!pending.message.content.includes(`[seq ${seq1}]`)) { throw new Error(`the first queued request lost seq ${seq1}: ${pending.message.content}`); } -contract(["propose", "--grant", "task-d"]); +contract(["propose", "--words", "merge task-d when green, then cut the prerelease\n\n"]); contract(["confirm"]); const processingMsg = { role: "custom", customType: pending.message.customType, content: pending.message.content, display: false }; let aborted = false; @@ -1806,11 +1806,13 @@ const awayPrompt = globalThis.__fmPrompts[1]; const head = "FIRSTMATE SUPERVISION WAKE: signal: away wake\n\nHandle this per your operating procedure and finish with fm_branch_report.\n\nPOSTURE: AWAY. "; if (!awayPrompt.startsWith(head)) throw new Error(`the away wake lost its shape or its tail: ${awayPrompt}`); const readback = contract(["readback"]); -if (!readback.includes("merge when green (task ids): task-d")) throw new Error(`the read-back lost the grant: ${readback}`); -if (!awayPrompt.endsWith(`The record, verbatim:\n${readback}`)) throw new Error(`the tail does not end with the record's read-back verbatim: ${awayPrompt}`); +if (!readback.endsWith(" merge task-d when green, then cut the prerelease\n \n")) throw new Error(`the read-back lost the captain's words or their trailing blank line: ${JSON.stringify(readback)}`); +if (!awayPrompt.includes("act on them by your own judgment")) throw new Error(`the away tail lost the words-execution rule: ${awayPrompt}`); +if (awayPrompt.includes("does not execute them")) throw new Error(`the away tail still calls the words inert: ${awayPrompt}`); +if (!awayPrompt.endsWith(`The record, verbatim:\n${readback}`)) throw new Error(`the tail does not end with the record's read-back verbatim, trailing whitespace included: ${JSON.stringify(awayPrompt)}`); const snapshot = readFileSync(`${home}/state/.branch-eligible-rows`, "utf8").trim().split("\n").join(","); if (snapshot !== "1,2,3") throw new Error(`the away wake claimed rows ${snapshot}, not every row`); -const fleet = await report.execute("c2", { task: "fleet", verdict: "captain", summary: "merged task-d's PR under its grant" }, undefined, undefined, {}); +const fleet = await report.execute("c2", { task: "fleet", verdict: "captain", summary: "per your away instructions: merged task-d's PR once green" }, undefined, undefined, {}); if (fleet.isError) throw new Error(`a fleet report under a claimed check row was refused: ${JSON.stringify(fleet)}`); finishPrompt(); await awayOffer.settlement; @@ -1871,7 +1873,7 @@ const contract = (args) => { env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state` }, }); if (result.status !== 0) throw new Error(`fm-afk-contract.sh ${args.join(" ")} failed: ${result.stderr}`); - return (result.stdout || "").trim(); + return result.stdout || ""; }; await fire("session_start", {}); @@ -1938,7 +1940,7 @@ const contract = (args) => { env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state` }, }); if (result.status !== 0) throw new Error(`fm-afk-contract.sh ${args.join(" ")} failed: ${result.stderr}`); - return (result.stdout || "").trim(); + return result.stdout || ""; }; await fire("session_start", {}, defaultSessionCtx); diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 364c2d8fba5..c403ea3cae8 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -2218,18 +2218,19 @@ test_merged_poll_row_carries_the_merge_authority() { local dir state url expected posture url=https://github.com/o/r/pull/1 - for posture in yolo grant; do + # Both a yolo=on task and an ordinary one merge under the record's away + # authority; the words model retired the per-task grant and the yolo tag. + for posture in yolo words; do dir=$(make_case "queued-merge-authority-$posture") state="$dir/home/state" write_task_meta "$dir" task-a if [ "$posture" = yolo ]; then printf 'yolo=on\n' >> "$state/task-a.meta" write_away_record "$dir" - expected=yolo else - write_away_record "$dir" --grant task-a - expected=away-grant + write_away_record "$dir" --words 'merge task-a when green' fi + expected=away run_check_entry "$dir" task-a "$url" >/dev/null 2> "$dir/seed.err" \ || fail "$posture: could not arm the merge poll" queue_merge "$dir" "$url" @@ -2241,7 +2242,7 @@ test_merged_poll_row_carries_the_merge_authority() { || fail "$posture: published merge left its authority record behind" done - pass "queued merges retain yolo and away-grant after captain return" + pass "queued merges retain their away authority after captain return" } test_merged_poll_row_names_no_authority_when_no_record_grants_one() { @@ -2367,7 +2368,7 @@ test_teardown_cannot_race_authority_consumption() { rc=0 wait "$watcher_pid" || rc=$? [ "$rc" -eq 0 ] || fail "teardown race: watcher failed with $rc: $(cat "$dir/watch.err")" - [ "$(merged_ledger_row "$state" task-a)" = "check: merge landed: task-a $url yolo" ] \ + [ "$(merged_ledger_row "$state" task-a)" = "check: merge landed: task-a $url away" ] \ || fail "teardown race: concurrent cleanup downgraded the merge authority" pass "teardown cannot race merged-poll authority consumption" } diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index 9104bbb730f..cfa9d4f83af 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -170,9 +170,9 @@ case "${1:-} ${2:-}" in away_rc=0 "$FM_TEST_AWAY_MUTATE_AT_MERGE" > "$FM_TEST_AWAY_MUTATE_OUT" 2>&1 || away_rc=$? printf '%s\n' "$away_rc" > "$FM_TEST_AWAY_MUTATE_RC" - "$FM_TEST_ROOT/bin/fm-afk-contract.sh" grants \ - > "$FM_TEST_AWAY_GRANTS_AT_MERGE" 2>/dev/null \ - || printf 'no-live-record\n' > "$FM_TEST_AWAY_GRANTS_AT_MERGE" + "$FM_TEST_ROOT/bin/fm-afk-contract.sh" words \ + > "$FM_TEST_AWAY_WORDS_AT_MERGE" 2>/dev/null \ + || printf 'no-live-record\n' > "$FM_TEST_AWAY_WORDS_AT_MERGE" fi if [ -n "${FM_TEST_GH_MERGE_OUTPUT:-}" ]; then printf '%s\n' "$FM_TEST_GH_MERGE_OUTPUT" @@ -396,7 +396,7 @@ run_pr_merge() { FM_TEST_AWAY_MUTATE_AT_MERGE="${FM_TEST_AWAY_MUTATE_AT_MERGE:-}" \ FM_TEST_AWAY_MUTATE_OUT="$case_dir/away-mutate-output" \ FM_TEST_AWAY_MUTATE_RC="$case_dir/away-mutate-rc" \ - FM_TEST_AWAY_GRANTS_AT_MERGE="$case_dir/away-grants-at-merge" \ + FM_TEST_AWAY_WORDS_AT_MERGE="$case_dir/away-words-at-merge" \ FM_TEST_REAL_MV="$REAL_MV" \ FM_TEST_GLAB_LOG="$case_dir/glab.log" \ FM_TEST_GLAB_JSON="$case_dir/mr.json" \ @@ -875,8 +875,7 @@ test_github_plan_gated_403_reads_as_no_queue() { pass "fm-pr-merge reads a plan-gated 403 on branch rules as no merge queue, not unreadable" } -# The practical effect of the fix: while away under a standing yolo=on -# posture (no per-task merge grant), a private repository's plan-gated 403 +# The practical effect of the fix: while away, a private repository's plan-gated 403 # must no longer refuse the merge the way any other unreadable queue response # does. test_away_plan_gated_403_does_not_block_the_merge() { @@ -2652,7 +2651,7 @@ test_allow_red_is_refused_while_away() { mkdir -p "$case_dir/wt" add_gh_mocks "$case_dir" "$head" write_github_red_json "$case_dir" "$head" lint - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' set +e run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/82 \ --allow-red lint \ @@ -2669,7 +2668,7 @@ test_allow_red_is_refused_while_away() { mkdir -p "$case_dir/wt" add_gh_mocks "$case_dir" "$head" write_github_red_json "$case_dir" "$head" lint - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' mv "$case_dir/state/.afk-contract" "$case_dir/away-record-after-view" set +e run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/82 \ @@ -2717,49 +2716,29 @@ test_allow_red_requires_one_separate_name() { pass "fm-pr-merge accepts exactly one separately named red-check waiver" } -test_away_grant_and_yolo_and_hold_for_return() { +test_away_record_permits_any_green_merge_under_away_authority() { local case_dir rc url head head=acacacacacacacacacacacacacacacacacacacac url=https://github.com/example/repo/pull/83 - case_dir=$(make_case away-held) - mkdir -p "$case_dir/wt" - add_gh_mocks "$case_dir" "$head" - write_away_record "$case_dir" - set +e - run_pr_merge "$case_dir" task-x1 "$url" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - expect_code 1 "$rc" "away-held: ungranted merge must refuse" - assert_grep 'task task-x1 is held for the captain return' "$case_dir/stderr" \ - "away-held: refusal did not name hold-for-return" - assert_no_grep 'pr merge' "$case_dir/gh.log" \ - "away-held: gh pr merge ran without a grant" - - case_dir=$(make_case away-held-attended-override) - mkdir -p "$case_dir/wt" - add_gh_mocks "$case_dir" "$head" - write_away_record "$case_dir" - set +e - run_pr_merge "$case_dir" task-x1 "$url" --attended-override \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - expect_code 1 "$rc" "away-held-override: --attended-override must not skip the grant" - assert_grep 'task task-x1 is held for the captain return' "$case_dir/stderr" \ - "away-held-override: override skipped the grant" - - case_dir=$(make_case away-grant) + # No yolo, no per-task grant: the record's presence is the whole mechanical + # fact, so a green merge proceeds and the ledger tags it away. + case_dir=$(make_case away-green) mkdir -p "$case_dir/wt" "$case_dir/home" add_gh_mocks "$case_dir" "$head" - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge the windows fix when green' FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "away-grant: granted green merge should succeed" + > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "away-green: a green merge under the record should succeed: $(cat "$case_dir/stderr")" assert_logged_gh_merge "$case_dir" 83 example/repo --squash - assert_grep "merge landed: task-x1 $url away-grant" "$case_dir/state/.wake-queue" \ - "away-grant: the durable outcome did not tag away-grant" - + assert_grep "merge landed: task-x1 $url away" "$case_dir/state/.wake-queue" \ + "away-green: the durable outcome did not tag away" + assert_no_grep 'away-grant' "$case_dir/state/.wake-queue" \ + "away-green: the retired away-grant tag reappeared" + [ "$(sed -n 6p "$case_dir/state/task-x1.merge-authority" 2>/dev/null || true)" = away ] \ + || fail "away-green: the persisted merge authority is not away: $(cat "$case_dir/state/task-x1.merge-authority" 2>/dev/null || true)" + + # A yolo=on task merges under the same away authority: the posture, not the + # task's standing autonomy, is what the ledger records while away. case_dir=$(make_case away-yolo) mkdir -p "$case_dir/wt" "$case_dir/home" add_gh_mocks "$case_dir" "$head" @@ -2767,18 +2746,40 @@ test_away_grant_and_yolo_and_hold_for_return() { write_away_record "$case_dir" FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "away-yolo: yolo green merge should succeed" - assert_grep "merge landed: task-x1 $url yolo" "$case_dir/state/.wake-queue" \ - "away-yolo: the durable outcome did not tag yolo" - pass "away merges require yolo or a grant, and --attended-override does not skip that" + assert_grep "merge landed: task-x1 $url away" "$case_dir/state/.wake-queue" \ + "away-yolo: the durable outcome did not tag away" + + # --attended-override re-enables forge flags for an explicit instruction; it + # never skips the record read, and the merge still lands under away authority. + case_dir=$(make_case away-attended-override) + mkdir -p "$case_dir/wt" "$case_dir/home" + add_gh_mocks "$case_dir" "$head" + write_away_record "$case_dir" --words 'merge it when green' + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" --attended-override \ + > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "away-attended-override: a green merge should succeed: $(cat "$case_dir/stderr")" + assert_grep "merge landed: task-x1 $url away" "$case_dir/state/.wake-queue" \ + "away-attended-override: the durable outcome did not tag away" + + # Without the record the merge is attended and the ledger row stays untagged. + case_dir=$(make_case attended-untagged) + mkdir -p "$case_dir/wt" "$case_dir/home" + add_gh_mocks "$case_dir" "$head" + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" || fail "attended-untagged: an attended green merge should succeed" + case "$(grep -F "merge landed: task-x1 $url" "$case_dir/state/.wake-queue")" in + *"$url") ;; + *) fail "attended-untagged: the attended outcome carried an authority tag: $(grep -F 'merge landed' "$case_dir/state/.wake-queue")" ;; + esac + pass "while the away-posture record exists any green merge lands under away authority, yolo or not, and attended merges stay untagged" } # While the away-posture record exists main is parked, so the supervision # branch actor may reach the merge gate - and meets exactly the gate main -# would: a granted task merges green at its live head under away-grant -# authority, an ungranted one is held for the return, and without the record -# the branch is refused at the role partition before any forge call +# would: any task merges green at its live head under away authority, a red +# one is refused whatever the words say, and without the record the branch is +# refused at the role partition before any forge call # (docs/pi-supervision-branch.md "Postures"). -test_away_branch_actor_merges_only_with_a_grant() { +test_away_branch_actor_merges_green_under_the_record() { local case_dir rc url head head=dadadadadadadadadadadadadadadadadadadada url=https://github.com/example/repo/pull/93 @@ -2797,41 +2798,38 @@ test_away_branch_actor_merges_only_with_a_grant() { [ ! -e "$case_dir/gh.log" ] || assert_no_grep 'pr ' "$case_dir/gh.log" \ "away-branch-attended: gh ran for an attended branch merge" - case_dir=$(make_case away-branch-held) - mkdir -p "$case_dir/wt" - add_gh_mocks "$case_dir" "$head" - write_away_record "$case_dir" - set +e - FM_SUPERVISION_ACTOR=branch run_pr_merge "$case_dir" task-x1 "$url" \ - > "$case_dir/stdout" 2> "$case_dir/stderr" - rc=$? - set -e - expect_code 1 "$rc" "away-branch-held: an ungranted task must be held for the return" - assert_grep 'main is parked' "$case_dir/stderr" \ - "away-branch-held: the relocation note was not printed" - assert_grep 'task task-x1 is held for the captain return' "$case_dir/stderr" \ - "away-branch-held: refusal did not name hold-for-return" - assert_no_grep 'pr merge' "$case_dir/gh.log" \ - "away-branch-held: gh pr merge ran for an ungranted branch merge" - - case_dir=$(make_case away-branch-grant) + # No yolo and no grant list: the record alone relocates the green merge. + case_dir=$(make_case away-branch-green) mkdir -p "$case_dir/wt" "$case_dir/home" add_gh_mocks "$case_dir" "$head" - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge the windows fix when green' FM_SUPERVISION_ACTOR=branch FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ > "$case_dir/stdout" 2> "$case_dir/stderr" \ - || fail "away-branch-grant: a granted green merge must succeed for the branch: $(cat "$case_dir/stderr")" + || fail "away-branch-green: a green merge must succeed for the branch under the record: $(cat "$case_dir/stderr")" + assert_grep 'main is parked' "$case_dir/stderr" \ + "away-branch-green: the relocation note was not printed" assert_logged_gh_merge "$case_dir" 93 example/repo --squash - assert_grep "merge landed: task-x1 $url away-grant" "$case_dir/state/.wake-queue" \ - "away-branch-grant: the durable outcome did not tag away-grant" + assert_grep "merge landed: task-x1 $url away" "$case_dir/state/.wake-queue" \ + "away-branch-green: the durable outcome did not tag away" - # The green gate is absolute in this posture for the branch as for main. + # The green gate is absolute in this posture for the branch as for main: a + # red check refuses on its own, and the attended waiver is refused too. case_dir=$(make_case away-branch-red) mkdir -p "$case_dir/wt" "$case_dir/home" add_gh_mocks "$case_dir" "$head" write_github_rollup_json "$case_dir" "$head" \ '{"__typename":"CheckRun","name":"lint","status":"COMPLETED","conclusion":"FAILURE","startedAt":"2026-09-01T00:00:00Z"}' - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 even if lint is red' + set +e + FM_SUPERVISION_ACTOR=branch FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 1 "$rc" "away-branch-red: a red check must refuse the branch whatever the words say" + assert_grep "check 'lint' is not green" "$case_dir/stderr" \ + "away-branch-red: refusal did not name the red check" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "away-branch-red: gh pr merge ran for a red branch merge while away" set +e FM_SUPERVISION_ACTOR=branch FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" --allow-red lint \ > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -2841,11 +2839,11 @@ test_away_branch_actor_merges_only_with_a_grant() { assert_grep 'allow-red is attended-only' "$case_dir/stderr" \ "away-branch-red: refusal did not name the attended-only waiver" assert_no_grep 'pr merge' "$case_dir/gh.log" \ - "away-branch-red: gh pr merge ran for a red branch merge while away" - pass "under the away-posture record the branch merges a granted green task, is held without a grant, cannot waive a red check, and is refused at the partition while attended" + "away-branch-red: gh pr merge ran for a waived red branch merge while away" + pass "under the away-posture record the branch merges a green task, is refused on a red check with or without --allow-red, and is refused at the partition while attended" } -# The race this closes: a granted branch merge passes the opening partition +# The race this closes: a branch merge passes the opening partition # because the live record exists, then the captain returns and archives that # record during the slow forge preflight. The locked authority recheck must # treat that archive as absence and refuse the branch before gh pr merge. @@ -2858,7 +2856,7 @@ test_away_branch_refuses_when_record_archived_during_preflight() { case_dir=$(make_case away-branch-archived-during-preflight) mkdir -p "$case_dir/wt" "$case_dir/home" add_gh_mocks "$case_dir" "$head" - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' : > "$case_dir/away-record-after-view" set +e FM_SUPERVISION_ACTOR=branch FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ @@ -2885,7 +2883,7 @@ test_away_posture_refuses_asynchronous_merge_paths() { case_dir=$(make_case away-auto-refused) mkdir -p "$case_dir/wt" add_gh_mocks "$case_dir" "$head" - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' set +e run_pr_merge "$case_dir" task-x1 "$url" --attended-override -- --auto --merge \ > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -2901,7 +2899,7 @@ test_away_posture_refuses_asynchronous_merge_paths() { mkdir -p "$case_dir/wt" add_gh_mocks "$case_dir" "$head" printf 'merge_method=MERGE\n' > "$case_dir/github-rules" - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' set +e run_pr_merge "$case_dir" task-x1 "$url" \ > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -2914,7 +2912,7 @@ test_away_posture_refuses_asynchronous_merge_paths() { "away-queue-refused: gh received a merge that could enter its queue" case_dir=$(make_gitlab_case away-gitlab-auto) - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' set +e run_pr_merge "$case_dir" task-x1 "$MR_URL" --attended-override -- --auto-merge \ > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -2927,7 +2925,7 @@ test_away_posture_refuses_asynchronous_merge_paths() { || fail "away-gitlab-auto: glab received an asynchronous merge" case_dir=$(make_gitlab_case away-gitlab-configured merge_when_pipeline_succeeds=true) - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' set +e run_pr_merge "$case_dir" task-x1 "$MR_URL" \ > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -2938,10 +2936,10 @@ test_away_posture_refuses_asynchronous_merge_paths() { || fail "away-gitlab-configured: glab received a configured asynchronous merge" case_dir=$(make_gitlab_case away-gitlab-sync) - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' run_pr_merge "$case_dir" task-x1 "$MR_URL" \ > "$case_dir/stdout" 2> "$case_dir/stderr" \ - || fail "away-gitlab-sync: an immediate granted merge should succeed" + || fail "away-gitlab-sync: an immediate merge under the record should succeed" merge_line=$(glab_merge_line "$case_dir/glab.log") case "$merge_line" in *" --auto-merge=false") ;; @@ -2950,24 +2948,24 @@ test_away_posture_refuses_asynchronous_merge_paths() { pass "away posture permits immediate merges but refuses every asynchronous path" } -test_away_grant_does_not_bypass_red_or_identity() { +test_away_record_does_not_bypass_red_or_identity() { local case_dir rc head head=adadadadadadadadadadadadadadadadadadadad - case_dir=$(make_case away-grant-red) + case_dir=$(make_case away-record-red) mkdir -p "$case_dir/wt" add_gh_mocks "$case_dir" "$head" write_github_red_json "$case_dir" "$head" lint - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' set +e run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/84 \ > "$case_dir/stdout" 2> "$case_dir/stderr" rc=$? set -e - expect_code 1 "$rc" "away-grant-red: a grant must not waive red checks" + expect_code 1 "$rc" "away-record-red: the record must not waive red checks" assert_grep "check 'lint' is not green" "$case_dir/stderr" \ - "away-grant-red: C1 did not refuse the red check" + "away-record-red: C1 did not refuse the red check" assert_no_grep 'pr merge' "$case_dir/gh.log" \ - "away-grant-red: gh pr merge ran on a granted red PR" + "away-record-red: gh pr merge ran on a red PR while away" case_dir=$(make_case pr-identity-mismatch) mkdir -p "$case_dir/wt" @@ -2981,7 +2979,7 @@ test_away_grant_does_not_bypass_red_or_identity() { expect_code 1 "$rc" "pr-identity: a different recorded URL must refuse" assert_grep 'is bound to https://github.com/example/repo/pull/99' "$case_dir/stderr" \ "pr-identity: refusal did not name the recorded URL" - pass "a grant does not bypass red checks, and a recorded pr= must match the URL" + pass "the away record does not bypass red checks, and a recorded pr= must match the URL" } test_unreadable_away_record_refuses_merge() { @@ -3000,12 +2998,13 @@ test_unreadable_away_record_refuses_merge() { "away-unreadable: refusal did not fail closed" assert_no_grep 'pr merge' "$case_dir/gh.log" \ "away-unreadable: gh pr merge ran despite an unreadable record" - pass "an unreadable away-posture record refuses the merge instead of skipping the grant" + pass "an unreadable away-posture record refuses the merge instead of skipping the record" } # The race this closes: the away record is read for merge authority and the -# forge is called afterwards, so an archive (the captain's return) or a grant -# revocation landing in between would merge on authority that no longer holds. +# forge is called afterwards, so an archive (the captain's return) or a +# replacement of the words landing in between would merge on authority that no +# longer holds. # away_change_script writes the change the gh mock attempts from inside the # forge call, which IS that window. Its body drives the real away-record # commands /afk and the return use, never a file edit, and takes a one-second @@ -3025,15 +3024,15 @@ away_change_script() { # <case-dir> <name>; script body on stdin } # Two away-record changes, each attempted from inside the merge's critical -# section: the archive a captain return performs, and the replacement that -# revokes a grant. Neither may land there, and the merge must still complete on -# the authority it read. +# section: the archive a captain return performs, and the replacement /afk with +# new words performs. Neither may land there, and the merge must still complete +# on the authority it read. test_away_record_cannot_change_between_the_authority_read_and_the_merge() { local case_dir rc mutate case_dir=$(make_case away-archive-at-merge) mkdir -p "$case_dir/wt" add_gh_mocks "$case_dir" 1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b - write_away_record "$case_dir" --grant task-x1 + write_away_record "$case_dir" --words 'merge task-x1 when green' mutate=$(away_change_script "$case_dir" archive-at-merge <<'SH' "$CONTRACT" archive SH @@ -3047,30 +3046,30 @@ SH set -e unset FM_TEST_AWAY_MUTATE_AT_MERGE - expect_code 0 "$rc" "away-archive-at-merge: the granted green merge should still land" + expect_code 0 "$rc" "away-archive-at-merge: the green merge should still land" [ -s "$case_dir/away-mutate-rc" ] \ || fail "away-archive-at-merge: the archive was never attempted inside the merge" [ "$(cat "$case_dir/away-mutate-rc")" != 0 ] \ || fail "away-archive-at-merge: the archive landed inside the merge's critical section" assert_grep 'locked by live process' "$case_dir/away-mutate-output" \ "away-archive-at-merge: the refused archive did not name the live holder" - assert_equals task-x1 "$(cat "$case_dir/away-grants-at-merge" 2>/dev/null || true)" \ - "away-archive-at-merge: the grant this merge read was not still standing at the forge call" - assert_grep "merge landed: task-x1 https://github.com/example/repo/pull/71 away-grant" \ + assert_equals 'merge task-x1 when green' "$(cat "$case_dir/away-words-at-merge" 2>/dev/null || true)" \ + "away-archive-at-merge: the record this merge read was not still standing at the forge call" + assert_grep "merge landed: task-x1 https://github.com/example/repo/pull/71 away" \ "$case_dir/state/.wake-queue" \ - "away-archive-at-merge: the landed merge was not recorded under the grant it read" + "away-archive-at-merge: the landed merge was not recorded under the away authority it read" # The lock goes with the merge rather than leaking: the captain's return # archives the record on its first try once the merge is done. FM_HOME="$case_dir/home" FM_STATE_OVERRIDE="$case_dir/state" \ "$ROOT/bin/fm-afk-contract.sh" archive >/dev/null \ || fail "away-archive-at-merge: the record stayed locked after the merge" - case_dir=$(make_case away-revoke-at-merge) + case_dir=$(make_case away-replace-at-merge) mkdir -p "$case_dir/wt" add_gh_mocks "$case_dir" 2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c - write_away_record "$case_dir" --grant task-x1 - mutate=$(away_change_script "$case_dir" revoke-at-merge <<'SH' -"$CONTRACT" propose --grant task-other + write_away_record "$case_dir" --words 'merge task-x1 when green' + mutate=$(away_change_script "$case_dir" replace-at-merge <<'SH' +"$CONTRACT" propose --words 'hold everything for my return' "$CONTRACT" confirm SH ) @@ -3082,25 +3081,26 @@ SH set -e unset FM_TEST_AWAY_MUTATE_AT_MERGE - expect_code 0 "$rc" "away-revoke-at-merge: the granted green merge should still land" + expect_code 0 "$rc" "away-replace-at-merge: the green merge should still land" [ "$(cat "$case_dir/away-mutate-rc" 2>/dev/null || true)" != 0 ] \ - || fail "away-revoke-at-merge: the replacement landed inside the critical section" - assert_equals task-x1 "$(cat "$case_dir/away-grants-at-merge" 2>/dev/null || true)" \ - "away-revoke-at-merge: the grant was revoked inside the merge's critical section" - pass "no away-record archive or grant revocation lands between the authority read and the merge" -} - -# The same serialization from the other side. A revocation that wins the race -# lands BEFORE the in-lock authority read, and the merge then refuses: the lock -# decides an order, it never lets a stale grant through. -test_a_grant_revoked_before_the_merge_refuses_it() { + || fail "away-replace-at-merge: the replacement landed inside the critical section" + assert_equals 'merge task-x1 when green' "$(cat "$case_dir/away-words-at-merge" 2>/dev/null || true)" \ + "away-replace-at-merge: the words were replaced inside the merge's critical section" + pass "no away-record archive or replacement lands between the authority read and the merge" +} + +# The same serialization from the other side. A record change that wins the +# race lands BEFORE the in-lock authority read, and the merge then answers to +# what it finds there: an unreadable record refuses rather than merging on the +# record the opening partition saw. The lock decides an order, it never lets a +# stale read through. +test_a_record_made_unreadable_before_the_merge_refuses_it() { local case_dir rc - case_dir=$(make_case away-revoked-before-merge) + case_dir=$(make_case away-unreadable-before-merge) mkdir -p "$case_dir/wt" add_gh_mocks "$case_dir" 3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d - write_away_record "$case_dir" - mv "$case_dir/state/.afk-contract" "$case_dir/away-record-after-view" - write_away_record "$case_dir" --grant task-x1 + printf 'not-a-contract\n' > "$case_dir/away-record-after-view" + write_away_record "$case_dir" --words 'merge task-x1 when green' set +e run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/73 \ @@ -3108,18 +3108,18 @@ test_a_grant_revoked_before_the_merge_refuses_it() { rc=$? set -e - expect_code 1 "$rc" "away-revoked-before-merge: a revoked grant must refuse" - assert_grep 'held for the captain return' "$case_dir/stderr" \ - "away-revoked-before-merge: refusal did not name hold-for-return" + expect_code 1 "$rc" "away-unreadable-before-merge: a record made unreadable before the authority read must refuse" + assert_grep 'away-posture record could not be read' "$case_dir/stderr" \ + "away-unreadable-before-merge: refusal did not fail closed" assert_no_grep 'pr merge' "$case_dir/gh.log" \ - "away-revoked-before-merge: gh pr merge ran on a revoked grant" - pass "a grant revoked before the merge's own authority read refuses the merge" + "away-unreadable-before-merge: gh pr merge ran on a record that could not be read" + pass "a record made unreadable before the merge's own authority read refuses the merge" } # Fail closed. The lock is what makes the authority read and the merge one # action, so a merge that cannot take it has no locked window to merge in and # refuses - including on this attended case, where the record is absent and -# there is no grant to check at all. +# there is no away authority to read at all. test_merge_refuses_when_the_away_record_cannot_be_locked() { local case_dir rc holder_pid i lock case_dir=$(make_case away-lock-unavailable) @@ -3207,14 +3207,14 @@ test_undated_runs_never_supersede test_allow_red_still_waives_only_the_current_failure test_allow_red_is_refused_while_away test_allow_red_requires_one_separate_name -test_away_grant_and_yolo_and_hold_for_return -test_away_branch_actor_merges_only_with_a_grant +test_away_record_permits_any_green_merge_under_away_authority +test_away_branch_actor_merges_green_under_the_record test_away_branch_refuses_when_record_archived_during_preflight test_away_posture_refuses_asynchronous_merge_paths test_away_plan_gated_403_does_not_block_the_merge -test_away_grant_does_not_bypass_red_or_identity +test_away_record_does_not_bypass_red_or_identity test_unreadable_away_record_refuses_merge test_away_record_cannot_change_between_the_authority_read_and_the_merge -test_a_grant_revoked_before_the_merge_refuses_it +test_a_record_made_unreadable_before_the_merge_refuses_it test_merge_refuses_when_the_away_record_cannot_be_locked test_allow_red_refused_on_gitlab