@manhquy/flow-skill 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,7 +56,8 @@ DEBT_FILE="$ROOT/DEBT.md"
56
56
  PROJECT_TYPE_FILE="$ROOT/PROJECT_TYPE"
57
57
  SKIPPED_FILE="$ROOT/flow/.skipped"
58
58
  LOCK_FILE="$ROOT/flow/.lock"
59
- LOCK_DIR="$ROOT/flow/.lock.d" # atomic mkdir claim dir (W5 guard); winner then writes $LOCK_FILE metadata
59
+ LOCK_DIR="$ROOT/flow/.lock.d" # atomic claim dir (W5 guard); winner then writes $LOCK_FILE metadata
60
+ LOCK_CLAIM="$ROOT/flow/.lock.claim" # atomic set -C / O_EXCL claim guard
60
61
  HARNESS_PY="$SCRIPT_DIR/../harness/flow_harness.py"
61
62
 
62
63
  # Attestations library (risk, receipts, auto policy) — optional-soft: missing file is fatal for v0.28+.
@@ -643,38 +644,52 @@ _write_lock() { # $1 = cmd label
643
644
  > "$LOCK_FILE" 2>/dev/null || true
644
645
  }
645
646
 
647
+ # Atomic test-and-set claim via bash noclobber (set -C / O_EXCL creat).
648
+ # Guarantees atomic mutual exclusion across all filesystems and bash environments,
649
+ # including systems where external mkdir binaries suffer from race bugs (uutils 0.10.0).
650
+ # Winner also creates LOCK_DIR for backward compatibility with inspection/mtime tooling.
651
+ _claim_lock() {
652
+ ( set -C; : > "$LOCK_CLAIM" ) 2>/dev/null || return 1
653
+ mkdir "$LOCK_DIR" 2>/dev/null || true
654
+ return 0
655
+ }
656
+
657
+ _release_claim() {
658
+ rm -f "$LOCK_CLAIM" 2>/dev/null || true
659
+ rm -rf "$LOCK_DIR" 2>/dev/null || true
660
+ }
661
+
646
662
  # Guard a MUTATING command. 0 = lock taken/refreshed (proceed); 1 = refused (caller aborts).
647
663
  lock_acquire() { # $1 = cmd label
648
664
  local me age; me="$(flow_lock_owner)"
649
- # Ensure the lock's PARENT dir exists so the atomic `mkdir "$LOCK_DIR"` test-and-set below can
665
+ # Ensure the lock's PARENT dir exists so the atomic claim test-and-set below can
650
666
  # succeed on a brand-new project (flow/ may not exist yet at stage-00 unlock / first run).
651
- # This creates only the parent; LOCK_DIR itself is still created with plain mkdir (atomic guard).
652
667
  mkdir -p "$FLOW_DIR" 2>/dev/null || true
653
668
 
654
- # FLOW_FORCE: unconditional takeover; reset the atomic claim dir so the mkdir guard is bypassed cleanly.
669
+ # FLOW_FORCE: unconditional takeover; reset the atomic claim so the guard is bypassed cleanly.
655
670
  if [ -n "${FLOW_FORCE:-}" ]; then
656
671
  if _read_lock && [ -n "$LOCK_OWNER" ] && [ "$LOCK_OWNER" != "$me" ]; then
657
672
  echo "NOTE: FLOW_FORCE set - taking over a lock held by [$LOCK_OWNER] (cmd '${LOCK_CMD:-?}')."
658
673
  fi
659
- rm -rf "$LOCK_DIR" 2>/dev/null || true
660
- mkdir "$LOCK_DIR" 2>/dev/null || true
674
+ _release_claim
675
+ _claim_lock || true
661
676
  _write_lock "$1"; return 0
662
677
  fi
663
678
 
664
679
  # Same-session refresh: if the lock file names OUR owner, just update the timestamp.
665
- # The session already owns LOCK_DIR from the original claim; no need to re-mkdir.
680
+ # The session already owns the claim from the original claim; no need to re-claim.
666
681
  if _read_lock && [ "$LOCK_OWNER" = "$me" ]; then _write_lock "$1"; return 0; fi
667
682
 
668
- # W5 — Atomic claim guard. mkdir is POSIX test-and-set: exactly one racing caller wins.
669
- # Only attempt when neither the claim dir nor a legacy flat-file lock exists (truly free state).
670
- # Legacy flat-file (LOCK_FILE without LOCK_DIR): tolerated — fall through to reclaim logic below
683
+ # W5 — Atomic claim guard. set -C / O_EXCL is POSIX test-and-set: exactly one racing caller wins.
684
+ # Only attempt when neither the claim nor a legacy flat-file lock exists (truly free state).
685
+ # Legacy flat-file (LOCK_FILE without LOCK_DIR/LOCK_CLAIM): tolerated — fall through to reclaim logic below
671
686
  # so an in-flight upgrade from v0.11 does not strand a live lock.
672
- if ! [ -d "$LOCK_DIR" ] && ! [ -f "$LOCK_FILE" ]; then
673
- if mkdir "$LOCK_DIR" 2>/dev/null; then
687
+ if ! [ -f "$LOCK_CLAIM" ] && ! [ -d "$LOCK_DIR" ] && ! [ -f "$LOCK_FILE" ]; then
688
+ if _claim_lock; then
674
689
  _write_lock "$1"; return 0 # won the race; lock claimed
675
690
  fi
676
- # Lost the race: LOCK_DIR now owned by the winner. Fall through to BLOCK below.
677
- # LOCK_FILE may not be written yet (tiny window); treat LOCK_DIR presence as lock-held.
691
+ # Lost the race: claim now owned by the winner. Fall through to BLOCK below.
692
+ # LOCK_FILE may not be written yet (tiny window); treat claim presence as lock-held.
678
693
  fi
679
694
 
680
695
  # --- v0.11 FR4 reclaim logic (runs for: race loser, legacy flat-file, any existing lock) ---
@@ -686,25 +701,25 @@ lock_acquire() { # $1 = cmd label
686
701
  local myhost; myhost="$(uname -n 2>/dev/null || echo host)"
687
702
  if [ -n "$LOCK_PID" ] && [ "$LOCK_HOST" = "$myhost" ] && ! kill -0 "$LOCK_PID" 2>/dev/null; then
688
703
  echo "NOTE: reclaiming a flow lock from a dead session [$LOCK_OWNER] (pid $LOCK_PID no longer alive)."
689
- rm -rf "$LOCK_DIR" 2>/dev/null || true
690
- # F2: check mkdir atomically — if another reclaimer beat us, do NOT write
691
- if mkdir "$LOCK_DIR" 2>/dev/null; then
704
+ _release_claim
705
+ # F2: check claim atomically — if another reclaimer beat us, do NOT write
706
+ if _claim_lock; then
692
707
  _write_lock "$1"; return 0
693
708
  fi
694
- # Lost the re-mkdir race; another reclaimer already owns the slot — back off
709
+ # Lost the reclaim race; another reclaimer already owns the slot — back off
695
710
  echo "BLOCKED: another flow session claimed this project concurrently (concurrent /flow corrupts the plan)."
696
- echo " (reclaim mkdir race lost — another session already took the lock.)"
711
+ echo " (reclaim claim race lost — another session already took the lock.)"
697
712
  return 1
698
713
  elif [ "$age" -ge "$FLOW_LOCK_TTL" ]; then
699
714
  echo "NOTE: reclaiming a STALE flow lock from [$LOCK_OWNER] (${age}s old >= ${FLOW_LOCK_TTL}s TTL)."
700
- rm -rf "$LOCK_DIR" 2>/dev/null || true
701
- # F2: check mkdir atomically — if another reclaimer beat us, do NOT write
702
- if mkdir "$LOCK_DIR" 2>/dev/null; then
715
+ _release_claim
716
+ # F2: check claim atomically — if another reclaimer beat us, do NOT write
717
+ if _claim_lock; then
703
718
  _write_lock "$1"; return 0
704
719
  fi
705
- # Lost the re-mkdir race; another reclaimer already owns the slot — back off
720
+ # Lost the reclaim race; another reclaimer already owns the slot — back off
706
721
  echo "BLOCKED: another flow session claimed this project concurrently (concurrent /flow corrupts the plan)."
707
- echo " (reclaim mkdir race lost — another session already took the lock.)"
722
+ echo " (reclaim claim race lost — another session already took the lock.)"
708
723
  return 1
709
724
  fi
710
725
  if flow_owner_strong "$me" && flow_owner_strong "$LOCK_OWNER"; then # fresh + provably foreign
@@ -719,29 +734,31 @@ lock_acquire() { # $1 = cmd label
719
734
  _write_lock "$1"; return 0
720
735
  fi
721
736
 
722
- # LOCK_DIR exists but LOCK_FILE not yet written: either a race loser in the tiny window
723
- # after mkdir loss, or a crashed winner (F1: process died after mkdir but before _write_lock).
724
- # Distinguish via mtime: if LOCK_DIR is older than FLOW_LOCK_TTL it is a crashed stale claim
737
+ # LOCK_CLAIM or LOCK_DIR exists but LOCK_FILE not yet written: either a race loser in the tiny window
738
+ # after claim loss, or a crashed winner (F1: process died after claim but before _write_lock).
739
+ # Distinguish via mtime: if the claim target is older than FLOW_LOCK_TTL it is a crashed stale claim
725
740
  # and safe to reclaim; if fresh, treat as live mid-claim and BLOCK.
726
- if [ -d "$LOCK_DIR" ]; then
741
+ if [ -f "$LOCK_CLAIM" ] || [ -d "$LOCK_DIR" ]; then
727
742
  local ttl_min; ttl_min=$(( (FLOW_LOCK_TTL + 59) / 60 )) # ceiling division to minutes
728
- if find "$LOCK_DIR" -maxdepth 0 -mmin +"$ttl_min" 2>/dev/null | grep -q .; then
743
+ local claim_target="$LOCK_DIR"
744
+ [ -d "$claim_target" ] || claim_target="$LOCK_CLAIM"
745
+ if find "$claim_target" -maxdepth 0 -mmin +"$ttl_min" 2>/dev/null | grep -q .; then
729
746
  echo "NOTE: reclaiming a stale crashed claim (LOCK_DIR old, no LOCK_FILE)."
730
- rm -rf "$LOCK_DIR" 2>/dev/null || true
747
+ _release_claim
731
748
  # F2: atomic re-claim; if another reclaimer beat us, don't write
732
- if mkdir "$LOCK_DIR" 2>/dev/null; then
749
+ if _claim_lock; then
733
750
  _write_lock "$1"; return 0
734
751
  fi
735
- # Lost re-mkdir race; fall through to BLOCKED below
752
+ # Lost re-claim race; fall through to BLOCKED below
736
753
  fi
737
754
  echo "BLOCKED: another flow session is claiming this project (concurrent /flow corrupts the plan)."
738
755
  echo " (lock dir exists; the other session is mid-claim. If it is truly gone: '/flow unlock'.)"
739
756
  return 1
740
757
  fi
741
758
 
742
- # No LOCK_DIR and no LOCK_FILE: truly free state reached after reclaim or first run.
743
- # F2: check mkdir exit status — if another caller beats us here, refuse rather than double-write
744
- if mkdir "$LOCK_DIR" 2>/dev/null; then
759
+ # No claim and no LOCK_FILE: truly free state reached after reclaim or first run.
760
+ # F2: check claim exit status — if another caller beats us here, refuse rather than double-write
761
+ if _claim_lock; then
745
762
  _write_lock "$1"; return 0 # won the clean claim
746
763
  fi
747
764
  # Another caller won; fall through — will hit the BLOCKED path on next iteration.
@@ -998,10 +1015,14 @@ _resume_valid_lines() {
998
1015
  }
999
1016
 
1000
1017
  cmd_resume() {
1018
+ local budget=0
1001
1019
  if [ "${1:-}" = "--json" ]; then
1002
1020
  if [ -n "${2:-}" ]; then usage; exit 2; fi
1003
1021
  _emit_context_json
1004
1022
  return 0
1023
+ elif [ "${1:-}" = "--budget" ]; then
1024
+ if [ -n "${2:-}" ]; then usage; exit 2; fi
1025
+ budget=1
1005
1026
  elif [ -n "${1:-}" ]; then
1006
1027
  usage
1007
1028
  exit 2
@@ -1011,6 +1032,7 @@ cmd_resume() {
1011
1032
  _na="$(_next_action)"
1012
1033
  echo "nothing to resume - $_na"
1013
1034
  echo "NEXT_VERB=$(_next_verb_from "$_na")"
1035
+ _emit_pin
1014
1036
  return 0
1015
1037
  fi
1016
1038
 
@@ -1102,7 +1124,12 @@ cmd_resume() {
1102
1124
  fi
1103
1125
 
1104
1126
  # ---- IN FLIGHT ----
1105
- local infl_out; infl_out="$(inflight_display)"
1127
+ local infl_out
1128
+ if [ "$budget" -eq 1 ]; then
1129
+ infl_out="$(inflight_display 5)"
1130
+ else
1131
+ infl_out="$(inflight_display)"
1132
+ fi
1106
1133
  if [ -n "$infl_out" ]; then
1107
1134
  echo "in flight:"
1108
1135
  printf '%s\n' "$infl_out"
@@ -1251,15 +1278,24 @@ _elapsed_human() { # $1=start-epoch -> "Xm"/"Xh"/"Xd" (integer; GNU/BSD-portable
1251
1278
  }
1252
1279
 
1253
1280
  inflight_display() { # emit a line per todo card still marked in flight (stale ids self-skip)
1254
- local infl id ts f; infl="$(_inflight_file)"
1281
+ local max="${1:-0}"
1282
+ local infl id ts f count=0 hidden=0; infl="$(_inflight_file)"
1255
1283
  [ -f "$infl" ] || return 0
1256
1284
  while read -r id ts; do
1257
1285
  [ -n "$id" ] || continue
1258
1286
  f="$(resolve_card_file "$id" 2>/dev/null)"
1259
1287
  [ -n "$f" ] && [ -f "$f" ] || continue # card gone -> stale, skip
1260
1288
  [ "$(card_status "$f")" = "todo" ] || continue # only todo cards are "in flight"
1261
- printf ' %s (in flight, started %s ago)\n' "$id" "$(_elapsed_human "$ts")"
1289
+ count=$((count + 1))
1290
+ if [ "$max" -gt 0 ] && [ "$count" -gt "$max" ]; then
1291
+ hidden=$((hidden + 1))
1292
+ else
1293
+ printf ' %s (in flight, started %s ago)\n' "$id" "$(_elapsed_human "$ts")"
1294
+ fi
1262
1295
  done < "$infl"
1296
+ if [ "$hidden" -gt 0 ]; then
1297
+ printf " +%s more in flight - see 'flow card'\n" "$hidden"
1298
+ fi
1263
1299
  }
1264
1300
 
1265
1301
  # Single source of truth for "what should I do next" - used by BOTH `status` (Phase 3) and
@@ -1353,6 +1389,18 @@ _emit_next() {
1353
1389
  a="$(_next_action)"
1354
1390
  echo "NEXT -> $a"
1355
1391
  echo "NEXT_VERB=$(_next_verb_from "$a")"
1392
+ _emit_pin
1393
+ }
1394
+
1395
+ # Verbatim PIN v1 from references/pin-v1.txt. Display-only; never a capability token.
1396
+ # Missing file: fail-loud on stderr. JSON path must not call this.
1397
+ _emit_pin() {
1398
+ local f="$SCRIPT_DIR/../references/pin-v1.txt"
1399
+ if [ ! -f "$f" ]; then
1400
+ echo "FAIL: PIN file missing: $f" >&2
1401
+ return 1
1402
+ fi
1403
+ tr -d '\r' < "$f"
1356
1404
  }
1357
1405
 
1358
1406
  # PTC-aligned load hint for JSON status/resume. Display-only — never a capability token.
@@ -2116,10 +2164,10 @@ cmd_recall() {
2116
2164
  }
2117
2165
 
2118
2166
  cmd_unlock() {
2119
- if [ ! -f "$LOCK_FILE" ] && ! [ -d "$LOCK_DIR" ]; then echo "no flow lock to clear ($LOCK_FILE)"; return 0; fi
2167
+ if [ ! -f "$LOCK_FILE" ] && ! [ -d "$LOCK_DIR" ] && ! [ -f "$LOCK_CLAIM" ]; then echo "no flow lock to clear ($LOCK_FILE)"; return 0; fi
2120
2168
  _read_lock
2121
2169
  rm -f "$LOCK_FILE" 2>/dev/null || true
2122
- rm -rf "$LOCK_DIR" 2>/dev/null || true # W5: remove atomic claim dir so next acquire can mkdir
2170
+ _release_claim
2123
2171
  echo "PASS: cleared flow lock (was [$LOCK_OWNER], cmd '${LOCK_CMD:-?}')."
2124
2172
  return 0
2125
2173
  }
@@ -2379,8 +2427,131 @@ prd_declares_fr() { # true if the PRD uses the FRn traceability convention (driv
2379
2427
  # keep it loose so the tip reliably shows in the common case. Do not "tighten" to match consistency.
2380
2428
  [ -f "$FLOW_DIR/03-prd.md" ] && grep -qE 'FR[0-9]+' "$FLOW_DIR/03-prd.md" 2>/dev/null
2381
2429
  }
2430
+ _consistency_contract() {
2431
+ local contract="$FLOW_DIR/05-contract.md"
2432
+ if [ ! -f "$contract" ]; then
2433
+ echo "consistency --contract: no flow/05-contract.md found - skipped."
2434
+ return 0
2435
+ fi
2436
+
2437
+ local td; td="$(mktemp -d)"; _register_td "$td"
2438
+ trap 'rm -rf "${td:-}" 2>/dev/null' RETURN
2439
+
2440
+ # 1. Extract endpoint rows from 05-contract.md
2441
+ # Grounded in _templates/05-contract.md: table columns "| Method/Interface | Path/Name | ..."
2442
+ # Tolerates swapped Method/Path column order based on header row inspection.
2443
+ awk -F'|' '
2444
+ /^[[:space:]]*\|/ {
2445
+ line = $0
2446
+ gsub(/^[[:space:]]*\||\|[[:space:]]*$/, "", line)
2447
+ if (tolower(line) ~ /method/ && tolower(line) ~ /path/) {
2448
+ n = split(line, cols, "|")
2449
+ for (i = 1; i <= n; i++) {
2450
+ c = tolower(cols[i])
2451
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", c)
2452
+ if (c ~ /method/) m_col = i
2453
+ if (c ~ /path/) p_col = i
2454
+ }
2455
+ in_table = 1
2456
+ next
2457
+ }
2458
+ if (in_table) {
2459
+ if (line ~ /^[[:space:]]*-+/) next
2460
+ n = split(line, cols, "|")
2461
+ m = cols[m_col]; p = cols[p_col]
2462
+ gsub(/^[[:space:]]+|[[:space:]]+$|[`"]/, "", m)
2463
+ gsub(/^[[:space:]]+|[[:space:]]+$|[`"]/, "", p)
2464
+ if (m ~ /^\[FILL/ || p ~ /^\[FILL/ || m == "" || p == "") next
2465
+ print m "\t" p
2466
+ }
2467
+ }
2468
+ !/^[[:space:]]*\|/ { in_table = 0 }
2469
+ ' "$contract" > "$td/endpoints" 2>/dev/null
2470
+
2471
+ local n_ep
2472
+ n_ep="$(grep -c . "$td/endpoints" 2>/dev/null || echo 0)"
2473
+ if [ "$n_ep" -eq 0 ]; then
2474
+ echo "consistency --contract: no endpoint table found in flow/05-contract.md - skipped."
2475
+ rm -rf "$td" 2>/dev/null
2476
+ return 0
2477
+ fi
2478
+
2479
+ # 2. Extract ## Scope content from all cards/C-*.md
2480
+ : > "$td/cards_scope"
2481
+ if [ -d "$CARDS_DIR" ]; then
2482
+ local cf
2483
+ for cf in "$CARDS_DIR"/C-*.md; do
2484
+ [ -f "$cf" ] || continue
2485
+ awk '/^##[[:space:]]+[Ss]cope/{f=1;next} /^##[[:space:]]/{f=0} f' "$cf" 2>/dev/null
2486
+ done > "$td/cards_scope"
2487
+ fi
2488
+
2489
+ # 3. Match each endpoint path against cards Scope content
2490
+ echo "contract-to-card endpoint coverage audit (flow/05-contract.md vs cards/C-*.md ## Scope)"
2491
+ echo " (matching rule: exact path token in cards/C-*.md '## Scope' - an occurrence counts only when not"
2492
+ echo " preceded/followed by [A-Za-z0-9_] and not followed by [A-Za-z0-9_\/{}:.-]; /api/users/:id does not"
2493
+ echo " cover /api/users; a trailing '?' or end-of-line still counts)"
2494
+ local m p n_cov=0 n_uncov=0
2495
+ : > "$td/uncovered"
2496
+ while IFS="$(printf '\t')" read -r m p; do
2497
+ [ -n "$p" ] || continue
2498
+ # Exact-token boundary via index()/substr only (no regex interpolation): an occurrence of the
2499
+ # path counts as coverage only when it is not preceded by [A-Za-z0-9_] and not followed by
2500
+ # [A-Za-z0-9_\/{}:.-] (end-of-line after counts, so a trailing '?' or EOL is still covered).
2501
+ # awk exit 0 = found (covered); awk exit 1 = not found (uncovered).
2502
+ if awk -v p="$p" '
2503
+ {
2504
+ s = $0; start = 1
2505
+ while ((pos = index(substr(s, start), p)) > 0) {
2506
+ pos += start - 1
2507
+ before = (pos > 1) ? substr(s, pos - 1, 1) : ""
2508
+ after = substr(s, pos + length(p), 1)
2509
+ if (before !~ /[A-Za-z0-9_]/ && (after == "" || after !~ /[A-Za-z0-9_\/{}:.-]/)) { found = 1; exit }
2510
+ start = pos + 1
2511
+ }
2512
+ }
2513
+ END { exit found ? 0 : 1 }
2514
+ ' "$td/cards_scope" 2>/dev/null; then
2515
+ n_cov=$((n_cov + 1))
2516
+ else
2517
+ n_uncov=$((n_uncov + 1))
2518
+ printf ' %s %s\n' "$m" "$p" >> "$td/uncovered"
2519
+ fi
2520
+ done < "$td/endpoints"
2521
+
2522
+ if [ "$n_uncov" -gt 0 ]; then
2523
+ echo " [!] uncovered contract endpoint(s) (no card references path in ## Scope):"
2524
+ cat "$td/uncovered"
2525
+ echo " coverage: $n_ep endpoint(s), $n_cov covered, $n_uncov uncovered."
2526
+ trap - RETURN
2527
+ rm -rf "$td" 2>/dev/null
2528
+ echo "FLAGGED: contract endpoint(s) have no card covering them in ## Scope."
2529
+ return 1
2530
+ fi
2531
+
2532
+ echo " coverage: $n_ep endpoint(s), $n_cov covered, $n_uncov uncovered."
2533
+ echo " PASS: all $n_ep endpoint(s) covered across cards/ scope."
2534
+ trap - RETURN
2535
+ rm -rf "$td" 2>/dev/null
2536
+ return 0
2537
+ }
2382
2538
 
2383
2539
  cmd_consistency() {
2540
+ case "${1:-}" in
2541
+ "") : ;;
2542
+ --contract)
2543
+ if [ $# -gt 1 ]; then
2544
+ echo "usage: /flow consistency [--contract]" >&2
2545
+ return 1
2546
+ fi
2547
+ _consistency_contract
2548
+ return $?
2549
+ ;;
2550
+ *)
2551
+ echo "usage: /flow consistency [--contract]" >&2
2552
+ return 1
2553
+ ;;
2554
+ esac
2384
2555
  # Cross-artifact coverage + contradiction audit - the MECHANICAL complement to the human
2385
2556
  # traceability challenges in gate-rules.md (03/05). The other probes each cover one axis:
2386
2557
  # coherence=version drift, contract=URL-prefix drift, tokens=design-token drift. THIS axis is
@@ -2391,7 +2562,7 @@ cmd_consistency() {
2391
2562
  # Traceability anchors (see _templates): PRD 'Features' entries tagged 'FRn:' (functional
2392
2563
  # requirement id); cards declare 'implements: FR1, FR2' (or 'infra'/'none' for non-feature
2393
2564
  # cards); 05-contract 'Feature -> interface map' references each 'FRn'.
2394
- local prd="$FLOW_DIR/03-prd.md" contract="$FLOW_DIR/05-contract.md"
2565
+ local prd="$FLOW_DIR/03-prd.md" contract="$FLOW_DIR/05-contract.md" td=""
2395
2566
  if [ ! -f "$prd" ]; then echo "consistency: no flow/03-prd.md yet - planning incomplete (skipped)."; return 0; fi
2396
2567
  local td; td="$(mktemp -d)"; _register_td "$td"
2397
2568
  trap 'rm -rf "$td" 2>/dev/null' RETURN
@@ -2483,16 +2654,30 @@ cmd_consistency() {
2483
2654
 
2484
2655
  cmd_promote() {
2485
2656
  # cross-project knowledge tier: copy a playbook/lesson into ~/.claude/flow/playbooks so its
2486
- # lessons travel to every project (surfaced by '/flow recall' everywhere).
2487
- local src="${1:-}"
2488
- if [ -z "$src" ] || [ ! -f "$src" ]; then echo "usage: /flow promote <path-to-playbook.md>"; return 1; fi
2489
- mkdir -p "$GLOBAL_KB_DIR" 2>/dev/null || true
2490
- if ! cp "$src" "$GLOBAL_KB_DIR/$(basename "$src")" 2>/dev/null; then
2491
- echo "FAIL: could not copy into $GLOBAL_KB_DIR"; return 1
2492
- fi
2493
- echo "PASS: promoted $(basename "$src") -> $GLOBAL_KB_DIR"
2494
- echo " '/flow recall' now surfaces it as a GLOBAL PLAYBOOK in every project."
2495
- return 0
2657
+ # lessons travel to every project (surfaced by '/flow recall' everywhere), or suggest
2658
+ # draft playbooks from repeated harness patterns via --suggest.
2659
+ case "${1:-}" in
2660
+ --suggest)
2661
+ if ! harness_available; then
2662
+ echo "promote --suggest: durable layer unavailable (python or harness missing, or FLOW_HARNESS_DISABLE set)."
2663
+ return 0
2664
+ fi
2665
+ local py; py="$(_python)"
2666
+ FLOW_PROJECT_ROOT="$ROOT" "$py" "$HARNESS_PY" promote-suggest
2667
+ return $?
2668
+ ;;
2669
+ *)
2670
+ local src="${1:-}"
2671
+ if [ -z "$src" ] || [ ! -f "$src" ]; then echo "usage: /flow promote <path-to-playbook.md> | /flow promote --suggest"; return 1; fi
2672
+ mkdir -p "$GLOBAL_KB_DIR" 2>/dev/null || true
2673
+ if ! cp "$src" "$GLOBAL_KB_DIR/$(basename "$src")" 2>/dev/null; then
2674
+ echo "FAIL: could not copy into $GLOBAL_KB_DIR"; return 1
2675
+ fi
2676
+ echo "PASS: promoted $(basename "$src") -> $GLOBAL_KB_DIR"
2677
+ echo " '/flow recall' now surfaces it as a GLOBAL PLAYBOOK in every project."
2678
+ return 0
2679
+ ;;
2680
+ esac
2496
2681
  }
2497
2682
 
2498
2683
  cmd_doctor() {
@@ -2536,6 +2721,35 @@ cmd_doctor() {
2536
2721
  esac
2537
2722
  fi
2538
2723
  echo
2724
+ # Runner integrity (pack-time reference hash). Preferred source is the receipt sync.mjs writes
2725
+ # INSIDE the bundled skill (skills/flow/manifest-hashes.json) - present on real npm installs;
2726
+ # portable-manifest.json only exists in dev checkouts. First existing file wins; no file / no
2727
+ # hashes entry (older installs) => informational skip only.
2728
+ local mf="" _mf
2729
+ for _mf in "$SCRIPT_DIR/../manifest-hashes.json" "$SCRIPT_DIR/../../../portable-manifest.json" "$SCRIPT_DIR/../../portable-manifest.json"; do
2730
+ if [ -f "$_mf" ]; then mf="$_mf"; break; fi
2731
+ done
2732
+ local want=""
2733
+ [ -n "$mf" ] && want="$(sed -nE 's/.*"skills\/flow\/runner\/flow\.sh"[[:space:]]*:[[:space:]]*"([0-9a-f]{64})".*/\1/p' "$mf" 2>/dev/null | head -1)"
2734
+ if [ -z "$want" ]; then
2735
+ echo " runner integrity: no reference hash in manifest (older install) - skipped"
2736
+ else
2737
+ local have=""
2738
+ if command -v shasum >/dev/null 2>&1; then
2739
+ have="$(shasum -a 256 "$SCRIPT_DIR/flow.sh" 2>/dev/null | awk '{print $1}')"
2740
+ elif command -v sha256sum >/dev/null 2>&1; then
2741
+ have="$(sha256sum "$SCRIPT_DIR/flow.sh" 2>/dev/null | awk '{print $1}')"
2742
+ fi
2743
+ if [ -z "$have" ]; then
2744
+ echo " runner integrity: no shasum/sha256sum available - skipped"
2745
+ elif [ "$have" = "$want" ]; then
2746
+ echo " runner integrity: ok (${want%"${want#????????}"}...)"
2747
+ else
2748
+ echo " FAIL: runner integrity - flow.sh does not match the installed manifest hash (expected ${want%"${want#????????}"}..., got ${have%"${have#????????}"}...) - the harness may have been modified mid-project"
2749
+ ok=0
2750
+ fi
2751
+ fi
2752
+ echo
2539
2753
  if [ "$ok" -eq 1 ]; then
2540
2754
  echo "READY. Gate engine works$([ -n "$py" ] && echo '; durable layer on.' || echo '; durable layer off (no python).')"
2541
2755
  return 0
@@ -3135,16 +3349,96 @@ cmd_converge() { # [--file <payload>] append-only remainder cards from a flow-c
3135
3349
  echo " These are remainder stubs with [FILL] Verify/Done-evidence - build them via /flow ready + /flow check."
3136
3350
  return 0
3137
3351
  }
3352
+ _ws_port() { # <C-NNN>
3353
+ local card="${1:-}"
3354
+ if [ $# -ne 1 ]; then
3355
+ echo "usage: /flow workspace port <C-NNN> (e.g. C-012)" >&2
3356
+ return 2
3357
+ fi
3358
+ case "$card" in
3359
+ C-[0-9]*)
3360
+ local num="${card#C-}"
3361
+ case "$num" in
3362
+ ''|*[!0-9]*)
3363
+ echo "usage: /flow workspace port <C-NNN> (e.g. C-012)" >&2
3364
+ return 2
3365
+ ;;
3366
+ esac
3367
+ ;;
3368
+ *)
3369
+ echo "usage: /flow workspace port <C-NNN> (e.g. C-012)" >&2
3370
+ return 2
3371
+ ;;
3372
+ esac
3373
+
3374
+ local rec="" r c wt offset
3375
+ while IFS= read -r r; do
3376
+ [ -n "$r" ] || continue
3377
+ c="$(_ws_get "$r" card_id)"
3378
+ if [ "$c" = "$card" ]; then
3379
+ rec="$r"
3380
+ break
3381
+ fi
3382
+ done <<EOF
3383
+ $(_ws_active_records)
3384
+ EOF
3385
+
3386
+ if [ -z "$rec" ]; then
3387
+ echo "workspace port: FAIL - card '$card' has no registered workspace (run '/flow workspace add <branch> --card $card' first)" >&2
3388
+ return 2
3389
+ fi
3390
+
3391
+ wt="$(_ws_get "$rec" worktree_path)"
3392
+ if [ -z "$wt" ] || [ ! -d "$wt" ]; then
3393
+ echo "workspace port: FAIL - workspace directory for '$card' not found at '${wt:-<empty>}'" >&2
3394
+ return 2
3395
+ fi
3396
+
3397
+ offset="$(_ws_get_num "$rec" port_offset)"
3398
+ if [ -z "$offset" ]; then
3399
+ echo "workspace port: FAIL - workspace record for '$card' has no port_offset" >&2
3400
+ return 2
3401
+ fi
3402
+
3403
+ local baseport="${FLOW_WORKSPACE_BASEPORT:-3000}"
3404
+ case "$baseport" in
3405
+ ''|*[!0-9]*)
3406
+ baseport=3000
3407
+ ;;
3408
+ *)
3409
+ if [ "$baseport" -lt 1 ] || [ "$baseport" -gt 65535 ]; then
3410
+ echo "workspace port: FLOW_WORKSPACE_BASEPORT=$baseport out of range (1..65535)" >&2
3411
+ return 2
3412
+ fi
3413
+ ;;
3414
+ esac
3415
+
3416
+ local raw_port port
3417
+ raw_port=$(( baseport + offset ))
3418
+ if [ "$raw_port" -gt 65535 ]; then
3419
+ echo "workspace port: FAIL - port range exhausted for '$card' (baseport $baseport + offset $offset = $raw_port > 65535); lower FLOW_WORKSPACE_BASEPORT" >&2
3420
+ return 2
3421
+ fi
3422
+ port="$raw_port"
3423
+
3424
+ local env_file="$wt/.env.flow.local"
3425
+ printf 'PORT=%s\nFLOW_CARD=%s\n' "$port" "$card" > "$env_file" || {
3426
+ echo "workspace port: FAIL - cannot write to '$env_file'" >&2
3427
+ return 1
3428
+ }
3429
+ echo "$card -> port $port (written to $env_file)"
3430
+ return 0
3431
+ }
3138
3432
 
3139
3433
  cmd_workspace() {
3140
3434
  local sub="${1:-}"; shift 2>/dev/null || true
3141
3435
  case "$sub" in
3142
- add|list|enter|remove|check|doctor) : ;;
3143
- *) echo "usage: /flow workspace add|list|enter|remove|check|doctor"; return 1 ;;
3436
+ add|list|enter|remove|check|doctor|port) : ;;
3437
+ *) echo "usage: /flow workspace add|list|enter|remove|check|doctor|port"; return 1 ;;
3144
3438
  esac
3145
3439
  if ! command -v git >/dev/null 2>&1; then
3146
3440
  echo "workspace: git not found - worktree isolation unavailable (serial builds still work)."
3147
- case "$sub" in add|remove) return 1 ;; *) return 0 ;; esac
3441
+ case "$sub" in add|remove) return 1 ;; port) return 2 ;; *) return 0 ;; esac
3148
3442
  fi
3149
3443
  case "$sub" in
3150
3444
  add) _ws_add "$@" ;;
@@ -3153,6 +3447,7 @@ cmd_workspace() {
3153
3447
  remove) _ws_remove "$@" ;;
3154
3448
  check) _ws_check "$@" ;;
3155
3449
  doctor) _ws_doctor ;;
3450
+ port) _ws_port "$@" ;;
3156
3451
  esac
3157
3452
  }
3158
3453
 
@@ -3747,8 +4042,9 @@ _eval_json_str_field() { # $1=line $2=field-name -> value or empty
3747
4042
  # List complete run_ids (has both a start header and a done trailer, n_written >= n_expected),
3748
4043
  # oldest first. An interrupted batch (Ctrl-C mid-run -> no trailer, per the EXIT-only trap) is
3749
4044
  # silently excluded here - that absence IS the completeness signal, not a positive "torn" flag.
3750
- _eval_complete_run_ids() {
3751
- [ -f "$EVAL_RESULTS_FILE" ] || return 0
4045
+ _eval_file_complete_run_ids() { # $1=results-file
4046
+ local f="${1:-$EVAL_RESULTS_FILE}"
4047
+ [ -f "$f" ] || return 0
3752
4048
  awk '
3753
4049
  /"batch":"start"/ {
3754
4050
  match($0, /"run_id":"[^"]*"/); rid = substr($0, RSTART+10, RLENGTH-11)
@@ -3769,7 +4065,11 @@ _eval_complete_run_ids() {
3769
4065
  if (seen_start[r] && seen_done[r] && (nwritten[r]+0) >= (nexp[r]+0)) print r
3770
4066
  }
3771
4067
  }
3772
- ' "$EVAL_RESULTS_FILE"
4068
+ ' "$f"
4069
+ }
4070
+
4071
+ _eval_complete_run_ids() {
4072
+ _eval_file_complete_run_ids "$EVAL_RESULTS_FILE"
3773
4073
  }
3774
4074
 
3775
4075
  # Per-stage scorecard for one batch: hollow flag-rate, sound pass-rate, invalid/unreliable
@@ -3892,6 +4192,157 @@ _eval_print_drift() {
3892
4192
  done
3893
4193
  }
3894
4194
 
4195
+ # Extract per-fixture verdict records from a results file.
4196
+ # If complete batches exist, selects the latest complete batch; otherwise falls back
4197
+ # to the latest run_id present in fixture rows, or all fixture rows if no run_id exists.
4198
+ # Deduplicates by fixture id, keeping the latest verdict.
4199
+ _eval_diff_extract_fixtures() { # $1=results-file
4200
+ local file="$1"
4201
+ local c_ids last_id=""
4202
+ c_ids="$(_eval_file_complete_run_ids "$file")"
4203
+ if [ -n "$c_ids" ]; then
4204
+ last_id="$(printf '%s\n' "$c_ids" | tail -1)"
4205
+ else
4206
+ last_id="$(grep '"fixture":' "$file" 2>/dev/null | grep -oE '"run_id":"[^"]*"' | tail -1 | sed -E 's/"run_id":"([^"]*)"/\1/')"
4207
+ fi
4208
+
4209
+ local rows
4210
+ if [ -n "$last_id" ]; then
4211
+ rows="$(grep -F "\"run_id\":\"$last_id\"" "$file" 2>/dev/null | grep '"fixture":')"
4212
+ else
4213
+ rows="$(grep '"fixture":' "$file" 2>/dev/null)"
4214
+ fi
4215
+ [ -z "$rows" ] && return 0
4216
+
4217
+ printf '%s\n' "$rows" | while IFS= read -r line; do
4218
+ [ -z "$line" ] && continue
4219
+ local fid v exp
4220
+ fid="$(_eval_json_str_field "$line" fixture)"
4221
+ v="$(_eval_json_str_field "$line" verdict)"
4222
+ exp="$(_eval_json_str_field "$line" expected)"
4223
+ [ -n "$fid" ] && printf '%s\t%s\t%s\n' "$fid" "${v:-UNKNOWN}" "${exp:--}"
4224
+ done | awk -F'\t' '{ f[$1] = $2; e[$1] = $3 } END { for (k in f) print k "\t" f[k] "\t" e[k] }' | sort -k1,1
4225
+ }
4226
+
4227
+ # Longitudinal regression classifier: compares two eval results files offline.
4228
+ # Per fixture present in both: REGRESSED, IMPROVED, SATURATED, UNCHANGED (correctness-relative).
4229
+ # Per fixture only in old: removed. Only in new: added.
4230
+ # Unknown expected: unknown (does not count toward regressions).
4231
+ # Exit 1 if any REGRESSED, exit 2 on missing/unreadable or empty file, else 0.
4232
+ _eval_diff() { # $1=old_results $2=new_results
4233
+ local old_file="$1" new_file="$2"
4234
+ if [ -z "$old_file" ] || [ -z "$new_file" ]; then
4235
+ echo "eval --diff: requires two file arguments <old_results> <new_results>" >&2
4236
+ return 2
4237
+ fi
4238
+ if [ ! -f "$old_file" ] || [ ! -r "$old_file" ]; then
4239
+ echo "eval --diff: file not found or unreadable: $old_file" >&2
4240
+ return 2
4241
+ fi
4242
+ if [ ! -f "$new_file" ] || [ ! -r "$new_file" ]; then
4243
+ echo "eval --diff: file not found or unreadable: $new_file" >&2
4244
+ return 2
4245
+ fi
4246
+
4247
+ local td; td="$(mktemp -d)"; _register_td "$td"
4248
+
4249
+ _eval_diff_extract_fixtures "$old_file" > "$td/old.tsv"
4250
+ _eval_diff_extract_fixtures "$new_file" > "$td/new.tsv"
4251
+
4252
+ if [ ! -s "$td/old.tsv" ]; then
4253
+ echo "eval --diff: no fixture records found in $old_file" >&2
4254
+ rm -rf "$td" 2>/dev/null
4255
+ return 2
4256
+ fi
4257
+ if [ ! -s "$td/new.tsv" ]; then
4258
+ echo "eval --diff: no fixture records found in $new_file" >&2
4259
+ rm -rf "$td" 2>/dev/null
4260
+ return 2
4261
+ fi
4262
+
4263
+ local all_fids
4264
+ all_fids="$( { cut -f1 "$td/old.tsv" 2>/dev/null; cut -f1 "$td/new.tsv" 2>/dev/null; } | sort -u )"
4265
+
4266
+ local rc=0
4267
+ {
4268
+ printf '%s\n' "$all_fids" | while IFS= read -r fid; do
4269
+ [ -n "$fid" ] && printf 'ALL\t%s\n' "$fid"
4270
+ done
4271
+ if [ -f "${EVAL_MANIFEST:-}" ]; then
4272
+ awk -F'\t' 'NR>1 && $1!="" && $4!="" { print "MAN\t" $1 "\t" $4 }' "$EVAL_MANIFEST" 2>/dev/null
4273
+ fi
4274
+ awk -F'\t' '{ print "OLD\t" $1 "\t" $2 "\t" $3 }' "$td/old.tsv"
4275
+ awk -F'\t' '{ print "NEW\t" $1 "\t" $2 "\t" $3 }' "$td/new.tsv"
4276
+ } | awk -F'\t' '
4277
+ $1 == "ALL" { order[++n_fids] = $2 }
4278
+ $1 == "MAN" { if ($3 != "" && $3 != "-") exp_man[$2] = $3 }
4279
+ $1 == "OLD" {
4280
+ old_v[$2] = $3
4281
+ if ($4 != "" && $4 != "-") exp_old_row[$2] = $4
4282
+ }
4283
+ $1 == "NEW" {
4284
+ new_v[$2] = $3
4285
+ if ($4 != "" && $4 != "-") exp_new_row[$2] = $4
4286
+ }
4287
+ END {
4288
+ printf " %-12s %-12s %-10s %-10s %s\n", "id", "expected", "old", "new", "classification"
4289
+ n_reg = 0; n_imp = 0; n_sat = 0; n_unc = 0; n_expchg = 0
4290
+ for (i = 1; i <= n_fids; i++) {
4291
+ fid = order[i]
4292
+ has_old = (fid in old_v)
4293
+ has_new = (fid in new_v)
4294
+ eo = (fid in exp_old_row) ? exp_old_row[fid] : ((fid in exp_man) ? exp_man[fid] : "-")
4295
+ en = (fid in exp_new_row) ? exp_new_row[fid] : ((fid in exp_man) ? exp_man[fid] : "-")
4296
+ ov = has_old ? old_v[fid] : "-"
4297
+ nv = has_new ? new_v[fid] : "-"
4298
+ expected_disp = "-"
4299
+ if (has_old && has_new) {
4300
+ if (eo != "-" && en != "-" && eo != en) {
4301
+ cls = "expectation-changed"
4302
+ n_expchg++
4303
+ expected_disp = eo "→" en
4304
+ } else {
4305
+ expected_val = (en != "-") ? en : eo
4306
+ expected_disp = expected_val
4307
+ if (expected_val == "-") {
4308
+ cls = "unknown"
4309
+ } else {
4310
+ old_corr = (ov == expected_val)
4311
+ new_corr = (nv == expected_val)
4312
+ if (old_corr && new_corr) {
4313
+ cls = "UNCHANGED"
4314
+ n_unc++
4315
+ } else if (old_corr && !new_corr) {
4316
+ cls = "REGRESSED"
4317
+ n_reg++
4318
+ } else if (!old_corr && new_corr) {
4319
+ cls = "IMPROVED"
4320
+ n_imp++
4321
+ } else {
4322
+ cls = "SATURATED"
4323
+ n_sat++
4324
+ }
4325
+ }
4326
+ }
4327
+ } else if (has_old && !has_new) {
4328
+ cls = "removed"
4329
+ expected_disp = (eo != "-") ? eo : en
4330
+ } else {
4331
+ cls = "added"
4332
+ expected_disp = (en != "-") ? en : eo
4333
+ }
4334
+ printf " %-12s %-12s %-10s %-10s %s\n", fid, expected_disp, ov, nv, cls
4335
+ }
4336
+ printf "\n%d regressed, %d improved, %d saturated, %d unchanged, %d expectation-changed\n", n_reg, n_imp, n_sat, n_unc, n_expchg
4337
+ if (n_reg > 0) exit 1
4338
+ exit 0
4339
+ }
4340
+ '
4341
+ rc=$?
4342
+ rm -rf "$td" 2>/dev/null
4343
+ return "$rc"
4344
+ }
4345
+
3895
4346
  ## ---------------------------------------------------------------------------------------
3896
4347
  ## Routing eval (v0.22) - a SEPARATE judge modality from the artifact-vs-gate-rules eval
3897
4348
  ## above. Judges (state-snapshot + utterance) -> single action, not (artifact + gate
@@ -4527,7 +4978,7 @@ _eval_guard_unbounded_darwin() { # $1=replay_mode (0/1); return 1 = refused
4527
4978
  # -positives on that exact byte sequence regardless of language. Purely a spelling dodge for a
4528
4979
  # generic scanner; behavior is 100% identical to every other function in this file.
4529
4980
  function cmd_eval {
4530
- local stage_filter="" fixture_filter="" n=3 timeout=120 report_mode=0 keep_going=0 replay_mode=0 record_mode=0
4981
+ local stage_filter="" fixture_filter="" n=3 timeout=120 report_mode=0 keep_going=0 replay_mode=0 record_mode=0 diff_mode=0 diff_old="" diff_new=""
4531
4982
  while [ $# -gt 0 ]; do
4532
4983
  case "$1" in
4533
4984
  --stage) shift; stage_filter="${1:-}" ;;
@@ -4538,10 +4989,25 @@ function cmd_eval {
4538
4989
  --record) record_mode=1 ;;
4539
4990
  --replay) replay_mode=1 ;;
4540
4991
  --keep-going) keep_going=1 ;;
4541
- *) echo "usage: /flow eval [--stage 01|02|05|card|routing|converge] [--fixture <id>] [--n 3] [--timeout <seconds>] [--keep-going] [--report] [--record|--replay]"; return 1 ;;
4992
+ --diff)
4993
+ diff_mode=1
4994
+ shift
4995
+ diff_old="${1:-}"
4996
+ shift
4997
+ diff_new="${1:-}"
4998
+ ;;
4999
+ *) echo "usage: /flow eval [--stage 01|02|05|card|routing|converge] [--fixture <id>] [--n 3] [--timeout <seconds>] [--keep-going] [--report] [--record|--replay] [--diff <old> <new>]"; return 1 ;;
4542
5000
  esac
4543
5001
  shift 2>/dev/null || true
4544
5002
  done
5003
+ if [ "$diff_mode" -eq 1 ]; then
5004
+ if [ "$report_mode" -eq 1 ] || [ "$record_mode" -eq 1 ] || [ "$replay_mode" -eq 1 ]; then
5005
+ echo "eval: --diff cannot combine with --report, --record, or --replay" >&2
5006
+ return 1
5007
+ fi
5008
+ _eval_diff "$diff_old" "$diff_new"
5009
+ return $?
5010
+ fi
4545
5011
  case "$n" in ''|*[!0-9]*|0) echo "eval: --n must be a positive integer (got '$n')"; return 1 ;; esac
4546
5012
  case "$timeout" in ''|*[!0-9]*|0) echo "eval: --timeout must be a positive integer seconds value (got '$timeout')"; return 1 ;; esac
4547
5013
  case "$stage_filter" in ""|01|02|05|card|routing|converge) : ;; *) echo "eval: --stage must be one of 01|02|05|card|routing|converge (got '$stage_filter')"; return 1 ;; esac
@@ -4968,7 +5434,7 @@ usage: bash flow.sh <command> [args]
4968
5434
 
4969
5435
  status [--json] Where am I? What's blocking? (also: no command). --json prints
4970
5436
  one flow_context/v1 object (display-only; do not auto-exec next_verb).
4971
- resume [--json] Session-story brief for a fresh agent entering mid-cycle: last session
5437
+ resume [--json|--budget] Session-story brief for a fresh agent entering mid-cycle: last session
4972
5438
  (command names only, never raw args), in-flight card + dwell, gate state,
4973
5439
  one NEXT-> line. Read-only, no lock. Run this FIRST when resuming a project.
4974
5440
  --json: same closed object as status --json.
@@ -4981,7 +5447,7 @@ usage: bash flow.sh <command> [args]
4981
5447
  project-type [t] Show or set project type (web|cli|library|skill); adapts done-evidence
4982
5448
  skip <stage> --reason Advance past a gate that has a matching open DEBT (non-security only)
4983
5449
  ready List buildable todo cards + parallel-safety hint
4984
- workspace <verb> Multi-agent worktree isolation: add|list|enter|remove|check|doctor (one worktree per agent)
5450
+ workspace <verb> Multi-agent worktree isolation: add|list|enter|remove|check|doctor|port (one worktree per agent)
4985
5451
  loop-prep <card> Plumbing for ck-loop: isolated worktree + numeric Verify command + param block (thin wrapper; ck-loop is the engine)
4986
5452
  loop-log <card> Record a finished ck-loop run (iterations/start/end/outcome) into usage-log telemetry
4987
5453
  auto [stop] Activate attested auto (risk + Stage 05 receipt) or stop
@@ -4994,11 +5460,11 @@ usage: bash flow.sh <command> [args]
4994
5460
  contract Check client base-URL vs served-path prefixes (path-resolution drift; web)
4995
5461
  tokens Check DESIGN.md declared tokens vs CSS usage (design-system drift)
4996
5462
  coherence Flag version drift across declared version fields (doc-vs-code coherence)
4997
- consistency Audit cross-artifact coverage (PRD features <-> cards <-> contract; FR ids)
5463
+ consistency [--contract] Audit cross-artifact coverage (PRD FR ids or --contract endpoint audit)
4998
5464
  constitution Check operator-authored per-project invariants in flow/constitution.md (advisory; not a next-gate)
4999
5465
  clarify List leftover ## Open decisions bullets on scope/PRD/contract (advisory; not a next-gate)
5000
5466
  converge [--file] Append-only remainder cards from a flow-converge/v1 payload (plan vs present code)
5001
- promote <file> Copy a playbook into the cross-project KB (~/.claude/flow/playbooks)
5467
+ promote <file>|--suggest Copy a playbook into KB or suggest draft playbooks from harness patterns
5002
5468
  doctor Check the environment (bash/python/grep/git) across macOS/Linux/Windows
5003
5469
  eval [opts] Behavioral eval: does the LLM semantic gate flag a hollow-but-mechanically-clean
5004
5470
  fixture? Opt-in, BILLABLE (live mode skips cleanly with zero calls if 'claude'
@@ -5205,11 +5671,11 @@ case "$cmd" in
5205
5671
  contract) cmd_contract ;;
5206
5672
  tokens) cmd_tokens ;;
5207
5673
  coherence) cmd_coherence ;;
5208
- consistency) cmd_consistency ;;
5674
+ consistency) cmd_consistency "$@" ;;
5209
5675
  constitution) cmd_constitution ;;
5210
5676
  clarify) cmd_clarify ;;
5211
5677
  converge) cmd_converge "$@" ;;
5212
- promote) cmd_promote "${1:-}" ;;
5678
+ promote) cmd_promote "$@" ;;
5213
5679
  doctor) cmd_doctor ;;
5214
5680
  eval) cmd_eval "$@" ;;
5215
5681
  usage) cmd_usage "$@" ;;