@manhquy/flow-skill 0.7.2 → 0.7.4

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.
@@ -886,6 +903,14 @@ _stage_dwell() { # $1 = current stage name -> "Xm/Xh/Xd" from genuine entry, or
886
903
  }
887
904
 
888
905
  cmd_status() {
906
+ if [ "${1:-}" = "--json" ]; then
907
+ if [ -n "${2:-}" ]; then usage; exit 2; fi
908
+ _emit_context_json
909
+ return 0
910
+ elif [ -n "${1:-}" ]; then
911
+ usage
912
+ exit 2
913
+ fi
889
914
  local idx; idx="$(current_stage_idx)"
890
915
  echo "flow status"
891
916
  echo " project: $ROOT"
@@ -990,6 +1015,18 @@ _resume_valid_lines() {
990
1015
  }
991
1016
 
992
1017
  cmd_resume() {
1018
+ local budget=0
1019
+ if [ "${1:-}" = "--json" ]; then
1020
+ if [ -n "${2:-}" ]; then usage; exit 2; fi
1021
+ _emit_context_json
1022
+ return 0
1023
+ elif [ "${1:-}" = "--budget" ]; then
1024
+ if [ -n "${2:-}" ]; then usage; exit 2; fi
1025
+ budget=1
1026
+ elif [ -n "${1:-}" ]; then
1027
+ usage
1028
+ exit 2
1029
+ fi
993
1030
  local idx; idx="$(current_stage_idx)"
994
1031
  if [ "$idx" -lt 0 ]; then
995
1032
  _na="$(_next_action)"
@@ -1086,7 +1123,12 @@ cmd_resume() {
1086
1123
  fi
1087
1124
 
1088
1125
  # ---- IN FLIGHT ----
1089
- local infl_out; infl_out="$(inflight_display)"
1126
+ local infl_out
1127
+ if [ "$budget" -eq 1 ]; then
1128
+ infl_out="$(inflight_display 5)"
1129
+ else
1130
+ infl_out="$(inflight_display)"
1131
+ fi
1090
1132
  if [ -n "$infl_out" ]; then
1091
1133
  echo "in flight:"
1092
1134
  printf '%s\n' "$infl_out"
@@ -1235,15 +1277,24 @@ _elapsed_human() { # $1=start-epoch -> "Xm"/"Xh"/"Xd" (integer; GNU/BSD-portable
1235
1277
  }
1236
1278
 
1237
1279
  inflight_display() { # emit a line per todo card still marked in flight (stale ids self-skip)
1238
- local infl id ts f; infl="$(_inflight_file)"
1280
+ local max="${1:-0}"
1281
+ local infl id ts f count=0 hidden=0; infl="$(_inflight_file)"
1239
1282
  [ -f "$infl" ] || return 0
1240
1283
  while read -r id ts; do
1241
1284
  [ -n "$id" ] || continue
1242
1285
  f="$(resolve_card_file "$id" 2>/dev/null)"
1243
1286
  [ -n "$f" ] && [ -f "$f" ] || continue # card gone -> stale, skip
1244
1287
  [ "$(card_status "$f")" = "todo" ] || continue # only todo cards are "in flight"
1245
- printf ' %s (in flight, started %s ago)\n' "$id" "$(_elapsed_human "$ts")"
1288
+ count=$((count + 1))
1289
+ if [ "$max" -gt 0 ] && [ "$count" -gt "$max" ]; then
1290
+ hidden=$((hidden + 1))
1291
+ else
1292
+ printf ' %s (in flight, started %s ago)\n' "$id" "$(_elapsed_human "$ts")"
1293
+ fi
1246
1294
  done < "$infl"
1295
+ if [ "$hidden" -gt 0 ]; then
1296
+ printf " +%s more in flight - see 'flow card'\n" "$hidden"
1297
+ fi
1247
1298
  }
1248
1299
 
1249
1300
  # Single source of truth for "what should I do next" - used by BOTH `status` (Phase 3) and
@@ -1317,7 +1368,8 @@ _next_action() {
1317
1368
  echo "run '/flow check' then ship per stage 09"
1318
1369
  }
1319
1370
 
1320
- # Closed advisory enum from _next_action prose only. Hosts must not auto-exec auto/skip.
1371
+ # Closed advisory enum from _next_action prose only. Hosts must not auto-exec
1372
+ # next/card/card-start/check/fix-gate/auto/skip.
1321
1373
  _next_verb_from() { # $1 = _next_action text
1322
1374
  local a="$1"
1323
1375
  case "$a" in
@@ -1338,6 +1390,54 @@ _emit_next() {
1338
1390
  echo "NEXT_VERB=$(_next_verb_from "$a")"
1339
1391
  }
1340
1392
 
1393
+ # PTC-aligned load hint for JSON status/resume. Display-only — never a capability token.
1394
+ # Matches SKILL.md load table: card/build -> law/CLAUDE.md; fix-gate/next/none stay empty
1395
+ # (mechanical FAIL is the blocked stage file; next PASS few-shots are after unlock).
1396
+ _json_load_for_verb() { # $1 = next_verb
1397
+ case "${1:-}" in
1398
+ card|card-start|check) echo "law/CLAUDE.md" ;;
1399
+ *) echo "" ;;
1400
+ esac
1401
+ }
1402
+
1403
+ # Closed flow_context/v1 printer. Scalars through _json_str. scan_gate never piped.
1404
+ # Hosts MUST NOT dispatch next_verb (next/card/card-start/check/fix-gate/auto/skip).
1405
+ _emit_context_json() {
1406
+ local idx cur f stage="" gate="none" next_verb card="" dwell="" load na infl id ts
1407
+ idx="$(current_stage_idx)"
1408
+ if [ "$idx" -ge 0 ]; then
1409
+ cur="$(stage_name_at "$idx")"
1410
+ stage="$cur"
1411
+ f="$FLOW_DIR/$cur.md"
1412
+ if [ -f "$f" ]; then
1413
+ if scan_gate "$f" >/dev/null 2>&1; then
1414
+ gate="PASS"
1415
+ else
1416
+ gate="BLOCKED"
1417
+ fi
1418
+ fi
1419
+ dwell="$(_stage_dwell "$cur")"
1420
+ fi
1421
+ na="$(_next_action)"
1422
+ next_verb="$(_next_verb_from "$na")"
1423
+ load="$(_json_load_for_verb "$next_verb")"
1424
+ infl="$(_inflight_file)"
1425
+ if [ -f "$infl" ]; then
1426
+ while read -r id ts; do
1427
+ [ -n "$id" ] || continue
1428
+ f="$(resolve_card_file "$id" 2>/dev/null)"
1429
+ [ -n "$f" ] && [ -f "$f" ] || continue
1430
+ [ "$(card_status "$f")" = "todo" ] || continue
1431
+ card="$id"
1432
+ break
1433
+ done < "$infl"
1434
+ fi
1435
+ printf '{"v":"%s","stage":"%s","gate":"%s","next_verb":"%s","card":"%s","dwell":"%s","load":"%s"}\n' \
1436
+ "$(_json_str "flow_context/v1")" "$(_json_str "$stage")" "$(_json_str "$gate")" \
1437
+ "$(_json_str "$next_verb")" "$(_json_str "$card")" "$(_json_str "$dwell")" "$(_json_str "$load")"
1438
+ }
1439
+
1440
+
1341
1441
  _set_card_status() { # $1=file $2=value -> rewrite the ^status: line (portable substitute; temp+mv)
1342
1442
  local f="$1" v="$2"
1343
1443
  sed "s/^status:.*/status: $v/" "$f" > "$f.tmp" 2>/dev/null || { rm -f "$f.tmp"; return 1; }
@@ -2051,10 +2151,10 @@ cmd_recall() {
2051
2151
  }
2052
2152
 
2053
2153
  cmd_unlock() {
2054
- if [ ! -f "$LOCK_FILE" ] && ! [ -d "$LOCK_DIR" ]; then echo "no flow lock to clear ($LOCK_FILE)"; return 0; fi
2154
+ if [ ! -f "$LOCK_FILE" ] && ! [ -d "$LOCK_DIR" ] && ! [ -f "$LOCK_CLAIM" ]; then echo "no flow lock to clear ($LOCK_FILE)"; return 0; fi
2055
2155
  _read_lock
2056
2156
  rm -f "$LOCK_FILE" 2>/dev/null || true
2057
- rm -rf "$LOCK_DIR" 2>/dev/null || true # W5: remove atomic claim dir so next acquire can mkdir
2157
+ _release_claim
2058
2158
  echo "PASS: cleared flow lock (was [$LOCK_OWNER], cmd '${LOCK_CMD:-?}')."
2059
2159
  return 0
2060
2160
  }
@@ -2314,8 +2414,131 @@ prd_declares_fr() { # true if the PRD uses the FRn traceability convention (driv
2314
2414
  # keep it loose so the tip reliably shows in the common case. Do not "tighten" to match consistency.
2315
2415
  [ -f "$FLOW_DIR/03-prd.md" ] && grep -qE 'FR[0-9]+' "$FLOW_DIR/03-prd.md" 2>/dev/null
2316
2416
  }
2417
+ _consistency_contract() {
2418
+ local contract="$FLOW_DIR/05-contract.md"
2419
+ if [ ! -f "$contract" ]; then
2420
+ echo "consistency --contract: no flow/05-contract.md found - skipped."
2421
+ return 0
2422
+ fi
2423
+
2424
+ local td; td="$(mktemp -d)"; _register_td "$td"
2425
+ trap 'rm -rf "${td:-}" 2>/dev/null' RETURN
2426
+
2427
+ # 1. Extract endpoint rows from 05-contract.md
2428
+ # Grounded in _templates/05-contract.md: table columns "| Method/Interface | Path/Name | ..."
2429
+ # Tolerates swapped Method/Path column order based on header row inspection.
2430
+ awk -F'|' '
2431
+ /^[[:space:]]*\|/ {
2432
+ line = $0
2433
+ gsub(/^[[:space:]]*\||\|[[:space:]]*$/, "", line)
2434
+ if (tolower(line) ~ /method/ && tolower(line) ~ /path/) {
2435
+ n = split(line, cols, "|")
2436
+ for (i = 1; i <= n; i++) {
2437
+ c = tolower(cols[i])
2438
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", c)
2439
+ if (c ~ /method/) m_col = i
2440
+ if (c ~ /path/) p_col = i
2441
+ }
2442
+ in_table = 1
2443
+ next
2444
+ }
2445
+ if (in_table) {
2446
+ if (line ~ /^[[:space:]]*-+/) next
2447
+ n = split(line, cols, "|")
2448
+ m = cols[m_col]; p = cols[p_col]
2449
+ gsub(/^[[:space:]]+|[[:space:]]+$|[`"]/, "", m)
2450
+ gsub(/^[[:space:]]+|[[:space:]]+$|[`"]/, "", p)
2451
+ if (m ~ /^\[FILL/ || p ~ /^\[FILL/ || m == "" || p == "") next
2452
+ print m "\t" p
2453
+ }
2454
+ }
2455
+ !/^[[:space:]]*\|/ { in_table = 0 }
2456
+ ' "$contract" > "$td/endpoints" 2>/dev/null
2457
+
2458
+ local n_ep
2459
+ n_ep="$(grep -c . "$td/endpoints" 2>/dev/null || echo 0)"
2460
+ if [ "$n_ep" -eq 0 ]; then
2461
+ echo "consistency --contract: no endpoint table found in flow/05-contract.md - skipped."
2462
+ rm -rf "$td" 2>/dev/null
2463
+ return 0
2464
+ fi
2465
+
2466
+ # 2. Extract ## Scope content from all cards/C-*.md
2467
+ : > "$td/cards_scope"
2468
+ if [ -d "$CARDS_DIR" ]; then
2469
+ local cf
2470
+ for cf in "$CARDS_DIR"/C-*.md; do
2471
+ [ -f "$cf" ] || continue
2472
+ awk '/^##[[:space:]]+[Ss]cope/{f=1;next} /^##[[:space:]]/{f=0} f' "$cf" 2>/dev/null
2473
+ done > "$td/cards_scope"
2474
+ fi
2475
+
2476
+ # 3. Match each endpoint path against cards Scope content
2477
+ echo "contract-to-card endpoint coverage audit (flow/05-contract.md vs cards/C-*.md ## Scope)"
2478
+ echo " (matching rule: exact path token in cards/C-*.md '## Scope' - an occurrence counts only when not"
2479
+ echo " preceded/followed by [A-Za-z0-9_] and not followed by [A-Za-z0-9_\/{}:.-]; /api/users/:id does not"
2480
+ echo " cover /api/users; a trailing '?' or end-of-line still counts)"
2481
+ local m p n_cov=0 n_uncov=0
2482
+ : > "$td/uncovered"
2483
+ while IFS="$(printf '\t')" read -r m p; do
2484
+ [ -n "$p" ] || continue
2485
+ # Exact-token boundary via index()/substr only (no regex interpolation): an occurrence of the
2486
+ # path counts as coverage only when it is not preceded by [A-Za-z0-9_] and not followed by
2487
+ # [A-Za-z0-9_\/{}:.-] (end-of-line after counts, so a trailing '?' or EOL is still covered).
2488
+ # awk exit 0 = found (covered); awk exit 1 = not found (uncovered).
2489
+ if awk -v p="$p" '
2490
+ {
2491
+ s = $0; start = 1
2492
+ while ((pos = index(substr(s, start), p)) > 0) {
2493
+ pos += start - 1
2494
+ before = (pos > 1) ? substr(s, pos - 1, 1) : ""
2495
+ after = substr(s, pos + length(p), 1)
2496
+ if (before !~ /[A-Za-z0-9_]/ && (after == "" || after !~ /[A-Za-z0-9_\/{}:.-]/)) { found = 1; exit }
2497
+ start = pos + 1
2498
+ }
2499
+ }
2500
+ END { exit found ? 0 : 1 }
2501
+ ' "$td/cards_scope" 2>/dev/null; then
2502
+ n_cov=$((n_cov + 1))
2503
+ else
2504
+ n_uncov=$((n_uncov + 1))
2505
+ printf ' %s %s\n' "$m" "$p" >> "$td/uncovered"
2506
+ fi
2507
+ done < "$td/endpoints"
2508
+
2509
+ if [ "$n_uncov" -gt 0 ]; then
2510
+ echo " [!] uncovered contract endpoint(s) (no card references path in ## Scope):"
2511
+ cat "$td/uncovered"
2512
+ echo " coverage: $n_ep endpoint(s), $n_cov covered, $n_uncov uncovered."
2513
+ trap - RETURN
2514
+ rm -rf "$td" 2>/dev/null
2515
+ echo "FLAGGED: contract endpoint(s) have no card covering them in ## Scope."
2516
+ return 1
2517
+ fi
2518
+
2519
+ echo " coverage: $n_ep endpoint(s), $n_cov covered, $n_uncov uncovered."
2520
+ echo " PASS: all $n_ep endpoint(s) covered across cards/ scope."
2521
+ trap - RETURN
2522
+ rm -rf "$td" 2>/dev/null
2523
+ return 0
2524
+ }
2317
2525
 
2318
2526
  cmd_consistency() {
2527
+ case "${1:-}" in
2528
+ "") : ;;
2529
+ --contract)
2530
+ if [ $# -gt 1 ]; then
2531
+ echo "usage: /flow consistency [--contract]" >&2
2532
+ return 1
2533
+ fi
2534
+ _consistency_contract
2535
+ return $?
2536
+ ;;
2537
+ *)
2538
+ echo "usage: /flow consistency [--contract]" >&2
2539
+ return 1
2540
+ ;;
2541
+ esac
2319
2542
  # Cross-artifact coverage + contradiction audit - the MECHANICAL complement to the human
2320
2543
  # traceability challenges in gate-rules.md (03/05). The other probes each cover one axis:
2321
2544
  # coherence=version drift, contract=URL-prefix drift, tokens=design-token drift. THIS axis is
@@ -2326,7 +2549,7 @@ cmd_consistency() {
2326
2549
  # Traceability anchors (see _templates): PRD 'Features' entries tagged 'FRn:' (functional
2327
2550
  # requirement id); cards declare 'implements: FR1, FR2' (or 'infra'/'none' for non-feature
2328
2551
  # cards); 05-contract 'Feature -> interface map' references each 'FRn'.
2329
- local prd="$FLOW_DIR/03-prd.md" contract="$FLOW_DIR/05-contract.md"
2552
+ local prd="$FLOW_DIR/03-prd.md" contract="$FLOW_DIR/05-contract.md" td=""
2330
2553
  if [ ! -f "$prd" ]; then echo "consistency: no flow/03-prd.md yet - planning incomplete (skipped)."; return 0; fi
2331
2554
  local td; td="$(mktemp -d)"; _register_td "$td"
2332
2555
  trap 'rm -rf "$td" 2>/dev/null' RETURN
@@ -2418,16 +2641,30 @@ cmd_consistency() {
2418
2641
 
2419
2642
  cmd_promote() {
2420
2643
  # cross-project knowledge tier: copy a playbook/lesson into ~/.claude/flow/playbooks so its
2421
- # lessons travel to every project (surfaced by '/flow recall' everywhere).
2422
- local src="${1:-}"
2423
- if [ -z "$src" ] || [ ! -f "$src" ]; then echo "usage: /flow promote <path-to-playbook.md>"; return 1; fi
2424
- mkdir -p "$GLOBAL_KB_DIR" 2>/dev/null || true
2425
- if ! cp "$src" "$GLOBAL_KB_DIR/$(basename "$src")" 2>/dev/null; then
2426
- echo "FAIL: could not copy into $GLOBAL_KB_DIR"; return 1
2427
- fi
2428
- echo "PASS: promoted $(basename "$src") -> $GLOBAL_KB_DIR"
2429
- echo " '/flow recall' now surfaces it as a GLOBAL PLAYBOOK in every project."
2430
- return 0
2644
+ # lessons travel to every project (surfaced by '/flow recall' everywhere), or suggest
2645
+ # draft playbooks from repeated harness patterns via --suggest.
2646
+ case "${1:-}" in
2647
+ --suggest)
2648
+ if ! harness_available; then
2649
+ echo "promote --suggest: durable layer unavailable (python or harness missing, or FLOW_HARNESS_DISABLE set)."
2650
+ return 0
2651
+ fi
2652
+ local py; py="$(_python)"
2653
+ FLOW_PROJECT_ROOT="$ROOT" "$py" "$HARNESS_PY" promote-suggest
2654
+ return $?
2655
+ ;;
2656
+ *)
2657
+ local src="${1:-}"
2658
+ if [ -z "$src" ] || [ ! -f "$src" ]; then echo "usage: /flow promote <path-to-playbook.md> | /flow promote --suggest"; return 1; fi
2659
+ mkdir -p "$GLOBAL_KB_DIR" 2>/dev/null || true
2660
+ if ! cp "$src" "$GLOBAL_KB_DIR/$(basename "$src")" 2>/dev/null; then
2661
+ echo "FAIL: could not copy into $GLOBAL_KB_DIR"; return 1
2662
+ fi
2663
+ echo "PASS: promoted $(basename "$src") -> $GLOBAL_KB_DIR"
2664
+ echo " '/flow recall' now surfaces it as a GLOBAL PLAYBOOK in every project."
2665
+ return 0
2666
+ ;;
2667
+ esac
2431
2668
  }
2432
2669
 
2433
2670
  cmd_doctor() {
@@ -2471,6 +2708,35 @@ cmd_doctor() {
2471
2708
  esac
2472
2709
  fi
2473
2710
  echo
2711
+ # Runner integrity (pack-time reference hash). Preferred source is the receipt sync.mjs writes
2712
+ # INSIDE the bundled skill (skills/flow/manifest-hashes.json) - present on real npm installs;
2713
+ # portable-manifest.json only exists in dev checkouts. First existing file wins; no file / no
2714
+ # hashes entry (older installs) => informational skip only.
2715
+ local mf="" _mf
2716
+ for _mf in "$SCRIPT_DIR/../manifest-hashes.json" "$SCRIPT_DIR/../../../portable-manifest.json" "$SCRIPT_DIR/../../portable-manifest.json"; do
2717
+ if [ -f "$_mf" ]; then mf="$_mf"; break; fi
2718
+ done
2719
+ local want=""
2720
+ [ -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)"
2721
+ if [ -z "$want" ]; then
2722
+ echo " runner integrity: no reference hash in manifest (older install) - skipped"
2723
+ else
2724
+ local have=""
2725
+ if command -v shasum >/dev/null 2>&1; then
2726
+ have="$(shasum -a 256 "$SCRIPT_DIR/flow.sh" 2>/dev/null | awk '{print $1}')"
2727
+ elif command -v sha256sum >/dev/null 2>&1; then
2728
+ have="$(sha256sum "$SCRIPT_DIR/flow.sh" 2>/dev/null | awk '{print $1}')"
2729
+ fi
2730
+ if [ -z "$have" ]; then
2731
+ echo " runner integrity: no shasum/sha256sum available - skipped"
2732
+ elif [ "$have" = "$want" ]; then
2733
+ echo " runner integrity: ok (${want%"${want#????????}"}...)"
2734
+ else
2735
+ 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"
2736
+ ok=0
2737
+ fi
2738
+ fi
2739
+ echo
2474
2740
  if [ "$ok" -eq 1 ]; then
2475
2741
  echo "READY. Gate engine works$([ -n "$py" ] && echo '; durable layer on.' || echo '; durable layer off (no python).')"
2476
2742
  return 0
@@ -3070,16 +3336,96 @@ cmd_converge() { # [--file <payload>] append-only remainder cards from a flow-c
3070
3336
  echo " These are remainder stubs with [FILL] Verify/Done-evidence - build them via /flow ready + /flow check."
3071
3337
  return 0
3072
3338
  }
3339
+ _ws_port() { # <C-NNN>
3340
+ local card="${1:-}"
3341
+ if [ $# -ne 1 ]; then
3342
+ echo "usage: /flow workspace port <C-NNN> (e.g. C-012)" >&2
3343
+ return 2
3344
+ fi
3345
+ case "$card" in
3346
+ C-[0-9]*)
3347
+ local num="${card#C-}"
3348
+ case "$num" in
3349
+ ''|*[!0-9]*)
3350
+ echo "usage: /flow workspace port <C-NNN> (e.g. C-012)" >&2
3351
+ return 2
3352
+ ;;
3353
+ esac
3354
+ ;;
3355
+ *)
3356
+ echo "usage: /flow workspace port <C-NNN> (e.g. C-012)" >&2
3357
+ return 2
3358
+ ;;
3359
+ esac
3360
+
3361
+ local rec="" r c wt offset
3362
+ while IFS= read -r r; do
3363
+ [ -n "$r" ] || continue
3364
+ c="$(_ws_get "$r" card_id)"
3365
+ if [ "$c" = "$card" ]; then
3366
+ rec="$r"
3367
+ break
3368
+ fi
3369
+ done <<EOF
3370
+ $(_ws_active_records)
3371
+ EOF
3372
+
3373
+ if [ -z "$rec" ]; then
3374
+ echo "workspace port: FAIL - card '$card' has no registered workspace (run '/flow workspace add <branch> --card $card' first)" >&2
3375
+ return 2
3376
+ fi
3377
+
3378
+ wt="$(_ws_get "$rec" worktree_path)"
3379
+ if [ -z "$wt" ] || [ ! -d "$wt" ]; then
3380
+ echo "workspace port: FAIL - workspace directory for '$card' not found at '${wt:-<empty>}'" >&2
3381
+ return 2
3382
+ fi
3383
+
3384
+ offset="$(_ws_get_num "$rec" port_offset)"
3385
+ if [ -z "$offset" ]; then
3386
+ echo "workspace port: FAIL - workspace record for '$card' has no port_offset" >&2
3387
+ return 2
3388
+ fi
3389
+
3390
+ local baseport="${FLOW_WORKSPACE_BASEPORT:-3000}"
3391
+ case "$baseport" in
3392
+ ''|*[!0-9]*)
3393
+ baseport=3000
3394
+ ;;
3395
+ *)
3396
+ if [ "$baseport" -lt 1 ] || [ "$baseport" -gt 65535 ]; then
3397
+ echo "workspace port: FLOW_WORKSPACE_BASEPORT=$baseport out of range (1..65535)" >&2
3398
+ return 2
3399
+ fi
3400
+ ;;
3401
+ esac
3402
+
3403
+ local raw_port port
3404
+ raw_port=$(( baseport + offset ))
3405
+ if [ "$raw_port" -gt 65535 ]; then
3406
+ echo "workspace port: FAIL - port range exhausted for '$card' (baseport $baseport + offset $offset = $raw_port > 65535); lower FLOW_WORKSPACE_BASEPORT" >&2
3407
+ return 2
3408
+ fi
3409
+ port="$raw_port"
3410
+
3411
+ local env_file="$wt/.env.flow.local"
3412
+ printf 'PORT=%s\nFLOW_CARD=%s\n' "$port" "$card" > "$env_file" || {
3413
+ echo "workspace port: FAIL - cannot write to '$env_file'" >&2
3414
+ return 1
3415
+ }
3416
+ echo "$card -> port $port (written to $env_file)"
3417
+ return 0
3418
+ }
3073
3419
 
3074
3420
  cmd_workspace() {
3075
3421
  local sub="${1:-}"; shift 2>/dev/null || true
3076
3422
  case "$sub" in
3077
- add|list|enter|remove|check|doctor) : ;;
3078
- *) echo "usage: /flow workspace add|list|enter|remove|check|doctor"; return 1 ;;
3423
+ add|list|enter|remove|check|doctor|port) : ;;
3424
+ *) echo "usage: /flow workspace add|list|enter|remove|check|doctor|port"; return 1 ;;
3079
3425
  esac
3080
3426
  if ! command -v git >/dev/null 2>&1; then
3081
3427
  echo "workspace: git not found - worktree isolation unavailable (serial builds still work)."
3082
- case "$sub" in add|remove) return 1 ;; *) return 0 ;; esac
3428
+ case "$sub" in add|remove) return 1 ;; port) return 2 ;; *) return 0 ;; esac
3083
3429
  fi
3084
3430
  case "$sub" in
3085
3431
  add) _ws_add "$@" ;;
@@ -3088,6 +3434,7 @@ cmd_workspace() {
3088
3434
  remove) _ws_remove "$@" ;;
3089
3435
  check) _ws_check "$@" ;;
3090
3436
  doctor) _ws_doctor ;;
3437
+ port) _ws_port "$@" ;;
3091
3438
  esac
3092
3439
  }
3093
3440
 
@@ -3317,22 +3664,24 @@ _eval_heading_pattern() {
3317
3664
  case "$1" in
3318
3665
  01) echo '^## Stage 01' ;;
3319
3666
  02) echo '^## Stage 02' ;;
3667
+ 05) echo '^## Stage 05' ;;
3320
3668
  card) echo '^## Card gate' ;;
3321
3669
  *) return 1 ;;
3322
3670
  esac
3323
3671
  }
3324
3672
 
3325
3673
  # Extract one stage's ritual text from the mapped file (01→gate-01.md, 02→gate-02.md,
3326
- # card→gate-card.md): from its heading line up to (not including) the next '## '
3327
- # heading, or EOF. Prints nothing on a bad stage/missing file - caller MUST assert
3674
+ # 05→gate-05.md, card→gate-card.md): from its heading line up to (not including) the next
3675
+ # '## ' heading, or EOF. Prints nothing on a bad stage/missing file - caller MUST assert
3328
3676
  # non-empty before any billable call (a silent empty extraction would judge against
3329
3677
  # no rule at all).
3330
- _eval_extract_section() { # $1 = stage (01|02|card)
3678
+ _eval_extract_section() { # $1 = stage (01|02|05|card)
3331
3679
  local pat src
3332
3680
  pat="$(_eval_heading_pattern "$1")" || return 1
3333
3681
  case "$1" in
3334
3682
  01) src="$SCRIPT_DIR/../references/gate-01.md" ;;
3335
3683
  02) src="$SCRIPT_DIR/../references/gate-02.md" ;;
3684
+ 05) src="$SCRIPT_DIR/../references/gate-05.md" ;;
3336
3685
  card) src="$SCRIPT_DIR/../references/gate-card.md" ;;
3337
3686
  *) return 1 ;;
3338
3687
  esac
@@ -3402,8 +3751,9 @@ _eval_build_prompt() { # $1=outfile $2=stage $3=artifact-file $4=nonce
3402
3751
  local outfile="$1" stage="$2" artifact="$3" nonce="$4" section shared
3403
3752
  section="$(_eval_extract_section "$stage")" || return 1
3404
3753
  [ -n "$section" ] || return 1
3405
- # Shared OD/authority lives in 02/03/05 challenges. Extract maps 01/02/card only;
3406
- # appending shared onto 01/card lets OD rules false-FLAG research/evidence.
3754
+ # Shared OD/authority lives in 02/03/05 challenges. Extract maps 01/02/05/card;
3755
+ # append shared onto 02 only. 05 extract may mention gate-shared.md; do not cat it
3756
+ # (shared OD on 05 false-FLAGs f05a — PASS fixture states bearer auth without an ADR).
3407
3757
  if [ "$stage" = "02" ]; then
3408
3758
  shared="$SCRIPT_DIR/../references/gate-shared.md"
3409
3759
  [ -f "$shared" ] && section="$section
@@ -3679,8 +4029,9 @@ _eval_json_str_field() { # $1=line $2=field-name -> value or empty
3679
4029
  # List complete run_ids (has both a start header and a done trailer, n_written >= n_expected),
3680
4030
  # oldest first. An interrupted batch (Ctrl-C mid-run -> no trailer, per the EXIT-only trap) is
3681
4031
  # silently excluded here - that absence IS the completeness signal, not a positive "torn" flag.
3682
- _eval_complete_run_ids() {
3683
- [ -f "$EVAL_RESULTS_FILE" ] || return 0
4032
+ _eval_file_complete_run_ids() { # $1=results-file
4033
+ local f="${1:-$EVAL_RESULTS_FILE}"
4034
+ [ -f "$f" ] || return 0
3684
4035
  awk '
3685
4036
  /"batch":"start"/ {
3686
4037
  match($0, /"run_id":"[^"]*"/); rid = substr($0, RSTART+10, RLENGTH-11)
@@ -3701,7 +4052,11 @@ _eval_complete_run_ids() {
3701
4052
  if (seen_start[r] && seen_done[r] && (nwritten[r]+0) >= (nexp[r]+0)) print r
3702
4053
  }
3703
4054
  }
3704
- ' "$EVAL_RESULTS_FILE"
4055
+ ' "$f"
4056
+ }
4057
+
4058
+ _eval_complete_run_ids() {
4059
+ _eval_file_complete_run_ids "$EVAL_RESULTS_FILE"
3705
4060
  }
3706
4061
 
3707
4062
  # Per-stage scorecard for one batch: hollow flag-rate, sound pass-rate, invalid/unreliable
@@ -3824,6 +4179,157 @@ _eval_print_drift() {
3824
4179
  done
3825
4180
  }
3826
4181
 
4182
+ # Extract per-fixture verdict records from a results file.
4183
+ # If complete batches exist, selects the latest complete batch; otherwise falls back
4184
+ # to the latest run_id present in fixture rows, or all fixture rows if no run_id exists.
4185
+ # Deduplicates by fixture id, keeping the latest verdict.
4186
+ _eval_diff_extract_fixtures() { # $1=results-file
4187
+ local file="$1"
4188
+ local c_ids last_id=""
4189
+ c_ids="$(_eval_file_complete_run_ids "$file")"
4190
+ if [ -n "$c_ids" ]; then
4191
+ last_id="$(printf '%s\n' "$c_ids" | tail -1)"
4192
+ else
4193
+ last_id="$(grep '"fixture":' "$file" 2>/dev/null | grep -oE '"run_id":"[^"]*"' | tail -1 | sed -E 's/"run_id":"([^"]*)"/\1/')"
4194
+ fi
4195
+
4196
+ local rows
4197
+ if [ -n "$last_id" ]; then
4198
+ rows="$(grep -F "\"run_id\":\"$last_id\"" "$file" 2>/dev/null | grep '"fixture":')"
4199
+ else
4200
+ rows="$(grep '"fixture":' "$file" 2>/dev/null)"
4201
+ fi
4202
+ [ -z "$rows" ] && return 0
4203
+
4204
+ printf '%s\n' "$rows" | while IFS= read -r line; do
4205
+ [ -z "$line" ] && continue
4206
+ local fid v exp
4207
+ fid="$(_eval_json_str_field "$line" fixture)"
4208
+ v="$(_eval_json_str_field "$line" verdict)"
4209
+ exp="$(_eval_json_str_field "$line" expected)"
4210
+ [ -n "$fid" ] && printf '%s\t%s\t%s\n' "$fid" "${v:-UNKNOWN}" "${exp:--}"
4211
+ 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
4212
+ }
4213
+
4214
+ # Longitudinal regression classifier: compares two eval results files offline.
4215
+ # Per fixture present in both: REGRESSED, IMPROVED, SATURATED, UNCHANGED (correctness-relative).
4216
+ # Per fixture only in old: removed. Only in new: added.
4217
+ # Unknown expected: unknown (does not count toward regressions).
4218
+ # Exit 1 if any REGRESSED, exit 2 on missing/unreadable or empty file, else 0.
4219
+ _eval_diff() { # $1=old_results $2=new_results
4220
+ local old_file="$1" new_file="$2"
4221
+ if [ -z "$old_file" ] || [ -z "$new_file" ]; then
4222
+ echo "eval --diff: requires two file arguments <old_results> <new_results>" >&2
4223
+ return 2
4224
+ fi
4225
+ if [ ! -f "$old_file" ] || [ ! -r "$old_file" ]; then
4226
+ echo "eval --diff: file not found or unreadable: $old_file" >&2
4227
+ return 2
4228
+ fi
4229
+ if [ ! -f "$new_file" ] || [ ! -r "$new_file" ]; then
4230
+ echo "eval --diff: file not found or unreadable: $new_file" >&2
4231
+ return 2
4232
+ fi
4233
+
4234
+ local td; td="$(mktemp -d)"; _register_td "$td"
4235
+
4236
+ _eval_diff_extract_fixtures "$old_file" > "$td/old.tsv"
4237
+ _eval_diff_extract_fixtures "$new_file" > "$td/new.tsv"
4238
+
4239
+ if [ ! -s "$td/old.tsv" ]; then
4240
+ echo "eval --diff: no fixture records found in $old_file" >&2
4241
+ rm -rf "$td" 2>/dev/null
4242
+ return 2
4243
+ fi
4244
+ if [ ! -s "$td/new.tsv" ]; then
4245
+ echo "eval --diff: no fixture records found in $new_file" >&2
4246
+ rm -rf "$td" 2>/dev/null
4247
+ return 2
4248
+ fi
4249
+
4250
+ local all_fids
4251
+ all_fids="$( { cut -f1 "$td/old.tsv" 2>/dev/null; cut -f1 "$td/new.tsv" 2>/dev/null; } | sort -u )"
4252
+
4253
+ local rc=0
4254
+ {
4255
+ printf '%s\n' "$all_fids" | while IFS= read -r fid; do
4256
+ [ -n "$fid" ] && printf 'ALL\t%s\n' "$fid"
4257
+ done
4258
+ if [ -f "${EVAL_MANIFEST:-}" ]; then
4259
+ awk -F'\t' 'NR>1 && $1!="" && $4!="" { print "MAN\t" $1 "\t" $4 }' "$EVAL_MANIFEST" 2>/dev/null
4260
+ fi
4261
+ awk -F'\t' '{ print "OLD\t" $1 "\t" $2 "\t" $3 }' "$td/old.tsv"
4262
+ awk -F'\t' '{ print "NEW\t" $1 "\t" $2 "\t" $3 }' "$td/new.tsv"
4263
+ } | awk -F'\t' '
4264
+ $1 == "ALL" { order[++n_fids] = $2 }
4265
+ $1 == "MAN" { if ($3 != "" && $3 != "-") exp_man[$2] = $3 }
4266
+ $1 == "OLD" {
4267
+ old_v[$2] = $3
4268
+ if ($4 != "" && $4 != "-") exp_old_row[$2] = $4
4269
+ }
4270
+ $1 == "NEW" {
4271
+ new_v[$2] = $3
4272
+ if ($4 != "" && $4 != "-") exp_new_row[$2] = $4
4273
+ }
4274
+ END {
4275
+ printf " %-12s %-12s %-10s %-10s %s\n", "id", "expected", "old", "new", "classification"
4276
+ n_reg = 0; n_imp = 0; n_sat = 0; n_unc = 0; n_expchg = 0
4277
+ for (i = 1; i <= n_fids; i++) {
4278
+ fid = order[i]
4279
+ has_old = (fid in old_v)
4280
+ has_new = (fid in new_v)
4281
+ eo = (fid in exp_old_row) ? exp_old_row[fid] : ((fid in exp_man) ? exp_man[fid] : "-")
4282
+ en = (fid in exp_new_row) ? exp_new_row[fid] : ((fid in exp_man) ? exp_man[fid] : "-")
4283
+ ov = has_old ? old_v[fid] : "-"
4284
+ nv = has_new ? new_v[fid] : "-"
4285
+ expected_disp = "-"
4286
+ if (has_old && has_new) {
4287
+ if (eo != "-" && en != "-" && eo != en) {
4288
+ cls = "expectation-changed"
4289
+ n_expchg++
4290
+ expected_disp = eo "→" en
4291
+ } else {
4292
+ expected_val = (en != "-") ? en : eo
4293
+ expected_disp = expected_val
4294
+ if (expected_val == "-") {
4295
+ cls = "unknown"
4296
+ } else {
4297
+ old_corr = (ov == expected_val)
4298
+ new_corr = (nv == expected_val)
4299
+ if (old_corr && new_corr) {
4300
+ cls = "UNCHANGED"
4301
+ n_unc++
4302
+ } else if (old_corr && !new_corr) {
4303
+ cls = "REGRESSED"
4304
+ n_reg++
4305
+ } else if (!old_corr && new_corr) {
4306
+ cls = "IMPROVED"
4307
+ n_imp++
4308
+ } else {
4309
+ cls = "SATURATED"
4310
+ n_sat++
4311
+ }
4312
+ }
4313
+ }
4314
+ } else if (has_old && !has_new) {
4315
+ cls = "removed"
4316
+ expected_disp = (eo != "-") ? eo : en
4317
+ } else {
4318
+ cls = "added"
4319
+ expected_disp = (en != "-") ? en : eo
4320
+ }
4321
+ printf " %-12s %-12s %-10s %-10s %s\n", fid, expected_disp, ov, nv, cls
4322
+ }
4323
+ printf "\n%d regressed, %d improved, %d saturated, %d unchanged, %d expectation-changed\n", n_reg, n_imp, n_sat, n_unc, n_expchg
4324
+ if (n_reg > 0) exit 1
4325
+ exit 0
4326
+ }
4327
+ '
4328
+ rc=$?
4329
+ rm -rf "$td" 2>/dev/null
4330
+ return "$rc"
4331
+ }
4332
+
3827
4333
  ## ---------------------------------------------------------------------------------------
3828
4334
  ## Routing eval (v0.22) - a SEPARATE judge modality from the artifact-vs-gate-rules eval
3829
4335
  ## above. Judges (state-snapshot + utterance) -> single action, not (artifact + gate
@@ -4459,7 +4965,7 @@ _eval_guard_unbounded_darwin() { # $1=replay_mode (0/1); return 1 = refused
4459
4965
  # -positives on that exact byte sequence regardless of language. Purely a spelling dodge for a
4460
4966
  # generic scanner; behavior is 100% identical to every other function in this file.
4461
4967
  function cmd_eval {
4462
- local stage_filter="" fixture_filter="" n=3 timeout=120 report_mode=0 keep_going=0 replay_mode=0 record_mode=0
4968
+ 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=""
4463
4969
  while [ $# -gt 0 ]; do
4464
4970
  case "$1" in
4465
4971
  --stage) shift; stage_filter="${1:-}" ;;
@@ -4470,10 +4976,25 @@ function cmd_eval {
4470
4976
  --record) record_mode=1 ;;
4471
4977
  --replay) replay_mode=1 ;;
4472
4978
  --keep-going) keep_going=1 ;;
4473
- *) echo "usage: /flow eval [--stage 01|02|05|card|routing|converge] [--fixture <id>] [--n 3] [--timeout <seconds>] [--keep-going] [--report] [--record|--replay]"; return 1 ;;
4979
+ --diff)
4980
+ diff_mode=1
4981
+ shift
4982
+ diff_old="${1:-}"
4983
+ shift
4984
+ diff_new="${1:-}"
4985
+ ;;
4986
+ *) 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 ;;
4474
4987
  esac
4475
4988
  shift 2>/dev/null || true
4476
4989
  done
4990
+ if [ "$diff_mode" -eq 1 ]; then
4991
+ if [ "$report_mode" -eq 1 ] || [ "$record_mode" -eq 1 ] || [ "$replay_mode" -eq 1 ]; then
4992
+ echo "eval: --diff cannot combine with --report, --record, or --replay" >&2
4993
+ return 1
4994
+ fi
4995
+ _eval_diff "$diff_old" "$diff_new"
4996
+ return $?
4997
+ fi
4477
4998
  case "$n" in ''|*[!0-9]*|0) echo "eval: --n must be a positive integer (got '$n')"; return 1 ;; esac
4478
4999
  case "$timeout" in ''|*[!0-9]*|0) echo "eval: --timeout must be a positive integer seconds value (got '$timeout')"; return 1 ;; esac
4479
5000
  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
@@ -4641,7 +5162,7 @@ function cmd_eval {
4641
5162
  [ -z "$fid" ] && continue
4642
5163
  # manifest's stage column uses flow.sh's own full STAGES names ("01-research", "02-scope",
4643
5164
  # "card") for consistency with the rest of the codebase; --stage/heading-map use the terse
4644
- # 01|02|card form the plan's CLI spec documents - normalize by stripping to the leading
5165
+ # 01|02|05|card form the plan's CLI spec documents - normalize by stripping to the leading
4645
5166
  # token before the first '-' (a bare "card" has none, so it passes through unchanged).
4646
5167
  local fstage_short="${fstage%%-*}"
4647
5168
  [ -n "$stage_filter" ] && [ "$fstage_short" != "$stage_filter" ] && continue
@@ -4667,7 +5188,7 @@ function cmd_eval {
4667
5188
  local promptfile="$rundir/prompt.txt"
4668
5189
  if ! _eval_build_prompt "$promptfile" "$fstage_short" "$artifact_path" "$nonce"; then
4669
5190
  if [ "$replay_mode" -eq 1 ]; then
4670
- echo " $fid: SKIP - no heading-mapped section (artifact-replay scope is 01|02|card)"
5191
+ echo " $fid: SKIP - no heading-mapped section (artifact-replay scope is 01|02|05|card)"
4671
5192
  rm -rf "$rundir" 2>/dev/null
4672
5193
  continue
4673
5194
  fi
@@ -4898,10 +5419,12 @@ flow.sh - buildflow gate runner (mechanical layer)
4898
5419
 
4899
5420
  usage: bash flow.sh <command> [args]
4900
5421
 
4901
- status Where am I? What's blocking? (also: no command)
4902
- resume Session-story brief for a fresh agent entering mid-cycle: last session
5422
+ status [--json] Where am I? What's blocking? (also: no command). --json prints
5423
+ one flow_context/v1 object (display-only; do not auto-exec next_verb).
5424
+ resume [--json|--budget] Session-story brief for a fresh agent entering mid-cycle: last session
4903
5425
  (command names only, never raw args), in-flight card + dwell, gate state,
4904
5426
  one NEXT-> line. Read-only, no lock. Run this FIRST when resuming a project.
5427
+ --json: same closed object as status --json.
4905
5428
  next Check current gate; unlock next stage (or start at 00)
4906
5429
  assess Brownfield: scaffold + gate a current-state assessment (flow/00-inspect.md) before planning
4907
5430
  card Create the next build card (after planning complete)
@@ -4911,7 +5434,7 @@ usage: bash flow.sh <command> [args]
4911
5434
  project-type [t] Show or set project type (web|cli|library|skill); adapts done-evidence
4912
5435
  skip <stage> --reason Advance past a gate that has a matching open DEBT (non-security only)
4913
5436
  ready List buildable todo cards + parallel-safety hint
4914
- workspace <verb> Multi-agent worktree isolation: add|list|enter|remove|check|doctor (one worktree per agent)
5437
+ workspace <verb> Multi-agent worktree isolation: add|list|enter|remove|check|doctor|port (one worktree per agent)
4915
5438
  loop-prep <card> Plumbing for ck-loop: isolated worktree + numeric Verify command + param block (thin wrapper; ck-loop is the engine)
4916
5439
  loop-log <card> Record a finished ck-loop run (iterations/start/end/outcome) into usage-log telemetry
4917
5440
  auto [stop] Activate attested auto (risk + Stage 05 receipt) or stop
@@ -4924,11 +5447,11 @@ usage: bash flow.sh <command> [args]
4924
5447
  contract Check client base-URL vs served-path prefixes (path-resolution drift; web)
4925
5448
  tokens Check DESIGN.md declared tokens vs CSS usage (design-system drift)
4926
5449
  coherence Flag version drift across declared version fields (doc-vs-code coherence)
4927
- consistency Audit cross-artifact coverage (PRD features <-> cards <-> contract; FR ids)
5450
+ consistency [--contract] Audit cross-artifact coverage (PRD FR ids or --contract endpoint audit)
4928
5451
  constitution Check operator-authored per-project invariants in flow/constitution.md (advisory; not a next-gate)
4929
5452
  clarify List leftover ## Open decisions bullets on scope/PRD/contract (advisory; not a next-gate)
4930
5453
  converge [--file] Append-only remainder cards from a flow-converge/v1 payload (plan vs present code)
4931
- promote <file> Copy a playbook into the cross-project KB (~/.claude/flow/playbooks)
5454
+ promote <file>|--suggest Copy a playbook into KB or suggest draft playbooks from harness patterns
4932
5455
  doctor Check the environment (bash/python/grep/git) across macOS/Linux/Windows
4933
5456
  eval [opts] Behavioral eval: does the LLM semantic gate flag a hollow-but-mechanically-clean
4934
5457
  fixture? Opt-in, BILLABLE (live mode skips cleanly with zero calls if 'claude'
@@ -5110,8 +5633,8 @@ fi
5110
5633
  FLOW_LOG_START="$(_now)"
5111
5634
  trap '_log_on_exit' EXIT
5112
5635
  case "$cmd" in
5113
- status|"") cmd_status ;;
5114
- resume) cmd_resume ;;
5636
+ status|"") cmd_status "$@" ;;
5637
+ resume) cmd_resume "$@" ;;
5115
5638
  next) cmd_next ;;
5116
5639
  assess) cmd_assess ;;
5117
5640
  card) cmd_card "$@" ;;
@@ -5135,11 +5658,11 @@ case "$cmd" in
5135
5658
  contract) cmd_contract ;;
5136
5659
  tokens) cmd_tokens ;;
5137
5660
  coherence) cmd_coherence ;;
5138
- consistency) cmd_consistency ;;
5661
+ consistency) cmd_consistency "$@" ;;
5139
5662
  constitution) cmd_constitution ;;
5140
5663
  clarify) cmd_clarify ;;
5141
5664
  converge) cmd_converge "$@" ;;
5142
- promote) cmd_promote "${1:-}" ;;
5665
+ promote) cmd_promote "$@" ;;
5143
5666
  doctor) cmd_doctor ;;
5144
5667
  eval) cmd_eval "$@" ;;
5145
5668
  usage) cmd_usage "$@" ;;