Просмотр исходного кода

merge: fleetflow Wave 1 — manifest, resume, schema-retry, skew-guard, effort, archiving, cache-redirect, autoclean, native import

Nine features across three GLM worker legs (two session-crash recoveries -
the branch survived both; commits are the durability layer). Suite 52 -> 118.
ff-import acceptance-tested against a real GlyphWeb wf_* run: 7 agents
recovered with prompts + structured results.
0xDarkMatter 2 недель назад
Родитель
Сommit
5d8086f3a4

+ 57 - 7
skills/fleetflow/SKILL.md

@@ -127,11 +127,35 @@ sandbox is confined to the lane, so out-of-repo specs are unreadable anyway.
 
 
 **Resume.** The journal (`.fleetflow/<run>/journal.jsonl`) uses the native
 **Resume.** The journal (`.fleetflow/<run>/journal.jsonl`) uses the native
 tool's mechanism: each spawn is keyed by a content hash of
 tool's mechanism: each spawn is keyed by a content hash of
-`(brain, prompt, opts)`. Re-running `ff-spawn` with an unchanged packet returns
-the cached result instantly (exit 3 + path); change the prompt and only that
-lane re-runs. Corollary (same reason the native tool bans `Date.now()`): keep
-timestamps and random values OUT of packet prompts, or the key changes and the
-cache never hits.
+`(brain, prompt, opts)` — and `opts` includes `--effort`, so changing only the
+effort lever is a cache miss (a different run). Re-running `ff-spawn` with an
+unchanged packet returns the cached result instantly (exit 3 + path); change
+the prompt and only that lane re-runs. Corollary (same reason the native tool
+bans `Date.now()`): keep timestamps and random values OUT of packet prompts, or
+the key changes and the cache never hits.
+
+**Manifest & resume.** Each spawn also upserts a packet into
+`.fleetflow/<run>/manifest.json` (`{run, base, created_by, phases[], packets[]}`,
+one entry per id — idempotent — carrying `{id, brain, phase, prompt_file,
+worktree, max_turns, effort, schema, key}`). It is the orchestrator-side plan,
+distinct from the per-spawn journal: it records *what was intended* so a whole
+run can be replayed. `ff-run.sh resume --run NAME` snapshots the manifest's
+packets once, then replays each through `ff-spawn` in manifest order — unchanged
+packets cache-hit (`"cached"`), changed or new ones run live; per-lane summary
+to stderr, a JSON result list on stdout, exit 0 if all ok/cached, 10 if any
+lane failed. `ff-run status --run NAME` is an alias for `ff-status`. Snapshots
+matter: ff-spawn re-orders the live manifest on each upsert (remove-then-append),
+so the replay reads from a frozen copy. When you're done,
+`ff-clean.sh --run NAME [--force]` reclaims zero-commit lanes (worktree + branch
+deleted), keeps committed ones, and removes the run's cache dirs.
+
+**Cache & tmp redirect.** Workers' `UV_CACHE_DIR`, `TMPDIR`, `TMP`, and `TEMP`
+are pointed at `${FLEETFLOW_CACHE_ROOT:-$HOME/.fleet-worker/cache}/<run>-<id>/`
+(created before launch), so pytest/uv litter and codex's AppContainer-ACL'd
+sandbox dirs land OUTSIDE the repo and lanes — never inside a worktree that
+`git worktree remove` later needs to delete. Set `FLEETFLOW_CACHE_ROOT` once for
+the whole run and pass the same value to `ff-clean` so it can find and remove
+those dirs.
 
 
 ## Patterns ported from the native Workflow tool
 ## Patterns ported from the native Workflow tool
 
 
@@ -208,8 +232,11 @@ default the script-author follows, not an option — and real runs routinely hit
 |---|---|
 |---|---|
 | [scripts/ff-doctor.sh](scripts/ff-doctor.sh) | `--offline` structural preflight; `--live` probes GLM endpoint, Codex auth, Anthropic models, reports orchestrator tier (fable/opus) |
 | [scripts/ff-doctor.sh](scripts/ff-doctor.sh) | `--offline` structural preflight; `--live` probes GLM endpoint, Codex auth, Anthropic models, reports orchestrator tier (fable/opus) |
 | [scripts/ff-spawn.sh](scripts/ff-spawn.sh) | uniform spawner: worktree lane + guard preamble + journal + per-brain launch (GLM via fleet-worker, Codex via `codex exec`, Anthropic via `claude -p`) |
 | [scripts/ff-spawn.sh](scripts/ff-spawn.sh) | uniform spawner: worktree lane + guard preamble + journal + per-brain launch (GLM via fleet-worker, Codex via `codex exec`, Anthropic via `claude -p`) |
-| [scripts/ff-collect.sh](scripts/ff-collect.sh) | per-brain result gate; `--check-main-clean` escape guard |
-| [scripts/ff-status.sh](scripts/ff-status.sh) | run status as JSON (lane state, elapsed, commits, tools, tokens, activity); `--watch N --out status.json` feeds the live monitor |
+| [scripts/ff-collect.sh](scripts/ff-collect.sh) | per-brain result gate; strips ```json fences before `--schema` validation; `--repair` respawns a `<id>-repair` lane on validation failure; `--check-main-clean` escape guard |
+| [scripts/ff-status.sh](scripts/ff-status.sh) | run status as JSON (lane state, elapsed, commits, tools, tokens, activity, manifest summary); `--watch N --out status.json` feeds the live monitor |
+| [scripts/ff-run.sh](scripts/ff-run.sh) | `resume --run NAME` replays every manifest packet through ff-spawn in order (unchanged = cached, changed/new = live); `status --run NAME` aliases ff-status |
+| [scripts/ff-clean.sh](scripts/ff-clean.sh) | `--run NAME [--force]` reclaims zero-commit lanes (worktree remove + branch -D), keeps committed lanes, removes the run's cache dirs; reports locked ACL-litter dirs |
+| [scripts/ff-import.sh](scripts/ff-import.sh) | `--wf DIR --run NAME` imports a native Claude Code Workflow run dir (`wf_*/`) — completed agents become lanes (prompt + result envelope + journal + manifest), started-only agents are flagged incomplete; native keys are terminal, not replayable |
 
 
 **Live monitor** ([assets/ff-monitor.html](assets/ff-monitor.html)): a
 **Live monitor** ([assets/ff-monitor.html](assets/ff-monitor.html)): a
 zero-dependency page reproducing the native /workflows progress surface — run
 zero-dependency page reproducing the native /workflows progress surface — run
@@ -231,6 +258,29 @@ All follow the Skill Resource Protocol: stdout is data, chatter on stderr,
 semantic exit codes (`0` ok, `2` usage, `3` cached/missing, `7` unreachable,
 semantic exit codes (`0` ok, `2` usage, `3` cached/missing, `7` unreachable,
 `10` worker failed, `12` escape detected), `--help` with EXAMPLES.
 `10` worker failed, `12` escape detected), `--help` with EXAMPLES.
 
 
+## Importing a native Workflow run
+
+`ff-import --wf <wf_*/> --run <name>` reads a native Claude Code Workflow run
+directory — its `journal.jsonl` (`started`/`result` records with `v2:` hash
+keys and `agentId`) plus per-agent `agent-<id>.jsonl` transcripts — and lands
+each completed agent as a fleetflow lane: the agent's first user-role message
+(string content or content-array-with-text-blocks, both handled) becomes
+`<id>.prompt.txt`, its native `result` object becomes `<id>.result.json`
+(`{is_error:false, result:<native-result>|tojson}`), and a `native`-brain
+packet is appended to the manifest (`imported_from: <DIR>`) for provenance.
+Agents with a `started` but no `result` get a prompt file only and are
+reported `incomplete` on stdout's TSV — respawn candidates.
+
+**Caveat — imported results are terminal facts, not a replayable cache.** The
+native `v2:` keys are content hashes of the *native* `(prompt, opts)` call, not
+fleetflow's `sha256(brain+prompt+opts)`, and `native` is not a spawnable brain
+— so `ff-run resume` **skips** native packets rather than replaying them (it
+reports each `imported` and exits 0). The native script's control flow
+(pipeline/barrier/loop) is not recovered either. To continue from an imported
+result, spawn a fresh lane with a real brain and paste the imported result into
+its packet (the hub-and-spoke handoff). Use import for salvage, provenance, and
+visual continuity in the monitor — not to resume native work in place.
+
 ## References
 ## References
 
 
 - [references/native-workflow-insights.md](references/native-workflow-insights.md)
 - [references/native-workflow-insights.md](references/native-workflow-insights.md)

+ 11 - 5
skills/fleetflow/assets/ff-monitor.html

@@ -70,14 +70,20 @@ const esc = t => (t||"").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&
 const open = new Set();
 const open = new Set();
 let misses = 0;
 let misses = 0;
 async function tick() {
 async function tick() {
-  let d;
-  try { d = await (await fetch("status.json?t="+Date.now())).json(); misses = 0; }
-  catch (e) {
+  let d = null;
+  try { d = await (await fetch("status.json?t="+Date.now())).json(); }
+  catch (e) { d = null; }
+  // torn-write guard: ff-status rewrites status.json via status.json.tmp -> mv,
+  // so a reader can catch a transient {"lanes":[]} (or an unparseable shard)
+  // mid-replace. Treat an empty/missing-lanes payload like a fetch miss - keep
+  // the previous render and count it toward the 3-miss warning.
+  if (!d || !Array.isArray(d.lanes) || d.lanes.length === 0) {
     if (++misses >= 3) document.getElementById("foot").textContent =
     if (++misses >= 3) document.getElementById("foot").textContent =
-      "status.json unreachable — is ff-status --watch running?";
+      "status.json empty or unreachable — torn write, or ff-status --watch not running?";
     return;
     return;
   }
   }
-  const lanes = d.lanes || [];
+  misses = 0;
+  const lanes = d.lanes;
   const running = lanes.filter(l => l.state === "running").length;
   const running = lanes.filter(l => l.state === "running").length;
   document.getElementById("run").textContent = d.run;
   document.getElementById("run").textContent = d.run;
   const oldest = Math.min(...lanes.map(l => l.started).filter(Boolean));
   const oldest = Math.min(...lanes.map(l => l.started).filter(Boolean));

+ 46 - 1
skills/fleetflow/references/worker-contracts.md

@@ -109,11 +109,15 @@ judge", applied to yourself).
 
 
 ```
 ```
 <repo>/.fleetflow/<run>/
 <repo>/.fleetflow/<run>/
-├── journal.jsonl        # started/result records, v2 content-hash keys
+├── journal.jsonl        # started/result records, v2 content-hash keys (v: FF_VERSION)
+├── manifest.json        # orchestrator plan: {run,base,created_by,phases[],packets[]}
+├── main-baseline.txt    # main-checkout status snapshot (escape-guard baseline)
 ├── <id>.prompt.txt      # guard preamble + packet (what was actually sent)
 ├── <id>.prompt.txt      # guard preamble + packet (what was actually sent)
 ├── <id>.result.json     # claude JSON envelope (glm/anthropic brains)
 ├── <id>.result.json     # claude JSON envelope (glm/anthropic brains)
 ├── <id>.last.txt        # codex last-message (codex brain)
 ├── <id>.last.txt        # codex last-message (codex brain)
 ├── <id>.events.jsonl    # codex --json event stream (codex brain)
 ├── <id>.events.jsonl    # codex --json event stream (codex brain)
+├── <id>.transcript.jsonl# archived session transcript (claude-brain lanes; best-effort)
+├── <id>.invalid.txt     # last failed schema output (only when --repair ran)
 ├── <id>.err             # stderr
 ├── <id>.err             # stderr
 └── wt-<id>/             # the lane worktree (branch fleetflow/<run>/<id>)
 └── wt-<id>/             # the lane worktree (branch fleetflow/<run>/<id>)
 ```
 ```
@@ -121,3 +125,44 @@ judge", applied to yourself).
 `.fleetflow/` sits at the repo top (never under `.claude/` — Claude Code's
 `.fleetflow/` sits at the repo top (never under `.claude/` — Claude Code's
 sensitive-file guard fires there before `bypassPermissions`) and must be
 sensitive-file guard fires there before `bypassPermissions`) and must be
 gitignored; `ff-spawn` appends it to `.git/info/exclude` if absent.
 gitignored; `ff-spawn` appends it to `.git/info/exclude` if absent.
+
+## 6. Effort lever, cache redirect, transcript archiving (Wave 1)
+
+**Effort lever** (`ff-spawn --effort low|medium|high|max`). Default unset =
+inherit the brain's own. The mapping (effort IS part of the cache-key OPTS
+string, so changing only effort is a cache miss):
+
+| Brain | How effort is applied |
+|---|---|
+| glm (fleet-worker) | `FLEET_WORKER_EFFORT=<v>` in the worker env |
+| codex (`codex exec`) | `-c model_reasoning_effort="<v>"` |
+| sonnet/opus/haiku/fable | `claude -p --settings '{"effortLevel":"<v>"}'` |
+
+Doctrine (carried from the native tool): reach for the effort lever before the
+model lever. Effort is recorded in the manifest packet, so `ff-run resume`
+replays each lane at its captured effort.
+
+**Cache & tmp redirect.** Every worker launch exports `UV_CACHE_DIR`,
+`TMPDIR`, `TMP`, `TEMP` into the worker env pointing at
+`${FLEETFLOW_CACHE_ROOT:-$HOME/.fleet-worker/cache}/<run>-<id>/` (created
+pre-launch). This keeps uv/pytest cache and codex sandbox litter OUT of the
+repo and lanes — the AppContainer-ACL'd dirs codex leaves behind resist
+unelevated deletion and would otherwise block `git worktree remove` (the
+`sandbox litter gotcha`, §2). Set `FLEETFLOW_CACHE_ROOT` once for the whole run
+and pass the same value to `ff-clean` so it can reclaim the dirs.
+
+**Transcript archiving** (best-effort, never fails the lane). After a
+non-dry-run claude-brain lane, the session transcript is copied to
+`<rundir>/<id>.transcript.jsonl`:
+
+- **glm**: newest `projects/*/*.jsonl` under the worker's isolated config dir
+  (`FLEET_WORKER_CONFIG_DIR`, default `$HOME/.fleet-worker/cfg-ff-<id>`).
+- **sonnet/opus/haiku/fable**: the result envelope's `session_id` resolves to
+  `$HOME/.claude/projects/<encoded-workdir>/<session_id>.jsonl`, where the
+  encoding is per-char `[:\\/.]` → `-` (verified empirically: `C:\Users\Mack`
+  → `C--Users-Mack`). Falls back to a `<session_id>.jsonl` search across all
+  project dirs if the encoded path misses.
+
+If the source transcript can't be found, the lane logs a `transcript source
+not found ... skipped (non-fatal)` warning and continues — the transcript is a
+convenience for inspection/audit, not a gate.

+ 135 - 0
skills/fleetflow/scripts/ff-clean.sh

@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+# ff-clean.sh - reclaim a fleetflow run's lanes, branches, and cache dirs.
+#
+# Per lane (worktree lane under <run>/wt-<id>):
+#   zero commits + clean      -> git worktree remove + branch -D   ("removed")
+#   zero commits + dirty      -> "kept" unless --force              (--force removes)
+#   N>0 commits               -> "kept (N commits)"                 (never auto-remove)
+#   locked ACL-litter dir     -> "locked (needs elevation)"         (continue, non-fatal)
+# Then removes each lane's cache dir under FLEETFLOW_CACHE_ROOT (default
+# $HOME/.fleet-worker/cache/<run>-<id>). Removal never aborts the run: a locked
+# lane is reported and skipped, and the script exits 0 once it has tried them all.
+# stdout: one TSV line per lane: id<TAB>status<TAB>detail. stderr: chatter.
+#
+# Exit codes: 0 done (incl. some locked lanes) | 2 usage / no such run
+set -u
+
+FF_VERSION="1.1.0"
+
+usage() {
+  cat <<'EOF'
+Usage: ff-clean.sh --run NAME [--repo PATH] [--force]
+
+  --run NAME      run name under <repo>/.fleetflow/
+  --repo PATH     repo root (default: git toplevel of cwd)
+  --force         also remove DIRTY zero-commit lanes (committed lanes are
+                  ALWAYS kept - land or branch them first, then clean)
+
+  Lanes with commits are never removed (their work would be lost); clean only
+  after ff-collect has gated them and the orchestrator has landed/branched the
+  keepers. Locked codex sandbox-litter dirs are reported and skipped.
+
+EXAMPLES
+  ff-clean.sh --run currency
+  ff-clean.sh --run currency --force
+  ff-clean.sh --run currency --repo /path/to/repo | column -t
+EOF
+}
+
+err() { echo "ff-clean: $*" >&2; }
+emit() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; }   # id <TAB> status <TAB> detail
+
+RUN="" REPO="" FORCE=0
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --run) RUN="${2:-}"; shift 2 ;;
+    --repo) REPO="${2:-}"; shift 2 ;;
+    --force) FORCE=1; shift ;;
+    -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 "$RUN" ] || { err "--run required"; usage >&2; 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"
+MANIFEST="$RUNDIR/manifest.json"
+[ -d "$RUNDIR" ] || { err "no such run: $RUNDIR"; exit 2; }
+
+CACHE_ROOT="${FLEETFLOW_CACHE_ROOT:-$HOME/.fleet-worker/cache}"
+BASE="$(jq -r '.base // "main"' "$MANIFEST" 2>/dev/null)"; [ -n "$BASE" ] || BASE="main"
+
+# enumerate lane ids: manifest packets (authoritative) + any wt-* dirs left on
+# disk (e.g. manifest pruned but lanes remain) + journal started ids (fallback).
+list_lane_ids() {
+  local ids=""
+  [ -f "$MANIFEST" ] && ids="$(jq -r '.packets[].id' "$MANIFEST" 2>/dev/null)"
+  local d
+  for d in "$RUNDIR"/wt-*; do
+    [ -d "$d" ] || continue
+    ids="$ids
+$(basename "$d" | sed 's/^wt-//')"
+  done
+  if [ -z "$ids" ] && [ -f "$RUNDIR/journal.jsonl" ]; then
+    ids="$(jq -r 'select(.type=="started") | .id' "$RUNDIR/journal.jsonl" 2>/dev/null)"
+  fi
+  printf '%s\n' "$ids" | awk 'NF && !seen[$0]++'
+}
+
+# remove_lane <id> <force>: worktree remove (+ branch -D on success). Reports
+# "locked" if the OS refuses to delete (codex AppContainer-ACL litter).
+remove_lane() {
+  local id="$1" f="$2" wt="$RUNDIR/wt-$id" br="fleetflow/$RUN/$id" wflag=""
+  [ "$f" = 1 ] && wflag="--force"
+  if git -C "$REPO" worktree remove $wflag "$wt" 2>>"$RUNDIR/$id.clean.err"; then
+    git -C "$REPO" branch -D "$br" >/dev/null 2>>"$RUNDIR/$id.clean.err" \
+      && emit "$id" removed "worktree + branch gone" \
+      || emit "$id" removed "worktree gone (branch delete skipped)"
+  else
+    emit "$id" locked "needs elevation (see $RUNDIR/$id.clean.err)"
+  fi
+}
+
+# clean_cache <id>: best-effort removal of the lane's cache/tmp dir.
+clean_cache() {
+  local id="$1" c="$CACHE_ROOT/$RUN-$id"
+  [ -d "$c" ] || return 0
+  if rm -rf "$c" 2>>"$RUNDIR/$id.clean.err"; then err "cache removed: $c"
+  else err "cache locked (needs elevation): $c"; fi
+}
+
+NLANES="$(list_lane_ids | wc -l | tr -d ' ')"
+err "cleaning run '$RUN': $NLANES lane(s); FORCE=$FORCE; base=$BASE; cache=$CACHE_ROOT"
+if [ "$NLANES" = "0" ]; then
+  err "no lanes to clean"
+  exit 0
+fi
+
+# base ref must exist for rev-list counting; fall back to HEAD once for the run
+git -C "$REPO" show-ref --verify --quiet "refs/heads/$BASE" || BASE="HEAD"
+
+while read -r id; do
+  [ -n "$id" ] || continue
+  wt="$RUNDIR/wt-$id"
+  if [ -d "$wt" ]; then
+    commits="$(git -C "$wt" rev-list --count "$BASE..HEAD" 2>/dev/null || echo 0)"
+    dirt="$(git -C "$wt" status --porcelain 2>/dev/null)"
+    if [ "${commits:-0}" -eq 0 ] && [ -z "$dirt" ]; then
+      remove_lane "$id" 0
+    elif [ "${commits:-0}" -eq 0 ] && [ -n "$dirt" ]; then
+      if [ "$FORCE" = 1 ]; then remove_lane "$id" 1
+      else emit "$id" kept "dirty, zero commits (use --force)"; fi
+    else
+      emit "$id" kept "$commits commits"
+    fi
+  else
+    emit "$id" kept "no worktree lane"
+  fi
+  clean_cache "$id"
+done < <(list_lane_ids)
+
+exit 0

+ 63 - 5
skills/fleetflow/scripts/ff-collect.sh

@@ -12,35 +12,42 @@
 #             12 escape detected
 #             12 escape detected
 set -u
 set -u
 
 
+FF_VERSION="1.1.0"
+
 usage() {
 usage() {
   cat <<'EOF'
   cat <<'EOF'
-Usage: ff-collect.sh --run NAME --id ID [--repo PATH] [--schema]
+Usage: ff-collect.sh --run NAME --id ID [--repo PATH] [--schema] [--repair]
        ff-collect.sh --check-main-clean [--repo PATH] [--run NAME]
        ff-collect.sh --check-main-clean [--repo PATH] [--run NAME]
 
 
   --run NAME           run name
   --run NAME           run name
   --id ID              lane id to gate
   --id ID              lane id to gate
   --repo PATH          repo root (default: git toplevel of cwd)
   --repo PATH          repo root (default: git toplevel of cwd)
   --schema             the lane used a JSON schema: require the final text to
   --schema             the lane used a JSON schema: require the final text to
-                       parse as JSON
+                       parse as JSON (markdown code fences are stripped first)
+  --repair             on --schema failure: save the bad output to
+                       <run>/<id>.invalid.txt and respawn a <id>-repair lane
+                       (one attempt); print the repaired text on success
   --check-main-clean   escape guard - compare the MAIN checkout's git status
   --check-main-clean   escape guard - compare the MAIN checkout's git status
                        against the run's baseline; exit 12 on new entries
                        against the run's baseline; exit 12 on new entries
 
 
 EXAMPLES
 EXAMPLES
   ff-collect.sh --run audit --id ts-refresh
   ff-collect.sh --run audit --id ts-refresh
   ff-collect.sh --run audit --id dissent-1 --schema
   ff-collect.sh --run audit --id dissent-1 --schema
+  ff-collect.sh --run audit --id verdict --schema --repair
   ff-collect.sh --check-main-clean --run audit
   ff-collect.sh --check-main-clean --run audit
 EOF
 EOF
 }
 }
 
 
 err() { echo "ff-collect: $*" >&2; }
 err() { echo "ff-collect: $*" >&2; }
 
 
-RUN="" ID="" REPO="" SCHEMA=0 CHECK_CLEAN=0
+RUN="" ID="" REPO="" SCHEMA=0 CHECK_CLEAN=0 REPAIR=0 JQERR=""
 while [ $# -gt 0 ]; do
 while [ $# -gt 0 ]; do
   case "$1" in
   case "$1" in
     --run) RUN="${2:-}"; shift 2 ;;
     --run) RUN="${2:-}"; shift 2 ;;
     --id) ID="${2:-}"; shift 2 ;;
     --id) ID="${2:-}"; shift 2 ;;
     --repo) REPO="${2:-}"; shift 2 ;;
     --repo) REPO="${2:-}"; shift 2 ;;
     --schema) SCHEMA=1; shift ;;
     --schema) SCHEMA=1; shift ;;
+    --repair) REPAIR=1; shift ;;
     --check-main-clean) CHECK_CLEAN=1; shift ;;
     --check-main-clean) CHECK_CLEAN=1; shift ;;
     -h|--help) usage; exit 0 ;;
     -h|--help) usage; exit 0 ;;
     *) err "unknown argument: $1"; usage >&2; exit 2 ;;
     *) err "unknown argument: $1"; usage >&2; exit 2 ;;
@@ -51,6 +58,45 @@ command -v jq >/dev/null || { err "jq required"; exit 2; }
 [ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true
 [ -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; }
 [ -n "$REPO" ] && [ -d "$REPO" ] || { err "not in a git repo (or --repo invalid)"; exit 2; }
 
 
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SPAWN="$HERE/ff-spawn.sh"
+COLLECT="$HERE/ff-collect.sh"
+
+# strip markdown code fences (```json ... ```) so schema gating tolerates fenced replies
+strip_fences() { sed -E '/^[[:space:]]*```[[:alnum:]]*[[:space:]]*$/d'; }
+
+# stdin = text; returns 0 if valid JSON else 1; sets global JQERR to the parse error
+json_ok() {
+  local t; t="$(cat)"
+  JQERR="$(printf '%s' "$t" | jq empty 2>&1 1>/dev/null | head -1)"
+  printf '%s' "$t" | jq empty >/dev/null 2>&1
+}
+
+# do_repair <bad_text> <jq_error>: save bad output, respawn a <id>-repair lane
+# (same brain, --max-turns 3, no worktree), gate it, print its text on success.
+# One attempt only. Exits 0 (repaired) or 10 (repair also failed). FLEETFLOW_REPAIR_DRYRUN
+# forces the respawn to --dry-run (test/offline seam).
+do_repair() {
+  local bad="$1" jqerr="$2" rid="${ID}-repair" pf spawn_rc=0 rout rc2
+  pf="$RUNDIR/$rid.prompt-src.txt"
+  printf '%s' "$bad" > "$RUNDIR/$ID.invalid.txt"
+  err "schema validation failed (--repair): saved $RUNDIR/$ID.invalid.txt; respawning lane $rid"
+  printf 'Your previous FINAL REPLY failed JSON validation. Error: %s. Previous output: %s. Reply with ONLY the corrected JSON object, nothing else.' \
+    "$jqerr" "$bad" > "$pf"
+  if [ -n "${FLEETFLOW_REPAIR_DRYRUN:-}" ]; then
+    bash "$SPAWN" --run "$RUN" --id "$rid" --brain "$BRAIN" --max-turns 3 \
+      --repo "$REPO" --prompt-file "$pf" --dry-run >/dev/null 2>"$RUNDIR/$rid.spawn.err" || spawn_rc=$?
+  else
+    bash "$SPAWN" --run "$RUN" --id "$rid" --brain "$BRAIN" --max-turns 3 \
+      --repo "$REPO" --prompt-file "$pf" >/dev/null 2>"$RUNDIR/$rid.spawn.err" || spawn_rc=$?
+  fi
+  case "$spawn_rc" in 0|3) ;; *) err "repair spawn failed (rc=$spawn_rc; see $RUNDIR/$rid.spawn.err)"; exit 10 ;; esac
+  # gate the repair lane WITHOUT --repair (one attempt); its stdout is the repaired text
+  rout="$(bash "$COLLECT" --run "$RUN" --id "$rid" --repo "$REPO" --schema 2>"$RUNDIR/$rid.collect.err")"; rc2=$?
+  if [ "$rc2" = 0 ]; then printf '%s\n' "$rout"; exit 0; fi
+  err "repair lane also failed validation (see $RUNDIR/$rid.collect.err)"; exit 10
+}
+
 # --- escape guard -------------------------------------------------------------
 # --- escape guard -------------------------------------------------------------
 if [ "$CHECK_CLEAN" = 1 ]; then
 if [ "$CHECK_CLEAN" = 1 ]; then
   BASELINE=""
   BASELINE=""
@@ -86,7 +132,13 @@ if [ "$BRAIN" = "codex" ] || { [ -z "$BRAIN" ] && [ -f "$RUNDIR/$ID.last.txt" ];
   ART="$RUNDIR/$ID.last.txt"
   ART="$RUNDIR/$ID.last.txt"
   [ -s "$ART" ] || { err "codex last-message missing/empty: $ART"; exit 3; }
   [ -s "$ART" ] || { err "codex last-message missing/empty: $ART"; exit 3; }
   if [ "$SCHEMA" = 1 ]; then
   if [ "$SCHEMA" = 1 ]; then
-    jq empty < "$ART" 2>/dev/null || { err "final message is not valid JSON (schema lane)"; exit 10; }
+    CLEAN="$(strip_fences < "$ART")"
+    if ! printf '%s' "$CLEAN" | json_ok; then
+      [ "$REPAIR" = 1 ] && do_repair "$CLEAN" "$JQERR"
+      err "final message is not valid JSON (schema lane): $JQERR"; exit 10
+    fi
+    printf '%s\n' "$CLEAN"
+    exit 0
   fi
   fi
   cat "$ART"
   cat "$ART"
   exit 0
   exit 0
@@ -104,7 +156,13 @@ if [ "$IS_ERR" != "false" ]; then
 fi
 fi
 TEXT="$(jq -r '.result // empty' "$ART")"
 TEXT="$(jq -r '.result // empty' "$ART")"
 if [ "$SCHEMA" = 1 ]; then
 if [ "$SCHEMA" = 1 ]; then
-  printf '%s' "$TEXT" | jq empty 2>/dev/null || { err "final text is not valid JSON (schema lane)"; exit 10; }
+  CLEAN="$(printf '%s' "$TEXT" | strip_fences)"
+  if ! printf '%s' "$CLEAN" | json_ok; then
+    [ "$REPAIR" = 1 ] && do_repair "$CLEAN" "$JQERR"
+    err "final text is not valid JSON (schema lane): $JQERR"; exit 10
+  fi
+  printf '%s\n' "$CLEAN"
+  exit 0
 fi
 fi
 printf '%s\n' "$TEXT"
 printf '%s\n' "$TEXT"
 exit 0
 exit 0

+ 27 - 0
skills/fleetflow/scripts/ff-doctor.sh

@@ -12,6 +12,8 @@
 #             10 structural failure
 #             10 structural failure
 set -u
 set -u
 
 
+FF_VERSION="1.1.0"
+
 usage() {
 usage() {
   cat <<'EOF'
   cat <<'EOF'
 Usage: ff-doctor.sh [--offline | --live]
 Usage: ff-doctor.sh [--offline | --live]
@@ -54,6 +56,31 @@ for s in ff-spawn.sh ff-collect.sh ff-status.sh; do
 done
 done
 [ -f "$HERE/../assets/guard-preamble.txt" ] && say "guard-preamble" ok "present" || { say "guard-preamble" fail "missing"; FAIL=1; }
 [ -f "$HERE/../assets/guard-preamble.txt" ] && say "guard-preamble" ok "present" || { say "guard-preamble" fail "missing"; FAIL=1; }
 
 
+# --- install-sync: repo copy vs the installed copy at $HOME/.claude/skills ---
+# version-skew guard. Only compares when an installed copy exists AND is a
+# different directory from the one being run (running from the install itself
+# trivially matches). Drift is advisory, never a hard fail.
+INST="$HOME/.claude/skills/fleetflow/scripts"
+if [ -d "$INST" ]; then
+  INST_ABS="$(cd "$INST" 2>/dev/null && pwd)"
+  if [ -n "$INST_ABS" ] && [ "$INST_ABS" != "$HERE" ]; then
+    DIFF=0
+    for s in ff-spawn.sh ff-collect.sh ff-status.sh ff-doctor.sh ff-run.sh ff-clean.sh; do
+      [ -f "$HERE/$s" ] || continue            # not shipped here; skip
+      if [ ! -f "$INST/$s" ]; then DIFF=1; break; fi
+      h1="$(sha256sum "$HERE/$s" | cut -d' ' -f1)"
+      h2="$(sha256sum "$INST/$s" | cut -d' ' -f1)"
+      [ "$h1" = "$h2" ] || { DIFF=1; break; }
+    done
+    if [ "$DIFF" = 0 ]; then say "install-sync" ok "repo and installed copies match"
+    else say "install-sync" advisory "repo and installed copies differ - re-run install"; fi
+  else
+    say "install-sync" ok "running from the installed copy"
+  fi
+else
+  say "install-sync" advisory "no installed copy at $INST"
+fi
+
 [ "$MODE" = "live" ] || { [ "$FAIL" = 0 ] && exit 0 || exit 10; }
 [ "$MODE" = "live" ] || { [ "$FAIL" = 0 ] && exit 0 || exit 10; }
 
 
 # --- live probes ----------------------------------------------------------------
 # --- live probes ----------------------------------------------------------------

+ 183 - 0
skills/fleetflow/scripts/ff-import.sh

@@ -0,0 +1,183 @@
+#!/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
+
+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

+ 133 - 0
skills/fleetflow/scripts/ff-run.sh

@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+# ff-run.sh - whole-run replay/status for a fleetflow run.
+#
+# resume: replay every packet in <run>/manifest.json through ff-spawn, IN
+#   MANIFEST ORDER, sequential. Unchanged packets cache-hit (ff-spawn exit 3)
+#   and are reported "cached"; changed/new packets run live. A per-lane summary
+#   goes to stderr; a JSON result array goes to stdout. Exit 0 if every lane is
+#   ok or cached, 10 if any lane failed.
+# status: convenience alias for ff-status (its JSON on stdout, identical exit).
+# stdout: the JSON result list (resume) / ff-status JSON (status). stderr: chatter.
+#
+# Exit codes: 0 all ok/cached | 2 usage | 10 a lane failed
+set -u
+
+FF_VERSION="1.1.0"
+
+usage() {
+  cat <<'EOF'
+Usage: ff-run.sh resume  --run NAME [--repo PATH]
+       ff-run.sh status  --run NAME [--repo PATH]
+
+  resume --run NAME   replay every packet in <run>/manifest.json through
+                      ff-spawn, in order. Unchanged packets cache-hit ("cached"),
+                      changed/new ones run live. JSON result list on stdout.
+  status --run NAME   alias for ff-status (run status JSON on stdout).
+  --repo PATH         repo root (default: git toplevel of cwd)
+
+EXAMPLES
+  ff-run.sh resume --run currency
+  ff-run.sh resume --run currency --repo /path/to/repo | jq '.[] | select(.status!="cached")'
+  ff-run.sh status --run currency | jq '.lanes | length'
+EOF
+}
+
+err() { echo "ff-run: $*" >&2; }
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SPAWN="$HERE/ff-spawn.sh"
+
+MODE="" RUN="" REPO=""
+[ $# -gt 0 ] || { err "a subcommand is required (resume|status)"; usage >&2; exit 2; }
+case "$1" in
+  resume) MODE="resume"; shift ;;
+  status) MODE="status"; shift ;;
+  -h|--help) usage; exit 0 ;;
+  *) err "unknown subcommand: $1"; usage >&2; exit 2 ;;
+esac
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --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 "$RUN" ] || { err "--run required"; usage >&2; 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; }
+
+# status = alias for ff-status (hand off and let it own exit codes)
+if [ "$MODE" = "status" ]; then
+  if [ -n "$REPO" ]; then exec "$HERE/ff-status.sh" --run "$RUN" --repo "$REPO"; fi
+  exec "$HERE/ff-status.sh" --run "$RUN"
+fi
+
+RUNDIR="$REPO/.fleetflow/$RUN"
+MANIFEST="$RUNDIR/manifest.json"
+[ -f "$MANIFEST" ] || { err "no manifest at $MANIFEST (run ff-spawn first)"; exit 2; }
+
+# Snapshot the packets ONCE, before replay. ff-spawn upserts each packet on the
+# way in (remove-then-append), which REORDERS the live manifest - so re-reading
+# .packets[$i] mid-loop would drift and revisit the same packet. The snapshot is
+# the replay contract: spawn order = the order captured here, frozen.
+PACKETS="$(jq -c '.packets' "$MANIFEST" 2>/dev/null)"
+N="$(printf '%s' "$PACKETS" | jq -r 'length' 2>/dev/null)"
+[ "${N:-0}" -gt 0 ] 2>/dev/null || { err "manifest has no packets to replay"; exit 2; }
+
+err "resume: replaying $N packet(s) from $MANIFEST (sequential)"
+err "  #   id                       brain     status"
+err "  --  -----------------------  --------  --------"
+RESULTS="[]"
+ANY_FAIL=0
+i=0
+while [ "$i" -lt "$N" ]; do
+  pid="$(printf '%s' "$PACKETS" | jq -r ".[$i].id")"
+  pbrain="$(printf '%s' "$PACKETS" | jq -r ".[$i].brain")"
+  # imported native packets are terminal facts, not replayable ("native" is not a
+  # spawnable brain - ff-spawn rejects it). ff-run resume SKIPS them; to continue
+  # from an imported result, spawn a fresh lane with a real brain. See ff-import.
+  if [ "$pbrain" = "native" ]; then
+    err "  $((i+1))   $(printf '%-23s' "$pid")  native     imported (skipped)"
+    RESULTS="$(jq -nc --argjson R "$RESULTS" --arg id "$pid" --arg s "imported" --argjson rc 0 \
+      '$R + [{id:$id,status:$s,rc:$rc}]')"
+    i=$((i+1)); continue
+  fi
+  pphase="$(printf '%s' "$PACKETS" | jq -r ".[$i].phase // \"build\"")"
+  ppf="$(printf '%s' "$PACKETS" | jq -r ".[$i].prompt_file")"
+  pwt="$(printf '%s' "$PACKETS" | jq -r ".[$i].worktree // false")"
+  pmt="$(printf '%s' "$PACKETS" | jq -r ".[$i].max_turns // 100")"
+  peff="$(printf '%s' "$PACKETS" | jq -r ".[$i].effort // \"\"")"
+  psch="$(printf '%s' "$PACKETS" | jq -r ".[$i].schema // \"\"")"
+  # worktree is a boolean string ("true"/"false"); both are non-empty, so gate on
+  # the literal value rather than ${pwt:+...} (which would always fire).
+  WT_FLAG=""; [ "$pwt" = "true" ] && WT_FLAG="1"
+
+  bash "$SPAWN" --run "$RUN" --id "$pid" --brain "$pbrain" --phase "$pphase" \
+    --prompt-file "$ppf" --max-turns "$pmt" --repo "$REPO" \
+    ${WT_FLAG:+--worktree} ${peff:+--effort "$peff"} ${psch:+--schema "$psch"} \
+    >/dev/null 2>>"$RUNDIR/$pid.resume.err"
+  rc=$?
+  case "$rc" in
+    0) status="ran" ;;
+    3) status="cached" ;;
+    *) status="failed"; ANY_FAIL=1 ;;
+  esac
+  err "  $((i+1))   $(printf '%-23s' "$pid")  $(printf '%-8s' "$pbrain")  $status${rc:+ (rc=$rc)}"
+  RESULTS="$(jq -nc --argjson R "$RESULTS" --arg id "$pid" --arg s "$status" --argjson rc "$rc" \
+    '$R + [{id:$id,status:$s,rc:$rc}]')"
+  i=$((i+1))
+done
+
+RAN="$(printf '%s' "$RESULTS" | jq -r '[.[]|select(.status=="ran")]|length')"
+CACHED="$(printf '%s' "$RESULTS" | jq -r '[.[]|select(.status=="cached")]|length')"
+FAILED="$(printf '%s' "$RESULTS" | jq -r '[.[]|select(.status=="failed")]|length')"
+err "  --"
+err "  summary: $RAN ran, $CACHED cached, $FAILED failed"
+
+printf '%s\n' "$RESULTS"
+[ "$ANY_FAIL" = 1 ] && exit 10
+exit 0

+ 86 - 8
skills/fleetflow/scripts/ff-spawn.sh

@@ -10,11 +10,14 @@
 #             5 missing dependency | 10 worker failed
 #             5 missing dependency | 10 worker failed
 set -u
 set -u
 
 
+FF_VERSION="1.1.0"
+
 usage() {
 usage() {
   cat <<'EOF'
   cat <<'EOF'
 Usage: ff-spawn.sh --run NAME --id ID --brain BRAIN --prompt-file FILE
 Usage: ff-spawn.sh --run NAME --id ID --brain BRAIN --prompt-file FILE
                    [--worktree] [--base BRANCH] [--repo PATH] [--max-turns N]
                    [--worktree] [--base BRANCH] [--repo PATH] [--max-turns N]
-                   [--schema FILE] [--no-guard] [--force] [--dry-run]
+                   [--effort low|medium|high|max] [--schema FILE] [--no-guard]
+                   [--force] [--dry-run]
 
 
   --run NAME       run name (groups lanes; [a-z0-9-]+)
   --run NAME       run name (groups lanes; [a-z0-9-]+)
   --id ID          lane id within the run ([a-z0-9-]+)
   --id ID          lane id within the run ([a-z0-9-]+)
@@ -25,6 +28,10 @@ Usage: ff-spawn.sh --run NAME --id ID --brain BRAIN --prompt-file FILE
   --base BRANCH    worktree base (default: main, falls back to HEAD)
   --base BRANCH    worktree base (default: main, falls back to HEAD)
   --repo PATH      repo root (default: git toplevel of cwd)
   --repo PATH      repo root (default: git toplevel of cwd)
   --max-turns N    worker turn cap (default: 100)
   --max-turns N    worker turn cap (default: 100)
+  --effort LEVEL   reasoning effort lever: low|medium|high|max (default: unset =
+                   inherit the brain's own default). GLM -> FLEET_WORKER_EFFORT;
+                   claude brains -> --settings effortLevel; codex -> model_reasoning_effort.
+                   Effort IS part of the cache key (different effort = different run).
   --schema FILE    JSON Schema for the final answer (codex: native
   --schema FILE    JSON Schema for the final answer (codex: native
                    --output-schema; other brains: appended to the prompt)
                    --output-schema; other brains: appended to the prompt)
   --no-guard       skip the guard preamble injection
   --no-guard       skip the guard preamble injection
@@ -34,16 +41,16 @@ Usage: ff-spawn.sh --run NAME --id ID --brain BRAIN --prompt-file FILE
 EXAMPLES
 EXAMPLES
   ff-spawn.sh --run audit --id ts-refresh --brain glm --worktree \
   ff-spawn.sh --run audit --id ts-refresh --brain glm --worktree \
               --prompt-file packets/ts.txt
               --prompt-file packets/ts.txt
-  ff-spawn.sh --run audit --id dissent-1 --brain codex \
+  ff-spawn.sh --run audit --id dissent-1 --brain codex --effort high \
               --prompt-file packets/refute.txt --schema verdict.schema.json
               --prompt-file packets/refute.txt --schema verdict.schema.json
-  ff-spawn.sh --run audit --id judge --brain opus --prompt-file packets/judge.txt
+  ff-spawn.sh --run audit --id judge --brain opus --effort max --prompt-file packets/judge.txt
 EOF
 EOF
 }
 }
 
 
 err() { echo "ff-spawn: $*" >&2; }
 err() { echo "ff-spawn: $*" >&2; }
 
 
 RUN="" ID="" BRAIN="" PROMPT_FILE="" WORKTREE=0 BASE="main" REPO=""
 RUN="" ID="" BRAIN="" PROMPT_FILE="" WORKTREE=0 BASE="main" REPO=""
-MAX_TURNS=100 SCHEMA="" GUARD=1 FORCE=0 DRYRUN=0 PHASE="build"
+MAX_TURNS=100 SCHEMA="" GUARD=1 FORCE=0 DRYRUN=0 PHASE="build" EFFORT=""
 while [ $# -gt 0 ]; do
 while [ $# -gt 0 ]; do
   case "$1" in
   case "$1" in
     --run) RUN="${2:-}"; shift 2 ;;
     --run) RUN="${2:-}"; shift 2 ;;
@@ -55,6 +62,7 @@ while [ $# -gt 0 ]; do
     --base) BASE="${2:-}"; shift 2 ;;
     --base) BASE="${2:-}"; shift 2 ;;
     --repo) REPO="${2:-}"; shift 2 ;;
     --repo) REPO="${2:-}"; shift 2 ;;
     --max-turns) MAX_TURNS="${2:-}"; shift 2 ;;
     --max-turns) MAX_TURNS="${2:-}"; shift 2 ;;
+    --effort) EFFORT="${2:-}"; shift 2 ;;
     --schema) SCHEMA="${2:-}"; shift 2 ;;
     --schema) SCHEMA="${2:-}"; shift 2 ;;
     --no-guard) GUARD=0; shift ;;
     --no-guard) GUARD=0; shift ;;
     --force) FORCE=1; shift ;;
     --force) FORCE=1; shift ;;
@@ -65,6 +73,7 @@ while [ $# -gt 0 ]; do
 done
 done
 
 
 case "$BRAIN" in glm|codex|sonnet|opus|haiku|fable) ;; *) err "invalid --brain '$BRAIN'"; exit 2 ;; esac
 case "$BRAIN" in glm|codex|sonnet|opus|haiku|fable) ;; *) err "invalid --brain '$BRAIN'"; exit 2 ;; esac
+case "$EFFORT" in ""|low|medium|high|max) ;; *) err "invalid --effort '$EFFORT' (low|medium|high|max)"; exit 2 ;; esac
 echo "$RUN" | grep -qE '^[a-z0-9-]+$' || { err "invalid --run"; exit 2; }
 echo "$RUN" | grep -qE '^[a-z0-9-]+$' || { err "invalid --run"; exit 2; }
 echo "$ID"  | grep -qE '^[a-z0-9-]+$' || { err "invalid --id"; exit 2; }
 echo "$ID"  | grep -qE '^[a-z0-9-]+$' || { err "invalid --id"; exit 2; }
 [ -f "$PROMPT_FILE" ] || { err "prompt file not found: $PROMPT_FILE"; exit 2; }
 [ -f "$PROMPT_FILE" ] || { err "prompt file not found: $PROMPT_FILE"; exit 2; }
@@ -100,9 +109,34 @@ if [ -n "$SCHEMA" ] && [ "$BRAIN" != "codex" ]; then
 fi
 fi
 
 
 # --- journal: hash-keyed replay cache (native Workflow pattern) --------------
 # --- journal: hash-keyed replay cache (native Workflow pattern) --------------
-OPTS="turns=$MAX_TURNS|wt=$WORKTREE|schema=$( [ -n "$SCHEMA" ] && basename "$SCHEMA" )"
+# effort is part of the key (different effort = a different run), per Wave 1.
+OPTS="turns=$MAX_TURNS|wt=$WORKTREE|schema=$( [ -n "$SCHEMA" ] && basename "$SCHEMA" )|effort=$EFFORT"
 KEY="v2:$( { printf '%s\n' "$BRAIN"; cat "$SENT"; printf '%s' "$OPTS"; } | sha256sum | cut -d' ' -f1)"
 KEY="v2:$( { printf '%s\n' "$BRAIN"; cat "$SENT"; printf '%s' "$OPTS"; } | sha256sum | cut -d' ' -f1)"
 JOURNAL="$RUNDIR/journal.jsonl"
 JOURNAL="$RUNDIR/journal.jsonl"
+
+# --- run manifest (orchestrator-side packet metadata; ff-run replays it) ----
+# Created on first spawn; each spawn upserts its packet by id (idempotent).
+MANIFEST="$RUNDIR/manifest.json"
+prompt_abs() {
+  local d
+  d="$(cd "$(dirname "$PROMPT_FILE")" 2>/dev/null && pwd)" || { printf '%s' "$PROMPT_FILE"; return; }
+  printf '%s/%s' "$d" "$(basename "$PROMPT_FILE")"
+}
+WT_JSON="false"; [ "$WORKTREE" = 1 ] && WT_JSON="true"
+MENTRY="$(jq -nc --arg id "$ID" --arg b "$BRAIN" --arg p "$PHASE" --arg pf "$(prompt_abs)" \
+  --argjson wt "$WT_JSON" --argjson mt "$MAX_TURNS" --arg e "$EFFORT" --arg s "${SCHEMA:-}" --arg k "$KEY" \
+  '{id:$id,brain:$b,phase:$p,prompt_file:$pf,worktree:$wt,max_turns:$mt,effort:$e,schema:$s,key:$k}')"
+if [ ! -s "$MANIFEST" ]; then
+  jq -nc --arg run "$RUN" --arg base "$BASE" --arg by "ff-spawn/$FF_VERSION" \
+    --argjson entry "$MENTRY" --arg phase "$PHASE" \
+    '{run:$run,base:$base,created_by:$by,phases:[$phase],packets:[$entry]}' > "$MANIFEST"
+else
+  jq --argjson entry "$MENTRY" --arg id "$ID" --arg phase "$PHASE" \
+    '.packets = ((.packets // []) | map(select(.id != $id))) + [$entry]
+     | .phases = (((.phases // []) + [$phase]) | unique)' \
+    "$MANIFEST" > "$MANIFEST.tmp" && mv -f "$MANIFEST.tmp" "$MANIFEST"
+fi
+
 if [ "$FORCE" = 0 ] && [ -f "$JOURNAL" ]; then
 if [ "$FORCE" = 0 ] && [ -f "$JOURNAL" ]; then
   CACHED="$(jq -r --arg k "$KEY" 'select(.type=="result" and .key==$k and .rc==0) | .artifact' "$JOURNAL" 2>/dev/null | tail -1)"
   CACHED="$(jq -r --arg k "$KEY" 'select(.type=="result" and .key==$k and .rc==0) | .artifact' "$JOURNAL" 2>/dev/null | tail -1)"
   if [ -n "$CACHED" ] && [ -f "$CACHED" ]; then
   if [ -n "$CACHED" ] && [ -f "$CACHED" ]; then
@@ -124,13 +158,50 @@ if [ "$WORKTREE" = 1 ]; then
 fi
 fi
 
 
 # phase is display metadata only - deliberately NOT part of the cache key
 # phase is display metadata only - deliberately NOT part of the cache key
-jq -nc --arg k "$KEY" --arg id "$ID" --arg b "$BRAIN" --arg p "$PHASE" \
-  '{type:"started",key:$k,id:$id,brain:$b,phase:$p}' >> "$JOURNAL"
+jq -nc --arg k "$KEY" --arg id "$ID" --arg b "$BRAIN" --arg p "$PHASE" --arg v "$FF_VERSION" \
+  '{type:"started",key:$k,id:$id,brain:$b,phase:$p,v:$v}' >> "$JOURNAL"
 
 
 # --- launch -------------------------------------------------------------------
 # --- launch -------------------------------------------------------------------
 ART="$RUNDIR/$ID.result.json"
 ART="$RUNDIR/$ID.result.json"
 ERRF="$RUNDIR/$ID.err"
 ERRF="$RUNDIR/$ID.err"
 RC=0
 RC=0
+
+# cache/tmp redirect: worker/pytest/uv litter lands OUTSIDE repo + lanes
+# (a codex sandbox once left AppContainer-ACL'd pytest dirs inside a lane that
+# resisted unelevated deletion and blocked a repo move).
+CACHE_DIR="${FLEETFLOW_CACHE_ROOT:-$HOME/.fleet-worker/cache}/$RUN-$ID"
+mkdir -p "$CACHE_DIR"
+CFGD="${FLEET_WORKER_CONFIG_DIR:-$HOME/.fleet-worker/cfg-ff-$ID}"
+# precompute the claude --settings effortLevel JSON so ${EFFORT:+...} stays simple
+EFF_JSON=""
+[ -n "$EFFORT" ] && EFF_JSON="$(jq -nc --arg e "$EFFORT" '{"effortLevel":$e}' 2>/dev/null)"
+
+# archive the session transcript next to the artifact (best-effort, never fatal)
+archive_transcript() {
+  local dest="$RUNDIR/$ID.transcript.jsonl" src="" sid enc
+  case "$BRAIN" in
+    glm)
+      src="$(ls -t "$CFGD"/projects/*/*.jsonl 2>/dev/null | head -1)"
+      ;;
+    sonnet|opus|haiku|fable)
+      sid="$(jq -r '.session_id // empty' "$ART" 2>/dev/null)"
+      if [ -n "$sid" ]; then
+        # workdir encoding: per-char [:\\/.] -> "-" (verified empirically:
+        # C:\Users\Mack -> C--Users-Mack under ~/.claude/projects)
+        enc="$(printf '%s' "$WORKDIR" | sed 's#[:\\/.]#-#g')"
+        src="$HOME/.claude/projects/$enc/$sid.jsonl"
+        [ -f "$src" ] || src="$(ls "$HOME"/.claude/projects/*/"$sid".jsonl 2>/dev/null | head -1)"
+      fi
+      ;;
+  esac
+  if [ -n "$src" ] && [ -f "$src" ]; then
+    cp -f "$src" "$dest" 2>/dev/null && err "archived transcript -> $dest" \
+      || err "transcript copy failed ($src), skipped"
+  else
+    err "transcript source not found (${src:-no session_id}), skipped (non-fatal)"
+  fi
+}
+
 if [ "$DRYRUN" = 1 ]; then
 if [ "$DRYRUN" = 1 ]; then
   jq -nc '{is_error:false,result:"DRYRUN"}' > "$ART"
   jq -nc '{is_error:false,result:"DRYRUN"}' > "$ART"
 else
 else
@@ -139,7 +210,9 @@ else
       FW="${FLEETFLOW_FLEET_WORKER:-$HOME/.claude/skills/fleet-worker/scripts/fleet-worker}"
       FW="${FLEETFLOW_FLEET_WORKER:-$HOME/.claude/skills/fleet-worker/scripts/fleet-worker}"
       [ -f "$FW" ] || { err "fleet-worker launcher not found ($FW)"; exit 5; }
       [ -f "$FW" ] || { err "fleet-worker launcher not found ($FW)"; exit 5; }
       ( cd "$WORKDIR" && \
       ( cd "$WORKDIR" && \
-        FLEET_WORKER_CONFIG_DIR="${FLEET_WORKER_CONFIG_DIR:-$HOME/.fleet-worker/cfg-ff-$ID}" \
+        FLEET_WORKER_CONFIG_DIR="$CFGD" \
+        UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
+        ${EFFORT:+FLEET_WORKER_EFFORT="$EFFORT"} \
         bash "$FW" --output-format json --max-turns "$MAX_TURNS" "$(cat "$SENT")" \
         bash "$FW" --output-format json --max-turns "$MAX_TURNS" "$(cat "$SENT")" \
       ) > "$ART" 2> "$ERRF" || RC=$?
       ) > "$ART" 2> "$ERRF" || RC=$?
       ;;
       ;;
@@ -150,9 +223,11 @@ else
       # codex sandbox's writable root - so git commit fails without this carve-out
       # codex sandbox's writable root - so git commit fails without this carve-out
       GITDIR=""; [ "$WORKTREE" = 1 ] && GITDIR="$(git -C "$REPO" rev-parse --absolute-git-dir)"
       GITDIR=""; [ "$WORKTREE" = 1 ] && GITDIR="$(git -C "$REPO" rev-parse --absolute-git-dir)"
       ( cd "$WORKDIR" && \
       ( cd "$WORKDIR" && \
+        UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
         codex exec --full-auto --ephemeral --color never --json \
         codex exec --full-auto --ephemeral --color never --json \
           ${GITDIR:+--add-dir "$GITDIR"} \
           ${GITDIR:+--add-dir "$GITDIR"} \
           ${FLEETFLOW_CODEX_MODEL:+-m "$FLEETFLOW_CODEX_MODEL"} \
           ${FLEETFLOW_CODEX_MODEL:+-m "$FLEETFLOW_CODEX_MODEL"} \
+          ${EFFORT:+-c "model_reasoning_effort=$EFFORT"} \
           ${SCHEMA:+--output-schema "$SCHEMA"} \
           ${SCHEMA:+--output-schema "$SCHEMA"} \
           -o "$ART" - < "$SENT" \
           -o "$ART" - < "$SENT" \
       ) > "$RUNDIR/$ID.events.jsonl" 2> "$ERRF" || RC=$?
       ) > "$RUNDIR/$ID.events.jsonl" 2> "$ERRF" || RC=$?
@@ -161,12 +236,15 @@ else
       command -v claude >/dev/null || { err "claude CLI not found"; exit 5; }
       command -v claude >/dev/null || { err "claude CLI not found"; exit 5; }
       MODEL="$BRAIN"; [ "$BRAIN" = "fable" ] && MODEL="claude-fable-5"
       MODEL="$BRAIN"; [ "$BRAIN" = "fable" ] && MODEL="claude-fable-5"
       ( cd "$WORKDIR" && \
       ( cd "$WORKDIR" && \
+        UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
         claude -p --model "$MODEL" --output-format json --max-turns "$MAX_TURNS" \
         claude -p --model "$MODEL" --output-format json --max-turns "$MAX_TURNS" \
           --permission-mode "${FLEETFLOW_PERMISSION_MODE:-bypassPermissions}" \
           --permission-mode "${FLEETFLOW_PERMISSION_MODE:-bypassPermissions}" \
+          ${EFFORT:+--settings "$EFF_JSON"} \
         < "$SENT" \
         < "$SENT" \
       ) > "$ART" 2> "$ERRF" || RC=$?
       ) > "$ART" 2> "$ERRF" || RC=$?
       ;;
       ;;
   esac
   esac
+  archive_transcript
 fi
 fi
 
 
 jq -nc --arg k "$KEY" --arg id "$ID" --arg b "$BRAIN" --arg a "$ART" --argjson rc "$RC" \
 jq -nc --arg k "$KEY" --arg id "$ID" --arg b "$BRAIN" --arg a "$ART" --argjson rc "$RC" \

+ 14 - 2
skills/fleetflow/scripts/ff-status.sh

@@ -11,6 +11,8 @@
 # Exit codes: 0 ok | 2 usage
 # Exit codes: 0 ok | 2 usage
 set -u
 set -u
 
 
+FF_VERSION="1.1.0"
+
 usage() {
 usage() {
   cat <<'EOF'
   cat <<'EOF'
 Usage: ff-status.sh --run NAME [--repo PATH] [--out FILE] [--watch SECONDS]
 Usage: ff-status.sh --run NAME [--repo PATH] [--out FILE] [--watch SECONDS]
@@ -55,9 +57,14 @@ emit() {
   for id in $(jq -r 'select(.type=="started") | .id' "$RUNDIR/journal.jsonl" | awk '!seen[$0]++'); do
   for id in $(jq -r 'select(.type=="started") | .id' "$RUNDIR/journal.jsonl" | awk '!seen[$0]++'); do
     brain="$(jq -r --arg id "$id" 'select(.id==$id) | .brain' "$RUNDIR/journal.jsonl" | head -1)"
     brain="$(jq -r --arg id "$id" 'select(.id==$id) | .brain' "$RUNDIR/journal.jsonl" | head -1)"
     phase="$(jq -r --arg id "$id" 'select(.type=="started" and .id==$id) | .phase // "build"' "$RUNDIR/journal.jsonl" | head -1)"
     phase="$(jq -r --arg id "$id" 'select(.type=="started" and .id==$id) | .phase // "build"' "$RUNDIR/journal.jsonl" | head -1)"
+    # state derives from the LAST journal record for this id: a respawn appends a
+    # fresh "started" AFTER an old "result", which means the lane is running again.
+    # last-result-wins (tail -1 of result records) would wrongly show done/failed.
+    last_type="$(jq -r --arg id "$id" 'select(.id==$id) | .type' "$RUNDIR/journal.jsonl" | tail -1)"
     rc="$(jq -r --arg id "$id" 'select(.type=="result" and .id==$id) | .rc' "$RUNDIR/journal.jsonl" | tail -1)"
     rc="$(jq -r --arg id "$id" 'select(.type=="result" and .id==$id) | .rc' "$RUNDIR/journal.jsonl" | tail -1)"
     art="$(jq -r --arg id "$id" 'select(.type=="result" and .id==$id) | .artifact' "$RUNDIR/journal.jsonl" | tail -1)"
     art="$(jq -r --arg id "$id" 'select(.type=="result" and .id==$id) | .artifact' "$RUNDIR/journal.jsonl" | tail -1)"
-    if [ -z "$rc" ]; then state="running"; finished=0
+    if [ "$last_type" = "started" ]; then state="running"; finished=0
+    elif [ -z "$rc" ]; then state="running"; finished=0
     elif [ "$rc" = "0" ]; then state="done"; finished=$(mtime "$art")
     elif [ "$rc" = "0" ]; then state="done"; finished=$(mtime "$art")
     else state="failed"; finished=$(mtime "$art"); fi
     else state="failed"; finished=$(mtime "$art"); fi
     started=$(mtime "$RUNDIR/$id.prompt.txt")
     started=$(mtime "$RUNDIR/$id.prompt.txt")
@@ -103,8 +110,13 @@ emit() {
               commits:$commits,tools:$tools,tokens:$tokens,activity:$activity,
               commits:$commits,tools:$tools,tokens:$tokens,activity:$activity,
               last_commit:$last_c,artifact:$art,err_tail:$etail}]')"
               last_commit:$last_c,artifact:$art,err_tail:$etail}]')"
   done
   done
+  local manifest="null"
+  if [ -f "$RUNDIR/manifest.json" ]; then
+    manifest="$(jq -c '{packet_count:(.packets|length), phases:(.phases // [])}' "$RUNDIR/manifest.json" 2>/dev/null)"
+  fi
   jq -nc --arg run "$RUN" --arg repo "$REPO" --argjson now "$now" --argjson lanes "$lanes" \
   jq -nc --arg run "$RUN" --arg repo "$REPO" --argjson now "$now" --argjson lanes "$lanes" \
-    '{run:$run,repo:$repo,generated_at:$now,lanes:$lanes}'
+    --argjson manifest "${manifest:-null}" \
+    '{run:$run,repo:$repo,generated_at:$now,lanes:$lanes,manifest:$manifest}'
 }
 }
 
 
 if [ -n "$WATCH" ]; then
 if [ -n "$WATCH" ]; then

+ 186 - 1
skills/fleetflow/tests/run.sh

@@ -26,7 +26,7 @@ git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
 PKT="$TMP/packet.txt"; echo "Do the thing. FINAL REPLY: one line." > "$PKT"
 PKT="$TMP/packet.txt"; echo "Do the thing. FINAL REPLY: one line." > "$PKT"
 
 
 # --- syntax + help ------------------------------------------------------------
 # --- syntax + help ------------------------------------------------------------
-for s in ff-spawn.sh ff-collect.sh ff-doctor.sh ff-status.sh; do
+for s in ff-spawn.sh ff-collect.sh ff-doctor.sh ff-status.sh ff-run.sh ff-clean.sh ff-import.sh; do
   bash -n "$S/$s" 2>/dev/null && ok "syntax $s" || bad "syntax $s"
   bash -n "$S/$s" 2>/dev/null && ok "syntax $s" || bad "syntax $s"
   bash "$S/$s" --help 2>/dev/null | grep -q "EXAMPLES" && ok "$s --help has EXAMPLES" || bad "$s --help lacks EXAMPLES"
   bash "$S/$s" --help 2>/dev/null | grep -q "EXAMPLES" && ok "$s --help has EXAMPLES" || bad "$s --help lacks EXAMPLES"
   check "$s --help exits 0" 0 bash "$S/$s" --help
   check "$s --help exits 0" 0 bash "$S/$s" --help
@@ -94,6 +94,11 @@ bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.lanes[] | s
   && ok "status: dry-run lane state done" || bad "status: lane state wrong"
   && ok "status: dry-run lane state done" || bad "status: lane state wrong"
 [ -f "$HERE/../assets/ff-monitor.html" ] && grep -q "status.json" "$HERE/../assets/ff-monitor.html" \
 [ -f "$HERE/../assets/ff-monitor.html" ] && grep -q "status.json" "$HERE/../assets/ff-monitor.html" \
   && ok "monitor asset present + polls status.json" || bad "monitor asset missing"
   && ok "monitor asset present + polls status.json" || bad "monitor asset missing"
+# torn-write guard: an empty/missing-lanes payload is treated as a fetch miss
+grep -q "torn-write guard" "$HERE/../assets/ff-monitor.html" \
+  && ok "monitor: empty-lanes torn-write guard present" || bad "monitor: torn-write guard missing"
+grep -q "d.lanes.length === 0" "$HERE/../assets/ff-monitor.html" \
+  && ok "monitor: guards on empty lanes array" || bad "monitor: empty-lanes guard logic missing"
 
 
 # --- escape guard ------------------------------------------------------------------
 # --- escape guard ------------------------------------------------------------------
 check "escape guard: clean main" 0 bash "$S/ff-collect.sh" --check-main-clean --run r1 --repo "$REPO"
 check "escape guard: clean main" 0 bash "$S/ff-collect.sh" --check-main-clean --run r1 --repo "$REPO"
@@ -107,6 +112,186 @@ bash "$S/ff-doctor.sh" --offline >/dev/null 2>&1; RC=$?
 [ "$RC" = 0 ] || [ "$RC" = 10 ] && ok "doctor --offline runs (rc=$RC)" || bad "doctor --offline rc=$RC"
 [ "$RC" = 0 ] || [ "$RC" = 10 ] && ok "doctor --offline runs (rc=$RC)" || bad "doctor --offline rc=$RC"
 bash "$S/ff-doctor.sh" --offline 2>/dev/null | grep -qE "^bin-jq	ok" && ok "doctor TSV output" || bad "doctor TSV output missing"
 bash "$S/ff-doctor.sh" --offline 2>/dev/null | grep -qE "^bin-jq	ok" && ok "doctor TSV output" || bad "doctor TSV output missing"
 
 
+# --- manifest (feature 1): created on first spawn, append, idempotent -----------
+M="$REPO/.fleetflow/r1/manifest.json"
+[ -f "$M" ] && ok "manifest created on first spawn" || bad "manifest not created"
+jq -e '.run=="r1" and .base=="main" and (.created_by|startswith("ff-spawn/"))' "$M" >/dev/null \
+  && ok "manifest header fields" || bad "manifest header wrong"
+# lane 'a' was spawned several times (cache-hit, --force, changed) yet stays 1 entry
+NA="$(jq '[.packets[]|select(.id=="a")]|length' "$M")"
+[ "$NA" = "1" ] && ok "manifest packet idempotent (one entry per id)" || bad "manifest has $NA 'a' entries"
+# every packet carries the Wave-1 fields the brief requires
+jq -e '.packets[]|select(.id=="a")|has("effort") and has("key") and has("max_turns") and has("worktree")' "$M" >/dev/null \
+  && ok "manifest packet has effort+key+max_turns+worktree" || bad "manifest packet fields missing"
+# ff-status surfaces the manifest summary
+bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.manifest.packet_count >= 1' >/dev/null \
+  && ok "status: surfaces manifest.packet_count" || bad "status: manifest summary missing"
+
+# --- ff-run.sh (feature 2): whole-run resume + status alias ---------------------
+check "ff-run: no subcommand -> 2" 2 bash "$S/ff-run.sh"
+check "ff-run: bad subcommand -> 2" 2 bash "$S/ff-run.sh" frobnicate --run r1 --repo "$REPO"
+check "ff-run: resume missing run -> 2" 2 bash "$S/ff-run.sh" resume --run nope --repo "$REPO"
+check "ff-run: resume no manifest -> 2" 2 bash "$S/ff-run.sh" resume --run r1 --repo "$TMP"
+# fresh run, two DISTINCT packets so replay order is unambiguous
+PA="$TMP/r2-a.txt"; echo "do A. FINAL REPLY: a" > "$PA"
+PB="$TMP/r2-b.txt"; echo "do B. FINAL REPLY: b" > "$PB"
+bash "$S/ff-spawn.sh" --run r2 --id a --brain sonnet --prompt-file "$PA" --repo "$REPO" --dry-run >/dev/null 2>&1
+bash "$S/ff-spawn.sh" --run r2 --id b --brain sonnet --prompt-file "$PB" --repo "$REPO" --dry-run >/dev/null 2>&1
+# resume re-spawns both -> both cache-hit -> exit 0; ids return in manifest order
+RR="$(bash "$S/ff-run.sh" resume --run r2 --repo "$REPO" 2>/dev/null)"; RC=$?
+[ "$RC" = "0" ] && ok "ff-run: all-cached resume exits 0" || bad "ff-run: resume rc=$RC"
+printf '%s' "$RR" | jq -e 'length==2 and .[0].id=="a" and .[1].id=="b" and all(.[]; .status=="cached")' >/dev/null \
+  && ok "ff-run: resume preserves packet order (no reorder drift)" || bad "ff-run: resume order wrong: $RR"
+# status subcommand == ff-status (compare stable fields; generated_at differs by design)
+SA="$(bash "$S/ff-run.sh" status --run r2 --repo "$REPO" 2>/dev/null | jq -c '{run,lanes:[.lanes[].id]}')"
+SB="$(bash "$S/ff-status.sh" --run r2 --repo "$REPO" 2>/dev/null | jq -c '{run,lanes:[.lanes[].id]}')"
+[ "$SA" = "$SB" ] && ok "ff-run status aliases ff-status" || bad "ff-run status != ff-status"
+
+# --- schema fence-strip (feature 3) --------------------------------------------
+# a result whose text is fenced JSON must still validate (--schema strips fences)
+jq -nc '{is_error:false,result:"```json\n{\"verdict\":\"ok\"}\n```"}' > "$REPO/.fleetflow/r1/fence.result.json"
+jq -nc '{type:"result",key:"v2:fence",id:"fence",brain:"sonnet",rc:0,artifact:"x"}' >> "$REPO/.fleetflow/r1/journal.jsonl"
+FOUT="$(bash "$S/ff-collect.sh" --run r1 --id fence --repo "$REPO" --schema 2>/dev/null)"; FRC=$?
+[ "$FRC" = "0" ] && ok "collect: fence-strip lets fenced JSON validate" || bad "collect: fence-strip failed rc=$FRC"
+printf '%s' "$FOUT" | jq -e '.verdict=="ok"' >/dev/null && ok "collect: fence-strip returns inner JSON" || bad "collect: fence-strip output wrong"
+
+# --- schema --repair seam (feature 3) ------------------------------------------
+# bad result + FLEETFLOW_REPAIR_DRYRUN: do_repair saves <id>.invalid.txt and
+# respawns a <id>-repair lane. The dry-run lane replies "DRYRUN" (not JSON), so
+# the repair gate fails -> exit 10; we assert the SEAM fired, not a happy path.
+jq -nc '{is_error:false,result:"this is not json"}' > "$REPO/.fleetflow/r1/rp.result.json"
+jq -nc '{type:"result",key:"v2:rp",id:"rp",brain:"sonnet",rc:0,artifact:"x"}' >> "$REPO/.fleetflow/r1/journal.jsonl"
+FLEETFLOW_REPAIR_DRYRUN=1 bash "$S/ff-collect.sh" --run r1 --id rp --repo "$REPO" --schema --repair >/dev/null 2>&1; RPRC=$?
+[ "$RPRC" = "10" ] && ok "collect: --repair exits 10 when corrected output invalid" || bad "collect: --repair rc=$RPRC (want 10)"
+[ -f "$REPO/.fleetflow/r1/rp.invalid.txt" ] && ok "collect: --repair saved <id>.invalid.txt" || bad "collect: invalid.txt missing"
+NREP="$(jq -r 'select(.type=="result" and .id=="rp-repair")|.id' "$REPO/.fleetflow/r1/journal.jsonl" | wc -l | tr -d ' ')"
+[ "$NREP" -ge 1 ] && ok "collect: --repair respawned rp-repair lane" || bad "collect: no repair lane spawned"
+grep -q 'corrected JSON' "$REPO/.fleetflow/r1/rp-repair.prompt-src.txt" 2>/dev/null \
+  && ok "collect: --repair lane got the corrective prompt" || bad "collect: repair prompt missing"
+
+# --- FF_VERSION in journal (feature 4) -----------------------------------------
+grep -q '"v":"1.1.0"' "$REPO/.fleetflow/r1/journal.jsonl" && ok "journal records FF_VERSION 1.1.0" || bad "journal missing FF_VERSION"
+# every operational script pins the same version (version-skew spine)
+VS=0
+for s in ff-spawn.sh ff-collect.sh ff-status.sh ff-doctor.sh ff-run.sh ff-clean.sh ff-import.sh; do
+  grep -q '^FF_VERSION="1.1.0"$' "$S/$s" || VS=1
+done
+[ "$VS" = "0" ] && ok "all scripts pin FF_VERSION=1.1.0" || bad "version skew across scripts"
+
+# --- effort lever (feature 5): effort is part of the cache key ------------------
+EP="$TMP/effort.txt"; echo "effort test. FINAL REPLY: e" > "$EP"
+check "spawn: effort lane first run -> 0" 0 bash "$S/ff-spawn.sh" --run r3 --id e --brain sonnet --prompt-file "$EP" --repo "$REPO" --dry-run
+check "spawn: effort lane identical -> cached" 3 bash "$S/ff-spawn.sh" --run r3 --id e --brain sonnet --prompt-file "$EP" --repo "$REPO" --dry-run
+# changing ONLY the effort must bust the cache (effort is baked into the OPTS key)
+check "spawn: effort change -> cache miss" 0 bash "$S/ff-spawn.sh" --run r3 --id e --brain sonnet --prompt-file "$EP" --repo "$REPO" --dry-run --effort high
+jq -e '.packets[]|select(.id=="e")|.effort=="high"' "$REPO/.fleetflow/r3/manifest.json" >/dev/null \
+  && ok "manifest records effort=high" || bad "manifest effort field wrong"
+
+# --- cache/tmp redirect (feature 7) --------------------------------------------
+CRT="$TMP/ffcache"; CDP="$TMP/cdp.txt"; echo "cache test. FINAL REPLY: c" > "$CDP"
+FLEETFLOW_CACHE_ROOT="$CRT" bash "$S/ff-spawn.sh" --run r4 --id c --brain sonnet --prompt-file "$CDP" --repo "$REPO" --dry-run >/dev/null 2>&1
+[ -d "$CRT/r4-c" ] && ok "spawn: cache dir created under FLEETFLOW_CACHE_ROOT" || bad "spawn: cache dir not redirected to FLEETFLOW_CACHE_ROOT"
+
+# --- ff-clean.sh (feature 8): autoclean lanes + cache --------------------------
+check "ff-clean: usage -> 2" 2 bash "$S/ff-clean.sh"
+check "ff-clean: no such run -> 2" 2 bash "$S/ff-clean.sh" --run ghost --repo "$REPO"
+# fresh run, cache redirected, three DISTINCT-prompt worktree lanes (distinct so
+# they don't cache-hit and skip worktree creation)
+CLEANROOT="$TMP/cleancache"
+for lid in cleanlane keeplane dirtlane; do
+  echo "clean-$lid task. FINAL REPLY: $lid" > "$TMP/clean-$lid.txt"
+  FLEETFLOW_CACHE_ROOT="$CLEANROOT" bash "$S/ff-spawn.sh" --run rc --id "$lid" --brain sonnet \
+    --prompt-file "$TMP/clean-$lid.txt" --repo "$REPO" --dry-run --worktree >/dev/null 2>&1
+done
+# keeplane gets a real commit (must survive every clean); dirtlane gets untracked junk
+( cd "$REPO/.fleetflow/rc/wt-keeplane" && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m "real work" )
+echo "junk" > "$REPO/.fleetflow/rc/wt-dirtlane/junk.txt"
+[ -d "$CLEANROOT/rc-cleanlane" ] && ok "ff-clean: setup created cache dir" || bad "ff-clean: setup cache dir missing"
+# no --force: cleanlane removed, dirtlane kept (dirty), keeplane kept (1 commit).
+# pass the SAME FLEETFLOW_CACHE_ROOT to ff-clean so it finds the redirected dirs.
+CL="$(FLEETFLOW_CACHE_ROOT="$CLEANROOT" bash "$S/ff-clean.sh" --run rc --repo "$REPO" 2>/dev/null)"
+printf '%s' "$CL" | awk -F'\t' '$1=="cleanlane"&&$2=="removed"{f=1} END{exit !f}' && ok "ff-clean: removes zero-commit clean lane" || bad "ff-clean: clean lane not removed"
+printf '%s' "$CL" | awk -F'\t' '$1=="dirtlane"&&$2=="kept"&&$3~/dirty/{f=1} END{exit !f}' && ok "ff-clean: keeps dirty zero-commit lane (no --force)" || bad "ff-clean: dirty lane mishandled"
+printf '%s' "$CL" | awk -F'\t' '$1=="keeplane"&&$2=="kept"&&$3~/1 commits/{f=1} END{exit !f}' && ok "ff-clean: keeps committed lane" || bad "ff-clean: committed lane mishandled"
+[ -d "$REPO/.fleetflow/rc/wt-cleanlane" ] && bad "ff-clean: clean worktree dir remains" || ok "ff-clean: clean worktree removed"
+git -C "$REPO" show-ref --verify --quiet refs/heads/fleetflow/rc/keeplane && ok "ff-clean: committed branch preserved" || bad "ff-clean: keeper branch deleted"
+# --force: dirtlane now removed; keeplane STILL kept (committed lanes are never force-removed)
+CL2="$(FLEETFLOW_CACHE_ROOT="$CLEANROOT" bash "$S/ff-clean.sh" --run rc --repo "$REPO" --force 2>/dev/null)"
+printf '%s' "$CL2" | awk -F'\t' '$1=="dirtlane"&&$2=="removed"{f=1} END{exit !f}' && ok "ff-clean: --force removes dirty zero-commit lane" || bad "ff-clean: --force dirty mishandled"
+printf '%s' "$CL2" | awk -F'\t' '$1=="keeplane"&&$2=="kept"{f=1} END{exit !f}' && ok "ff-clean: --force still keeps committed lane" || bad "ff-clean: --force removed committed lane!"
+[ -d "$CLEANROOT/rc-cleanlane" ] || [ -d "$CLEANROOT/rc-dirtlane" ] || [ -d "$CLEANROOT/rc-keeplane" ] \
+  && bad "ff-clean: cache dir remains" || ok "ff-clean: cache dirs removed"
+
+# --- state derivation (feature C): last journal record wins -----------------------
+# a respawn appends "started" AFTER an old "result" -> the lane is running again,
+# NOT done/failed (the last-result-wins bug this fixes).
+RD5="$REPO/.fleetflow/r5"; mkdir -p "$RD5"
+: > "$RD5/z.prompt.txt"   # mtime source for elapsed
+printf '%s\n' \
+  '{"type":"started","key":"v2:z","id":"z","brain":"sonnet","phase":"build","v":"1.1.0"}' \
+  '{"type":"result","key":"v2:z","id":"z","brain":"sonnet","rc":0,"artifact":"x"}' \
+  '{"type":"started","key":"v2:z","id":"z","brain":"sonnet","phase":"build","v":"1.1.0"}' \
+  > "$RD5/journal.jsonl"
+bash "$S/ff-status.sh" --run r5 --repo "$REPO" 2>/dev/null \
+  | jq -e '.lanes[]|select(.id=="z")|.state=="running"' >/dev/null \
+  && ok "status: respawned lane (started,result,started) is running" || bad "status: respawned lane state wrong"
+# regression guard: same lane with result-last is still done (common path unchanged)
+printf '%s\n' \
+  '{"type":"started","key":"v2:z","id":"z","brain":"sonnet","phase":"build","v":"1.1.0"}' \
+  '{"type":"result","key":"v2:z","id":"z","brain":"sonnet","rc":0,"artifact":"x"}' \
+  > "$RD5/journal.jsonl"
+bash "$S/ff-status.sh" --run r5 --repo "$REPO" 2>/dev/null \
+  | jq -e '.lanes[]|select(.id=="z")|.state=="done"' >/dev/null \
+  && ok "status: result-last lane is still done (no regression)" || bad "status: result-last state wrong"
+
+# --- ff-import.sh (feature B): native Workflow run import ------------------------
+# build a synthetic native wf_ dir: journal.jsonl (started/result keyed by
+# agentId) + two agent transcripts (one string content, one content-array).
+WFD="$TMP/wf_ab12cd34-ef"; mkdir -p "$WFD"
+printf '%s\n' \
+  '{"type":"started","key":"v2:aaaa","agentId":"a01cb5f01fadf5610"}' \
+  '{"type":"result","key":"v2:aaaa","agentId":"a01cb5f01fadf5610","result":{"verdict":"ok","score":7}}' \
+  '{"type":"started","key":"v2:bbbb","agentId":"a02deadbeef00000"}' \
+  > "$WFD/journal.jsonl"
+jq -nc '{type:"user",message:{role:"user",content:"Refute the claim that X is safe."}}' \
+  > "$WFD/agent-a01cb5f01fadf5610.jsonl"
+jq -nc '{type:"user",message:{role:"user",content:[{type:"text",text:"Find any bugs in module Y."}]}}' \
+  > "$WFD/agent-a02deadbeef00000.jsonl"
+IMP="$(bash "$S/ff-import.sh" --wf "$WFD" --run imp1 --repo "$REPO" 2>/dev/null)"; IRC=$?
+[ "$IRC" = "0" ] && ok "ff-import: exits 0 on import" || bad "ff-import: rc=$IRC"
+printf '%s' "$IMP" | awk -F'\t' '$1=="a01cb5f01fadf5610"&&$2=="imported"{f=1} END{exit !f}' \
+  && ok "ff-import: completed agent reported imported" || bad "ff-import: imported TSV wrong"
+printf '%s' "$IMP" | awk -F'\t' '$1=="a02deadbeef00000"&&$2=="incomplete"{f=1} END{exit !f}' \
+  && ok "ff-import: started-only agent reported incomplete" || bad "ff-import: incomplete TSV wrong"
+PC="$(printf '%s' "$IMP" | awk -F'\t' '$1=="a01cb5f01fadf5610"{print $3}')"
+[ -n "$PC" ] && [ "$PC" -gt 0 ] 2>/dev/null && ok "ff-import: prompt_chars > 0" || bad "ff-import: prompt_chars wrong ($PC)"
+IRD="$REPO/.fleetflow/imp1"
+[ -f "$IRD/a01cb5f01fadf5610.prompt.txt" ] && ok "ff-import: wrote prompt.txt (completed)" || bad "ff-import: prompt.txt missing"
+grep -q "Refute the claim" "$IRD/a01cb5f01fadf5610.prompt.txt" && ok "ff-import: prompt extracted (string content)" || bad "ff-import: string-content prompt wrong"
+grep -q "Find any bugs" "$IRD/a02deadbeef00000.prompt.txt" && ok "ff-import: prompt extracted (content array)" || bad "ff-import: array-content prompt wrong"
+[ -f "$IRD/a01cb5f01fadf5610.result.json" ] && ok "ff-import: wrote result.json (completed)" || bad "ff-import: result.json missing"
+[ ! -f "$IRD/a02deadbeef00000.result.json" ] && ok "ff-import: incomplete agent has no result.json" || bad "ff-import: incomplete got result.json"
+jq -e '.is_error==false and (.result|fromjson|.verdict=="ok" and .score==7)' "$IRD/a01cb5f01fadf5610.result.json" >/dev/null \
+  && ok "ff-import: result.json wraps native result (tojson)" || bad "ff-import: result.json shape wrong"
+NJ="$IRD/journal.jsonl"
+[ "$(jq -r 'select(.type=="result" and .id=="a01cb5f01fadf5610")|.brain' "$NJ")" = "native" ] \
+  && ok "ff-import: journal result brain=native" || bad "ff-import: journal brain wrong"
+# phase lives on the started record (same convention as ff-spawn), not the result
+[ "$(jq -r 'select(.type=="started" and .id=="a01cb5f01fadf5610")|.phase' "$NJ")" = "imported" ] \
+  && ok "ff-import: journal phase=imported" || bad "ff-import: journal phase wrong"
+[ -z "$(jq -r 'select(.type=="result" and .id=="a02deadbeef00000")' "$NJ")" ] \
+  && ok "ff-import: incomplete agent has no result record" || bad "ff-import: incomplete got a result record"
+jq -e --arg wf "$WFD" '.packets[]|select(.id=="a01cb5f01fadf5610")|.brain=="native" and .imported_from==$wf' "$IRD/manifest.json" >/dev/null \
+  && ok "ff-import: manifest packet brain=native + imported_from" || bad "ff-import: manifest packet wrong"
+# nothing to import -> exit 3
+WFE="$TMP/wf_empty"; mkdir -p "$WFE"; : > "$WFE/journal.jsonl"
+check "ff-import: empty wf journal -> 3" 3 bash "$S/ff-import.sh" --wf "$WFE" --run imp2 --repo "$REPO"
+# ff-run resume SKIPS imported native packets (terminal, not replayable)
+RRN="$(bash "$S/ff-run.sh" resume --run imp1 --repo "$REPO" 2>/dev/null)"; RCRR=$?
+[ "$RCRR" = "0" ] && ok "ff-run: resume skips native packets (exit 0)" || bad "ff-run: resume on imported run rc=$RCRR"
+printf '%s' "$RRN" | jq -e 'any(.[]; .id=="a01cb5f01fadf5610" and .status=="imported")' >/dev/null \
+  && ok "ff-run: native packet reported imported (skipped)" || bad "ff-run: native packet not skipped"
+
 echo "=== $PASS passed, $FAILN failed ==="
 echo "=== $PASS passed, $FAILN failed ==="
 [ "$FAILN" = 0 ] || exit 1
 [ "$FAILN" = 0 ] || exit 1
 exit 0
 exit 0