| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- #!/usr/bin/env bash
- # ff-import.sh - turn a native Claude Code Workflow run into fleetflow lanes.
- #
- # A native Workflow run dir (wf_*/) holds journal.jsonl (hash-keyed started/
- # result records keyed by agentId) plus agent-<id>.jsonl transcripts. This
- # script folds its COMPLETED agents into <repo>/.fleetflow/<run>/ as fleetflow
- # lanes so ff-collect can read them and ff-status can show them, and surfaces
- # incomplete agents (started, never finished) as respawn candidates.
- #
- # result record -> <id>.prompt.txt + <id>.result.json envelope + journal
- # started/result (brain "native", phase "imported") +
- # manifest packet {id, brain:"native", imported_from}
- # started-only -> <id>.prompt.txt only; reported INCOMPLETE (respawn via
- # ff-spawn --run <run> --id <id> --brain <choice> --prompt-file)
- #
- # CAVEAT: native hash keys are NOT replayable here. An imported result is a
- # terminal fact (the object the agent returned); the native script's control
- # flow (pipeline/barrier/loop) is not recovered. Continuing the work = spawning
- # NEW lanes (e.g. off an incomplete agent's prompt), not resuming the native run.
- #
- # stdout: one TSV line per agent: id<TAB>imported|incomplete<TAB>prompt_chars.
- # stderr: chatter. Exit: 0 ok | 2 usage/bad dir | 3 nothing to import.
- set -u
- . "$(dirname "${BASH_SOURCE[0]}")/_env.sh"
- FF_VERSION="1.1.0"
- usage() {
- cat <<'EOF'
- Usage: ff-import.sh --wf DIR --run NAME [--repo PATH]
- --wf DIR native Claude Code Workflow run directory (wf_*/) containing
- journal.jsonl and agent-<id>.jsonl transcripts (+ .meta.json)
- --run NAME fleetflow run name to create/extend under <repo>/.fleetflow/
- --repo PATH repo root (default: git toplevel of cwd)
- Completed native agents become fleetflow lanes (prompt + result envelope +
- journal + manifest). Incomplete agents (started, no result) get a prompt
- file and are flagged INCOMPLETE for respawn. Native keys are NOT replayable -
- imported results are terminal facts; the native control flow is not recovered.
- EXAMPLES
- ff-import.sh --wf ~/.claude/projects/<enc>/<sess>/subagents/workflows/wf_ab12cd34-ef \
- --run imported-currency
- ff-import.sh --wf ./wf_ab12cd34-ef --run imp --repo /path/to/repo \
- | awk -F'\t' '$2=="incomplete"{print "respawn:", $1}'
- EOF
- }
- err() { echo "ff-import: $*" >&2; }
- tsv() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; } # id <TAB> status <TAB> chars
- WF="" RUN="" REPO=""
- while [ $# -gt 0 ]; do
- case "$1" in
- --wf) WF="${2:-}"; shift 2 ;;
- --run) RUN="${2:-}"; shift 2 ;;
- --repo) REPO="${2:-}"; shift 2 ;;
- -h|--help) usage; exit 0 ;;
- *) err "unknown argument: $1"; usage >&2; exit 2 ;;
- esac
- done
- command -v jq >/dev/null || { err "jq required"; exit 2; }
- command -v git >/dev/null || { err "git required"; exit 2; }
- [ -n "$WF" ] || { err "--wf required"; usage >&2; exit 2; }
- [ -n "$RUN" ] || { err "--run required"; usage >&2; exit 2; }
- echo "$RUN" | grep -qE '^[a-z0-9-]+$' || { err "invalid --run '$RUN' ([a-z0-9-]+)"; exit 2; }
- [ -d "$WF" ] || { err "--wf not a directory: $WF"; exit 2; }
- NJ="$WF/journal.jsonl"
- [ -f "$NJ" ] || { err "no journal.jsonl in $WF (not a native Workflow run?)"; exit 2; }
- [ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true
- [ -n "$REPO" ] && [ -d "$REPO" ] || { err "not in a git repo (or --repo invalid)"; exit 2; }
- RUNDIR="$REPO/.fleetflow/$RUN"
- JOURNAL="$RUNDIR/journal.jsonl"
- MANIFEST="$RUNDIR/manifest.json"
- mkdir -p "$RUNDIR"
- # keep the scratch tree out of git without touching the repo's .gitignore
- EXCL="$(git -C "$REPO" rev-parse --absolute-git-dir)/info/exclude"
- mkdir -p "$(dirname "$EXCL")"
- grep -qs '^\.fleetflow/$' "$EXCL" 2>/dev/null || echo ".fleetflow/" >> "$EXCL"
- # Extract the agent's ORIGINAL prompt from its transcript: the first user-role
- # message's text. content may be a string (the prompt) or an array of blocks
- # (later tool_result turns); we want the first one carrying text. "" if absent.
- extract_prompt() { # <transcript-path> -> echoes prompt text
- local t="$1"
- [ -f "$t" ] || return 1
- jq -rs '
- [.[] | select(.type=="user" and .message.role=="user") | .message.content
- | if type=="string" then .
- elif type=="array" then (map(select(.type=="text")) | .[0].text // empty)
- else empty end]
- | map(select((. // "") != ""))
- | .[0] // ""' "$t" 2>/dev/null | tr -d '\r'
- }
- prompt_abs() { ( cd "$RUNDIR" && pwd ) >/dev/null 2>&1; printf '%s/%s' "$RUNDIR" "$1"; }
- # is this agent already imported into the fleetflow journal? (idempotent re-run)
- already_imported() { # <id> <key> -> rc 0 if present, 1 if not (stdout suppressed)
- [ -f "$JOURNAL" ] || return 1
- jq -es --arg id "$1" --arg k "$2" \
- '[.[] | objects | select(.type=="started" and .brain=="native" and .id==$id and .key==$k)] | length > 0' \
- "$JOURNAL" >/dev/null 2>&1
- }
- # upsert one packet into the manifest (idempotent by id), creating it if absent
- upsert_packet() { # <id> <key>
- local id="$1" key="$2" entry
- entry="$(jq -nc --arg id "$id" --arg pf "$(prompt_abs "$id.prompt.txt")" \
- --arg k "$key" --arg wf "$WF" \
- '{id:$id,brain:"native",phase:"imported",prompt_file:$pf,imported_from:$wf,key:$k}')"
- if [ ! -s "$MANIFEST" ]; then
- jq -nc --arg run "$RUN" --arg by "ff-import/$FF_VERSION" --argjson entry "$entry" \
- '{run:$run,base:"main",created_by:$by,phases:["imported"],packets:[$entry]}' > "$MANIFEST"
- else
- jq --argjson entry "$entry" --arg id "$id" \
- '.packets = ((.packets // []) | map(select(.id != $id))) + [$entry]
- | .phases = (((.phases // []) + ["imported"]) | unique)' \
- "$MANIFEST" > "$MANIFEST.tmp" && mv -f "$MANIFEST.tmp" "$MANIFEST"
- fi
- }
- # --- enumerate agents from the native journal ----------------------------------
- # result agents (completed) and started-only agents (incomplete), each with key.
- # NB: this Windows jq emits CRLF, so strip \r lest it cling to mid-stream values
- # when we re-split the multi-line capture with `read` (the native file itself is LF).
- RESULT_IDS="$(jq -r 'select(.type=="result") | "\(.agentId)\t\(.key)"' "$NJ" 2>/dev/null | tr -d '\r')"
- STARTED_IDS="$(jq -r 'select(.type=="started") | "\(.agentId)\t\(.key)"' "$NJ" 2>/dev/null | tr -d '\r')"
- N_STARTED="$(printf '%s\n' "$STARTED_IDS" | grep -c . || true)"
- N_RESULT="$(printf '%s\n' "$RESULT_IDS" | grep -c . || true)"
- if [ "$N_STARTED" -eq 0 ] && [ "$N_RESULT" -eq 0 ]; then
- err "no started or result records in $NJ - nothing to import"
- exit 3
- fi
- # ids that have a result -> imported; the rest of the started set -> incomplete
- HAVE_RESULT="$(printf '%s\n' "$RESULT_IDS" | awk -F'\t' '{print $1}' | sort -u)"
- err "importing native run $WF -> $RUNDIR"
- # --- completed agents ----------------------------------------------------------
- printf '%s\n' "$RESULT_IDS" | while IFS=$'\t' read -r id key; do
- [ -n "$id" ] || continue
- tf="$WF/agent-$id.jsonl"
- prompt="$(extract_prompt "$tf" || true)"
- if [ -z "$prompt" ] && [ ! -f "$tf" ]; then
- err "WARN: $id has a result but no transcript $tf - empty prompt"
- fi
- printf '%s' "$prompt" > "$RUNDIR/$id.prompt.txt"
- # fleetflow-compatible envelope: result is the native object serialized to a
- # JSON STRING so ff-collect prints the original JSON text
- jq -c 'select(.type=="result" and .agentId==$id) | {is_error:false, result: ((.result // {}) | tojson)}' \
- --arg id "$id" "$NJ" | tail -1 > "$RUNDIR/$id.result.json"
- upsert_packet "$id" "$key"
- if ! already_imported "$id" "$key"; then
- jq -nc --arg k "$key" --arg id "$id" --arg v "$FF_VERSION" \
- '{type:"started",key:$k,id:$id,brain:"native",phase:"imported",v:$v}' >> "$JOURNAL"
- jq -nc --arg k "$key" --arg id "$id" --arg a "$(prompt_abs "$id.result.json")" \
- '{type:"result",key:$k,id:$id,brain:"native",rc:0,artifact:$a}' >> "$JOURNAL"
- err "imported $id (result)"
- else
- err "imported $id (result, already present - refreshed files)"
- fi
- tsv "$id" imported "$(printf '%s' "$prompt" | wc -c | tr -d ' ')"
- done
- # --- incomplete agents (started, no result) ------------------------------------
- printf '%s\n' "$STARTED_IDS" | while IFS=$'\t' read -r id key; do
- [ -n "$id" ] || continue
- printf '%s\n' "$HAVE_RESULT" | grep -qxF "$id" && continue # has a result -> skip
- tf="$WF/agent-$id.jsonl"
- prompt="$(extract_prompt "$tf" || true)"
- if [ -z "$prompt" ] && [ ! -f "$tf" ]; then
- err "WARN: incomplete $id has no transcript $tf - empty prompt"
- fi
- printf '%s' "$prompt" > "$RUNDIR/$id.prompt.txt"
- err "incomplete $id (started, no result) - respawn candidate"
- tsv "$id" incomplete "$(printf '%s' "$prompt" | wc -c | tr -d ' ')"
- done
- exit 0
|