Skip to content
Open
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ grok --trust
pi
```

**Codex**

```sh
bin/fm-primary-codex.sh
```

Codex can execute tools outside the interactive process's ancestry.
The launcher gives that session a private fleet-lock identity and releases its lock when Codex exits.

For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too.
For Pi, approve the project trust prompt once per clone on first launch so both tracked `.pi/extensions/*.ts` files auto-load.

Expand Down
124 changes: 124 additions & 0 deletions bin/fm-bridge-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Shared helpers for the fm-bridge.v1 machine contract.

fm_bridge_fail() { # <code> <message>
jq -n --arg code "$1" --arg message "$2" \
'{accepted:false,error:{code:$code,message:$message}}'
}

fm_bridge_digest() {
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 | awk '{print $1}'
else
sha256sum | awk '{print $1}'
fi
}

fm_bridge_task_revision() { # <task-json>
printf '%s' "$1" | jq -cS . | fm_bridge_digest
}

fm_bridge_state() { # <snapshot state>
case "$1" in
parked|needs-decision|awaiting-captain) printf 'awaitingCaptain' ;;
blocked|failed) printf 'blocked' ;;
validating|reviewing|ci|checks-running) printf 'verifying' ;;
pr-ready|ready) printf 'prReady' ;;
working|running|fixing) printf 'working' ;;
paused|idle) printf 'paused' ;;
approved|signed-off) printf 'approved' ;;
done|merged|completed) printf 'completed' ;;
*) printf 'unknown' ;;
esac
}

fm_bridge_capabilities() { # <state> <pr-url>
local state=$1
jq -n --arg state "$state" '
["feedback","defer"]
+ (if ($state == "awaitingCaptain" or $state == "prReady") then ["sign-off"] else [] end)
'
}

fm_bridge_evidence() { # <task-json> <revision>
local task=$1 revision=$2 report pr
report=$(printf '%s' "$task" | jq -r '.paths.report.path // empty')
pr=$(printf '%s' "$task" | jq -r '.pr.url // empty')
jq -n --arg report "$report" --arg pr "$pr" --arg revision "$revision" '
[
if $report != "" then {
id:"report",kind:"Report",status:"present",summary:"Shipmate report is available",
source:"FirstMate",taskRevision:$revision,reference:$report
} else empty end,
if $pr != "" then {
id:"pull-request",kind:"Pull request",status:"present",summary:"Pull request is available",
source:"GitHub",taskRevision:$revision,reference:$pr
} else empty end
]
'
}

fm_bridge_snapshot() {
local raw captured snapshot_revision
raw=$("$SCRIPT_DIR/fm-fleet-snapshot.sh" --json) || return
captured=$(printf '%s' "$raw" | jq -r '.generated')
snapshot_revision=$(printf '%s' "$raw" | jq -cS '{tasks,backlog,scout_reports,secondmate_current}' | fm_bridge_digest)
printf '%s' "$raw" | jq -c \
--arg protocol "fm-bridge.v1" \
--arg snapshot_revision "$snapshot_revision" --arg captured "$captured" '
{
protocolVersion:$protocol,
snapshotRevision:$snapshot_revision,
capturedAt:.generated,
freshness:"fresh",
tasks:[.tasks[] | {
id:.id,
title:(.backlog.title // .id),
project:(.project // "unknown"),
shipmate:(.harness // "shipmate"),
rawState:(.current_state.state // "unknown"),
updatedAt:$captured,
attentionReason:(.current_state.detail // null),
summary:(.backlog.body_excerpt // null),
pr:(.pr.url // ""),
source:.
}]
}'
}

fm_bridge_project_snapshot() {
local base tasks='[]' task source revision state evidence evidence_revision capabilities projected
base=$(fm_bridge_snapshot) || return
while IFS= read -r task; do
source=$(printf '%s' "$task" | jq -c '.source | {
id,
current_state:{
state:.current_state.state,
source:.current_state.source,
detail:.current_state.detail
},
pr,
backlog,
hints,
report:.paths.report
}')
revision=$(fm_bridge_task_revision "$source")
state=$(fm_bridge_state "$(printf '%s' "$task" | jq -r '.rawState')")
evidence=$(fm_bridge_evidence "$(printf '%s' "$task" | jq -c '.source')" "$revision" | jq -c .)
evidence_revision=$(printf '%s' "$evidence" | jq -cS . | fm_bridge_digest)
capabilities=$(fm_bridge_capabilities "$state" "$(printf '%s' "$task" | jq -r '.pr')" | jq -c .)
projected=$(printf '%s' "$task" | jq -c \
--arg revision "$revision" --arg state "$state" \
--arg evidence_revision "$evidence_revision" \
--argjson evidence "$evidence" --argjson capabilities "$capabilities" '
del(.rawState,.pr,.source) + {
state:$state,
taskRevision:$revision,
capabilities:$capabilities,
evidenceRevision:$evidence_revision,
evidence:$evidence
}')
tasks=$(jq -c --argjson tasks "$tasks" --argjson task "$projected" '$tasks + [$task]' <<< '{}')
done < <(printf '%s' "$base" | jq -c '.tasks[]')
printf '%s' "$base" | jq -c --argjson tasks "$tasks" '.tasks=$tasks'
}
149 changes: 149 additions & 0 deletions bin/fm-bridge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# fm-bridge.sh - versioned machine boundary for Captain's Log and local clients.
#
# Reads one JSON request from stdin.
# Supported operations:
# {"protocolVersion":"fm-bridge.v1","operation":"snapshot"}
# {"protocolVersion":"fm-bridge.v1","operation":"command","command":{...}}
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"

# shellcheck source=bin/fm-bridge-lib.sh
# shellcheck disable=SC1091
. "$SCRIPT_DIR/fm-bridge-lib.sh"

command -v jq >/dev/null 2>&1 || {
echo "fm-bridge: jq not found" >&2
exit 1
}

request=$(head -c 65537)
[ "${#request}" -le 65536 ] || {
fm_bridge_fail request_too_large "Bridge requests are limited to 64 KiB"
exit 2
}
printf '%s' "$request" | jq -e . >/dev/null 2>&1 || {
fm_bridge_fail malformed_request "Request must be valid JSON"
exit 2
}
version=$(printf '%s' "$request" | jq -r '.protocolVersion // empty')
[ "$version" = "fm-bridge.v1" ] || {
fm_bridge_fail unsupported_version "Only fm-bridge.v1 is supported"
exit 2
}
operation=$(printf '%s' "$request" | jq -r '.operation // empty')
case "$operation" in
snapshot)
fm_bridge_project_snapshot
;;
command)
command_json=$(printf '%s' "$request" | jq -c '.command // empty')
command_id=$(printf '%s' "$command_json" | jq -r '.commandId // empty')
action=$(printf '%s' "$command_json" | jq -r '.action // empty')
task_id=$(printf '%s' "$command_json" | jq -r '.taskId // empty')
expected=$(printf '%s' "$command_json" | jq -r '.expectedRevision // empty')
[ -n "$command_id" ] && [ -n "$task_id" ] && [ -n "$expected" ] || {
fm_bridge_fail malformed_command "commandId, taskId, and expectedRevision are required"
exit 2
}
case "$command_id" in
*[!A-Za-z0-9._-]*|'') fm_bridge_fail malformed_command "commandId contains unsafe characters"; exit 2 ;;
esac
[ "${#command_id}" -le 128 ] || {
fm_bridge_fail malformed_command "commandId is too long"
exit 2
}
case "$action" in sign-off|defer|feedback|merge) ;; *)
fm_bridge_fail illegal_action "Unsupported Bridge action"
exit 2
esac
journal="$STATE/bridge-command-journal"
mkdir -p "$journal"
lock="$journal/$command_id.lock"
mkdir "$lock" 2>/dev/null || {
fm_bridge_fail command_busy "This command is already being processed"
exit 2
}
trap 'rmdir "$lock" 2>/dev/null || true' EXIT
digest=$(printf '%s' "$command_json" | jq -cS . | fm_bridge_digest)
record="$journal/$command_id.json"
if [ -f "$record" ]; then
prior_digest=$(jq -r '.requestDigest' "$record")
[ "$prior_digest" = "$digest" ] || {
fm_bridge_fail command_id_conflict "commandId was already used for a different request"
exit 2
}
jq -c '.outcome + {replayed:true}' "$record"
exit 0
fi
snapshot=$(fm_bridge_project_snapshot) || exit 1
task=$(printf '%s' "$snapshot" | jq -c --arg id "$task_id" '.tasks[] | select(.id==$id)')
[ -n "$task" ] || {
fm_bridge_fail task_not_found "Task is not present in the current fleet"
exit 2
}
current=$(printf '%s' "$task" | jq -r '.taskRevision')
[ "$current" = "$expected" ] || {
jq -n --arg current "$current" \
'{accepted:false,error:{code:"stale_revision",message:"Task revision changed",currentRevision:$current}}'
exit 2
}
capability=$(printf '%s' "$task" | jq -e --arg action "$action" '.capabilities | index($action) != null')
[ "$capability" = true ] || {
fm_bridge_fail capability_absent "Action is not legal for the current task state"
exit 2
}
if [ "$action" = merge ]; then
fm_bridge_fail merge_requires_guarded_mode "Merge is unavailable until a guarded project mode is configured"
exit 2
fi
if [ "$action" = sign-off ]; then
reviewed_evidence=$(printf '%s' "$command_json" | jq -r '.evidenceRevision // empty')
current_evidence=$(printf '%s' "$task" | jq -r '.evidenceRevision')
[ "$reviewed_evidence" = "$current_evidence" ] || {
fm_bridge_fail stale_evidence "Evidence changed before sign-off"
exit 2
}
fi
case "$action" in
feedback)
feedback=$(printf '%s' "$command_json" | jq -r '.feedback // empty')
[ -n "$feedback" ] || {
fm_bridge_fail malformed_command "Feedback text is required"
exit 2
}
FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-send.sh" "$task_id" "$feedback" >/dev/null || {
fm_bridge_fail endpoint_unavailable "Feedback could not be delivered to the shipmate"
exit 2
}
;;
sign-off|defer)
review_dir="$FM_HOME/data/$task_id"
mkdir -p "$review_dir"
review_record="$review_dir/bridge-review.json"
review_tmp="$review_record.$$"
jq -n --arg action "$action" --arg revision "$current" \
--arg evidenceRevision "$(printf '%s' "$task" | jq -r '.evidenceRevision')" \
--arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{action:$action,taskRevision:$revision,evidenceRevision:$evidenceRevision,recordedAt:$at}' \
> "$review_tmp"
mv "$review_tmp" "$review_record"
;;
esac
outcome=$(jq -n --arg action "$action" --arg task_id "$task_id" \
'{accepted:true,message:("FirstMate accepted " + $action + " for " + $task_id)}')
tmp="$record.$$"
jq -n --arg requestDigest "$digest" --argjson outcome "$outcome" \
'{requestDigest:$requestDigest,outcome:$outcome}' > "$tmp"
mv "$tmp" "$record"
printf '%s' "$outcome"
;;
*)
fm_bridge_fail unsupported_operation "Operation must be snapshot or command"
exit 2
;;
esac
3 changes: 2 additions & 1 deletion bin/fm-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}"
detect_own() {
# Layer 1: environment markers for verified harnesses.
[ "${CLAUDECODE:-}" = "1" ] && { echo claude; return; }
[ "${CODEX_SHELL:-}" = "1" ] && { echo codex; return; }
[ "${PI_CODING_AGENT:-}" = "true" ] && { echo pi; return; }
# grok sets GROK_AGENT=1 for its child/tool processes (verified, grok 0.2.73).
# It does NOT set CLAUDECODE despite being Claude-Code-compatible, so this marker
Expand All @@ -39,7 +40,7 @@ detect_own() {
local pid=$$ comm args
for _ in 1 2 3 4 5 6 7 8; do
comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break
case "$(basename "$comm")" in
case "$(basename -- "$comm")" in
*claude*) echo claude; return ;;
*codex*) echo codex; return ;;
*opencode*) echo opencode; return ;;
Expand Down
Loading
Loading