ff-import.sh 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. #!/usr/bin/env bash
  2. # ff-import.sh - turn a native Claude Code Workflow run into fleetflow lanes.
  3. #
  4. # A native Workflow run dir (wf_*/) holds journal.jsonl (hash-keyed started/
  5. # result records keyed by agentId) plus agent-<id>.jsonl transcripts. This
  6. # script folds its COMPLETED agents into <repo>/.fleetflow/<run>/ as fleetflow
  7. # lanes so ff-collect can read them and ff-status can show them, and surfaces
  8. # incomplete agents (started, never finished) as respawn candidates.
  9. #
  10. # result record -> <id>.prompt.txt + <id>.result.json envelope + journal
  11. # started/result (brain "native", phase "imported") +
  12. # manifest packet {id, brain:"native", imported_from}
  13. # started-only -> <id>.prompt.txt only; reported INCOMPLETE (respawn via
  14. # ff-spawn --run <run> --id <id> --brain <choice> --prompt-file)
  15. #
  16. # CAVEAT: native hash keys are NOT replayable here. An imported result is a
  17. # terminal fact (the object the agent returned); the native script's control
  18. # flow (pipeline/barrier/loop) is not recovered. Continuing the work = spawning
  19. # NEW lanes (e.g. off an incomplete agent's prompt), not resuming the native run.
  20. #
  21. # stdout: one TSV line per agent: id<TAB>imported|incomplete<TAB>prompt_chars.
  22. # stderr: chatter. Exit: 0 ok | 2 usage/bad dir | 3 nothing to import.
  23. set -u
  24. . "$(dirname "${BASH_SOURCE[0]}")/_env.sh"
  25. FF_VERSION="1.1.0"
  26. usage() {
  27. cat <<'EOF'
  28. Usage: ff-import.sh --wf DIR --run NAME [--repo PATH]
  29. --wf DIR native Claude Code Workflow run directory (wf_*/) containing
  30. journal.jsonl and agent-<id>.jsonl transcripts (+ .meta.json)
  31. --run NAME fleetflow run name to create/extend under <repo>/.fleetflow/
  32. --repo PATH repo root (default: git toplevel of cwd)
  33. Completed native agents become fleetflow lanes (prompt + result envelope +
  34. journal + manifest). Incomplete agents (started, no result) get a prompt
  35. file and are flagged INCOMPLETE for respawn. Native keys are NOT replayable -
  36. imported results are terminal facts; the native control flow is not recovered.
  37. EXAMPLES
  38. ff-import.sh --wf ~/.claude/projects/<enc>/<sess>/subagents/workflows/wf_ab12cd34-ef \
  39. --run imported-currency
  40. ff-import.sh --wf ./wf_ab12cd34-ef --run imp --repo /path/to/repo \
  41. | awk -F'\t' '$2=="incomplete"{print "respawn:", $1}'
  42. EOF
  43. }
  44. err() { echo "ff-import: $*" >&2; }
  45. tsv() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; } # id <TAB> status <TAB> chars
  46. WF="" RUN="" REPO=""
  47. while [ $# -gt 0 ]; do
  48. case "$1" in
  49. --wf) WF="${2:-}"; shift 2 ;;
  50. --run) RUN="${2:-}"; shift 2 ;;
  51. --repo) REPO="${2:-}"; shift 2 ;;
  52. -h|--help) usage; exit 0 ;;
  53. *) err "unknown argument: $1"; usage >&2; exit 2 ;;
  54. esac
  55. done
  56. command -v jq >/dev/null || { err "jq required"; exit 2; }
  57. command -v git >/dev/null || { err "git required"; exit 2; }
  58. [ -n "$WF" ] || { err "--wf required"; usage >&2; exit 2; }
  59. [ -n "$RUN" ] || { err "--run required"; usage >&2; exit 2; }
  60. echo "$RUN" | grep -qE '^[a-z0-9-]+$' || { err "invalid --run '$RUN' ([a-z0-9-]+)"; exit 2; }
  61. [ -d "$WF" ] || { err "--wf not a directory: $WF"; exit 2; }
  62. NJ="$WF/journal.jsonl"
  63. [ -f "$NJ" ] || { err "no journal.jsonl in $WF (not a native Workflow run?)"; exit 2; }
  64. [ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true
  65. [ -n "$REPO" ] && [ -d "$REPO" ] || { err "not in a git repo (or --repo invalid)"; exit 2; }
  66. RUNDIR="$REPO/.fleetflow/$RUN"
  67. JOURNAL="$RUNDIR/journal.jsonl"
  68. MANIFEST="$RUNDIR/manifest.json"
  69. mkdir -p "$RUNDIR"
  70. # keep the scratch tree out of git without touching the repo's .gitignore
  71. EXCL="$(git -C "$REPO" rev-parse --absolute-git-dir)/info/exclude"
  72. mkdir -p "$(dirname "$EXCL")"
  73. grep -qs '^\.fleetflow/$' "$EXCL" 2>/dev/null || echo ".fleetflow/" >> "$EXCL"
  74. # Extract the agent's ORIGINAL prompt from its transcript: the first user-role
  75. # message's text. content may be a string (the prompt) or an array of blocks
  76. # (later tool_result turns); we want the first one carrying text. "" if absent.
  77. extract_prompt() { # <transcript-path> -> echoes prompt text
  78. local t="$1"
  79. [ -f "$t" ] || return 1
  80. jq -rs '
  81. [.[] | select(.type=="user" and .message.role=="user") | .message.content
  82. | if type=="string" then .
  83. elif type=="array" then (map(select(.type=="text")) | .[0].text // empty)
  84. else empty end]
  85. | map(select((. // "") != ""))
  86. | .[0] // ""' "$t" 2>/dev/null | tr -d '\r'
  87. }
  88. prompt_abs() { ( cd "$RUNDIR" && pwd ) >/dev/null 2>&1; printf '%s/%s' "$RUNDIR" "$1"; }
  89. # is this agent already imported into the fleetflow journal? (idempotent re-run)
  90. already_imported() { # <id> <key> -> rc 0 if present, 1 if not (stdout suppressed)
  91. [ -f "$JOURNAL" ] || return 1
  92. jq -es --arg id "$1" --arg k "$2" \
  93. '[.[] | objects | select(.type=="started" and .brain=="native" and .id==$id and .key==$k)] | length > 0' \
  94. "$JOURNAL" >/dev/null 2>&1
  95. }
  96. # upsert one packet into the manifest (idempotent by id), creating it if absent
  97. upsert_packet() { # <id> <key>
  98. local id="$1" key="$2" entry
  99. entry="$(jq -nc --arg id "$id" --arg pf "$(prompt_abs "$id.prompt.txt")" \
  100. --arg k "$key" --arg wf "$WF" \
  101. '{id:$id,brain:"native",phase:"imported",prompt_file:$pf,imported_from:$wf,key:$k}')"
  102. if [ ! -s "$MANIFEST" ]; then
  103. jq -nc --arg run "$RUN" --arg by "ff-import/$FF_VERSION" --argjson entry "$entry" \
  104. '{run:$run,base:"main",created_by:$by,phases:["imported"],packets:[$entry]}' > "$MANIFEST"
  105. else
  106. jq --argjson entry "$entry" --arg id "$id" \
  107. '.packets = ((.packets // []) | map(select(.id != $id))) + [$entry]
  108. | .phases = (((.phases // []) + ["imported"]) | unique)' \
  109. "$MANIFEST" > "$MANIFEST.tmp" && mv -f "$MANIFEST.tmp" "$MANIFEST"
  110. fi
  111. }
  112. # --- enumerate agents from the native journal ----------------------------------
  113. # result agents (completed) and started-only agents (incomplete), each with key.
  114. # NB: this Windows jq emits CRLF, so strip \r lest it cling to mid-stream values
  115. # when we re-split the multi-line capture with `read` (the native file itself is LF).
  116. RESULT_IDS="$(jq -r 'select(.type=="result") | "\(.agentId)\t\(.key)"' "$NJ" 2>/dev/null | tr -d '\r')"
  117. STARTED_IDS="$(jq -r 'select(.type=="started") | "\(.agentId)\t\(.key)"' "$NJ" 2>/dev/null | tr -d '\r')"
  118. N_STARTED="$(printf '%s\n' "$STARTED_IDS" | grep -c . || true)"
  119. N_RESULT="$(printf '%s\n' "$RESULT_IDS" | grep -c . || true)"
  120. if [ "$N_STARTED" -eq 0 ] && [ "$N_RESULT" -eq 0 ]; then
  121. err "no started or result records in $NJ - nothing to import"
  122. exit 3
  123. fi
  124. # ids that have a result -> imported; the rest of the started set -> incomplete
  125. HAVE_RESULT="$(printf '%s\n' "$RESULT_IDS" | awk -F'\t' '{print $1}' | sort -u)"
  126. err "importing native run $WF -> $RUNDIR"
  127. # --- completed agents ----------------------------------------------------------
  128. printf '%s\n' "$RESULT_IDS" | while IFS=$'\t' read -r id key; do
  129. [ -n "$id" ] || continue
  130. tf="$WF/agent-$id.jsonl"
  131. prompt="$(extract_prompt "$tf" || true)"
  132. if [ -z "$prompt" ] && [ ! -f "$tf" ]; then
  133. err "WARN: $id has a result but no transcript $tf - empty prompt"
  134. fi
  135. printf '%s' "$prompt" > "$RUNDIR/$id.prompt.txt"
  136. # fleetflow-compatible envelope: result is the native object serialized to a
  137. # JSON STRING so ff-collect prints the original JSON text
  138. jq -c 'select(.type=="result" and .agentId==$id) | {is_error:false, result: ((.result // {}) | tojson)}' \
  139. --arg id "$id" "$NJ" | tail -1 > "$RUNDIR/$id.result.json"
  140. upsert_packet "$id" "$key"
  141. if ! already_imported "$id" "$key"; then
  142. jq -nc --arg k "$key" --arg id "$id" --arg v "$FF_VERSION" \
  143. '{type:"started",key:$k,id:$id,brain:"native",phase:"imported",v:$v}' >> "$JOURNAL"
  144. jq -nc --arg k "$key" --arg id "$id" --arg a "$(prompt_abs "$id.result.json")" \
  145. '{type:"result",key:$k,id:$id,brain:"native",rc:0,artifact:$a}' >> "$JOURNAL"
  146. err "imported $id (result)"
  147. else
  148. err "imported $id (result, already present - refreshed files)"
  149. fi
  150. tsv "$id" imported "$(printf '%s' "$prompt" | wc -c | tr -d ' ')"
  151. done
  152. # --- incomplete agents (started, no result) ------------------------------------
  153. printf '%s\n' "$STARTED_IDS" | while IFS=$'\t' read -r id key; do
  154. [ -n "$id" ] || continue
  155. printf '%s\n' "$HAVE_RESULT" | grep -qxF "$id" && continue # has a result -> skip
  156. tf="$WF/agent-$id.jsonl"
  157. prompt="$(extract_prompt "$tf" || true)"
  158. if [ -z "$prompt" ] && [ ! -f "$tf" ]; then
  159. err "WARN: incomplete $id has no transcript $tf - empty prompt"
  160. fi
  161. printf '%s' "$prompt" > "$RUNDIR/$id.prompt.txt"
  162. err "incomplete $id (started, no result) - respawn candidate"
  163. tsv "$id" incomplete "$(printf '%s' "$prompt" | wc -c | tr -d ' ')"
  164. done
  165. exit 0