Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ Hold-for-return is the default and the only reach profile this release records:
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.
2. **Propose and read back.**
Run `bin/fm-afk-launch.sh propose --words-file <path> [--action <verb> --object <text> --when <text> [--stop <text>]]... [--expected-return <UTC ISO 8601>] [--spend <n>]` (or `--words <text>`), 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, and the one-sentence reach announcement.
Run `bin/fm-afk-launch.sh propose --words-file <path> [--action <verb> --object <text> --when <text> [--stop <text>]]... [--expected-return <UTC ISO 8601>] [--spend <n>] [--grant <task-id>]...` (or `--words <text>`), 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 <id>` 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.
3. **Confirm on the captain's go.**
Expand Down Expand Up @@ -79,6 +82,9 @@ Bias ambiguous cases toward exit: a present captain beats token savings, and a f
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.
`--allow-red` remains attended-only and is refused 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.
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,9 @@ The path's worker, automated gates, and captain approval remain authoritative:

Delivery mode and `yolo` are orthogonal.
`yolo` governs merge authority only: with it off, the captain approves every PR merge and every local-only landing; with it on, firstmate merges green, in-scope work itself.
Never merge a red PR under either setting; destructive, irreversible, and security-sensitive merges still escalate.
Without a current explicit captain instruction that states the concrete merge, that default stands, and standing `yolo` cannot authorize a red merge; section 1 owns when such an instruction overrides a Firstmate-written standing rule within its exact scope.
Never merge a red PR under either setting unless a current explicit captain instruction names the single GitHub check waived through `fm-pr-merge.sh --allow-red`; that attended-only waiver still requires every other check green.
Destructive, irreversible, and security-sensitive merges still escalate.
Without a current explicit captain instruction that states the concrete merge, the green default stands, and standing `yolo` cannot authorize a red merge; section 1 owns when such an instruction overrides a Firstmate-written standing rule within its exact scope.
Load `ask-user-authority` before deciding any ask-user finding; the implementation worker never answers its own finding.
Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded and an unproved merge is refused instead of reported as landed, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards.
After an autonomous merge, give the captain a one-line full-URL or local-main outcome.
Expand Down
119 changes: 112 additions & 7 deletions bin/fm-afk-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
# reach_channels: none
# reach_announced: <the one-sentence reach announcement>
# spend_max_concurrent_workers: <n>
# merge_grants: - | task ids that may merge while this record exists
# - <task-id> (empty is `merge_grants: -`; a missing field on
# ... a pre-field v1 record reads as an empty list)
# confirmed: <UTC ISO 8601>
# confirmed_epoch: <seconds>
# words: | or |- the captain's words, verbatim, never edited,
Expand Down Expand Up @@ -82,12 +85,14 @@
# Usage:
# fm-afk-contract.sh propose [--words-file <path> | --words <text>]
# [--action <verb> --object <text> --when <text> [--stop <text>]]...
# [--expected-return <UTC ISO 8601>] [--spend <n>]
# [--expected-return <UTC ISO 8601>] [--spend <n>] [--grant <task-id>]...
# 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.
# 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.
# 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
Expand All @@ -103,6 +108,7 @@
# (`\\`, `\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 <record>] TSV: id text missing
# fm-afk-contract.sh grants [--proposal | --path <record>] one task id per line
# fm-afk-contract.sh archive move the record aside; print its path
# fm-afk-contract.sh archived <entered_epoch> print that archived record's path
#
Expand Down Expand Up @@ -162,6 +168,15 @@ fm_afk_contract_blank() { # <text>
[ -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() { # <id>
local LC_ALL=C id=${1-}
case "$id" in
''|.*|*[!A-Za-z0-9._-]*) return 1 ;;
esac
}

fm_afk_contract_escape() { # <text>
local value=$1
value=${value//\\/\\\\}
Expand Down Expand Up @@ -266,9 +281,10 @@ fm_afk_contract_validate_iso() { # <ts>

# 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.
# CLAUSE_ACTIONS CLAUSE_OBJECTS CLAUSE_WHENS CLAUSE_STOPS, EXPECTED_RETURN,
# SPEND, MERGE_GRANTS.
fm_afk_contract_render_body() { # <entered-iso> <entered-epoch>
local entered=$1 entered_epoch=$2 ordinal=0 i as_given
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
Expand Down Expand Up @@ -303,6 +319,14 @@ fm_afk_contract_render_body() { # <entered-iso> <entered-epoch>
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
Expand Down Expand Up @@ -370,6 +394,52 @@ fm_afk_contract_read_words() { # <path>
' "$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() { # <path>
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: <section> is clauses or refused.
fm_afk_contract_read_list() { # <path> <section>
local path=$1 section=$2
Expand Down Expand Up @@ -466,6 +536,10 @@ fm_afk_contract_validate() { # <path> <require-confirmed 0|1>
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; }
Expand Down Expand Up @@ -526,13 +600,22 @@ EOF
# --- rendering --------------------------------------------------------------

fm_afk_contract_render_readback() { # <path> <title>
local path=$1 title=$2 words count id action object when stop text missing expected spend flag
local path=$1 title=$2 words count id action object when stop text missing expected spend flag grants grant_list
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=${words%x}
Expand Down Expand Up @@ -601,10 +684,11 @@ fm_afk_contract_render_announcement() { # <path>

# --- subcommands ------------------------------------------------------------

fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, the CLAUSE_* arrays, EXPECTED_RETURN, SPEND
local words_file='' open=-1
fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, the CLAUSE_* arrays, EXPECTED_RETURN, SPEND, MERGE_GRANTS
local words_file='' open=-1 grant
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)
Expand Down Expand Up @@ -642,6 +726,23 @@ 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'
return 2 ;;
*)
fm_afk_contract_log "unknown option '$1'"
return 2 ;;
Expand Down Expand Up @@ -812,6 +913,10 @@ fm_afk_contract_main() {
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" ;;
archive) fm_afk_contract_cmd_archive ;;
archived)
[ "$#" -eq 1 ] || { fm_afk_contract_usage >&2; return 2; }
Expand Down
3 changes: 3 additions & 0 deletions bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@
# 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.
# 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
Expand Down
18 changes: 14 additions & 4 deletions bin/fm-merge-outcome-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,27 +35,37 @@ _FM_MERGE_OUTCOME_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC2034 # Public result consumed by sourcing callers.
FM_MERGE_OUTCOME_ALREADY_RECORDED=false

# fm_merge_outcome_report <home> <state> <task-id> <pr-url> <origin>
# fm_merge_outcome_report <home> <state> <task-id> <pr-url> <origin> [authority]
#
# <origin> says who observed the merge, because that decides whether the
# existing poll path also needs a local wake:
# 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 or away-grant when the merge ran while the
# away-posture record existed; it is appended to the ledger line. Known audit
# gap: queued merges and a poll that wins direct-merge deduplication publish an
# untagged row because the poll path does not persist merge authority.
#
# 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
# enough to say where the outcome belongs, and 1 on any other failure to
# record. A caller that has already merged must report a non-zero return rather
# than treat it as success: the merge landed and the record did not.
fm_merge_outcome_report() { # <home> <state> <task-id> <pr-url> <origin>
fm_merge_outcome_report() { # <home> <state> <task-id> <pr-url> <origin> [authority]
local home=$1 state=$2 id=$3 url=$4 origin=$5
local authority=${6-} suffix=
local self_rc=0 destination='' line lock status=0
local provider host path number
# shellcheck disable=SC2034 # Sourced wake helpers consume these scoped globals.
local STATE FM_WAKE_QUEUE FM_WAKE_QUEUE_LOCK
FM_MERGE_OUTCOME_ALREADY_RECORDED=false
case "$origin" in self|poll) ;; *) return 2 ;; esac
case "$authority" in
yolo|away-grant) suffix=" $authority" ;;
'') ;;
*) return 2 ;;
esac
fm_pr_task_id_valid "$id" || return 2
fm_pr_url_parse "$url" || return 2
provider=$FM_PR_PROVIDER
Expand All @@ -65,7 +75,7 @@ fm_merge_outcome_report() { # <home> <state> <task-id> <pr-url> <origin>
[ -d "$state" ] && [ ! -L "$state" ] || return 1

if destination=$(fm_parent_channel_destination "$home" "$state"); then
line="done [key=merged-$id]: merged $id $FM_PR_URL"
line="done [key=merged-$id]: merged $id $FM_PR_URL$suffix"
else
self_rc=$?
[ "$self_rc" -eq 1 ] || return 3
Expand All @@ -90,7 +100,7 @@ fm_merge_outcome_report() { # <home> <state> <task-id> <pr-url> <origin>
fi
if [ "$status" -eq 0 ] && { [ "$origin" = poll ] || [ -z "$destination" ]; }; then
fm_wake_append check "merged-$id-$FM_PR_URL" \
"check: merge landed: $id $FM_PR_URL" || status=1
"check: merge landed: $id $FM_PR_URL$suffix" || status=1
fi
if [ "$status" -eq 0 ]; then
fm_pr_poll_merge_mark_notified "$state" "$id" \
Expand Down
Loading
Loading