Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
015c7dd
fix(calm): show persistent on/off footer status
Aug 1, 2026
acaaf2e
feat(fleet): mechanical refill checker — exit 1 = DISPATCH-NEEDED
Aug 1, 2026
378b564
chore(pi): ignore local package clones
Aug 3, 2026
2c924fe
fix: preserve fleet state in truncated session-start digests (#1798)
kunchenguid Aug 6, 2026
193a2fc
feat(send): close answered decisions at answer time via --resolve-key…
kunchenguid Aug 6, 2026
d92dd1d
fix(bin): seed remote secondmates from supplied origins (#1836)
kunchenguid Aug 7, 2026
fd8e98d
fix(tests): restore reliable fm-send backend parity coverage (#1851)
kunchenguid Aug 7, 2026
10944f7
fix(bin): mirror remote secondmate status streams (#1846)
kunchenguid Aug 7, 2026
a8f1e6e
docs(agents): describe the digest's fleet-state-before-context order …
kunchenguid Aug 7, 2026
546459a
fix(bin): fail closed on NUL bytes in the durable parent binding (#1847)
kunchenguid Aug 7, 2026
1ff6fa7
fix(skills): reconcile inherited secondmate plans with shipped state …
kunchenguid Aug 7, 2026
defe616
fix: move network checks off the session-start blocking path (#1860)
kunchenguid Aug 7, 2026
e635c28
fix(procevent): apply remote replies during capture (#1831)
kunchenguid Aug 7, 2026
9ed38ea
feat(skills): port internal stow curation disciplines to the public s…
kunchenguid Aug 7, 2026
887f3eb
chore(bootstrap): raise lavish-axi version floor to 0.1.46 (#1865)
kunchenguid Aug 7, 2026
f4ae695
docs: encode bead closure delivery discipline
Aug 7, 2026
3472d87
fix(dos-3ndcm): canonicalize review evidence
Aug 7, 2026
184e07f
no-mistakes(review): guard empty-lane review linking behind slot acti…
Aug 7, 2026
94d4caa
feat: encode proactive fleet scaling doctrine
Aug 7, 2026
651be21
feat(dos-k1z81): surface canonical checkout debt in serialization probe
Aug 7, 2026
71b1e86
feat(dos-cdxq1): surface lane-contract checker debt in serialization …
Aug 7, 2026
4c3ed17
docs(architecture): design durable fleet refill lifecycle
Aug 8, 2026
38b4eb6
docs(architecture): tighten fleet lifecycle design
Aug 8, 2026
9e2d815
docs(plans): add durable fleet refill and terminal lifecycle implemen…
Aug 8, 2026
d40fd7f
docs(plans): revise fleet-refill terminal lifecycle plan per review F…
Aug 8, 2026
454b10d
reconcile(fleet): merge origin/main onto the fleet-refill integration…
Aug 8, 2026
9e7247b
feat(refill): quarantine legacy capacity arithmetic until parity cutover
Aug 8, 2026
384e68d
feat(attempts): add write-once receipt-derived attempt identity
Aug 8, 2026
7b0a995
feat(capacity): structured crew-state contract and shared projection …
Aug 8, 2026
58cffcb
feat(capacity): shadow-only count-json and latency measurement with q…
Aug 8, 2026
ec3e2d2
feat(disposition): centralize live disposition and reconcile stale re…
Aug 8, 2026
022fdb3
fix(disposition): resolve empty forge output to unknown, never a guess
Aug 8, 2026
4b7ef96
feat(tracker): authoritative br receipts with pathspec transaction an…
Aug 8, 2026
13e8a02
feat(tracker): claim-before-allocation handshake and brief wiring
Aug 8, 2026
f3d11dc
feat(attempts): make workspace claims attempt-bound and provider-wide
Aug 8, 2026
feb4fb2
refactor(cleanup): extract teardown cleanup into fm-cleanup-lib.sh wi…
Aug 8, 2026
35871d0
feat(cleanup): structured per-effect receipts and teardown compatibil…
Aug 8, 2026
73c131d
fix(cleanup): make terminal replay a no-op success before preflight r…
Aug 8, 2026
6c63f30
feat(delivery): journal provisional forge observations and write the …
Aug 8, 2026
01a9ab3
feat(terminal): ordered terminal transaction with fresh authority and…
Aug 8, 2026
74a6cec
fix(tracker): persist the tracker receipt under the terminal's held lock
Aug 8, 2026
3eac13a
feat(refill): retirement-before-projection admission with automatic-r…
Aug 8, 2026
fb230a0
feat(refill): consumer cutover after fixture and live parity with aut…
Aug 8, 2026
9cb53bb
test(refill): adapt quarantine-era fixtures to the shared-projection …
Aug 8, 2026
45d0560
docs(verification): record the cut-over fixture reconciliation in the…
Aug 8, 2026
b50fdce
feat(capacity): bounded-parallel default for real-fleet latency under…
Aug 8, 2026
e5cff35
feat(fleet): expose and recover delivery attempts
Aug 8, 2026
d769ccc
fix(watch): fire the reconciliation wake once for empty-signature rows
Aug 8, 2026
f2383b8
feat(refill): delete obsolete arithmetic after parity and add alert-o…
Aug 8, 2026
be7e025
fix(tests): locale-neutral coverage comms and gotmp fixture lib siblings
Aug 8, 2026
4f6e780
no-mistakes(review): Fix durable fleet lifecycle recovery and admission
Aug 9, 2026
f8f4d80
no-mistakes(review): Normalize local-only repository identity
Aug 9, 2026
8bd867a
no-mistakes(review): Recover terminal replay and launch receipts
Aug 9, 2026
5f0e69f
no-mistakes(review): Fix refill exit, alert-only tolerance, teardown …
Aug 9, 2026
ebcf659
no-mistakes(test): Auto-fixed orca claim and calm stub tests pass; Pi…
Aug 9, 2026
d670a84
no-mistakes(document): docs: refresh scripts inventory and fleet life…
Aug 9, 2026
1b32480
no-mistakes(document): docs: tick plan checkboxes and record current-…
Aug 9, 2026
6587732
no-mistakes(lint): Fix shellcheck findings across receipt refill and …
Aug 9, 2026
192de8f
reconcile(fleet): merge the advanced base onto the fleet-refill deliv…
Aug 9, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ __pycache__/
*.pyc
.env
config/

# pi package clones (installed via `pi install -l`)
.pi/git/
12 changes: 10 additions & 2 deletions .pi/extensions/fm-calm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ export default function (pi: ExtensionAPI) {
const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root;
const configDirectory = process.env.FM_CONFIG_OVERRIDE || resolve(fmHome, "config");
const calmPreferencePath = resolve(configDirectory, "calm");
// Visible footer indicator so the captain can always see whether Calm is on or
// off (2026-08-01: the previous code cleared the status with setStatus(key,
// undefined), so an active Calm looked identical to no Calm at all).
const applyCalmStatus = (ui: ExtensionUIContext, active: boolean): void => {
const theme = ui.theme;
const label = active ? "● calm on" : "○ calm off";
ui.setStatus("firstmate-calm", theme?.fg ? theme.fg(active ? "accent" : "dim", label) : label);
};
const loadCalmPreference = (): boolean => {
try {
return readFileSync(calmPreferencePath, "utf8").trim() === "on";
Expand Down Expand Up @@ -397,7 +405,7 @@ export default function (pi: ExtensionAPI) {
workingShipAnimation.reset();
applyWorkingPresentation(ctx.ui, true);
ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined);
ctx.ui.setStatus("firstmate-calm", undefined);
applyCalmStatus(ctx.ui, calmPresentationIsActive());
removeTerminalInputHandler?.();
removeTerminalInputHandler = ctx.ui.onTerminalInput((data) => {
if (!getKeybindings().matches(data, "tui.input.submit")) return;
Expand Down Expand Up @@ -451,7 +459,7 @@ export default function (pi: ExtensionAPI) {
publishPresentationState();
applyWorkingPresentation(ctx.ui, true);
ctx.ui.setHiddenThinkingLabel(active ? "" : undefined);
ctx.ui.setStatus("firstmate-calm", undefined);
applyCalmStatus(ctx.ui, active);

const expanded = ctx.ui.getToolsExpanded();
ctx.ui.setToolsExpanded(!expanded);
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ An unregistered project or absent registry resolves to `no-mistakes` with yolo o
Record the resulting mode, yolo, and the one-line reason for any deviation in the backlog item note.

Treat file or subsystem overlap as a risk signal rather than an automatic reason to wait, and dispatch isolated work immediately with no concurrency cap when each change can be independently implemented and validated and the selected delivery path can reconcile ordinary rebases or conflicts.
For Decision OS implementation work, automatically refill ready P0/P1 work to this fleet's safe concurrency ceiling whenever quota headroom, green main, disjoint write sets, and a clear serialization lane permit; an operator request is never a prerequisite, but none of these conditions may be relaxed merely to raise utilization.
Record one durable operator-visible reason whenever capacity is added or deliberately held back; silent slow mode is invalid, and unused safe capacity while ready P0/P1 beads exist is a reportable operational defect.
Serialize only for a true semantic dependency, shared mutable external state, incompatible concurrent migration, or another concrete condition that makes independent progress or reconciliation unsafe; same-file editing alone is insufficient, and genuine blockers remain durable.
Write the task-specific brief under section 11 before spawning.

Expand Down Expand Up @@ -352,6 +354,8 @@ A captain instruction to merge is explicit authority; `yolo` is the only standin
For any custom `state/<id>.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh <id>` before the watcher may execute it.

Tear down a ship task only after landing is confirmed.
For bead-backed work, a merged PR is not delivery completion: before cleanup, attach the project's required Closure-Receipt and run its canonical bead close command.
Land any tracker-serialization work in the same attended shift as the work it records; never leave closure truth solely on an unlanded branch.
A teardown refusal for uncommitted or unlanded work is a stop-and-investigate result, never an obstacle to bypass.
Never force teardown without explicit discard authority.
After successful teardown, record completion, retain only the configured recent Done history, and re-evaluate queued work whose blockers and time gates have cleared.
Expand Down
332 changes: 332 additions & 0 deletions bin/fm-attempt-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
#!/usr/bin/env bash
# Durable delivery-attempt identity, write-once and receipt-derived.
#
# Single owner of schema fm-attempt.v1: the immutable envelope
# {task_source, task_key, home_id, attempt_id, generation}; write-once
# provider/delivery fields frozen at allocation; effect receipts where each
# effect is observed at most once under its explicit identity (identical
# replay is a no-op, a second different observation refuses); an append-only
# observations journal for provisional evidence that is never authority; and
# named obligations derived solely from missing observed effects. There is NO
# mutable phase field; lifecycle helpers derive from the envelope plus the
# observed effect set.
#
# This record is coordination state only. Decision OS beads remain task truth;
# this file never mirrors live bead state, semantic worker state, endpoint
# liveness, Git refs, cleanliness, ancestry, or forge status.
#
# Records live under $FM_STATE_OVERRIDE/attempts/<attempt_id>.json when
# FM_STATE_OVERRIDE is set (tests), else $FM_HOME/state/attempts/. All
# mutations happen under the attempt lock using the shared primitives from
# bin/fm-wake-lib.sh (fm_lock_try_acquire / fm_lock_release), which are
# non-reentrant: internal _held variants never reacquire. Publication is
# write-temp-then-mv.

set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"

attempts_dir() {
printf '%s/attempts' "${FM_STATE_OVERRIDE:-$FM_HOME/state}"
}

attempt_path() { # <attempt_id>
printf '%s/%s.json' "$(attempts_dir)" "$1"
}

attempt_lock() { # <attempt_id>
printf '%s/.%s.lock' "$(attempts_dir)" "$1"
}

# shellcheck disable=SC2034
FM_ATTEMPT_LIB_SOURCED=1

fm_attempt_alloc() { # <task_source> <task_key> <home_id> -> prints <attempt_id>
local task_source=$1 task_key=$2 home_id=$3
local dir gen attempt_id tmp f g
dir="$(attempts_dir)"
mkdir -p "$dir"
fm_lock_try_acquire "$dir/.alloc.lock" || {
echo "attempt-alloc: allocation lock busy" >&2
return 1
}
gen=0
for f in "$dir"/"$task_key"-a*.json; do
[ -e "$f" ] || continue
g=${f##*-a}
g=${g%.json}
case "$g" in
''|*[!0-9]*) continue ;;
esac
[ "$g" -gt "$gen" ] && gen=$g
done
gen=$((gen + 1))
attempt_id="$task_key-a$gen"
tmp="$dir/.$attempt_id.tmp.$$"
jq -n \
--arg schema fm-attempt.v1 \
--arg task_source "$task_source" --arg task_key "$task_key" \
--arg home_id "$home_id" --arg attempt_id "$attempt_id" \
--argjson generation "$gen" \
'{schema:$schema,envelope:{task_source:$task_source,task_key:$task_key,home_id:$home_id,attempt_id:$attempt_id,generation:$generation},receipts:{},observations:[],created_at:(now|todateiso8601)}' \
> "$tmp" || { fm_lock_release "$dir/.alloc.lock"; return 1; }
mv -f "$tmp" "$(attempt_path "$attempt_id")" || {
fm_lock_release "$dir/.alloc.lock"
return 1
}
fm_lock_release "$dir/.alloc.lock"
printf '%s\n' "$attempt_id"
}

fm_attempt_load() { # <attempt_id> -> JSON on stdout; nonzero when absent
local f
f="$(attempt_path "$1")"
[ -f "$f" ] || return 1
cat "$f"
}

fm_attempt_generation() { # <attempt_id>
fm_attempt_load "$1" | jq -r '.envelope.generation'
}

# --- lock ownership (non-reentrant) ----------------------------------------

fm_attempt_lock_acquire() { # <attempt_id>
fm_lock_try_acquire "$(attempt_lock "$1")"
}

fm_attempt_lock_release() { # <attempt_id>
fm_lock_release "$(attempt_lock "$1")"
}

# --- lock-held internal primitives (never reacquire) -----------------------

fm_attempt_generation_held() { # <attempt_id>
fm_attempt_load "$1" | jq -r '.envelope.generation'
}

fm_attempt_effect_observe_held() { # <attempt_id> <generation> <name> <evidence-json>
local attempt=$1 gen=$2 name=$3 evidence=$4
local path tmp live_gen seq existing_evidence existing_norm
path="$(attempt_path "$attempt")"
live_gen=$(fm_attempt_generation_held "$attempt") || return 1
[ "$live_gen" = "$gen" ] || {
echo "stale generation: $attempt expects gen $gen, record is gen $live_gen" >&2
return 1
}
# write-once: an existing observed entry with different evidence refuses;
# identical replay is a no-op. Both sides are normalized through jq -cS so
# semantically identical evidence (key order differences) is replay equality.
existing_evidence=$(jq -r --arg name "$name" \
'[.receipts[$name][]? | select(.state == "observed")][0].evidence // ""' "$path")
if [ -n "$existing_evidence" ]; then
existing_norm=$(printf '%s' "$existing_evidence" | jq -cS . 2>/dev/null || printf '%s' "$existing_evidence")
local ev_norm
ev_norm=$(printf '%s' "$evidence" | jq -cS . 2>/dev/null || printf '%s' "$evidence")
if [ "$existing_norm" = "$ev_norm" ]; then
return 0
fi
echo "contradiction: effect $name already observed on $attempt with different evidence" >&2
return 1
fi
seq=$(jq -r --arg name "$name" '[.receipts[$name][]? | .seq] | (max // 0) + 1' "$path")
tmp="$(attempts_dir)/.$attempt.tmp.$$"
jq --arg name "$name" --argjson seq "$seq" --argjson evidence "$evidence" \
'.receipts[$name] += [{seq:$seq,state:"observed",generation:.envelope.generation,observed_at:(now|todateiso8601),evidence:$evidence}]' \
"$path" > "$tmp" || return 1
mv -f "$tmp" "$path" || return 1
}

fm_attempt_effect_pending_held() { # <attempt_id> <generation> <name> <reason-json>
local attempt=$1 gen=$2 name=$3 reason=$4
local path tmp live_gen seq
path="$(attempt_path "$attempt")"
live_gen=$(fm_attempt_generation_held "$attempt") || return 1
[ "$live_gen" = "$gen" ] || return 1
seq=$(jq -r --arg name "$name" '[.receipts[$name][]? | .seq] | (max // 0) + 1' "$path")
tmp="$(attempts_dir)/.$attempt.tmp.$$"
jq --arg name "$name" --argjson seq "$seq" --argjson reason "$reason" \
'.receipts[$name] += [{seq:$seq,state:"pending",generation:.envelope.generation,observed_at:(now|todateiso8601),reason:$reason}]' \
"$path" > "$tmp" || return 1
mv -f "$tmp" "$path" || return 1
}

fm_attempt_freeze_allocation_held() { # <attempt_id> <generation> <provider-json> <delivery-json>
local attempt=$1 gen=$2 provider=$3 delivery=$4
local path tmp live_gen existing_p existing_d
path="$(attempt_path "$attempt")"
live_gen=$(fm_attempt_generation_held "$attempt") || return 1
[ "$live_gen" = "$gen" ] || {
echo "stale generation at freeze: $attempt" >&2
return 1
}
existing_p=$(jq -r '.provider // empty' "$path")
existing_d=$(jq -r '.delivery // empty' "$path")
if [ -n "$existing_p" ] || [ -n "$existing_d" ]; then
# Normalize both sides through jq -cS (compact, sorted keys): jq -r prints
# stored objects pretty-printed, so raw textual comparison would refuse an
# identical replay; semantically identical JSON is replay equality.
local existing_p_norm existing_d_norm p_norm d_norm
existing_p_norm=$(printf '%s' "$existing_p" | jq -cS . 2>/dev/null || printf '%s' "$existing_p")
existing_d_norm=$(printf '%s' "$existing_d" | jq -cS . 2>/dev/null || printf '%s' "$existing_d")
p_norm=$(printf '%s' "$provider" | jq -cS . 2>/dev/null || printf '%s' "$provider")
d_norm=$(printf '%s' "$delivery" | jq -cS . 2>/dev/null || printf '%s' "$delivery")
if [ "$existing_p_norm" = "$p_norm" ] && [ "$existing_d_norm" = "$d_norm" ]; then
return 0
fi
echo "contradiction: provider/delivery already frozen on $attempt with different values" >&2
return 1
fi
tmp="$(attempts_dir)/.$attempt.tmp.$$"
# The successful first freeze IS the provider effect: the frozen provider
# copy identity is observed as the provider receipt, so the release
# obligation derives from the missing launch effect (plan: a crash after
# allocation leaves the provider effect in place). The receipt is written
# only on the first freeze; the identical-replay and contradiction paths
# above return before this write.
jq --argjson provider "$provider" --argjson delivery "$delivery" \
'.provider=$provider | .delivery=$delivery | .receipts.provider += [{seq:1,state:"observed",generation:.envelope.generation,observed_at:(now|todateiso8601),evidence:$provider}]' \
"$path" > "$tmp" || return 1
mv -f "$tmp" "$path" || return 1
}

fm_attempt_observe_held() { # <attempt_id> <generation> <name> <evidence-json> (journal)
local attempt=$1 gen=$2 name=$3 evidence=$4
local path tmp live_gen
path="$(attempt_path "$attempt")"
live_gen=$(fm_attempt_generation_held "$attempt") || return 1
[ "$live_gen" = "$gen" ] || return 1
tmp="$(attempts_dir)/.$attempt.tmp.$$"
jq --arg name "$name" --argjson evidence "$evidence" \
'.observations += [{name:$name,observed_at:(now|todateiso8601),generation:.envelope.generation,evidence:$evidence}]' \
"$path" > "$tmp" || return 1
mv -f "$tmp" "$path" || return 1
}

fm_attempt_obligations_held() { # <attempt_id> -> missing observed effect names
local attempt=$1 disp required
# derived solely from receipts; no phase
disp=$(jq -r --arg name landing \
'[.receipts[$name][]? | select(.state == "observed")][0].evidence.disposition // ""' \
"$(attempt_path "$attempt")")
if jq -e --arg name claim \
'[.receipts[$name][]? | select(.state == "observed")][0].evidence.status == "refused"' \
"$(attempt_path "$attempt")" >/dev/null 2>&1; then
return 0
fi
required="claim provider launch landing"
case "$disp" in
landed) required="$required tracker cleanup.endpoint cleanup.branch cleanup.provider cleanup.runtime" ;;
preserved_unlanded) required="$required cleanup.endpoint cleanup.branch cleanup.preservation cleanup.provider cleanup.runtime" ;;
esac
jq -r --argjson required "$(printf '%s' "$required" | jq -R 'split(" ")')" \
'. as $root |
[ $required[] as $name | select(([$root.receipts[$name][]? | select(.state == "observed")] | length) == 0) | $name ] | join(" ")' \
"$(attempt_path "$attempt")"
}

fm_attempt_retire_held() { # <attempt_id> <generation> <audit-json>
local attempt=$1 gen=$2 audit=$3
local path tmp live_gen missing seq
path="$(attempt_path "$attempt")"
live_gen=$(fm_attempt_generation_held "$attempt") || return 1
[ "$live_gen" = "$gen" ] || {
echo "stale generation at retirement: $attempt" >&2
return 1
}
missing=$(fm_attempt_obligations_held "$attempt")
[ -z "$missing" ] || {
echo "retirement blocked: missing observed effects: $missing" >&2
return 1
}
seq=$(jq -r --arg name retirement '[.receipts[$name][]? | .seq] | (max // 0) + 1' "$path")
tmp="$(attempts_dir)/.$attempt.tmp.$$"
jq --argjson seq "$seq" --argjson audit "$audit" \
'.receipts.retirement += [{seq:$seq,state:"observed",generation:.envelope.generation,observed_at:(now|todateiso8601),evidence:$audit}]' \
"$path" > "$tmp" || return 1
mv -f "$tmp" "$path" || return 1
}

# --- public wrappers (acquire once, call held, release) --------------------

fm_attempt_effect_observe() { # <attempt_id> <generation> <name> <evidence-json>
local attempt=$1 rc
fm_attempt_lock_acquire "$attempt" || return 1
fm_attempt_effect_observe_held "$@"
rc=$?
fm_attempt_lock_release "$attempt"
return $rc
}

fm_attempt_effect_pending() { # <attempt_id> <generation> <name> <reason-json>
local attempt=$1 rc
fm_attempt_lock_acquire "$attempt" || return 1
fm_attempt_effect_pending_held "$@"
rc=$?
fm_attempt_lock_release "$attempt"
return $rc
}

fm_attempt_freeze_allocation() { # <attempt_id> <generation> <provider-json> <delivery-json>
local attempt=$1 rc
fm_attempt_lock_acquire "$attempt" || return 1
fm_attempt_freeze_allocation_held "$@"
rc=$?
fm_attempt_lock_release "$attempt"
return $rc
}

fm_attempt_observe() { # <attempt_id> <generation> <name> <evidence-json>
local attempt=$1 rc
fm_attempt_lock_acquire "$attempt" || return 1
fm_attempt_observe_held "$@"
rc=$?
fm_attempt_lock_release "$attempt"
return $rc
}

fm_attempt_retire() { # <attempt_id> <generation> <audit-json>
local attempt=$1 rc
fm_attempt_lock_acquire "$attempt" || return 1
fm_attempt_retire_held "$@"
rc=$?
fm_attempt_lock_release "$attempt"
return $rc
}

fm_attempt_obligations() { # <attempt_id>
local attempt=$1
fm_attempt_lock_acquire "$attempt" || return 1
fm_attempt_obligations_held "$attempt"
local rc=$?
fm_attempt_lock_release "$attempt"
return $rc
}

# --- derived lifecycle helpers (no mutable phase) --------------------------

fm_attempt_is_allocated() { # 0 when the provider effect is observed
jq -e --arg name provider \
'[.receipts[$name][]? | select(.state == "observed")] | length > 0' \
"$(attempt_path "$1")" >/dev/null 2>&1
}

fm_attempt_is_launched() { # 0 when the launch effect is observed
jq -e --arg name launch \
'[.receipts[$name][]? | select(.state == "observed")] | length > 0' \
"$(attempt_path "$1")" >/dev/null 2>&1
}

fm_attempt_is_retired() { # 0 when the retirement effect is observed
jq -e --arg name retirement \
'[.receipts[$name][]? | select(.state == "observed")] | length > 0' \
"$(attempt_path "$1")" >/dev/null 2>&1
}

fm_attempt_landing_disposition() { # prints landed | preserved_unlanded | unknown | ""
jq -r --arg name landing \
'[.receipts[$name][]? | select(.state == "observed")][0].evidence.disposition // ""' \
"$(attempt_path "$1")" 2>/dev/null || true
}
Loading
Loading