@manhquy/flow-skill 0.1.0-rc.1

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -0
  4. package/README_VN.md +134 -0
  5. package/SECURITY.md +69 -0
  6. package/bin/cli.mjs +382 -0
  7. package/package.json +52 -0
  8. package/skills/flow/SKILL.md +234 -0
  9. package/skills/flow/_templates/00-idea.md +16 -0
  10. package/skills/flow/_templates/00-inspect.md +42 -0
  11. package/skills/flow/_templates/01-research.md +59 -0
  12. package/skills/flow/_templates/02-scope.md +71 -0
  13. package/skills/flow/_templates/03-prd.md +57 -0
  14. package/skills/flow/_templates/04-adr.md +21 -0
  15. package/skills/flow/_templates/05-contract.md +59 -0
  16. package/skills/flow/_templates/card.md +26 -0
  17. package/skills/flow/_templates/constitution.md +22 -0
  18. package/skills/flow/eval/fixtures/f01a/flow/01-research.md +77 -0
  19. package/skills/flow/eval/fixtures/f01b/flow/01-research.md +75 -0
  20. package/skills/flow/eval/fixtures/f02a/flow/02-scope.md +56 -0
  21. package/skills/flow/eval/fixtures/f02b/flow/02-scope.md +65 -0
  22. package/skills/flow/eval/fixtures/fcda/.flow/.gitkeep +0 -0
  23. package/skills/flow/eval/fixtures/fcda/cards/C-001.md +46 -0
  24. package/skills/flow/eval/fixtures/fcdb/.flow/.gitkeep +0 -0
  25. package/skills/flow/eval/fixtures/fcdb/cards/C-001.md +35 -0
  26. package/skills/flow/eval/manifest.tsv +7 -0
  27. package/skills/flow/harness/README.md +117 -0
  28. package/skills/flow/harness/_db.py +228 -0
  29. package/skills/flow/harness/_domain.py +161 -0
  30. package/skills/flow/harness/_presence.py +109 -0
  31. package/skills/flow/harness/flow_harness.py +1103 -0
  32. package/skills/flow/harness/repo_map.py +93 -0
  33. package/skills/flow/harness/schema/001-init.sql +129 -0
  34. package/skills/flow/harness/schema/002-story-verify.sql +9 -0
  35. package/skills/flow/harness/schema/003-tool-registry.sql +15 -0
  36. package/skills/flow/harness/schema/004-intervention.sql +15 -0
  37. package/skills/flow/harness/schema/005-tool-extensions.sql +20 -0
  38. package/skills/flow/harness/schema/009-accessed-count.sql +11 -0
  39. package/skills/flow/harness/schema/010-usage-event.sql +42 -0
  40. package/skills/flow/harness/schema/011-usage-gate-reason.sql +8 -0
  41. package/skills/flow/harness/schema/012-usage-ephemeral.sql +10 -0
  42. package/skills/flow/law/CLAUDE.md +140 -0
  43. package/skills/flow/law/DESIGN.md +141 -0
  44. package/skills/flow/law/RETRO.md +13 -0
  45. package/skills/flow/playbooks/README.md +28 -0
  46. package/skills/flow/playbooks/cloudflare-ai-gateway-qwen.md +346 -0
  47. package/skills/flow/playbooks/docker-deploy-stale-cache.md +32 -0
  48. package/skills/flow/playbooks/rag-lite-postgres-cloudflare.md +192 -0
  49. package/skills/flow/playbooks/windows-gitbash-jsonl-telemetry.md +57 -0
  50. package/skills/flow/references/adversarial-review.md +197 -0
  51. package/skills/flow/references/agent-detection.md +88 -0
  52. package/skills/flow/references/agent-stage-mapping.md +114 -0
  53. package/skills/flow/references/antigravity-integration.md +92 -0
  54. package/skills/flow/references/auto-run.md +81 -0
  55. package/skills/flow/references/claudekit-skills.md +128 -0
  56. package/skills/flow/references/codex-integration.md +104 -0
  57. package/skills/flow/references/command-dispatch.md +54 -0
  58. package/skills/flow/references/debt-and-halts.md +41 -0
  59. package/skills/flow/references/design-review-checklist.md +42 -0
  60. package/skills/flow/references/gate-eval.md +186 -0
  61. package/skills/flow/references/gate-rules.md +171 -0
  62. package/skills/flow/references/ground-truth-gates.md +49 -0
  63. package/skills/flow/references/loop-harness-2026-principles.md +52 -0
  64. package/skills/flow/references/mode-work.md +37 -0
  65. package/skills/flow/references/project-types.md +42 -0
  66. package/skills/flow/references/stage-state-machine.md +47 -0
  67. package/skills/flow/references/ui-patterns-tcr.md +49 -0
  68. package/skills/flow/runner/flow.cmd +18 -0
  69. package/skills/flow/runner/flow.sh +3297 -0
  70. package/skills-manifest.json +70 -0
  71. package/src/constants.mjs +65 -0
  72. package/src/detect.mjs +36 -0
  73. package/src/help.mjs +42 -0
  74. package/src/installer.mjs +231 -0
  75. package/src/prompt.mjs +60 -0
  76. package/src/tty.mjs +11 -0
@@ -0,0 +1,3297 @@
1
+ #!/usr/bin/env bash
2
+ # flow.sh - buildflow gate runner (mechanical layer)
3
+ #
4
+ # Two-layer harness: this script is the DETERMINISTIC layer. It checks gates
5
+ # mechanically (unchecked boxes, [FILL] placeholders, card status/evidence) and
6
+ # manages the stage/card lifecycle. The /flow SKILL.md is the SEMANTIC layer
7
+ # (quality gatekeeper) that runs on top of these exit codes.
8
+ #
9
+ # Exit codes: 0 = pass / advanced, 1 = gate fail or usage error.
10
+ #
11
+ # Paths:
12
+ # templates -> <script>/../_templates (00-idea .. 05-contract, card)
13
+ # project -> $FLOW_PROJECT_ROOT or $PWD (holds flow/ and cards/)
14
+ #
15
+ # Portable bash (Git Bash on Windows + Unix). No GNU-only features.
16
+
17
+ set -u
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20
+ TEMPLATE_DIR="$SCRIPT_DIR/../_templates"
21
+ LAW_DIR="$SCRIPT_DIR/../law"
22
+ PLAYBOOKS_DIR="$SCRIPT_DIR/../playbooks"
23
+ GLOBAL_KB_DIR="${FLOW_GLOBAL_KB:-${HOME:-}/.claude/flow/playbooks}" # cross-project knowledge tier (set -u safe if HOME unset)
24
+ # Root resolution (monorepo dual-root guard). An explicit FLOW_PROJECT_ROOT always wins. Otherwise,
25
+ # if the CWD is NOT itself a flow project (no flow/ planning dir and no cards/) but an ANCESTOR is,
26
+ # adopt that ancestor — so running flow from a monorepo subdir (e.g. frontend/) does NOT silently
27
+ # mint a SECOND, fragmented .flow root with its own cycle_id (the real C2-App-001 failure mode).
28
+ # A subdir that has its own flow/ or cards/ is a deliberate sub-project and is left untouched.
29
+ FLOW_ROOT_ADOPTED=""
30
+ if [ -n "${FLOW_PROJECT_ROOT:-}" ]; then
31
+ ROOT="$FLOW_PROJECT_ROOT"
32
+ else
33
+ ROOT="$PWD"
34
+ if [ ! -d "$PWD/flow" ] && [ ! -d "$PWD/cards" ]; then
35
+ _rd="$PWD"; _rn=0
36
+ while [ "$_rn" -lt 24 ]; do
37
+ _rp="$(dirname "$_rd")"; [ "$_rp" = "$_rd" ] && break # reached filesystem root
38
+ _rd="$_rp"; _rn=$((_rn + 1))
39
+ # Require a REAL flow signature, not just a dir literally named flow/ or cards/ (a bare 'flow/'
40
+ # working folder — common, e.g. ~/flow — must NOT be adopted): a stage artifact, run-state, or
41
+ # an actual card file. This keeps the walk from attaching telemetry to the wrong ancestor.
42
+ if [ -f "$_rd/flow/00-idea.md" ] || [ -f "$_rd/flow/00-inspect.md" ] || [ -d "$_rd/.flow" ] \
43
+ || { [ -d "$_rd/cards" ] && ls "$_rd"/cards/C-*.md >/dev/null 2>&1; }; then
44
+ ROOT="$_rd"; FLOW_ROOT_ADOPTED="$_rd"; break
45
+ fi
46
+ done
47
+ unset _rd _rp _rn
48
+ fi
49
+ fi
50
+ [ -n "$FLOW_ROOT_ADOPTED" ] && printf 'note: using flow root %s (CWD is inside it; no flow/ here). Set FLOW_PROJECT_ROOT to override.\n' "$FLOW_ROOT_ADOPTED" >&2
51
+ FLOW_DIR="$ROOT/flow"
52
+ CARDS_DIR="$ROOT/cards"
53
+ MODE_FILE="$ROOT/MODE"
54
+ RETRO_FILE="$ROOT/RETRO.md"
55
+ DEBT_FILE="$ROOT/DEBT.md"
56
+ PROJECT_TYPE_FILE="$ROOT/PROJECT_TYPE"
57
+ SKIPPED_FILE="$ROOT/flow/.skipped"
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
60
+ HARNESS_PY="$SCRIPT_DIR/../harness/flow_harness.py"
61
+
62
+ # Eval (LLM semantic-gate behavioral harness; opt-in, billable - see 'eval' command).
63
+ # FLOW_EVAL_MANIFEST is a TEST-ONLY override (e.g. a synthetic CRLF manifest fixture) - it does
64
+ # NOT widen the v1 trust boundary: artifact paths still resolve as $EVAL_DIR/fixtures/<id>/<rel>,
65
+ # so an overridden manifest can only ever point at real shipped fixtures under the real EVAL_DIR.
66
+ EVAL_DIR="$SCRIPT_DIR/../eval"
67
+ EVAL_MANIFEST="${FLOW_EVAL_MANIFEST:-$EVAL_DIR/manifest.tsv}"
68
+ GATE_RULES_FILE="$SCRIPT_DIR/../references/gate-rules.md"
69
+
70
+ # Usage-log sinks (run-state dir, gitignored). Mechanical flight-recorder: every invocation
71
+ # self-records here. Local-only, never transmitted. See flow/05-contract.md (this feature's plan).
72
+ LOG_DIR="$ROOT/.flow"
73
+ EVENTS_FILE="$LOG_DIR/events.jsonl" # per-project FULL event
74
+ EVAL_RESULTS_FILE="$LOG_DIR/eval-results.jsonl" # per-project eval-batch results
75
+ CYCLE_FILE="$LOG_DIR/cycle_id" # stamped when stage 00 unlocks
76
+ GLOBAL_LOG="${HOME:-}/.claude/flow/usage.jsonl" # device-global COMPACT event
77
+ # Stage/card carried into the exit event by the commands that know them (set during run).
78
+ FLOW_LOG_STAGE_FROM=""; FLOW_LOG_STAGE_TO=""; FLOW_LOG_CARD=""; FLOW_LAST_GATE_FAIL=""
79
+
80
+ # Tempdir cleanup list: advisory probe functions register their mktemp -d paths here so the
81
+ # EXIT trap (which fires on normal exit AND signals) removes them even on SIGINT/SIGTERM.
82
+ # Array (not a space-joined string): a space-containing TMPDIR (routine on Windows, e.g. a
83
+ # "Local Settings\Temp" or "John Doe\AppData" path) previously split into bogus fragments under
84
+ # unquoted `for d in $_CLEANUP_TDS`, silently no-op'ing the rm -rf (found reviewing the eval
85
+ # verb's temp-file hygiene, which is the first caller that actually exercises a spaced TMPDIR).
86
+ _CLEANUP_TDS=()
87
+ _register_td() { _CLEANUP_TDS+=("$1"); }
88
+ # Guard the [@] expansion on array length first: bash < 4.4 (macOS ships 3.2.57 as /bin/bash)
89
+ # treats a zero-element array as UNSET under `set -u`, so "${_CLEANUP_TDS[@]}" on an empty
90
+ # array throws "unbound variable" and - since this runs inside the EXIT trap, ahead of
91
+ # _log_event - silently broke telemetry for every single flow.sh invocation on macOS (found via
92
+ # a real 3-OS CI run: ubuntu/windows ship bash >= 4.4, immune; macOS is not).
93
+ _cleanup_tds() { local d; if [ "${#_CLEANUP_TDS[@]}" -gt 0 ]; then for d in "${_CLEANUP_TDS[@]}"; do rm -rf "$d" 2>/dev/null; done; fi; _CLEANUP_TDS=(); }
94
+
95
+ # Keep pure run-state (MODE / PROJECT_TYPE / .flow/) out of the host repo's git status.
96
+ # Idempotent; only acts in a real git repo OR where a .gitignore already exists (so test
97
+ # sandboxes and non-git dirs are untouched — no surprise file creation).
98
+ _ignore_run_state() {
99
+ { [ -d "$ROOT/.git" ] || [ -f "$ROOT/.gitignore" ]; } || return 0
100
+ local gi="$ROOT/.gitignore"
101
+ grep -q 'flow run-state' "$gi" 2>/dev/null && return 0
102
+ printf '\n# /flow run-state (generated; safe to ignore)\nMODE\nPROJECT_TYPE\n.flow/\n' >> "$gi"
103
+ }
104
+
105
+ # Concurrency lock: seconds a lock stays "fresh"; older locks are stale and auto-reclaimed.
106
+ FLOW_LOCK_TTL="${FLOW_LOCK_TTL:-900}"
107
+
108
+ STAGES="00-idea 01-research 02-scope 03-prd 04-adr 05-contract"
109
+ LAST_STAGE_IDX=5
110
+
111
+ # ---------- helpers ----------
112
+
113
+ stage_name_at() { # $1 = index -> "NN-name"
114
+ local i=0 s
115
+ for s in $STAGES; do
116
+ if [ "$i" -eq "$1" ]; then echo "$s"; return 0; fi
117
+ i=$((i + 1))
118
+ done
119
+ return 1
120
+ }
121
+
122
+ current_stage_idx() { # highest CONTIGUOUS-from-00 stage index, or -1 if 00 missing
123
+ # Contiguous (not just highest-existing) so a manually-dropped future stage file
124
+ # cannot make 'next' report a false PLANNING COMPLETE while earlier stages are missing.
125
+ local i=0 s idx=-1
126
+ for s in $STAGES; do
127
+ if [ -f "$FLOW_DIR/$s.md" ]; then idx=$i; else break; fi
128
+ i=$((i + 1))
129
+ done
130
+ echo "$idx"
131
+ }
132
+
133
+ # Scan a stage/planning file for gate violations.
134
+ # Prints violations; returns 0 = clean, 1 = violations found.
135
+ scan_gate() {
136
+ local file="$1" found=0 unchecked fills
137
+ if [ ! -f "$file" ]; then
138
+ echo " missing file: $file"
139
+ return 1
140
+ fi
141
+ unchecked="$(grep -nE '^[[:space:]]*- \[ \]' "$file" 2>/dev/null || true)"
142
+ fills="$(grep -n '\[FILL' "$file" 2>/dev/null || true)"
143
+ if [ -n "$unchecked" ]; then
144
+ echo " [x] unchecked gate boxes:"
145
+ printf '%s\n' "$unchecked" | sed 's/^/ L/'
146
+ found=1
147
+ fi
148
+ if [ -n "$fills" ]; then
149
+ echo " [x] unfilled [FILL] placeholders:"
150
+ printf '%s\n' "$fills" | sed 's/^/ L/'
151
+ found=1
152
+ fi
153
+ return $found
154
+ }
155
+
156
+ _python() {
157
+ # prefer python3, and only accept an interpreter that is actually Python 3.x (the harness +
158
+ # repo_map.py are py3-only — a py2 `python` must NOT be selected or it fails silently).
159
+ # Returns 0 + prints path when a valid interpreter is found; returns 1 (no output) when none.
160
+ local p
161
+ for p in python3 python; do
162
+ if command -v "$p" >/dev/null 2>&1 && "$p" -c 'import sys; sys.exit(0 if sys.version_info[0]>=3 else 1)' >/dev/null 2>&1; then
163
+ command -v "$p"; return 0
164
+ fi
165
+ done
166
+ return 1
167
+ }
168
+
169
+ harness_call() {
170
+ # best-effort durable-layer write; NEVER breaks the engine if python/harness absent.
171
+ [ -n "${FLOW_HARNESS_DISABLE:-}" ] && return 0
172
+ [ -f "$HARNESS_PY" ] || return 0
173
+ local py; py="$(_python)"; [ -n "$py" ] || return 0
174
+ FLOW_PROJECT_ROOT="$ROOT" "$py" "$HARNESS_PY" "$@" >/dev/null 2>&1
175
+ return 0
176
+ }
177
+
178
+ harness_available() { # 0 = durable layer usable (python + harness present, not disabled)
179
+ [ -n "${FLOW_HARNESS_DISABLE:-}" ] && return 1
180
+ [ -f "$HARNESS_PY" ] || return 1
181
+ local py; py="$(_python)"; [ -n "$py" ] || return 1
182
+ "$py" --version >/dev/null 2>&1 || return 1
183
+ return 0
184
+ }
185
+
186
+ harness_emit() { # run a harness subcommand and ECHO its stdout (best-effort; nothing if unavailable)
187
+ harness_available || return 0
188
+ FLOW_PROJECT_ROOT="$ROOT" "$(_python)" "$HARNESS_PY" "$@" 2>/dev/null || true
189
+ }
190
+
191
+ cmd_harness() {
192
+ # passthrough to the full durable-layer CLI (visible output + real exit code)
193
+ if [ ! -f "$HARNESS_PY" ]; then echo "harness not installed at $HARNESS_PY"; return 1; fi
194
+ local py; py="$(_python)"
195
+ # validate the interpreter actually runs (Git Bash on Windows may resolve a Store stub)
196
+ if [ -z "$py" ] || ! "$py" --version >/dev/null 2>&1; then
197
+ echo "FAIL: a working python was not found - durable layer needs python (stdlib sqlite3)."
198
+ return 1
199
+ fi
200
+ FLOW_PROJECT_ROOT="$ROOT" "$py" "$HARNESS_PY" "$@"
201
+ }
202
+
203
+ seed_law_files() {
204
+ # Make the project-root law files available (buildflow expects them in context).
205
+ # Non-destructive: never overwrite an existing project file.
206
+ [ -f "$LAW_DIR/DESIGN.md" ] && [ ! -f "$ROOT/DESIGN.md" ] && cp "$LAW_DIR/DESIGN.md" "$ROOT/DESIGN.md"
207
+ [ -f "$LAW_DIR/RETRO.md" ] && [ ! -f "$RETRO_FILE" ] && cp "$LAW_DIR/RETRO.md" "$RETRO_FILE"
208
+ return 0
209
+ }
210
+
211
+ highest_card() {
212
+ local max=0 f n
213
+ if [ -d "$CARDS_DIR" ]; then
214
+ for f in "$CARDS_DIR"/C-*.md; do
215
+ [ -e "$f" ] || continue
216
+ n="$(basename "$f" .md)"; n="${n#C-}"
217
+ case "$n" in (*[!0-9]*) continue;; esac
218
+ n=$((10#$n))
219
+ [ "$n" -gt "$max" ] && max=$n
220
+ done
221
+ fi
222
+ echo "$max"
223
+ }
224
+
225
+ resolve_card_file() { # $1 = "C-001" | "001" | "1" -> path or empty
226
+ local arg="$1" num
227
+ num="${arg#C-}"; num="${num#c-}"
228
+ case "$num" in (*[!0-9]*) echo ""; return 1;; esac
229
+ num=$((10#$num))
230
+ printf '%s/C-%03d.md' "$CARDS_DIR" "$num"
231
+ }
232
+
233
+ card_status() { # $1 = file
234
+ grep -m1 -E '^status:' "$1" 2>/dev/null | sed 's/^status:[[:space:]]*//' | tr -d '\r' | awk '{print $1}'
235
+ }
236
+
237
+ stage_skipped() { # $1 = stage name; 0 = yes (operator debt-skipped it)
238
+ [ -f "$SKIPPED_FILE" ] && grep -qxF "$1" "$SKIPPED_FILE" 2>/dev/null
239
+ }
240
+
241
+ planning_complete() { # 0 = yes: each stage is clean, OR debt-skipped (a skipped stage may have no file)
242
+ local s
243
+ for s in $STAGES; do
244
+ if [ ! -f "$FLOW_DIR/$s.md" ]; then
245
+ stage_skipped "$s" || return 1
246
+ continue
247
+ fi
248
+ if ! scan_gate "$FLOW_DIR/$s.md" >/dev/null 2>&1; then
249
+ stage_skipped "$s" || return 1
250
+ fi
251
+ done
252
+ return 0
253
+ }
254
+
255
+ # project type (web|cli|library|skill); absent => web. Adapts done-evidence + guidance.
256
+ get_project_type() {
257
+ local t; t="$(cat "$PROJECT_TYPE_FILE" 2>/dev/null | tr -d '\r' | awk 'NF{print; exit}')"
258
+ printf '%s' "${t:-web}"
259
+ }
260
+ done_def_for_type() {
261
+ case "${1:-web}" in
262
+ web) echo "a live deployed URL you can click + real curl output (NOT 'tests pass')" ;;
263
+ cli) echo "the tool installs and a real invocation returns the expected output + exit code" ;;
264
+ library) echo "the public API imports + a usage example runs + the coverage threshold is met" ;;
265
+ skill) echo "installed into ~/.claude/skills and a real run reaches its own done-definition" ;;
266
+ *) echo "world-state proof appropriate to the project type" ;;
267
+ esac
268
+ }
269
+
270
+ # ---------- concurrency lock (prevents two sessions stomping one project's plan) ----------
271
+ # A COORDINATION lock, not a per-file mutex: it records who last mutated this project's flow/
272
+ # and when, so a second concurrent session is refused (strong identity) or warned (weak
273
+ # identity) instead of silently corrupting the plan. Locks older than FLOW_LOCK_TTL are stale
274
+ # and auto-reclaimed. FLOW_FORCE=1 takes over a live foreign lock.
275
+
276
+ _now() { date +%s 2>/dev/null || echo 0; }
277
+
278
+ # First non-empty explicit/AI-harness session id, auto-derived with NO operator action. The
279
+ # cascade makes the lock hard-refusable in agent sessions (where no tty exists and operators
280
+ # never export FLOW_SESSION_ID): an explicit override wins, then the harness-injected id of
281
+ # whichever engine is driving. | and newlines are stripped so the id cannot corrupt the
282
+ # pipe-delimited lock line. Empty output = no stable id available (fall back to tty/ppid).
283
+ _session_env_id() {
284
+ local v
285
+ for v in "${FLOW_SESSION_ID:-}" "${CLAUDE_CODE_SESSION_ID:-}" "${CODEX_SESSION_ID:-}" \
286
+ "${CODEX_THREAD_ID:-}" "${AGY_SESSION_ID:-}" "${ANTIGRAVITY_SESSION_ID:-}"; do
287
+ [ -n "$v" ] && { printf '%s' "$(printf '%s' "$v" | tr -d '\r\n|')"; return; }
288
+ done
289
+ }
290
+
291
+ # Raw session id for the usage-log `session_id` field (auto-derived; never empty).
292
+ _session_id() {
293
+ local s; s="$(_session_env_id)"
294
+ if [ -n "$s" ]; then printf '%s' "$s"; return; fi
295
+ local t; t="$(tty 2>/dev/null || true)"
296
+ if [ -n "$t" ] && [ "$t" != "not a tty" ]; then printf '%s' "$t"; return; fi
297
+ printf 'ppid:%s:%s' "$(uname -n 2>/dev/null || echo host)" "${PPID:-0}"
298
+ }
299
+
300
+ # Identity of THIS invocation for the lock. Strong (hard-refusable) via an explicit/harness
301
+ # session id, else a real tty; weak (warn-only, never self-block) via host+PPID otherwise.
302
+ flow_lock_owner() {
303
+ local s; s="$(_session_env_id)"
304
+ if [ -n "$s" ]; then printf 'sid:%s' "$s"; return; fi
305
+ local t; t="$(tty 2>/dev/null || true)"
306
+ if [ -n "$t" ] && [ "$t" != "not a tty" ]; then printf 'tty:%s' "$t"; return; fi
307
+ printf 'ppid:%s:%s' "$(uname -n 2>/dev/null || echo host)" "${PPID:-0}"
308
+ }
309
+ flow_owner_strong() { case "$1" in sid:*|tty:*) return 0 ;; *) return 1 ;; esac; }
310
+
311
+ # Parse current lock -> LOCK_TS LOCK_OWNER LOCK_PID LOCK_HOST LOCK_CMD. 0 = a lock exists.
312
+ _read_lock() {
313
+ LOCK_TS=0; LOCK_OWNER=""; LOCK_PID=""; LOCK_HOST=""; LOCK_CMD=""
314
+ [ -f "$LOCK_FILE" ] || return 1
315
+ IFS='|' read -r LOCK_TS LOCK_OWNER LOCK_PID LOCK_HOST LOCK_CMD < "$LOCK_FILE" 2>/dev/null || return 1
316
+ case "$LOCK_TS" in ''|*[!0-9]*) LOCK_TS=0 ;; esac
317
+ return 0
318
+ }
319
+ _write_lock() { # $1 = cmd label
320
+ mkdir -p "$FLOW_DIR" 2>/dev/null || true
321
+ printf '%s|%s|%s|%s|%s\n' "$(_now)" "$(flow_lock_owner)" "$$" "$(uname -n 2>/dev/null || echo host)" "${1:-?}" \
322
+ > "$LOCK_FILE" 2>/dev/null || true
323
+ }
324
+
325
+ # Guard a MUTATING command. 0 = lock taken/refreshed (proceed); 1 = refused (caller aborts).
326
+ lock_acquire() { # $1 = cmd label
327
+ local me age; me="$(flow_lock_owner)"
328
+ # Ensure the lock's PARENT dir exists so the atomic `mkdir "$LOCK_DIR"` test-and-set below can
329
+ # succeed on a brand-new project (flow/ may not exist yet at stage-00 unlock / first run).
330
+ # This creates only the parent; LOCK_DIR itself is still created with plain mkdir (atomic guard).
331
+ mkdir -p "$FLOW_DIR" 2>/dev/null || true
332
+
333
+ # FLOW_FORCE: unconditional takeover; reset the atomic claim dir so the mkdir guard is bypassed cleanly.
334
+ if [ -n "${FLOW_FORCE:-}" ]; then
335
+ if _read_lock && [ -n "$LOCK_OWNER" ] && [ "$LOCK_OWNER" != "$me" ]; then
336
+ echo "NOTE: FLOW_FORCE set - taking over a lock held by [$LOCK_OWNER] (cmd '${LOCK_CMD:-?}')."
337
+ fi
338
+ rm -rf "$LOCK_DIR" 2>/dev/null || true
339
+ mkdir "$LOCK_DIR" 2>/dev/null || true
340
+ _write_lock "$1"; return 0
341
+ fi
342
+
343
+ # Same-session refresh: if the lock file names OUR owner, just update the timestamp.
344
+ # The session already owns LOCK_DIR from the original claim; no need to re-mkdir.
345
+ if _read_lock && [ "$LOCK_OWNER" = "$me" ]; then _write_lock "$1"; return 0; fi
346
+
347
+ # W5 — Atomic claim guard. mkdir is POSIX test-and-set: exactly one racing caller wins.
348
+ # Only attempt when neither the claim dir nor a legacy flat-file lock exists (truly free state).
349
+ # Legacy flat-file (LOCK_FILE without LOCK_DIR): tolerated — fall through to reclaim logic below
350
+ # so an in-flight upgrade from v0.11 does not strand a live lock.
351
+ if ! [ -d "$LOCK_DIR" ] && ! [ -f "$LOCK_FILE" ]; then
352
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
353
+ _write_lock "$1"; return 0 # won the race; lock claimed
354
+ fi
355
+ # Lost the race: LOCK_DIR now owned by the winner. Fall through to BLOCK below.
356
+ # LOCK_FILE may not be written yet (tiny window); treat LOCK_DIR presence as lock-held.
357
+ fi
358
+
359
+ # --- v0.11 FR4 reclaim logic (runs for: race loser, legacy flat-file, any existing lock) ---
360
+ if _read_lock; then
361
+ age=$(( $(_now) - LOCK_TS )); [ "$age" -lt 0 ] && age=0
362
+ # Dead-process reclaim: if the lock's owner PID is on THIS host and no longer alive, reclaim
363
+ # immediately rather than waiting out the TTL. Gated on same-host (a PID is meaningless across
364
+ # hosts); kill -0 works on macOS/Linux/Git-Bash. PID reuse only ever DEFERS a reclaim (safe).
365
+ local myhost; myhost="$(uname -n 2>/dev/null || echo host)"
366
+ if [ -n "$LOCK_PID" ] && [ "$LOCK_HOST" = "$myhost" ] && ! kill -0 "$LOCK_PID" 2>/dev/null; then
367
+ echo "NOTE: reclaiming a flow lock from a dead session [$LOCK_OWNER] (pid $LOCK_PID no longer alive)."
368
+ rm -rf "$LOCK_DIR" 2>/dev/null || true
369
+ # F2: check mkdir atomically — if another reclaimer beat us, do NOT write
370
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
371
+ _write_lock "$1"; return 0
372
+ fi
373
+ # Lost the re-mkdir race; another reclaimer already owns the slot — back off
374
+ echo "BLOCKED: another flow session claimed this project concurrently (concurrent /flow corrupts the plan)."
375
+ echo " (reclaim mkdir race lost — another session already took the lock.)"
376
+ return 1
377
+ elif [ "$age" -ge "$FLOW_LOCK_TTL" ]; then
378
+ echo "NOTE: reclaiming a STALE flow lock from [$LOCK_OWNER] (${age}s old >= ${FLOW_LOCK_TTL}s TTL)."
379
+ rm -rf "$LOCK_DIR" 2>/dev/null || true
380
+ # F2: check mkdir atomically — if another reclaimer beat us, do NOT write
381
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
382
+ _write_lock "$1"; return 0
383
+ fi
384
+ # Lost the re-mkdir race; another reclaimer already owns the slot — back off
385
+ echo "BLOCKED: another flow session claimed this project concurrently (concurrent /flow corrupts the plan)."
386
+ echo " (reclaim mkdir race lost — another session already took the lock.)"
387
+ return 1
388
+ fi
389
+ if flow_owner_strong "$me" && flow_owner_strong "$LOCK_OWNER"; then # fresh + provably foreign
390
+ echo "BLOCKED: another flow session is active on this project (concurrent /flow corrupts the plan)."
391
+ echo " lock: [$LOCK_OWNER] cmd '${LOCK_CMD:-?}', ${age}s ago (TTL ${FLOW_LOCK_TTL}s) -> $LOCK_FILE"
392
+ echo " Close the other session. If it is truly gone: re-run with FLOW_FORCE=1, or '/flow unlock'."
393
+ return 1
394
+ fi
395
+ echo "WARNING: a flow lock from [$LOCK_OWNER] is ${age}s old; this session has no stable FLOW_SESSION_ID"
396
+ echo " so I cannot prove it is a different session - PROCEEDING. Export FLOW_SESSION_ID per session"
397
+ echo " (see SKILL.md) for hard protection against concurrent runs."
398
+ _write_lock "$1"; return 0
399
+ fi
400
+
401
+ # LOCK_DIR exists but LOCK_FILE not yet written: either a race loser in the tiny window
402
+ # after mkdir loss, or a crashed winner (F1: process died after mkdir but before _write_lock).
403
+ # Distinguish via mtime: if LOCK_DIR is older than FLOW_LOCK_TTL it is a crashed stale claim
404
+ # and safe to reclaim; if fresh, treat as live mid-claim and BLOCK.
405
+ if [ -d "$LOCK_DIR" ]; then
406
+ local ttl_min; ttl_min=$(( (FLOW_LOCK_TTL + 59) / 60 )) # ceiling division to minutes
407
+ if find "$LOCK_DIR" -maxdepth 0 -mmin +"$ttl_min" 2>/dev/null | grep -q .; then
408
+ echo "NOTE: reclaiming a stale crashed claim (LOCK_DIR old, no LOCK_FILE)."
409
+ rm -rf "$LOCK_DIR" 2>/dev/null || true
410
+ # F2: atomic re-claim; if another reclaimer beat us, don't write
411
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
412
+ _write_lock "$1"; return 0
413
+ fi
414
+ # Lost re-mkdir race; fall through to BLOCKED below
415
+ fi
416
+ echo "BLOCKED: another flow session is claiming this project (concurrent /flow corrupts the plan)."
417
+ echo " (lock dir exists; the other session is mid-claim. If it is truly gone: '/flow unlock'.)"
418
+ return 1
419
+ fi
420
+
421
+ # No LOCK_DIR and no LOCK_FILE: truly free state reached after reclaim or first run.
422
+ # F2: check mkdir exit status — if another caller beats us here, refuse rather than double-write
423
+ if mkdir "$LOCK_DIR" 2>/dev/null; then
424
+ _write_lock "$1"; return 0 # won the clean claim
425
+ fi
426
+ # Another caller won; fall through — will hit the BLOCKED path on next iteration.
427
+ echo "BLOCKED: another flow session is claiming this project (concurrent /flow corrupts the plan)."
428
+ echo " (lost atomic claim race. If the other session is truly gone: '/flow unlock'.)"
429
+ return 1
430
+ }
431
+
432
+ # Read-only warning for 'status' - never blocks.
433
+ lock_warn() {
434
+ local me age; me="$(flow_lock_owner)"
435
+ _read_lock || return 0
436
+ [ -z "$LOCK_OWNER" ] && return 0
437
+ [ "$LOCK_OWNER" = "$me" ] && return 0
438
+ age=$(( $(_now) - LOCK_TS )); [ "$age" -lt 0 ] && age=0
439
+ [ "$age" -ge "$FLOW_LOCK_TTL" ] && return 0
440
+ echo " lock: WARNING - another session [$LOCK_OWNER] mutated ${age}s ago (cmd '${LOCK_CMD:-?}');"
441
+ echo " avoid running '/flow next|card' here concurrently (set FLOW_SESSION_ID to enforce)."
442
+ }
443
+
444
+ # ---------- recall (read-back of durable memory: close the capture->reuse loop) ----------
445
+ # The harness/RETRO/DEBT/playbooks layers CAPTURE knowledge; recall READS it back so an agent
446
+ # starts a stage/card with prior pain + decisions in view instead of cold. Degrades gracefully:
447
+ # markdown ledgers always; SQLite harness queries only when python+harness are present.
448
+
449
+ _harness_query() { harness_emit query "$1"; } # $1 = matrix|backlog|friction|tools
450
+
451
+ recall_open_debt() { [ -f "$DEBT_FILE" ] && grep -E '^- \[ \] DEBT:' "$DEBT_FILE" 2>/dev/null || true; }
452
+ recall_retro_tail() { # $1 = N lines; entries live after the '---' separator in RETRO.md
453
+ [ -f "$RETRO_FILE" ] || return 0
454
+ awk '/^---/{seen=1; next} seen && NF {print}' "$RETRO_FILE" 2>/dev/null | tail -"${1:-3}"
455
+ }
456
+ recall_playbooks() { # the "paid-for stack knowledge" index (skill-global, excludes README)
457
+ [ -d "$PLAYBOOKS_DIR" ] || return 0
458
+ local f n
459
+ for f in "$PLAYBOOKS_DIR"/*.md; do
460
+ [ -e "$f" ] || continue
461
+ n="$(basename "$f" .md)"; [ "$n" = "README" ] && continue
462
+ echo "$n"
463
+ done
464
+ }
465
+ recall_global_playbooks() { # cross-project playbooks dropped in ~/.claude/flow/playbooks (lessons travel A->B)
466
+ [ -d "$GLOBAL_KB_DIR" ] || return 0
467
+ local f n
468
+ for f in "$GLOBAL_KB_DIR"/*.md; do
469
+ [ -e "$f" ] || continue
470
+ n="$(basename "$f" .md)"; [ "$n" = "README" ] && continue
471
+ echo "$n"
472
+ done
473
+ }
474
+ recall_prev_card() { # most-recent DONE card's id + title + Scope (previous-card intelligence)
475
+ [ -d "$CARDS_DIR" ] || return 0
476
+ local f best=""
477
+ for f in "$CARDS_DIR"/C-*.md; do
478
+ [ -e "$f" ] || continue
479
+ [ "$(card_status "$f")" = "done" ] && best="$f"
480
+ done
481
+ [ -n "$best" ] || return 0
482
+ echo "last done: $(basename "$best" .md)"
483
+ grep -m1 '^# ' "$best" 2>/dev/null | sed 's/^# / title: /'
484
+ awk '/^## Scope/{f=1; next} f && /^## /{f=0} f && NF {print " | " $0}' "$best" 2>/dev/null | head -4
485
+ }
486
+
487
+ # ---------- gate-fired durable capture (engine writes the record, not agent goodwill) ----------
488
+ # Promotes the per-stage "durable hook" prose (agent-stage-mapping.md) to engine-fired calls so
489
+ # capture is reliable. Uses ONLY the existing harness CLI; no-op when the durable layer is off.
490
+ gate_durable_hook() { # $1 = stage just passed
491
+ harness_available || return 0
492
+ case "$1" in
493
+ 01-research)
494
+ # seed the work-classification the harness model starts from (lane=normal default).
495
+ local pitch out
496
+ pitch="$(awk '/^## Pitch/{f=1; next} f && NF {print; exit}' "$FLOW_DIR/00-idea.md" 2>/dev/null | cut -c1-160)"
497
+ [ -n "$pitch" ] || pitch="research gate passed"
498
+ out="$(harness_emit intake --type new_spec --summary "$pitch")"
499
+ printf '%s\n' "$out" | grep -i 'intake' | sed 's/^/ harness: /'
500
+ echo " harness: intake seeded (lane=normal). If it touches auth/data/external/contracts, reclassify:"
501
+ echo " flow harness intake --type new_spec --summary \"...\" --flags auth,data_model,external_systems"
502
+ ;;
503
+ 04-adr)
504
+ echo " harness: ADR passed - record each non-trivial decision durably (the ADR md is NOT a durable record):"
505
+ echo " flow harness decision add --id <slug> --summary \"<what + why>\" --doc flow/04-adr.md"
506
+ ;;
507
+ esac
508
+ }
509
+
510
+ # ---------- commands ----------
511
+
512
+ # Shared gate-state block: same idx/scan_gate logic status has always used, extracted so
513
+ # `resume` (Phase 2) prints byte-identical gate text and the two verbs can never disagree.
514
+ # Side-effect-free (only reads $idx + FLOW_DIR files via scan_gate, both already read-only).
515
+ _gate_state_brief() { # $1 = current_stage_idx (caller already computed it), $2 = optional dwell
516
+ # string (e.g. "45m") appended to the first line. Takes dwell as a plain arg (never wraps this
517
+ # function's own call site in a pipe) - the BLOCKED branch below re-invokes scan_gate
518
+ # unredirected, and piping that nested-call output into a consumer (e.g. `| while read`) was
519
+ # found to hang indefinitely under Git-Bash/MSYS (an early-pipe-reader-exit class issue) -
520
+ # verified by a live code-review reproduction, not merely suspected.
521
+ local idx="$1" dwell="${2:-}"
522
+ if [ "$idx" -lt 0 ]; then
523
+ echo "planning: not started"
524
+ echo " -> run '/flow next' to unlock stage 00 (idea)"
525
+ else
526
+ local cur; cur="$(stage_name_at "$idx")"
527
+ if [ -n "$dwell" ]; then
528
+ echo "planning: at stage $cur (for $dwell)"
529
+ else
530
+ echo "planning: at stage $cur"
531
+ fi
532
+ if scan_gate "$FLOW_DIR/$cur.md" >/dev/null 2>&1; then
533
+ if [ "$idx" -ge "$LAST_STAGE_IDX" ]; then
534
+ echo " gate: PASS - planning complete. '/flow card' is unlocked."
535
+ prd_declares_fr && echo " tip: '/flow consistency' checks FR->card->contract coverage."
536
+ else
537
+ echo " gate: PASS - run '/flow next' to unlock the next stage."
538
+ fi
539
+ else
540
+ echo " gate: BLOCKED -"
541
+ scan_gate "$FLOW_DIR/$cur.md"
542
+ fi
543
+ fi
544
+ }
545
+
546
+ _stage_dwell() { # $1 = current stage name -> "Xm/Xh/Xd" from genuine entry, or "" if no match
547
+ local cur="$1" lines epoch
548
+ [ -f "$EVENTS_FILE" ] || return 0
549
+ lines="$(_resume_valid_lines)"
550
+ [ -n "$lines" ] || return 0
551
+ # A genuine entry into $cur is the ONLY case where cmd_next's own event-write sets
552
+ # exit_code=0 while stage_to=$cur (flow.sh: unlock-stage-00 branch and the successful-advance
553
+ # branch). A failed `/flow next` retry ALSO writes stage_to=$cur but with exit_code=1 - and
554
+ # critically its stage_from is left at the script default "" (never set on that path), so a
555
+ # `stage_from != cur` check alone does NOT exclude it (empty string always != a real stage
556
+ # name) and the anchor would keep shrinking to the latest failed retry. exit_code is the only
557
+ # field that actually discriminates the two real event shapes cmd_next produces.
558
+ epoch="$(printf '%s\n' "$lines" | while IFS= read -r line; do
559
+ [ -n "$line" ] || continue
560
+ [ "$(_ws_get "$line" stage_to)" = "$cur" ] || continue
561
+ [ "$(_ws_get_num "$line" exit_code)" = "0" ] || continue
562
+ printf '%s\n' "$(_ws_get_num "$line" epoch_s)"
563
+ done | tail -1)"
564
+ [ -n "$epoch" ] && _elapsed_human "$epoch"
565
+ }
566
+
567
+ cmd_status() {
568
+ local idx; idx="$(current_stage_idx)"
569
+ echo "flow status"
570
+ echo " project: $ROOT"
571
+ echo " mode: $(cat "$MODE_FILE" 2>/dev/null | tr -d '\r' || echo teach) (default teach)"
572
+ echo " type: $(get_project_type) (done = $(done_def_for_type "$(get_project_type)"))"
573
+ lock_warn
574
+ echo
575
+ echo "NEXT -> $(_next_action)"
576
+ echo
577
+ if [ -f "$FLOW_DIR/00-inspect.md" ]; then
578
+ if scan_gate "$FLOW_DIR/00-inspect.md" >/dev/null 2>&1; then
579
+ echo "brownfield: assessment present, gate clean (flow/00-inspect.md)"
580
+ else
581
+ echo "brownfield: assessment present but gate NOT clean - run '/flow assess'"
582
+ fi
583
+ echo
584
+ fi
585
+ local dwell="" cur=""
586
+ [ "$idx" -ge 0 ] && { cur="$(stage_name_at "$idx")"; dwell="$(_stage_dwell "$cur")"; }
587
+ _gate_state_brief "$idx" "$dwell"
588
+ echo
589
+ local total; total="$(highest_card)"
590
+ if [ "$total" -gt 0 ]; then
591
+ if [ "$total" -gt 10 ]; then
592
+ # Compact form: respects the real 2-state (todo|done) + .inflight-registry data model -
593
+ # there is no "in-progress" status value (card_status/:228). Y = todo cards also present
594
+ # in the .inflight registry; Z = remaining todo. In-flight and todo cards are always
595
+ # listed individually; only done cards are summarized away.
596
+ # N in the header is the ACTUAL file count (done_n+infl_n+todo_n by construction), not
597
+ # highest_card()'s max-suffix value - card numbering can have gaps (a deleted card), and
598
+ # highest_card() would then silently break the "X+Y+Z=N" invariant the compact form
599
+ # promises. A single pass builds both the counts and the todo-line list together.
600
+ local f st infl_ids done_n=0 infl_n=0 todo_n=0 todo_lines=""
601
+ infl_ids="$(_inflight_file)"
602
+ for f in "$CARDS_DIR"/C-*.md; do
603
+ [ -e "$f" ] || continue
604
+ st="$(card_status "$f")"
605
+ if [ "$st" = "done" ]; then
606
+ done_n=$((done_n + 1))
607
+ elif [ "$st" = "todo" ]; then
608
+ local id; id="$(basename "$f" .md)"
609
+ if [ -f "$infl_ids" ] && grep -q "^$id " "$infl_ids" 2>/dev/null; then
610
+ infl_n=$((infl_n + 1))
611
+ else
612
+ todo_n=$((todo_n + 1))
613
+ todo_lines="$todo_lines $id: todo
614
+ "
615
+ fi
616
+ fi
617
+ done
618
+ echo "cards: $((done_n + infl_n + todo_n)) created ($done_n done · $infl_n in flight · $todo_n todo)"
619
+ local infl_out; infl_out="$(inflight_display)"
620
+ [ -n "$infl_out" ] && { echo " in flight (operator-marked via '/flow card start'):"; printf '%s\n' "$infl_out"; }
621
+ [ -n "$todo_lines" ] && printf '%s' "$todo_lines"
622
+ [ "$done_n" -gt 0 ] && echo " (+$done_n more done)"
623
+ else
624
+ echo "cards: $total created"
625
+ local f st
626
+ for f in "$CARDS_DIR"/C-*.md; do
627
+ [ -e "$f" ] || continue
628
+ st="$(card_status "$f")"
629
+ echo " $(basename "$f" .md): ${st:-?}"
630
+ done
631
+ local infl_out; infl_out="$(inflight_display)"
632
+ [ -n "$infl_out" ] && { echo " in flight (operator-marked via '/flow card start'):"; printf '%s\n' "$infl_out"; }
633
+ fi
634
+ else
635
+ echo "cards: none yet"
636
+ fi
637
+ local debt_n pb_n retro_last
638
+ debt_n="$(recall_open_debt | awk 'END{print NR}')"
639
+ pb_n="$(recall_playbooks | awk 'END{print NR}')"
640
+ retro_last="$(recall_retro_tail 1)"
641
+ echo
642
+ echo "memory: ${debt_n} open debt · ${pb_n} playbooks${retro_last:+ · last retro: [$retro_last]}"
643
+ echo " -> '/flow recall' reads back debt/retro/prev-card/harness before you work."
644
+ }
645
+
646
+ # ---------- resume (read-only session-story brief; the "AI context amnesia" fix) ----------
647
+
648
+ # Torn-line defense: the per-project events log is unbounded per-row and an EXIT-trap append
649
+ # can be in flight while resume reads it, so validate every candidate line's shape (must start
650
+ # with '{"ts":' and end with '}') and drop a non-conforming line rather than trust it. The
651
+ # python harness guards the same class of risk with json.loads; this is the shell equivalent.
652
+ # ALSO reject a line containing more than one '{"ts":' occurrence: two complete JSON objects
653
+ # glued together with no separating newline (a lost trailing \n on a non-atomic append) would
654
+ # still pass the edge-shape check above, and _ws_get's greedy match would then silently resolve
655
+ # every field to the SECOND object - the first event vanishes with no error or warning. A real
656
+ # single-object row's own "ts" key appears exactly once.
657
+ _resume_valid_lines() {
658
+ [ -f "$EVENTS_FILE" ] || return 0
659
+ tail -n 500 "$EVENTS_FILE" 2>/dev/null | tr -d '\r' | while IFS= read -r line; do
660
+ case "$line" in
661
+ '{"ts":'*'}')
662
+ n_ts="$(printf '%s' "$line" | grep -o '{"ts":' | wc -l)"
663
+ [ "$n_ts" -eq 1 ] && printf '%s\n' "$line"
664
+ ;;
665
+ esac
666
+ done
667
+ }
668
+
669
+ cmd_resume() {
670
+ local idx; idx="$(current_stage_idx)"
671
+ if [ "$idx" -lt 0 ]; then
672
+ echo "nothing to resume - $(_next_action)"
673
+ return 0
674
+ fi
675
+
676
+ echo "flow resume"
677
+ echo " project: $ROOT"
678
+ echo
679
+
680
+ if [ ! -s "$EVENTS_FILE" ]; then
681
+ echo "no telemetry - showing gate state only"
682
+ echo
683
+ _gate_state_brief "$idx"
684
+ echo
685
+ echo "NEXT -> $(_next_action)"
686
+ return 0
687
+ fi
688
+
689
+ local lines; lines="$(_resume_valid_lines)"
690
+ local cur_sess; cur_sess="$(_session_id)"
691
+
692
+ # ---- LAST SESSION (command NAMES + exit + stage transitions only - never raw args: they
693
+ # can carry unmasked secrets in free-text fields, and a quote-blind extractor would truncate
694
+ # a value containing an escaped quote anyway) ----
695
+ local first_own_epoch=""
696
+ first_own_epoch="$(printf '%s\n' "$lines" | while IFS= read -r line; do
697
+ [ -n "$line" ] || continue
698
+ [ "$(_ws_get "$line" session_id)" = "$cur_sess" ] || continue
699
+ printf '%s\n' "$(_ws_get_num "$line" epoch_s)"
700
+ break
701
+ done)"
702
+
703
+ local foreign_sess=""
704
+ foreign_sess="$(printf '%s\n' "$lines" | while IFS= read -r line; do
705
+ [ -n "$line" ] || continue
706
+ local s; s="$(_ws_get "$line" session_id)"
707
+ [ "$s" = "$cur_sess" ] && continue
708
+ printf '%s\n' "$s"
709
+ done | tail -1)"
710
+
711
+ if [ -n "$foreign_sess" ]; then
712
+ # session_id may be a reusable `ppid:host:NNN` form or a UUID. Detection = string
713
+ # inequality (above) PLUS a time-gap fallback: a `ppid:` scheme whose latest foreign row is
714
+ # < 15 min older than this session's own first row is more likely PID reuse than a genuine
715
+ # prior session - label it accordingly rather than asserting a "last session" that may
716
+ # never have existed. Absolute timestamps are shown either way.
717
+ local label="last session" f_epoch gap
718
+ case "$foreign_sess" in
719
+ ppid:*)
720
+ f_epoch="$(printf '%s\n' "$lines" | while IFS= read -r line; do
721
+ [ "$(_ws_get "$line" session_id)" = "$foreign_sess" ] || continue
722
+ printf '%s\n' "$(_ws_get_num "$line" epoch_s)"
723
+ done | tail -1)"
724
+ # Anchor on the current session's OWN first row when one exists; otherwise fall back
725
+ # to wall-clock now(). Without this fallback, the gap check was a no-op on resume's own
726
+ # primary documented trigger (SKILL.md: "run /flow resume BEFORE any other flow verb")
727
+ # - at that point there IS no own-session row yet, so the fallback never fired exactly
728
+ # when ppid-reuse risk is highest (fresh session, no prior activity to compare against).
729
+ local anchor="$first_own_epoch"
730
+ [ -n "$anchor" ] || anchor="$(_now)"
731
+ if [ -n "$f_epoch" ]; then
732
+ gap=$(( anchor - f_epoch )); [ "$gap" -lt 0 ] && gap=$(( -gap ))
733
+ [ "$gap" -lt 900 ] && label="recent activity"
734
+ fi
735
+ ;;
736
+ esac
737
+ local last_ts
738
+ last_ts="$(printf '%s\n' "$lines" | while IFS= read -r line; do
739
+ [ "$(_ws_get "$line" session_id)" = "$foreign_sess" ] || continue
740
+ printf '%s\n' "$(_ws_get "$line" ts)"
741
+ done | tail -1)"
742
+ echo "$label (as of $last_ts):"
743
+ printf '%s\n' "$lines" | while IFS= read -r line; do
744
+ [ -n "$line" ] || continue
745
+ [ "$(_ws_get "$line" session_id)" = "$foreign_sess" ] || continue
746
+ local cmd ec sf st ts marker trans
747
+ cmd="$(_ws_get "$line" command)"; ec="$(_ws_get_num "$line" exit_code)"
748
+ sf="$(_ws_get "$line" stage_from)"; st="$(_ws_get "$line" stage_to)"
749
+ ts="$(_ws_get "$line" ts)"
750
+ if [ "$ec" = "0" ]; then marker="ok"; else marker="FAIL($ec)"; fi
751
+ trans=""
752
+ { [ -n "$sf" ] || [ -n "$st" ]; } && trans=" [$sf -> $st]"
753
+ printf ' %s %-10s %s%s\n' "$ts" "$cmd" "$marker" "$trans"
754
+ done | tail -5
755
+ echo
756
+ fi
757
+
758
+ # ---- IN FLIGHT ----
759
+ local infl_out; infl_out="$(inflight_display)"
760
+ if [ -n "$infl_out" ]; then
761
+ echo "in flight:"
762
+ printf '%s\n' "$infl_out"
763
+ echo
764
+ fi
765
+
766
+ # ---- GATE STATE (identical to `status`'s own block - see _gate_state_brief). NOTE: this
767
+ # reports only the CURRENT stage's gate, while NEXT (below) scans back for the earliest
768
+ # genuinely-blocked stage - by design (an earlier un-skipped block is the real next action
769
+ # even when the current stage's own gate is clean). The two lines can read as contradictory
770
+ # in that rare state ("gate: PASS" immediately followed by "fix gate: ... in flow/02-..."); this
771
+ # is correct, not a bug - GATE STATE answers "is the CURRENT stage clean", NEXT answers "what
772
+ # do I actually do next", and those are different questions when an earlier stage is blocked. ----
773
+ _gate_state_brief "$idx"
774
+ echo
775
+
776
+ # ---- NEXT (shared with `status` via _next_action - the two verbs can never disagree) ----
777
+ echo "NEXT -> $(_next_action)"
778
+ }
779
+
780
+ cmd_next() {
781
+ lock_acquire next || return 1
782
+ local idx; idx="$(current_stage_idx)"
783
+ if [ "$idx" -lt 0 ]; then
784
+ mkdir -p "$FLOW_DIR"
785
+ cp "$TEMPLATE_DIR/00-idea.md" "$FLOW_DIR/00-idea.md"
786
+ seed_law_files
787
+ # Cycle id (idempotent): reuse one already stamped by a prior assess so assess->plan is ONE cycle.
788
+ _ensure_cycle
789
+ FLOW_LOG_STAGE_TO="00-idea"
790
+ echo "PASS: unlocked stage 00 -> flow/00-idea.md"
791
+ echo "Fill it in, check its gate boxes, then run '/flow next'."
792
+ return 0
793
+ fi
794
+ local cur; cur="$(stage_name_at "$idx")"
795
+ if ! scan_gate "$FLOW_DIR/$cur.md" >/dev/null 2>&1; then
796
+ echo "FAIL: gate for stage $cur is not clean."
797
+ scan_gate "$FLOW_DIR/$cur.md"
798
+ # attribute the failing event to THIS stage (so usage top-fail-stage + propose can see it)
799
+ # and record WHICH checks failed, so a chronically-failing stage is diagnosable.
800
+ FLOW_LOG_STAGE_TO="$cur"
801
+ FLOW_LAST_GATE_FAIL="fill:$(grep -c '\[FILL' "$FLOW_DIR/$cur.md" 2>/dev/null || echo 0),unchecked:$(grep -cE '^[[:space:]]*- \[ \]' "$FLOW_DIR/$cur.md" 2>/dev/null || echo 0)"
802
+ echo
803
+ echo "Fix the above, then run '/flow next' again. (Kill at a gate is also valid.)"
804
+ return 1
805
+ fi
806
+ if [ "$idx" -ge "$LAST_STAGE_IDX" ]; then
807
+ FLOW_LOG_STAGE_FROM="$cur"
808
+ if planning_complete; then
809
+ echo "PASS: stage $cur gate clean. Planning is COMPLETE."
810
+ echo "All planning stages passed (or were debt-skipped). Run '/flow card' to create build cards."
811
+ if prd_declares_fr; then
812
+ echo " tip: the PRD declares FR ids - run '/flow consistency' to check FR->card->contract coverage before building."
813
+ fi
814
+ else
815
+ echo "PASS: stage $cur gate clean - but an earlier stage is still BLOCKED (and not debt-skipped)."
816
+ echo "Run '/flow' to see which. If it is a legitimate, debt-recorded skip, use"
817
+ echo "'/flow skip <stage> --reason ...'. '/flow card' stays blocked until then."
818
+ fi
819
+ return 0
820
+ fi
821
+ local nxt; nxt="$(stage_name_at "$((idx + 1))")"
822
+ if [ -f "$FLOW_DIR/$nxt.md" ]; then
823
+ echo "PASS: stage $cur gate clean. Stage $((idx + 1)) ($nxt.md) already exists - not overwritten."
824
+ return 0
825
+ fi
826
+ cp "$TEMPLATE_DIR/$nxt.md" "$FLOW_DIR/$nxt.md"
827
+ FLOW_LOG_STAGE_FROM="$cur"; FLOW_LOG_STAGE_TO="$nxt"
828
+ echo "PASS: stage $cur gate clean -> unlocked stage $((idx + 1)) (flow/$nxt.md)"
829
+ gate_durable_hook "$cur"
830
+ echo " tip: '/flow recall' surfaces prior debt/retro/friction before you fill this stage."
831
+ return 0
832
+ }
833
+
834
+ # ---- card lifecycle: operator-marked start + CLI-owned done (legible-lifecycle layer) ----
835
+ # The card 'status:' field stays the 2-state todo|done the gate validates. "in flight" is a
836
+ # transient the runner tracks in a side registry ($CARDS_DIR/.inflight: "<id> <epoch>"), so it
837
+ # is PORTABLE (no python/harness needed to SHOW it) and never touches the gated frontmatter.
838
+ # Both verbs COEXIST with hand-editing 'status:' + '/flow check' — they add convenience, they
839
+ # do not replace it.
840
+ _inflight_file() { echo "$CARDS_DIR/.inflight"; }
841
+
842
+ _inflight_set() { # $1=id -> record/refresh an in-flight start stamp (dedup by id)
843
+ local id="$1" infl; infl="$(_inflight_file)"; mkdir -p "$CARDS_DIR"
844
+ if [ -f "$infl" ]; then grep -v "^$id " "$infl" 2>/dev/null > "$infl.tmp" || true; mv -f "$infl.tmp" "$infl" 2>/dev/null || true; fi
845
+ printf '%s %s\n' "$id" "$(_now)" >> "$infl"
846
+ }
847
+
848
+ _inflight_clear() { # $1=id -> drop an id (done/abandoned)
849
+ local id="$1" infl; infl="$(_inflight_file)"
850
+ [ -f "$infl" ] || return 0
851
+ grep -v "^$id " "$infl" 2>/dev/null > "$infl.tmp" || true; mv -f "$infl.tmp" "$infl" 2>/dev/null || true
852
+ }
853
+
854
+ _elapsed_human() { # $1=start-epoch -> "Xm"/"Xh"/"Xd" (integer; GNU/BSD-portable, no date -d/-r)
855
+ local start="${1:-0}" s now
856
+ case "$start" in ''|*[!0-9]*) start=0;; esac
857
+ now="$(_now)"; s=$(( now - start )); [ "$s" -lt 0 ] && s=0
858
+ if [ "$s" -lt 3600 ]; then echo "$(( s / 60 ))m"
859
+ elif [ "$s" -lt 86400 ]; then echo "$(( s / 3600 ))h"
860
+ else echo "$(( s / 86400 ))d"; fi
861
+ }
862
+
863
+ inflight_display() { # emit a line per todo card still marked in flight (stale ids self-skip)
864
+ local infl id ts f; infl="$(_inflight_file)"
865
+ [ -f "$infl" ] || return 0
866
+ while read -r id ts; do
867
+ [ -n "$id" ] || continue
868
+ f="$(resolve_card_file "$id" 2>/dev/null)"
869
+ [ -n "$f" ] && [ -f "$f" ] || continue # card gone -> stale, skip
870
+ [ "$(card_status "$f")" = "todo" ] || continue # only todo cards are "in flight"
871
+ printf ' %s (in flight, started %s ago)\n' "$id" "$(_elapsed_human "$ts")"
872
+ done < "$infl"
873
+ }
874
+
875
+ # Single source of truth for "what should I do next" - used by BOTH `status` (Phase 3) and
876
+ # `resume` (this phase) so the two verbs can never disagree about the next step. Pure function
877
+ # of existing state helpers; no side effects. Decision ladder, first match wins.
878
+ _next_action() {
879
+ local idx; idx="$(current_stage_idx)"
880
+ if [ "$idx" -lt 0 ]; then
881
+ echo "run '/flow next' to unlock stage 00"
882
+ return 0
883
+ fi
884
+ # Scan every stage through the current one for the FIRST that is neither gate-clean nor
885
+ # debt-skipped - mirrors planning_complete's own scan, so this can never recommend "cut
886
+ # cards" while an earlier (non-current) stage is still genuinely blocked.
887
+ local s blocked="" cur; cur="$(stage_name_at "$idx")"
888
+ for s in $STAGES; do
889
+ if [ ! -f "$FLOW_DIR/$s.md" ]; then
890
+ stage_skipped "$s" && continue
891
+ break
892
+ fi
893
+ if ! scan_gate "$FLOW_DIR/$s.md" >/dev/null 2>&1; then
894
+ stage_skipped "$s" && continue
895
+ blocked="$s"; break
896
+ fi
897
+ [ "$s" = "$cur" ] && break
898
+ done
899
+ if [ -n "$blocked" ]; then
900
+ # Materialize scan_gate's output into a variable FIRST, then extract from that string -
901
+ # never pipe scan_gate's own (multi-line, internally-piped) output straight into a reader
902
+ # that can exit early like `grep -m1`. An early-exiting reader closing its end of the pipe
903
+ # while scan_gate's own nested `printf | sed` is still writing was found to hang
904
+ # indefinitely under Git-Bash/MSYS (early-pipe-reader-exit class issue, confirmed by a live
905
+ # reproduction) - command substitution alone (no early-exit reader) does not hit this.
906
+ local gate_out reason
907
+ gate_out="$(scan_gate "$FLOW_DIR/$blocked.md" 2>/dev/null)"
908
+ reason="$(printf '%s\n' "$gate_out" | grep -m1 '\[x\]' | sed -E 's/^[[:space:]]*\[x\][[:space:]]*//; s/:$//')"
909
+ echo "fix gate: ${reason:-gate violations} in flow/$blocked.md"
910
+ return 0
911
+ fi
912
+ if [ "$idx" -lt "$LAST_STAGE_IDX" ]; then
913
+ echo "run '/flow next' to unlock the next stage"
914
+ return 0
915
+ fi
916
+ # Planning complete (every stage through the last is clean or debt-skipped).
917
+ local total; total="$(highest_card)"
918
+ if [ "$total" -eq 0 ]; then
919
+ echo "run '/flow card' to cut build cards"
920
+ return 0
921
+ fi
922
+ local infl id ts f
923
+ infl="$(_inflight_file)"
924
+ if [ -f "$infl" ]; then
925
+ while read -r id ts; do
926
+ [ -n "$id" ] || continue
927
+ f="$(resolve_card_file "$id" 2>/dev/null)"
928
+ [ -n "$f" ] && [ -f "$f" ] || continue
929
+ [ "$(card_status "$f")" = "todo" ] || continue
930
+ echo "continue $id (in flight $(_elapsed_human "$ts"))"
931
+ return 0
932
+ done < "$infl"
933
+ fi
934
+ local any_todo=0 fpath
935
+ for fpath in "$CARDS_DIR"/C-*.md; do
936
+ [ -e "$fpath" ] || continue
937
+ [ "$(card_status "$fpath")" = "todo" ] && { any_todo=1; break; }
938
+ done
939
+ if [ "$any_todo" -eq 1 ]; then
940
+ echo "start next card: '/flow card start C-NNN'"
941
+ return 0
942
+ fi
943
+ echo "run '/flow check' then ship per stage 09"
944
+ }
945
+
946
+ _set_card_status() { # $1=file $2=value -> rewrite the ^status: line (portable substitute; temp+mv)
947
+ local f="$1" v="$2"
948
+ sed "s/^status:.*/status: $v/" "$f" > "$f.tmp" 2>/dev/null || { rm -f "$f.tmp"; return 1; }
949
+ mv -f "$f.tmp" "$f" 2>/dev/null || { rm -f "$f.tmp"; return 1; }
950
+ return 0
951
+ }
952
+
953
+ cmd_card_start() { # mark a card actively in progress (operator-visible in_progress)
954
+ local arg="${1:-}"
955
+ if [ -z "$arg" ]; then echo "usage: /flow card start C-NNN"; return 1; fi
956
+ lock_acquire card || return 1
957
+ local file; file="$(resolve_card_file "$arg")"
958
+ if [ -z "$file" ] || [ ! -f "$file" ]; then echo "FAIL: card not found for '$arg' (looked for ${file:-?})"; return 1; fi
959
+ local id; id="$(basename "$file" .md)"; FLOW_LOG_CARD="$id"
960
+ if [ "$(card_status "$file")" = "done" ]; then echo "FAIL: $id is already done — nothing to start."; return 1; fi
961
+ _inflight_set "$id"
962
+ harness_call story update --id "$id" --status in_progress # durable mirror (best-effort)
963
+ echo "PASS: $id marked in flight. Build it, then '/flow card done $id' (or hand-edit status: done + '/flow check $id')."
964
+ return 0
965
+ }
966
+
967
+ cmd_card_done() { # CLI-owned flip to done, gated by the SAME done-rules as '/flow check' (revert on fail)
968
+ local arg="${1:-}"
969
+ if [ -z "$arg" ]; then echo "usage: /flow card done C-NNN"; return 1; fi
970
+ lock_acquire card || return 1
971
+ local file; file="$(resolve_card_file "$arg")"
972
+ if [ -z "$file" ] || [ ! -f "$file" ]; then echo "FAIL: card not found for '$arg' (looked for ${file:-?})"; return 1; fi
973
+ local id orig; id="$(basename "$file" .md)"; FLOW_LOG_CARD="$id"; orig="$(card_status "$file")"
974
+ if [ -z "$orig" ]; then echo "FAIL: $id has no 'status:' line — run '/flow check $id' to see what's missing."; return 1; fi
975
+ if ! _set_card_status "$file" done; then echo "FAIL: could not write status for $id"; return 1; fi
976
+ if cmd_check "$id"; then # cmd_check enforces evidence/verify AND records the durable done-trace
977
+ _inflight_clear "$id"
978
+ return 0
979
+ fi
980
+ _set_card_status "$file" "${orig:-todo}" # NOT done — never leave a hollow 'done'
981
+ echo
982
+ echo "REVERTED: $id left at status '${orig:-todo}' — the done-gate above must pass first."
983
+ return 1
984
+ }
985
+
986
+ cmd_card() {
987
+ case "${1:-}" in
988
+ start) shift 2>/dev/null || true; cmd_card_start "${1:-}"; return $? ;;
989
+ done) shift 2>/dev/null || true; cmd_card_done "${1:-}"; return $? ;;
990
+ esac
991
+ lock_acquire card || return 1
992
+ if ! planning_complete; then
993
+ echo "FAIL: finish planning first. All stages 00-05 must pass their gates before cards."
994
+ echo "Run '/flow' to see what blocks you."
995
+ return 1
996
+ fi
997
+ if [ ! -f "$TEMPLATE_DIR/card.md" ]; then
998
+ echo "FAIL: card template missing at $TEMPLATE_DIR/card.md"
999
+ return 1
1000
+ fi
1001
+ mkdir -p "$CARDS_DIR"
1002
+ local next; next="$(( $(highest_card) + 1 ))"
1003
+ local id; id="$(printf 'C-%03d' "$next")"
1004
+ FLOW_LOG_CARD="$id"
1005
+ local out="$CARDS_DIR/$id.md"
1006
+ if ! sed "s/C-NNN/$id/g" "$TEMPLATE_DIR/card.md" > "$out"; then
1007
+ rm -f "$out"
1008
+ echo "FAIL: could not write card $id (template/sed error)"
1009
+ return 1
1010
+ fi
1011
+ echo "PASS: created $id -> cards/$id.md"
1012
+ echo "Fill its Scope / Allowed files / Verify / Done-evidence, build it, then '/flow check $id'."
1013
+ local pc dbt
1014
+ pc="$(recall_prev_card)"
1015
+ dbt="$(recall_open_debt)"
1016
+ if [ -n "$pc" ] || [ -n "$dbt" ]; then
1017
+ echo
1018
+ echo "Prior knowledge to carry into this card (full read-back: '/flow recall'):"
1019
+ [ -n "$pc" ] && printf '%s\n' "$pc" | sed 's/^/ /'
1020
+ [ -n "$dbt" ] && { echo " open debt:"; printf '%s\n' "$dbt" | sed 's/^/ /'; }
1021
+ fi
1022
+ harness_call story add --id "$id" --title "$id" --lane normal # durable tracking handle
1023
+ return 0
1024
+ }
1025
+
1026
+ cmd_check() {
1027
+ local arg="${1:-}"
1028
+ if [ -z "$arg" ]; then echo "usage: /flow check C-NNN"; return 1; fi
1029
+ local file; file="$(resolve_card_file "$arg")"
1030
+ if [ -z "$file" ] || [ ! -f "$file" ]; then
1031
+ echo "FAIL: card not found for '$arg' (looked for ${file:-?})"
1032
+ return 1
1033
+ fi
1034
+ local id; id="$(basename "$file" .md)"
1035
+ FLOW_LOG_CARD="$id"
1036
+ local found=0
1037
+
1038
+ # 1) no [FILL]
1039
+ local fills; fills="$(grep -n '\[FILL' "$file" 2>/dev/null || true)"
1040
+ if [ -n "$fills" ]; then
1041
+ echo " [x] unfilled [FILL] placeholders:"
1042
+ printf '%s\n' "$fills" | sed 's/^/ L/'
1043
+ found=1
1044
+ fi
1045
+
1046
+ # 2) valid status
1047
+ local st; st="$(card_status "$file")"
1048
+ case "$st" in
1049
+ todo|done) : ;;
1050
+ "") echo " [x] missing 'status:' line (must be todo or done)"; found=1 ;;
1051
+ *) echo " [x] invalid status '$st' (must be todo or done)"; found=1 ;;
1052
+ esac
1053
+
1054
+ # 3) deps present
1055
+ if ! grep -qE '^deps:' "$file" 2>/dev/null; then
1056
+ echo " [x] missing 'deps:' line (card ids or \"none\")"
1057
+ found=1
1058
+ fi
1059
+
1060
+ # 4) required sections (anchored to line start so '### Scope' does not satisfy '## Scope';
1061
+ # prefix match allows trailing text like '## Verify (run these...)')
1062
+ local sec
1063
+ for sec in "## Scope" "## Allowed files" "## Verify" "## Done-evidence" "## Evidence"; do
1064
+ if ! grep -qE "^$sec" "$file" 2>/dev/null; then
1065
+ echo " [x] missing section: $sec"
1066
+ found=1
1067
+ fi
1068
+ done
1069
+
1070
+ # 5) if done: all Verify boxes checked AND Evidence is real world-state
1071
+ if [ "$st" = "done" ]; then
1072
+ local unchecked; unchecked="$(grep -nE '^[[:space:]]*- \[ \]' "$file" 2>/dev/null || true)"
1073
+ if [ -n "$unchecked" ]; then
1074
+ echo " [x] status is 'done' but Verify has unchecked boxes:"
1075
+ printf '%s\n' "$unchecked" | sed 's/^/ L/'
1076
+ found=1
1077
+ fi
1078
+ # Evidence body: lines between the '## Evidence' header and the next '## ' heading (or EOF).
1079
+ # Bounded awk avoids counting a stray later '## Evidence' as content. tr consumes all stdin
1080
+ # (no SIGPIPE); case-glob instead of 'grep -q' which closes the pipe early.
1081
+ local ev ev_lc empty_ev=0
1082
+ ev="$(awk '/^## Evidence/{f=1; next} f && /^## /{f=0} f{print}' "$file" | tr -d '\r' | sed '/^[[:space:]]*$/d')"
1083
+ ev_lc="$(printf '%s' "$ev" | tr '[:upper:]' '[:lower:]')"
1084
+ [ -z "$ev" ] && empty_ev=1
1085
+ case "$ev_lc" in *"(empty until done)"*|"---"|"--") empty_ev=1 ;; esac
1086
+ if [ "$empty_ev" -eq 1 ]; then
1087
+ echo " [x] status is 'done' but ## Evidence is empty (paste world-state proof: URL/curl/DB row)"
1088
+ found=1
1089
+ fi
1090
+ fi
1091
+
1092
+ if [ "$found" -eq 0 ]; then
1093
+ echo "PASS: $id is valid (status: $st)."
1094
+ case "$st" in
1095
+ todo) harness_call story update --id "$id" --status in_progress ;;
1096
+ done) harness_call story update --id "$id" --status implemented
1097
+ local tr; tr="$(harness_emit trace --summary "card $id reached done-evidence" --story "$id" --outcome completed)"
1098
+ [ -n "$tr" ] && printf '%s\n' "$tr" | grep -iE 'tier|to reach' | sed 's/^/ harness: /' ;;
1099
+ esac
1100
+ return 0
1101
+ fi
1102
+ FLOW_LAST_GATE_FAIL="fill:$(grep -c '\[FILL' "$file" 2>/dev/null || echo 0),unchecked:$(grep -cE '^[[:space:]]*- \[ \]' "$file" 2>/dev/null || echo 0)"
1103
+ echo "FAIL: $id has gate violations (above)."
1104
+ return 1
1105
+ }
1106
+
1107
+ cmd_mode() {
1108
+ local arg="${1:-}"
1109
+ if [ -z "$arg" ]; then
1110
+ echo "mode: $(cat "$MODE_FILE" 2>/dev/null | tr -d '\r' || echo teach) (default teach)"
1111
+ echo "set with: /flow mode teach|work"
1112
+ return 0
1113
+ fi
1114
+ case "$arg" in
1115
+ teach|work) printf '%s\n' "$arg" > "$MODE_FILE"; _ignore_run_state; echo "PASS: mode set to '$arg'."; return 0 ;;
1116
+ *) echo "FAIL: mode must be 'teach' or 'work'."; return 1 ;;
1117
+ esac
1118
+ }
1119
+
1120
+ cmd_project_type() {
1121
+ local arg="${1:-}"
1122
+ if [ -z "$arg" ]; then
1123
+ local t; t="$(get_project_type)"
1124
+ echo "project type: $t (default web)"
1125
+ echo " done-evidence for '$t': $(done_def_for_type "$t")"
1126
+ echo " set with: /flow project-type web|cli|library|skill"
1127
+ echo " per-type contract + card sequence: references/project-types.md"
1128
+ return 0
1129
+ fi
1130
+ case "$arg" in
1131
+ web|cli|library|skill)
1132
+ printf '%s\n' "$arg" > "$PROJECT_TYPE_FILE"; _ignore_run_state
1133
+ echo "PASS: project type set to '$arg'."
1134
+ echo " done-evidence now means: $(done_def_for_type "$arg")"
1135
+ return 0 ;;
1136
+ *) echo "FAIL: project type must be web|cli|library|skill."; return 1 ;;
1137
+ esac
1138
+ }
1139
+
1140
+ cmd_skip() {
1141
+ lock_acquire skip || return 1
1142
+ local stage="${1:-}" reason=""
1143
+ shift 2>/dev/null || true
1144
+ case "${1:-}" in --reason) shift 2>/dev/null || true; reason="$*" ;; *) reason="$*" ;; esac
1145
+ if [ -z "$stage" ] || [ -z "$reason" ]; then
1146
+ echo 'usage: /flow skip <stage e.g. 01-research> --reason "<why; a stage-matched open DEBT must already exist>"'
1147
+ return 1
1148
+ fi
1149
+ local known=0 s; for s in $STAGES; do [ "$s" = "$stage" ] && known=1; done
1150
+ if [ "$known" -eq 0 ]; then echo "FAIL: unknown stage '$stage' (one of: $STAGES)"; return 1; fi
1151
+ # PRIMARY guard (stage identity): the contract (05) is the seam - never skip it; adapt it
1152
+ # to the project type instead. This closes the worst case (skipping the auth-boundary stage).
1153
+ if [ "$stage" = "05-contract" ]; then
1154
+ echo "BLOCKED: the contract (05) is the seam - never skip it. Adapt it to your project type"
1155
+ echo " ('/flow project-type ...'; see references/project-types.md), then pass its gate."
1156
+ return 1
1157
+ fi
1158
+ # SECONDARY signal: a security-class-sounding reason HALTS (operator-only); not the sole gate.
1159
+ if printf '%s' "$reason" | grep -qiE 'auth|authoriz|authorize|admin|tenan|payment|billing|password|token|secret|credential|permission|role|rbac|login|pii|data loss|migration|validation'; then
1160
+ echo "BLOCKED: that reason looks security-class. Security skips are operator-only and HALT - never auto-skipped."
1161
+ return 1
1162
+ fi
1163
+ # the skip must already be written down as an open DEBT that NAMES this exact stage
1164
+ if [ ! -f "$DEBT_FILE" ] || ! grep -qE "^- \[ \] DEBT:.*$stage" "$DEBT_FILE" 2>/dev/null; then
1165
+ echo "FAIL: no open DEBT naming '$stage'. First run:"
1166
+ echo " /flow debt add \"skip $stage\" \"<the exposure>\" \"<close-before condition>\""
1167
+ return 1
1168
+ fi
1169
+ mkdir -p "$FLOW_DIR"
1170
+ stage_skipped "$stage" || printf '%s\n' "$stage" >> "$SKIPPED_FILE"
1171
+ local i=0 found=-1; for s in $STAGES; do [ "$s" = "$stage" ] && found=$i; i=$((i + 1)); done
1172
+ if [ "$found" -ge 0 ] && [ "$found" -lt "$LAST_STAGE_IDX" ]; then
1173
+ local nxt; nxt="$(stage_name_at "$((found + 1))")"
1174
+ [ -f "$FLOW_DIR/$nxt.md" ] || cp "$TEMPLATE_DIR/$nxt.md" "$FLOW_DIR/$nxt.md"
1175
+ echo "PASS: stage $stage debt-skipped (logged) -> $nxt available. planning_complete now tolerates it."
1176
+ else
1177
+ echo "PASS: stage $stage debt-skipped (logged). planning_complete now tolerates it."
1178
+ fi
1179
+ return 0
1180
+ }
1181
+
1182
+ cmd_retro() {
1183
+ [ -f "$RETRO_FILE" ] || { [ -f "$LAW_DIR/RETRO.md" ] && cp "$LAW_DIR/RETRO.md" "$RETRO_FILE"; }
1184
+ echo "flow retro - answer these 3, then append ONE honest line to RETRO.md:"
1185
+ echo " 1. Which gate did you skip or rush, and what did it cost?"
1186
+ echo " 2. What stack lesson did a card pay for? (-> harvest into playbooks/)"
1187
+ echo " 3. What about the flow itself slowed you down? (-> FLOW-FEEDBACK.md)"
1188
+ echo
1189
+ echo "RETRO.md: $RETRO_FILE"
1190
+ echo "(You write the line - the runner never fills it for you.)"
1191
+ if harness_available; then
1192
+ local pr; pr="$(harness_emit propose)"
1193
+ if [ -n "$pr" ] && ! printf '%s' "$pr" | grep -q 'nothing to propose'; then
1194
+ echo
1195
+ echo "Harness proposes (deterministic, from repeated friction/interventions + audit drift):"
1196
+ printf '%s\n' "$pr" | sed 's/^/ /'
1197
+ echo " -> commit the worth-doing ones: flow harness propose --commit"
1198
+ fi
1199
+ fi
1200
+ return 0
1201
+ }
1202
+
1203
+ # Print the non-empty lines under a card's '## Allowed files' section. Single source of truth for
1204
+ # the allowed-files invariant: cmd_ready advertises them and 'workspace check' computes overlap
1205
+ # from the SAME extraction (so the two can never diverge on what a card claims to own).
1206
+ _card_allowed_files() { # $1 = card file
1207
+ [ -f "$1" ] || return 0
1208
+ awk '/^## Allowed files/{f=1; next} f && /^## /{f=0} f && NF{print}' "$1"
1209
+ }
1210
+
1211
+ cmd_ready() {
1212
+ if ! planning_complete; then
1213
+ echo "FAIL: planning not complete - no cards to schedule yet."
1214
+ return 1
1215
+ fi
1216
+ local total; total="$(highest_card)"
1217
+ if [ "$total" -le 0 ]; then echo "No cards created yet. Run '/flow card'."; return 0; fi
1218
+ echo "flow ready - buildable todo cards (deps met). Operator dispatches; runner advises."
1219
+ echo "Parallel only when allowed-files do NOT overlap (review the lists below)."
1220
+ echo
1221
+ local f id st deps dep ok
1222
+ for f in "$CARDS_DIR"/C-*.md; do
1223
+ [ -e "$f" ] || continue
1224
+ id="$(basename "$f" .md)"
1225
+ st="$(card_status "$f")"
1226
+ [ "$st" = "todo" ] || continue
1227
+ deps="$(grep -m1 -E '^deps:' "$f" | sed 's/^deps:[[:space:]]*//' | tr -d '\r')"
1228
+ ok=1
1229
+ # deps met if every referenced card is status done (normalize C-1 / C-001 to canonical path)
1230
+ for dep in $(printf '%s' "$deps" | grep -oiE 'C-[0-9]+' || true); do
1231
+ local depnum depfile
1232
+ depnum="${dep#C-}"; depnum="${depnum#c-}"
1233
+ case "$depnum" in (*[!0-9]*) continue;; esac
1234
+ depfile="$(printf '%s/C-%03d.md' "$CARDS_DIR" "$((10#$depnum))")"
1235
+ if [ ! -f "$depfile" ] || [ "$(card_status "$depfile")" != "done" ]; then ok=0; fi
1236
+ done
1237
+ if [ "$ok" -eq 1 ]; then
1238
+ echo " BUILDABLE $id (deps: ${deps:-none})"
1239
+ _card_allowed_files "$f" | sed 's/^/ allowed: /'
1240
+ else
1241
+ echo " blocked $id (deps not all done: ${deps:-none})"
1242
+ fi
1243
+ done
1244
+ return 0
1245
+ }
1246
+
1247
+ cmd_auto() {
1248
+ lock_acquire auto || return 1
1249
+ echo "flow auto - preflight"
1250
+ if ! planning_complete; then
1251
+ echo "FAIL: planning not complete. Finish stages 00-05 first."
1252
+ return 1
1253
+ fi
1254
+ if [ "$(highest_card)" -le 0 ]; then
1255
+ echo "FAIL: no cards. Run '/flow card' to create the build cards first."
1256
+ return 1
1257
+ fi
1258
+ echo "PASS: preflight ok ($(highest_card) cards, planning complete)."
1259
+ echo
1260
+ echo "Autonomous orchestration is driven by the /flow SKILL.md AUTO PRINCIPLES"
1261
+ echo "(subagent per card, planner review + verify, worktree isolation, Tier-C halts"
1262
+ echo "for security-class debt, state in card files + AUTO-LOG.md)."
1263
+ echo "Run '/flow ready' to see parallel-safe groups."
1264
+ return 0
1265
+ }
1266
+
1267
+ cmd_recall() {
1268
+ echo "flow recall - prior knowledge for this project"
1269
+ echo " project: $ROOT"
1270
+ local proj_any=0 out
1271
+ out="$(recall_open_debt)"
1272
+ if [ -n "$out" ]; then echo; echo "OPEN DEBT (owed before shipping):"; printf '%s\n' "$out" | sed 's/^/ /'; proj_any=1; fi
1273
+ out="$(recall_retro_tail 5)"
1274
+ if [ -n "$out" ]; then echo; echo "RECENT RETRO (lessons from past runs):"; printf '%s\n' "$out" | sed 's/^/ - /'; proj_any=1; fi
1275
+ out="$(recall_prev_card)"
1276
+ if [ -n "$out" ]; then echo; echo "PREVIOUS CARD (carry its learnings into the next one):"; printf '%s\n' "$out" | sed 's/^/ /'; proj_any=1; fi
1277
+ out="$(_harness_query friction)"
1278
+ if [ -n "$out" ] && ! printf '%s' "$out" | grep -q 'no friction'; then echo; echo "FRICTION recorded earlier (do not repeat):"; printf '%s\n' "$out" | sed 's/^/ /'; proj_any=1; fi
1279
+ out="$(_harness_query backlog)"
1280
+ if [ -n "$out" ] && ! printf '%s' "$out" | grep -q 'no backlog'; then echo; echo "OPEN IMPROVEMENT BACKLOG:"; printf '%s\n' "$out" | sed 's/^/ /'; proj_any=1; fi
1281
+ out="$(harness_emit audit)"
1282
+ if [ -n "$out" ]; then printf '%s\n' "$out" | grep -iE 'entropy score' | sed 's/^/ health: /'; fi
1283
+ out="$(recall_playbooks)"
1284
+ if [ -n "$out" ]; then echo; echo "PLAYBOOKS available (read before building that stack):"; printf '%s\n' "$out" | sed 's/^/ - /'; fi
1285
+ out="$(recall_global_playbooks)"
1286
+ if [ -n "$out" ]; then echo; echo "GLOBAL PLAYBOOKS (cross-project, ~/.claude/flow/playbooks):"; printf '%s\n' "$out" | sed 's/^/ - /'; fi
1287
+ # mechanical usage log (best-effort): roll up, then surface the one-line digest so build history
1288
+ # reaches the operator at stage/card start. Silent when there is no data / no python.
1289
+ out="$(harness_emit rollup >/dev/null 2>&1; harness_emit usage --summary)"
1290
+ if [ -n "$out" ]; then echo; printf '%s\n' "$out" | sed 's/^/ /'; proj_any=1; fi
1291
+ if [ -f "$ROOT/flow/constitution.md" ]; then
1292
+ out="$(awk '/^[[:space:]]*```/{f=!f;next} f{next} /^[[:space:]]*\|/ && !/\|[[:space:]]*[Ii][Dd][[:space:]]*\|/ && !/\|[[:space:]]*:?-{2,}/{print}' "$ROOT/flow/constitution.md")"
1293
+ if [ -n "$out" ]; then echo; echo "PROJECT CONSTITUTION (operator invariants to honor):"; printf '%s\n' "$out" | sed 's/^/ /'; proj_any=1; fi
1294
+ fi
1295
+ echo
1296
+ if [ "$proj_any" -eq 0 ]; then
1297
+ echo "(no project-specific history yet - fills as you record debt, retros, decisions, and harness traces.)"
1298
+ else
1299
+ echo "Use the above to steer the next gate/card - don't re-learn what's already known."
1300
+ fi
1301
+ return 0
1302
+ }
1303
+
1304
+ cmd_unlock() {
1305
+ if [ ! -f "$LOCK_FILE" ] && ! [ -d "$LOCK_DIR" ]; then echo "no flow lock to clear ($LOCK_FILE)"; return 0; fi
1306
+ _read_lock
1307
+ rm -f "$LOCK_FILE" 2>/dev/null || true
1308
+ rm -rf "$LOCK_DIR" 2>/dev/null || true # W5: remove atomic claim dir so next acquire can mkdir
1309
+ echo "PASS: cleared flow lock (was [$LOCK_OWNER], cmd '${LOCK_CMD:-?}')."
1310
+ return 0
1311
+ }
1312
+
1313
+ cmd_design() {
1314
+ # mechanical half of the DESIGN.md review (grep-able never-dos). Semantic half = Claude
1315
+ # against references/design-review-checklist.md. Flags are signals; confirm in-code samples.
1316
+ local file="${1:-}"
1317
+ if [ -z "$file" ] || [ ! -f "$file" ]; then echo "usage: /flow design <htmlfile>"; return 1; fi
1318
+ local found=0 hits
1319
+ # grep -P needs a UTF-8 locale for the emoji ranges; Git Bash defaults to C. Force it.
1320
+ if printf 'a' | LC_ALL=C.UTF-8 grep -qP 'a' 2>/dev/null; then
1321
+ hits="$(LC_ALL=C.UTF-8 grep -nP '[\x{1F000}-\x{1FAFF}\x{2600}-\x{27BF}\x{2190}-\x{21FF}\x{2B00}-\x{2BFF}\x{FE0F}]' "$file" 2>/dev/null || true)"
1322
+ if [ -n "$hits" ]; then echo " [x] emoji / smart arrows (DESIGN.md: never):"; printf '%s\n' "$hits" | sed 's/^/ L/'; found=1; fi
1323
+ fi
1324
+ hits="$(grep -nE '\{\{.*\}\}' "$file" 2>/dev/null || true)"
1325
+ if [ -n "$hits" ]; then echo " [x] raw {{ }} template outside a power surface:"; printf '%s\n' "$hits" | sed 's/^/ L/'; found=1; fi
1326
+ hits="$(grep -niE '(^|[^[:alnum:]_])(workflow|trigger|webhook|cron|payload|queue)([^[:alnum:]_]|$)' "$file" 2>/dev/null || true)"
1327
+ if [ -n "$hits" ]; then echo " [!] possible engine words in copy (confirm not in <code>):"; printf '%s\n' "$hits" | sed 's/^/ L/'; found=1; fi
1328
+ hits="$(grep -niE 'linear-gradient' "$file" 2>/dev/null || true)"
1329
+ if [ -n "$hits" ]; then echo " [!] gradient(s) - confirm hero-surface only (never input/table/body):"; printf '%s\n' "$hits" | sed 's/^/ L/'; found=1; fi
1330
+ if [ "$found" -eq 0 ]; then
1331
+ echo "PASS: no mechanical DESIGN violations. Now do the semantic pass (object-first,"
1332
+ echo "affordance ladder, defaults<=6 fields) per references/design-review-checklist.md."
1333
+ return 0
1334
+ fi
1335
+ echo "FLAGGED: confirm each (some [!] may be false positives inside code), then the semantic pass."
1336
+ return 1
1337
+ }
1338
+
1339
+ cmd_debt() {
1340
+ local sub="${1:-list}"; shift 2>/dev/null || true
1341
+ case "$sub" in
1342
+ add)
1343
+ local what="${1:-}" exposure="${2:-}" close="${3:-}"
1344
+ if [ -z "$what" ] || [ -z "$exposure" ] || [ -z "$close" ]; then
1345
+ echo 'usage: /flow debt add "<what skipped>" "<exposure>" "<close-before condition>"'
1346
+ return 1
1347
+ fi
1348
+ # strip newlines so one debt is always exactly one line (keeps `debt list` counts honest)
1349
+ what="$(printf '%s' "$what" | tr -d '\n\r')"
1350
+ exposure="$(printf '%s' "$exposure" | tr -d '\n\r')"
1351
+ close="$(printf '%s' "$close" | tr -d '\n\r')"
1352
+ [ -f "$DEBT_FILE" ] || printf '# DEBT - deliberate gate-skips (a loan, written down)\n\n' > "$DEBT_FILE"
1353
+ printf -- '- [ ] DEBT: %s -- %s -- close before: %s -- opened %s\n' \
1354
+ "$what" "$exposure" "$close" "$(date +%Y-%m-%d)" >> "$DEBT_FILE"
1355
+ echo "PASS: debt recorded in DEBT.md"
1356
+ echo " Security-class (auth/authz/admin/tenancy/payments/data/validation)? The OPERATOR"
1357
+ echo " must accept this exposure in writing - it is never decided for them. In /flow auto this halts."
1358
+ return 0 ;;
1359
+ list|"")
1360
+ if [ ! -f "$DEBT_FILE" ]; then echo "No debts recorded."; return 0; fi
1361
+ local open; open="$(grep -nE '^- \[ \] DEBT:' "$DEBT_FILE" 2>/dev/null || true)"
1362
+ if [ -z "$open" ]; then echo "No OPEN debts (all closed)."; else
1363
+ echo "OPEN debts (close before their named condition):"; printf '%s\n' "$open" | sed 's/^/ L/'
1364
+ fi
1365
+ return 0 ;;
1366
+ *) echo "usage: /flow debt add|list"; return 1 ;;
1367
+ esac
1368
+ }
1369
+
1370
+ cmd_contract() {
1371
+ # F3: client base-URL vs served-path PREFIX drift - the class oasdiff/Pact/Spectral miss
1372
+ # (e.g. VITE_API_BASE='/api' + path '/api/admin' -> '/api/api/admin'; or auth at '/auth/*'
1373
+ # unreachable under a '/api' base). Served paths come from the contract seam (flow/05-contract.md)
1374
+ # or openapi.json; client base from .env*/frontend. Heuristic + advisory (web only).
1375
+ local pt; pt="$(get_project_type)"
1376
+ if [ "$pt" != "web" ]; then echo "contract: project type '$pt' is not web - path check skipped."; return 0; fi
1377
+ local spec="" paths=""
1378
+ if [ -f "$FLOW_DIR/05-contract.md" ]; then
1379
+ spec="flow/05-contract.md"
1380
+ paths="$(grep -oE '`/[A-Za-z0-9_/{}.:-]+`' "$FLOW_DIR/05-contract.md" 2>/dev/null | tr -d '`' | sort -u)"
1381
+ elif [ -f "$ROOT/openapi.json" ]; then
1382
+ spec="openapi.json"
1383
+ paths="$(grep -oE '"/[A-Za-z0-9_/{}.:-]+"' "$ROOT/openapi.json" 2>/dev/null | tr -d '"' | sort -u)"
1384
+ fi
1385
+ if [ -z "$paths" ]; then echo "contract: no served paths found (flow/05-contract.md or openapi.json) - skipped."; return 0; fi
1386
+ local base base_path prefixes nprefix found=0
1387
+ base="$(grep -rhoE '(VITE_API_BASE|VITE_API_URL|API_BASE|REACT_APP_API_BASE)[[:space:]]*[:=][[:space:]]*[^[:space:]]+' \
1388
+ "$ROOT/.env" "$ROOT/.env.example" "$ROOT/frontend" 2>/dev/null | head -1 | sed -E 's/^[^=:]*[:=][[:space:]]*//; s/["'"'"';,]//g')"
1389
+ base_path="$(printf '%s' "$base" | sed -E 's#^[a-z][a-z0-9+.-]*://[^/]+##; s#/$##')"
1390
+ prefixes="$(printf '%s\n' "$paths" | sed -E 's#^(/[^/]+).*#\1#' | sort -u | grep .)"
1391
+ nprefix="$(printf '%s\n' "$prefixes" | grep -c .)"
1392
+ echo "contract path-resolution check ($spec; client base: ${base:-<none found>})"
1393
+ if [ -n "$base_path" ] && printf '%s\n' "$prefixes" | grep -qxF "$base_path"; then
1394
+ echo " [!] double-prefix risk: client base path '$base_path' duplicates a served prefix."
1395
+ echo " base '$base' + a '$base_path/...' path -> '$base_path$base_path/...' (404). Use an origin-only base, or drop '$base_path' from the paths."
1396
+ found=1
1397
+ fi
1398
+ if [ "$nprefix" -gt 1 ] && [ -n "$base_path" ]; then
1399
+ echo " [!] mixed served prefixes under a single client base '$base' (base path '$base_path'):"
1400
+ printf '%s\n' "$prefixes" | sed 's/^/ served prefix: /'
1401
+ echo " one base resolves one prefix; the others (e.g. auth vs api) will 404. Confirm each path composes correctly."
1402
+ found=1
1403
+ fi
1404
+ if [ "$found" -eq 0 ]; then
1405
+ echo " PASS: no obvious base/prefix drift (served prefixes: $(printf '%s ' $prefixes))."
1406
+ return 0
1407
+ fi
1408
+ echo "FLAGGED: confirm against the running app - this catches the base/prefix class spec-diff tools miss."
1409
+ return 1
1410
+ }
1411
+
1412
+ cmd_tokens() {
1413
+ # F4: design-token divergence - DESIGN.md's declared tokens vs the CSS actually used.
1414
+ # Flags DESIGN.md tokens the CSS NEVER uses (the law-divergence signal: implemented UI
1415
+ # stopped following DESIGN.md); reports orphan CSS vars as info (may include framework tokens).
1416
+ # Advisory; never auto-fix - if the divergence is intentional, record a dated DESIGN.md amendment.
1417
+ local design="$ROOT/DESIGN.md"
1418
+ [ -f "$design" ] || design="$LAW_DIR/DESIGN.md"
1419
+ if [ ! -f "$design" ]; then echo "tokens: no DESIGN.md found - skipped."; return 0; fi
1420
+ local declared; declared="$(grep -oE '\-\-[a-zA-Z][a-zA-Z0-9_-]*' "$design" 2>/dev/null | sort -u)"
1421
+ if [ -z "$declared" ]; then echo "tokens: DESIGN.md declares no --tokens - skipped."; return 0; fi
1422
+ local cssroot="$ROOT/frontend"; [ -d "$cssroot" ] || cssroot="$ROOT"
1423
+ local used; used="$(grep -rhoE '\-\-[a-zA-Z][a-zA-Z0-9_-]*' --include='*.css' --include='*.scss' "$cssroot" 2>/dev/null | sort -u)"
1424
+ if [ -z "$used" ]; then echo "tokens: no CSS custom properties found under $cssroot - skipped."; return 0; fi
1425
+ local td td2=""; td="$(mktemp -d)"; _register_td "$td"
1426
+ trap 'rm -rf "$td" 2>/dev/null; [ -n "$td2" ] && rm -rf "$td2" 2>/dev/null' RETURN
1427
+ printf '%s\n' "$declared" > "$td/d"; printf '%s\n' "$used" > "$td/u"
1428
+ local unused orphan onum found=0
1429
+ unused="$(comm -23 "$td/d" "$td/u")"
1430
+ orphan="$(comm -13 "$td/d" "$td/u")"
1431
+ onum="$(printf '%s\n' "$orphan" | grep -c .)"
1432
+ rm -rf "$td"
1433
+ echo "design-token divergence check (DESIGN.md vs CSS under $(printf '%s' "$cssroot" | sed "s#$ROOT/##"))"
1434
+ if [ -n "$unused" ]; then
1435
+ echo " [!] DESIGN.md declares tokens the CSS never uses (the implemented UI diverged from the law):"
1436
+ printf '%s\n' "$unused" | sed 's/^/ /' | head -40
1437
+ found=1
1438
+ fi
1439
+ if [ "$onum" -gt 0 ]; then
1440
+ echo " [i] $onum CSS token(s) are not declared in DESIGN.md (may include framework tokens); sample:"
1441
+ printf '%s\n' "$orphan" | head -8 | sed 's/^/ /'
1442
+ fi
1443
+ # value-mismatch: tokens declared in DESIGN.md (table: | `--name` | `value` |) AND defined in
1444
+ # CSS (--name: value) but with a different value (same name, drifted value).
1445
+ local mism; td2="$(mktemp -d)"; _register_td "$td2"
1446
+ awk -F'`' 'NF>=4 && $2 ~ /^--[a-zA-Z]/ {gsub(/[ \t\\]/,"",$2); v=$4; gsub(/^[ \t]+|[ \t]+$|\\/,"",v); if(v!="") print $2"\t"v}' \
1447
+ "$design" 2>/dev/null | sort -u > "$td2/dv"
1448
+ grep -rhoE '\-\-[a-zA-Z][a-zA-Z0-9_-]*[[:space:]]*:[[:space:]]*[^;{}]+' --include='*.css' --include='*.scss' "$cssroot" 2>/dev/null \
1449
+ | sed -E 's/^(--[a-zA-Z0-9_-]+)[[:space:]]*:[[:space:]]*/\1\t/' | sort -u > "$td2/cv"
1450
+ mism="$(awk -F'\t' 'NR==FNR{if(NF>=2)d[$1]=$2; next}
1451
+ NF>=2 && ($1 in d){a=tolower(d[$1]); b=tolower($2); gsub(/[ \t]/,"",a); gsub(/[ \t]/,"",b);
1452
+ if(a!=b) print " "$1": DESIGN.md=\""d[$1]"\" vs CSS=\""$2"\""}' "$td2/dv" "$td2/cv")"
1453
+ rm -rf "$td2"
1454
+ if [ -n "$mism" ]; then
1455
+ echo " [!] token VALUE mismatch (same name, different value in DESIGN.md vs CSS):"
1456
+ printf '%s\n' "$mism" | head -40
1457
+ found=1
1458
+ fi
1459
+ if [ "$found" -eq 0 ]; then
1460
+ echo " PASS: DESIGN.md tokens are used by the CSS with matching values (where CSS defines them)."
1461
+ return 0
1462
+ fi
1463
+ echo "FLAGGED: if the swap is intentional, record a dated amendment in DESIGN.md (its own rule); else align the CSS to the tokens."
1464
+ return 1
1465
+ }
1466
+
1467
+ assess_scan() {
1468
+ # best-effort stack/CI/context detection from real files (fast: no recursive find).
1469
+ echo "stack:"
1470
+ [ -f "$ROOT/package.json" ] && echo " - node (package.json)"
1471
+ [ -f "$ROOT/frontend/package.json" ] && echo " - frontend (frontend/package.json)"
1472
+ { [ -f "$ROOT/pyproject.toml" ] || [ -f "$ROOT/requirements.txt" ]; } && echo " - python (pyproject/requirements)"
1473
+ [ -f "$ROOT/go.mod" ] && echo " - go (go.mod)"
1474
+ [ -f "$ROOT/Cargo.toml" ] && echo " - rust (Cargo.toml)"
1475
+ [ -d "$ROOT/.github/workflows" ] && echo " - CI: github actions (.github/workflows)"
1476
+ { [ -f "$ROOT/Dockerfile" ] || [ -f "$ROOT/docker-compose.yml" ]; } && echo " - docker"
1477
+ echo "context files present:"
1478
+ local k
1479
+ for k in README.md AGENTS.md CLAUDE.md ARCHITECTURE.md docs specs tests test; do
1480
+ [ -e "$ROOT/$k" ] && echo " - $k"
1481
+ done
1482
+ # ranked surfaces: highest-leverage code first (symbols referenced most widely). Optional helper
1483
+ # (stdlib reference-count ranker); degrades to a note if python/helper absent - the flat scan stands.
1484
+ echo "ranked surfaces (most-referenced first - inspect these before planning):"
1485
+ local rmap="${HARNESS_PY%/*}/repo_map.py" py rout
1486
+ py="$(_python)"
1487
+ if [ -n "$py" ] && [ -f "$rmap" ]; then
1488
+ rout="$("$py" "$rmap" "$ROOT" 10 2>/dev/null)"
1489
+ if [ -n "$rout" ]; then printf '%s\n' "$rout" | sed 's/^/ /'
1490
+ else echo " (no rankable source symbols found)"; fi
1491
+ else
1492
+ echo " (ranking unavailable - python or repo_map.py absent; the flat scan above stands)"
1493
+ fi
1494
+ }
1495
+
1496
+ cmd_assess() {
1497
+ # Brownfield/assessment mode. Scaffold + gate a current-state map of an EXISTING codebase
1498
+ # BEFORE planning. Reuses the stage gate machinery (unchecked boxes / [FILL]). Operator-gated.
1499
+ mkdir -p "$FLOW_DIR"
1500
+ _ensure_cycle # brownfield entry point also gets a cycle id (was previously next-only)
1501
+ local f="$FLOW_DIR/00-inspect.md"
1502
+ if [ ! -f "$f" ]; then
1503
+ if [ ! -f "$TEMPLATE_DIR/00-inspect.md" ]; then echo "FAIL: assess template missing at $TEMPLATE_DIR/00-inspect.md"; return 1; fi
1504
+ cp "$TEMPLATE_DIR/00-inspect.md" "$f"
1505
+ { echo; echo "<!-- auto-scan -->"; assess_scan; } >> "$f"
1506
+ seed_law_files
1507
+ echo "PASS: created flow/00-inspect.md (brownfield assessment) - auto-scan seeded."
1508
+ echo "Fill it from the code (functionality / UI-UX vs product / risks / tests), check the gate,"
1509
+ echo "then re-run '/flow assess' to verify; proceed to planning with '/flow next'."
1510
+ return 0
1511
+ fi
1512
+ if scan_gate "$f" >/dev/null 2>&1; then
1513
+ echo "PASS: brownfield assessment gate clean (flow/00-inspect.md). Proceed to planning ('/flow next')."
1514
+ return 0
1515
+ fi
1516
+ echo "FAIL: brownfield assessment gate not clean:"
1517
+ scan_gate "$f"
1518
+ echo
1519
+ echo "Fill the above (evidence, not vibes), then '/flow assess' again."
1520
+ return 1
1521
+ }
1522
+
1523
+ cmd_coherence() {
1524
+ # F7 (mechanical slice): flag VERSION drift across declared version fields. Semantic doc-vs-code
1525
+ # contradictions (e.g. a headline doc describing endpoints that don't exist) stay a human
1526
+ # gate-challenge in gate-rules.md - this catches the cheap, low-noise, structured case.
1527
+ local td; td="$(mktemp -d)"; _register_td "$td"; : > "$td/v"
1528
+ trap 'rm -rf "$td" 2>/dev/null' RETURN
1529
+ _ver() { [ -n "$2" ] && printf '%s\t%s\n' "$2" "$1" >> "$td/v"; } # $1=label $2=version
1530
+ [ -f "$ROOT/package.json" ] && _ver "package.json" \
1531
+ "$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' "$ROOT/package.json" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
1532
+ [ -f "$ROOT/frontend/package.json" ] && _ver "frontend/package.json" \
1533
+ "$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' "$ROOT/frontend/package.json" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
1534
+ [ -f "$ROOT/pyproject.toml" ] && _ver "pyproject.toml" \
1535
+ "$(grep -oE '^version[[:space:]]*=[[:space:]]*"[^"]+"' "$ROOT/pyproject.toml" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
1536
+ local cfgv; cfgv="$(grep -rhoE 'app_version[[:space:]]*[:=][[:space:]]*["'\''][^"'\'' ]+' "$ROOT/src" 2>/dev/null | head -1 | sed -E 's/.*["'\'']([^"'\'' ]+)$/\1/')"
1537
+ [ -n "$cfgv" ] && _ver "src app_version" "$cfgv"
1538
+ # skill-type version fields: SKILL.md YAML frontmatter, *-manifest.json, plugin.json
1539
+ # (without these, a project-type=skill had ZERO version source and coherence silently skipped).
1540
+ local sk; for sk in "$ROOT"/SKILL.md "$ROOT"/skills/*/SKILL.md; do
1541
+ [ -f "$sk" ] && _ver "${sk#$ROOT/}" \
1542
+ "$(sed -nE 's/^[[:space:]]*version:[[:space:]]*"?([0-9][.0-9A-Za-z-]*).*/\1/p' "$sk" | head -1)"
1543
+ done
1544
+ local mf; for mf in "$ROOT"/*-manifest.json "$ROOT"/.claude-plugin/plugin.json; do
1545
+ [ -f "$mf" ] && _ver "${mf#$ROOT/}" \
1546
+ "$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' "$mf" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
1547
+ done
1548
+ if [ ! -s "$td/v" ]; then rm -rf "$td"; echo "coherence: no declared version fields found - skipped."; return 0; fi
1549
+ local distinct; distinct="$(cut -f1 "$td/v" | sort -u | grep -c .)"
1550
+ echo "coherence check - declared versions:"
1551
+ sort -u "$td/v" | sed 's/^/ /'
1552
+ rm -rf "$td"
1553
+ if [ "$distinct" -gt 1 ]; then
1554
+ echo " [!] version drift: $distinct different versions declared across files - align them."
1555
+ echo " (semantic doc-vs-code contradictions remain a human gate-challenge; see references/gate-rules.md)"
1556
+ return 1
1557
+ fi
1558
+ echo " PASS: declared versions agree."
1559
+ return 0
1560
+ }
1561
+
1562
+ prd_declares_fr() { # true if the PRD uses the FRn traceability convention (drives the consistency nudge)
1563
+ # Deliberately a loose full-file grep (NOT the Features-section scoping cmd_consistency uses): this
1564
+ # only decides whether to print an advisory tip, so a rare over-fire on a prose 'FRn' is harmless;
1565
+ # keep it loose so the tip reliably shows in the common case. Do not "tighten" to match consistency.
1566
+ [ -f "$FLOW_DIR/03-prd.md" ] && grep -qE 'FR[0-9]+' "$FLOW_DIR/03-prd.md" 2>/dev/null
1567
+ }
1568
+
1569
+ cmd_consistency() {
1570
+ # Cross-artifact coverage + contradiction audit - the MECHANICAL complement to the human
1571
+ # traceability challenges in gate-rules.md (03/05). The other probes each cover one axis:
1572
+ # coherence=version drift, contract=URL-prefix drift, tokens=design-token drift. THIS axis is
1573
+ # "do the planning artifacts + cards trace to each other" - the spine gate-rules.md 03/05
1574
+ # demand but only a human checks today. Precise, ID-based only (NO fuzzy text matching);
1575
+ # advisory; project-type agnostic; degrades gracefully when artifacts are absent.
1576
+ #
1577
+ # Traceability anchors (see _templates): PRD 'Features' entries tagged 'FRn:' (functional
1578
+ # requirement id); cards declare 'implements: FR1, FR2' (or 'infra'/'none' for non-feature
1579
+ # cards); 05-contract 'Feature -> interface map' references each 'FRn'.
1580
+ local prd="$FLOW_DIR/03-prd.md" contract="$FLOW_DIR/05-contract.md"
1581
+ if [ ! -f "$prd" ]; then echo "consistency: no flow/03-prd.md yet - planning incomplete (skipped)."; return 0; fi
1582
+ local td; td="$(mktemp -d)"; _register_td "$td"
1583
+ trap 'rm -rf "$td" 2>/dev/null' RETURN
1584
+ : > "$td/find"; local cn=0 crit=0 high=0
1585
+ add() { # $1=severity $2=location $3=summary
1586
+ cn=$((cn+1)); printf '| CON-%d | %s | %s | %s |\n' "$cn" "$1" "$2" "$3" >> "$td/find"
1587
+ case "$1" in CRITICAL) crit=$((crit+1));; HIGH) high=$((high+1));; esac
1588
+ }
1589
+
1590
+ # FR universe declared in the PRD, and FR refs across all cards' 'implements:' lines.
1591
+ # scope FR extraction to the '## Features' section so a legacy/deferred id mentioned in PRD
1592
+ # prose does not inflate the declared set (would spuriously demand a card for it).
1593
+ awk '/^##[[:space:]]+[Ff]eatures/{f=1;next} /^##[[:space:]]/{f=0} f' "$prd" 2>/dev/null \
1594
+ | grep -oE 'FR[0-9]+' | sort -u > "$td/prd_fr"
1595
+ local nfr; nfr="$(grep -c . "$td/prd_fr" 2>/dev/null)"; nfr="${nfr:-0}"
1596
+ : > "$td/card_fr"
1597
+ if [ -d "$CARDS_DIR" ]; then
1598
+ grep -rhiE '^[[:space:]]*implements:' "$CARDS_DIR"/*.md 2>/dev/null \
1599
+ | grep -oE 'FR[0-9]+' | sort -u > "$td/card_fr" || true
1600
+ fi
1601
+
1602
+ echo "cross-artifact consistency audit (flow/02-05 + cards/)"
1603
+ if [ "$nfr" -eq 0 ]; then
1604
+ echo " [i] no FR ids found in flow/03-prd.md - coverage mapping skipped."
1605
+ echo " tag each v1 feature 'FRn:' (PRD), add 'implements: FRn' to cards, 'FRn ->' to the contract map"
1606
+ echo " (see _templates/03-prd.md) to enable mechanical traceability."
1607
+ else
1608
+ # 1) PRD feature with NO card implementing it -> CRITICAL (planned, nobody builds).
1609
+ local fr
1610
+ while IFS= read -r fr; do
1611
+ [ -n "$fr" ] || continue
1612
+ grep -qxF "$fr" "$td/card_fr" || add CRITICAL "03-prd.md:$fr" "PRD feature $fr has no card that 'implements:' it (zero coverage)"
1613
+ done < "$td/prd_fr"
1614
+ # 2) card implements an FR absent from the PRD -> HIGH (inconsistency).
1615
+ while IFS= read -r fr; do
1616
+ [ -n "$fr" ] || continue
1617
+ grep -qxF "$fr" "$td/prd_fr" || add HIGH "cards/*:$fr" "a card 'implements: $fr' but $fr is not declared in flow/03-prd.md"
1618
+ done < "$td/card_fr"
1619
+ # 3) PRD feature not referenced in the contract feature->interface map -> HIGH (the seam gap).
1620
+ if [ -f "$contract" ]; then
1621
+ while IFS= read -r fr; do
1622
+ [ -n "$fr" ] || continue
1623
+ # POSIX-ERE word boundary (BSD/macOS grep has no \b in -E): no alnum neighbour.
1624
+ # Also fixes the FR1-matches-FR10 substring collision.
1625
+ grep -qE "(^|[^A-Za-z0-9])${fr}([^A-Za-z0-9]|$)" "$contract" \
1626
+ || add HIGH "05-contract.md:$fr" "PRD feature $fr is absent from the contract (no interface serves it)"
1627
+ done < "$td/prd_fr"
1628
+ fi
1629
+ fi
1630
+
1631
+ # 4) Success metric must contain a NUMBER (mechanizes the PRD 'numbers only' gate rule).
1632
+ local metric; metric="$(awk 'tolower($0) ~ /^##[[:space:]]+success metric/{f=1;next} /^##[[:space:]]/{f=0} f' "$prd" 2>/dev/null)"
1633
+ if [ -n "$(printf '%s' "$metric" | tr -d '[:space:]')" ] && ! printf '%s' "$metric" | grep -q '[0-9]'; then
1634
+ add HIGH "03-prd.md:Success metric" "success metric has no number (the 'numbers only' rule: vague metrics are untestable)"
1635
+ fi
1636
+
1637
+ # 5) Placeholder sweep across the planning set (defense-in-depth vs a forced/skipped stage).
1638
+ local s
1639
+ for s in 00-idea 01-research 02-scope 03-prd 04-adr 05-contract; do
1640
+ [ -f "$FLOW_DIR/$s.md" ] || continue
1641
+ if grep -qE '\[FILL|TODO|TKTK|\?\?\?' "$FLOW_DIR/$s.md" 2>/dev/null; then
1642
+ add LOW "$s.md" "leftover placeholder ([FILL]/TODO/TKTK/???) - artifact not fully resolved"
1643
+ fi
1644
+ done
1645
+
1646
+ if [ "$cn" -eq 0 ]; then
1647
+ rm -rf "$td"
1648
+ if [ "$nfr" -eq 0 ]; then
1649
+ echo " PASS (nothing to map): no FR ids yet; success metric + placeholder passes clean."
1650
+ else
1651
+ echo " PASS: $nfr FR id(s) declared; every one is claimed by a card and served by the contract; no placeholders."
1652
+ fi
1653
+ echo " (semantic passes - terminology drift, conflicting requirements, hollow coverage - remain a human gate-challenge; see references/gate-rules.md)"
1654
+ return 0
1655
+ fi
1656
+ echo " findings:"
1657
+ echo " | ID | Severity | Location | Summary |"
1658
+ echo " | -- | -------- | -------- | ------- |"
1659
+ sed 's/^/ /' "$td/find"
1660
+ echo " coverage: $nfr FR declared, $(grep -c . "$td/card_fr" 2>/dev/null) referenced by cards. CRITICAL=$crit HIGH=$high."
1661
+ rm -rf "$td"
1662
+ echo " (terminology drift / conflicting requirements / hollow coverage remain a human gate-challenge; see references/gate-rules.md)"
1663
+ if [ "$crit" -gt 0 ] || [ "$high" -gt 0 ]; then
1664
+ echo "FLAGGED: resolve CRITICAL/HIGH before building cards - a planned feature with no card (or no interface) ships as a silent gap."
1665
+ return 1
1666
+ fi
1667
+ return 0
1668
+ }
1669
+
1670
+ cmd_promote() {
1671
+ # cross-project knowledge tier: copy a playbook/lesson into ~/.claude/flow/playbooks so its
1672
+ # lessons travel to every project (surfaced by '/flow recall' everywhere).
1673
+ local src="${1:-}"
1674
+ if [ -z "$src" ] || [ ! -f "$src" ]; then echo "usage: /flow promote <path-to-playbook.md>"; return 1; fi
1675
+ mkdir -p "$GLOBAL_KB_DIR" 2>/dev/null || true
1676
+ if ! cp "$src" "$GLOBAL_KB_DIR/$(basename "$src")" 2>/dev/null; then
1677
+ echo "FAIL: could not copy into $GLOBAL_KB_DIR"; return 1
1678
+ fi
1679
+ echo "PASS: promoted $(basename "$src") -> $GLOBAL_KB_DIR"
1680
+ echo " '/flow recall' now surfaces it as a GLOBAL PLAYBOOK in every project."
1681
+ return 0
1682
+ }
1683
+
1684
+ cmd_doctor() {
1685
+ # Cross-platform environment + quality check (macOS / Linux / Windows Git Bash).
1686
+ echo "flow doctor - environment check"
1687
+ local ok=1
1688
+ echo " os: $(uname -s 2>/dev/null || echo unknown)"
1689
+ local bv; bv="$(bash --version 2>/dev/null | head -1 | sed -n 's/.*version \([0-9][0-9.]*\).*/\1/p')"
1690
+ case "$bv" in
1691
+ 3.*) echo " bash: $bv (works; 3.x is the macOS default - 'brew install bash' for 4+ if you hit issues)" ;;
1692
+ "") echo " bash: unknown (assuming ok)" ;;
1693
+ *) echo " bash: $bv ok" ;;
1694
+ esac
1695
+ local py; py="$(_python)"
1696
+ if [ -n "$py" ] && "$py" --version >/dev/null 2>&1; then
1697
+ echo " python: $("$py" --version 2>&1 | awk '{print $2}') ($py) -> durable layer ENABLED"
1698
+ if "$py" -c 'import sqlite3' >/dev/null 2>&1; then
1699
+ echo " sqlite3: ok (python stdlib)"
1700
+ else
1701
+ echo " sqlite3: MISSING from python - durable layer needs it"; ok=0
1702
+ fi
1703
+ else
1704
+ echo " python: not found -> durable layer DISABLED (gate engine still works fully)"
1705
+ echo " install: macOS 'brew install python' | Ubuntu 'sudo apt install python3' | Windows python.org"
1706
+ fi
1707
+ if printf 'a' | LC_ALL=C.UTF-8 grep -qP 'a' 2>/dev/null; then
1708
+ echo " grep -P: ok (design emoji check available)"
1709
+ else
1710
+ echo " grep -P: unsupported (macOS BSD grep) - 'flow design' emoji check skips gracefully"
1711
+ fi
1712
+ command -v git >/dev/null 2>&1 && echo " git: ok (worktree parallel + auto-merge available)" || echo " git: not found (worktree/auto unavailable; serial builds still work)"
1713
+ command -v cargo >/dev/null 2>&1 && echo " cargo: ok (optional Rust harness power-path)" || true
1714
+ [ -d "$TEMPLATE_DIR" ] && echo " templates: ok" || { echo " templates: MISSING ($TEMPLATE_DIR)"; ok=0; }
1715
+ [ -f "$HARNESS_PY" ] && echo " harness: present ($HARNESS_PY)" || echo " harness: absent (durable layer off)"
1716
+ echo
1717
+ if [ "$ok" -eq 1 ]; then
1718
+ echo "READY. Gate engine works$([ -n "$py" ] && echo '; durable layer on.' || echo '; durable layer off (no python).')"
1719
+ return 0
1720
+ fi
1721
+ echo "DEGRADED - resolve the MISSING item(s) above."
1722
+ return 1
1723
+ }
1724
+
1725
+ cmd_constitution() {
1726
+ # Advisory: validate an operator-authored flow/constitution.md of per-project invariants and
1727
+ # advisory-scan each invariant's OPTIONAL grep-marker. Deliberately NOT called from cmd_next:
1728
+ # enforcing it at every gate would put an LLM token-tax on the hot path. The operator runs it
1729
+ # at the scope/PRD/contract seam. This is the mechanical half; the semantic challenge that
1730
+ # actually judges artifacts against the invariants lives in references/gate-rules.md.
1731
+ local cf="$ROOT/flow/constitution.md"
1732
+ if [ ! -f "$cf" ]; then
1733
+ echo "constitution: no flow/constitution.md - skipped (optional)."
1734
+ echo " to enforce per-project invariants, copy the template and fill the invariant table:"
1735
+ echo " cp \"$TEMPLATE_DIR/constitution.md\" \"$cf\""
1736
+ return 0
1737
+ fi
1738
+ local rc=0
1739
+ if grep -qE '\[FILL' "$cf"; then
1740
+ echo "constitution: FAIL - unfilled placeholder(s) remain:"
1741
+ grep -nE '\[FILL' "$cf" | sed 's/^/ /'
1742
+ rc=1
1743
+ fi
1744
+ # invariant rows = table rows OUTSIDE any fenced code block, excluding the header + |---| separator.
1745
+ # (a fenced ``` example containing pipe-rows must not be mistaken for invariants.)
1746
+ local rows; rows="$(awk '
1747
+ /^[[:space:]]*```/ { infence = !infence; next }
1748
+ infence { next }
1749
+ /^[[:space:]]*\|/ {
1750
+ if ($0 ~ /^[[:space:]]*\|[[:space:]]*[Ii][Dd][[:space:]]*\|/) next
1751
+ if ($0 ~ /^[[:space:]]*\|[[:space:]]*:?-{2,}/) next
1752
+ print
1753
+ }' "$cf")"
1754
+ if [ -z "$rows" ]; then
1755
+ echo "constitution: FAIL - no invariant rows found (the table is empty)."
1756
+ return 1
1757
+ fi
1758
+ local scan_root=""; [ -d "$ROOT/src" ] && scan_root="$ROOT/src"
1759
+ echo "constitution check - operator invariants (advisory; semantic challenge in gate-rules.md):"
1760
+ local n=0 markers=0 unmet=0
1761
+ while IFS= read -r line; do
1762
+ [ -z "${line//[[:space:][:punct:]]/}" ] && continue
1763
+ local id inv applies marker prot
1764
+ # protect markdown-escaped pipes (\|) so an alternation regex in a cell survives the |-split.
1765
+ # the sentinel is reserved: if it appears literally in source, fail LOUD rather than silently mangle.
1766
+ case "$line" in
1767
+ *__FLOW_ESC_PIPE__*) echo " [!] constitution line uses reserved token __FLOW_ESC_PIPE__ - rename it: $line"; rc=1; continue ;;
1768
+ esac
1769
+ prot="$(printf '%s\n' "$line" | sed 's/\\|/__FLOW_ESC_PIPE__/g')"
1770
+ id="$(printf '%s\n' "$prot" | awk -F'|' '{print $2}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/__FLOW_ESC_PIPE__/|/g')"
1771
+ inv="$(printf '%s\n' "$prot" | awk -F'|' '{print $3}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/__FLOW_ESC_PIPE__/|/g')"
1772
+ applies="$(printf '%s\n' "$prot" | awk -F'|' '{print $4}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/__FLOW_ESC_PIPE__/|/g')"
1773
+ marker="$(printf '%s\n' "$prot" | awk -F'|' '{print $5}' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//;s/__FLOW_ESC_PIPE__/|/g')"
1774
+ if [ -z "$id" ]; then echo " [!] invariant row has no ID: $line"; rc=1; continue; fi
1775
+ if [ -z "$inv" ] || [ -z "$applies" ]; then
1776
+ echo " [!] $id - malformed row (need ID | Invariant | Applies-at | grep-marker | Rationale): $line"; rc=1; continue
1777
+ fi
1778
+ n=$((n+1))
1779
+ if [ -n "$marker" ] && [ "$marker" != "-" ]; then
1780
+ markers=$((markers+1))
1781
+ if [ -z "$scan_root" ]; then
1782
+ echo " -- $id (marker '$marker' not scanned: no src/ dir - verify manually)"
1783
+ elif grep -rqsE -- "$marker" "$scan_root" 2>/dev/null; then
1784
+ echo " ok $id (grep-marker present)"
1785
+ else
1786
+ echo " [!] $id - declared grep-marker '$marker' not found under src/ (verify the invariant holds)"
1787
+ unmet=$((unmet+1))
1788
+ fi
1789
+ else
1790
+ echo " -- $id (semantic-only invariant - challenge it at the gate)"
1791
+ fi
1792
+ done <<EOF
1793
+ $rows
1794
+ EOF
1795
+ echo " $n invariant(s); $markers with markers, $unmet unmet (advisory)."
1796
+ if [ "$rc" -ne 0 ]; then
1797
+ echo "constitution: FAIL - fix the structural issue(s) above (placeholder / missing ID)."
1798
+ return 1
1799
+ fi
1800
+ echo "constitution: PASS (structure clean). Unmet markers are advisory - apply the semantic challenge."
1801
+ return 0
1802
+ }
1803
+
1804
+ # ---------- workspace (multi-agent worktree layer) ----------
1805
+ # Lets a HUMAN orchestrate several agents (Claude/Codex/Antigravity, many terminals) in PARALLEL
1806
+ # without the "one agent switches branch -> every terminal flips" trap: each agent gets its own
1807
+ # git worktree (own HEAD/index/files, shared object store). git IS the registry (git worktree list
1808
+ # is the live truth); this side-file only carries the 4 things git cannot know (vendor/card/port/
1809
+ # task). Append-only JSONL, last-record-per-branch wins; a torn final line is skipped. The single
1810
+ # coarse flow/.lock guards only the sub-millisecond registry append on add/remove - never the agent
1811
+ # run. git's own refusal to check out one branch in two worktrees is the strongest collision lock.
1812
+ WS_FILE="$LOG_DIR/workspaces.jsonl"
1813
+
1814
+ _ws_dirname() { # $1 = branch -> sibling dir name (branch '/' sanitized)
1815
+ printf '%s-%s' "$(basename "$ROOT" 2>/dev/null || echo proj)" "$(printf '%s' "$1" | tr '/' '-' | tr -cd 'A-Za-z0-9._-')"
1816
+ }
1817
+ _ws_path() { printf '%s/%s' "$(dirname "$ROOT")" "$(_ws_dirname "$1")"; } # sibling of the main repo
1818
+
1819
+ _ws_default_vendor() { # best-effort from the engine tier / harness session env; '-' if unknown
1820
+ case "${FLOW_ENGINE_TIER:-}" in
1821
+ claude*|ck*) echo claude; return ;; codex*) echo codex; return ;;
1822
+ antigravity*|agy*|gemini*) echo antigravity; return ;;
1823
+ esac
1824
+ [ -n "${CLAUDE_CODE_SESSION_ID:-}" ] && { echo claude; return; }
1825
+ [ -n "${CODEX_SESSION_ID:-}${CODEX_THREAD_ID:-}" ] && { echo codex; return; }
1826
+ [ -n "${AGY_SESSION_ID:-}${ANTIGRAVITY_SESSION_ID:-}" ] && { echo antigravity; return; }
1827
+ echo "-"
1828
+ }
1829
+
1830
+ # One physical line per event. Fixed field order (extractors below rely on it). Returns 1 if the
1831
+ # append fails so add/remove can WARN (git stays truth) instead of pretending it persisted.
1832
+ _ws_record_append() { # path branch vendor sid card task owned port status
1833
+ mkdir -p "$LOG_DIR" 2>/dev/null || true
1834
+ _ignore_run_state
1835
+ printf '{"worktree_path":"%s","branch":"%s","vendor":"%s","agent_session_id":"%s","card_id":"%s","task_label":"%s","owned_files_glob":"%s","port_offset":%s,"created_at":%s,"status":"%s"}\n' \
1836
+ "$(_json_str "$1")" "$(_json_str "$2")" "$(_json_str "$3")" "$(_json_str "$4")" "$(_json_str "$5")" \
1837
+ "$(_json_str "$6")" "$(_json_str "$7")" "$8" "$(_now)" "$9" >> "$WS_FILE" 2>/dev/null || return 1
1838
+ return 0
1839
+ }
1840
+
1841
+ # Latest-per-branch ACTIVE records. Lines lacking both branch+status (a torn final printf on a
1842
+ # disk-full write) are skipped, last valid record per branch wins.
1843
+ _ws_active_records() {
1844
+ [ -f "$WS_FILE" ] || return 0
1845
+ awk '
1846
+ /"branch":"/ && /"status":"/ { b=$0; sub(/.*"branch":"/,"",b); sub(/".*/,"",b); last[b]=$0 }
1847
+ END { for (b in last) { s=last[b]; st=s; sub(/.*"status":"/,"",st); sub(/".*/,"",st); if (st=="active") print last[b] } }
1848
+ ' "$WS_FILE"
1849
+ }
1850
+ _ws_latest_by_branch() { # $1 = branch -> latest record (any status), or empty
1851
+ [ -f "$WS_FILE" ] || return 0
1852
+ awk -v want="$1" '
1853
+ /"branch":"/ && /"status":"/ { b=$0; sub(/.*"branch":"/,"",b); sub(/".*/,"",b); if (b==want) line=$0 }
1854
+ END { if (line!="") print line }
1855
+ ' "$WS_FILE"
1856
+ }
1857
+ _ws_max_active_port() { # max port_offset among active records, or -1 if none (so +1 = 0 for the first)
1858
+ _ws_active_records | awk '
1859
+ { p=$0; sub(/.*"port_offset":/,"",p); sub(/[^0-9-].*/,"",p); if (p!="") { n=p+0; if (!seen || n>m) { m=n; seen=1 } } }
1860
+ END { print (seen ? m : -1) }'
1861
+ }
1862
+ _ws_get() { printf '%s' "$1" | sed -nE "s/.*\"$2\":\"([^\"]*)\".*/\1/p"; } # quoted no-comma field
1863
+ _ws_get_num() { printf '%s' "$1" | sed -nE "s/.*\"$2\":(-?[0-9]+).*/\1/p"; } # numeric field
1864
+ _ws_get_task(){ printf '%s' "$1" | sed -nE 's/.*"task_label":"(.*)","owned_files_glob":.*/\1/p'; } # may hold commas
1865
+ # Normalize allowed-files lines into sorted comparable path tokens (bullets/backticks/commas stripped).
1866
+ # tr (not sed s/../\n/) splits whitespace -> newlines: BSD sed does NOT expand \n in a replacement.
1867
+ _ws_tokens() { sed -E 's/^[[:space:]]*-[[:space:]]*//' | tr -s ' \t' '\n' | tr -d '`,' | grep . | sort -u; }
1868
+
1869
+ # Re-printable cd + per-worktree env block (print, never spawn: POSIX sh under Git Bash cannot
1870
+ # reliably re-parent a terminal across the 3-OS / flow.cmd surface).
1871
+ _ws_print_enter() { # branch wt vendor port
1872
+ local branch="$1" wt="$2" vendor="$3" port="$4" baseport
1873
+ baseport="${FLOW_WORKSPACE_BASEPORT:-3000}"
1874
+ echo " cd \"$wt\""
1875
+ case "$vendor" in codex) echo " export CODEX_HOME=\"$wt/.codex\" # isolate Codex history/config per worktree" ;; esac
1876
+ echo " export PORT=\$(( $baseport + $port )) # = $(( baseport + port )) (per-worktree; avoids dev-server clash)"
1877
+ case "$vendor" in
1878
+ claude) echo " # launch in this dir: claude (or from the main repo: claude --worktree $branch)" ;;
1879
+ codex) echo " # launch in this dir: codex \"<task>\"" ;;
1880
+ antigravity) echo " # open this dir as an Antigravity workspace/Project, assign one agent" ;;
1881
+ *) echo " # launch your agent with this dir as its working directory" ;;
1882
+ esac
1883
+ }
1884
+
1885
+ _ws_add() {
1886
+ local branch="" card="" vendor="" task="" copyenv=0
1887
+ branch="${1:-}"; shift 2>/dev/null || true
1888
+ case "$branch" in ""|--*) echo 'usage: /flow workspace add <branch> [--card C-NNN] [--vendor claude|codex|antigravity] [--task "..."] [--copy-env]'; return 1 ;; esac
1889
+ while [ $# -gt 0 ]; do
1890
+ case "$1" in
1891
+ --card) shift; card="${1:-}" ;;
1892
+ --vendor) shift; vendor="${1:-}" ;;
1893
+ --task) shift; task="${1:-}" ;;
1894
+ --copy-env) copyenv=1 ;;
1895
+ *) echo "workspace add: unknown arg '$1'"; return 1 ;;
1896
+ esac
1897
+ shift 2>/dev/null || true
1898
+ done
1899
+ [ -n "$vendor" ] || vendor="$(_ws_default_vendor)"
1900
+ lock_acquire workspace || return 1
1901
+ local wt addout rc
1902
+ wt="$(_ws_path "$branch")"
1903
+ if git -C "$ROOT" show-ref --verify --quiet "refs/heads/$branch" 2>/dev/null; then
1904
+ addout="$(git -C "$ROOT" worktree add "$wt" "$branch" 2>&1)"; rc=$? # existing branch: check it out
1905
+ else
1906
+ addout="$(git -C "$ROOT" worktree add "$wt" -b "$branch" 2>&1)"; rc=$? # new branch
1907
+ fi
1908
+ if [ "$rc" -ne 0 ]; then
1909
+ printf '%s\n' "$addout" # relay git's reason VERBATIM (e.g. already used by worktree)
1910
+ echo "FAIL: git worktree add failed (above is git's reason)."
1911
+ return 1
1912
+ fi
1913
+ local port owned=""
1914
+ port=$(( $(_ws_max_active_port) + 1 )) # lock-held: serialized adds get distinct ports by construction
1915
+ if [ -n "$card" ]; then
1916
+ local cf; cf="$(resolve_card_file "$card")"
1917
+ [ -n "$cf" ] && [ -f "$cf" ] && owned="$(_card_allowed_files "$cf" | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')"
1918
+ fi
1919
+ if ! _ws_record_append "$wt" "$branch" "$vendor" "$(_session_id)" "$card" "$task" "$owned" "$port" active; then
1920
+ echo "WARNING: worktree created but registry append failed - run '/flow workspace doctor' to reconcile (git still holds the tree)."
1921
+ fi
1922
+ if [ "$copyenv" -eq 1 ]; then
1923
+ local ef base
1924
+ for ef in "$ROOT"/.env "$ROOT"/.env.*; do
1925
+ [ -f "$ef" ] || continue
1926
+ base="$(basename "$ef")"; cp "$ef" "$wt/$base" 2>/dev/null && echo " copied $base -> worktree (copy, never symlink)"
1927
+ done
1928
+ fi
1929
+ echo "PASS: created worktree for '$branch' (vendor ${vendor:-?}, port-offset $port) -> $wt"
1930
+ _ws_print_enter "$branch" "$wt" "$vendor" "$port"
1931
+ echo " (tracked in .flow/workspaces.jsonl; see 'workspace list' / 'workspace doctor')"
1932
+ return 0
1933
+ }
1934
+
1935
+ _ws_list() {
1936
+ lock_warn
1937
+ echo "flow workspace - live worktrees (git is the registry; side-file adds vendor/card/port/task)"
1938
+ local td; td="$(mktemp -d)"; _register_td "$td"
1939
+ git -C "$ROOT" worktree list --porcelain 2>/dev/null | awk '
1940
+ /^worktree /{p=substr($0,10)}
1941
+ /^HEAD /{h=substr($2,1,12)}
1942
+ /^branch /{b=$2; sub(/^refs\/heads\//,"",b)}
1943
+ /^detached/{b="(detached)"}
1944
+ /^$/{ if(p!=""){print p"\t"b"\t"h; p="";b="";h=""} }
1945
+ END{ if(p!=""){print p"\t"b"\t"h} }
1946
+ ' > "$td/wt"
1947
+ printf ' %-22s %-12s %-8s %-9s %-5s %s\n' BRANCH VENDOR CARD HEAD PORT TASK
1948
+ local seen=" " p b h rec vendor card port task
1949
+ while IFS="$(printf '\t')" read -r p b h; do
1950
+ [ -n "$p" ] || continue
1951
+ rec="$(_ws_latest_by_branch "$b")"
1952
+ vendor="-"; card="-"; port="-"; task=""
1953
+ if [ -n "$rec" ] && [ "$(_ws_get "$rec" status)" = "active" ]; then
1954
+ vendor="$(_ws_get "$rec" vendor)"; vendor="${vendor:--}"
1955
+ card="$(_ws_get "$rec" card_id)"; card="${card:--}"
1956
+ port="$(_ws_get_num "$rec" port_offset)"; port="${port:--}"
1957
+ task="$(_ws_get_task "$rec")"
1958
+ fi
1959
+ seen="$seen$b "
1960
+ printf ' %-22s %-12s %-8s %-9s %-5s %s\n' "$b" "$vendor" "$card" "$h" "$port" "$task"
1961
+ done < "$td/wt"
1962
+ local orphans="" line ob
1963
+ while IFS= read -r line; do
1964
+ [ -n "$line" ] || continue
1965
+ ob="$(_ws_get "$line" branch)"
1966
+ case "$seen" in *" $ob "*) : ;; *) orphans="$orphans $ob (vendor $(_ws_get "$line" vendor), card $(_ws_get "$line" card_id))
1967
+ " ;; esac
1968
+ done <<EOF
1969
+ $(_ws_active_records)
1970
+ EOF
1971
+ rm -rf "$td"
1972
+ if [ -n "$orphans" ]; then
1973
+ echo
1974
+ echo " ORPHAN RECORDS (active in registry, no live worktree - run 'workspace doctor'):"
1975
+ printf '%s' "$orphans"
1976
+ fi
1977
+ return 0
1978
+ }
1979
+
1980
+ _ws_enter() {
1981
+ local branch="${1:-}"
1982
+ [ -n "$branch" ] || { echo "usage: /flow workspace enter <branch>"; return 1; }
1983
+ local rec; rec="$(_ws_latest_by_branch "$branch")"
1984
+ if [ -z "$rec" ] || [ "$(_ws_get "$rec" status)" != "active" ]; then
1985
+ echo "workspace: no active record for branch '$branch' (see 'workspace list')."; return 1
1986
+ fi
1987
+ local wt vendor port
1988
+ wt="$(_ws_get "$rec" worktree_path)"; vendor="$(_ws_get "$rec" vendor)"; port="$(_ws_get_num "$rec" port_offset)"
1989
+ echo "workspace '$branch' (vendor ${vendor:--}) - paste to re-enter:"
1990
+ _ws_print_enter "$branch" "$wt" "$vendor" "${port:-0}"
1991
+ return 0
1992
+ }
1993
+
1994
+ _ws_remove() {
1995
+ local branch="" force=0
1996
+ while [ $# -gt 0 ]; do
1997
+ case "$1" in
1998
+ --force) force=1 ;;
1999
+ --*) echo "workspace remove: unknown arg '$1'"; return 1 ;;
2000
+ *) [ -z "$branch" ] && branch="$1" || { echo "workspace remove: extra arg '$1'"; return 1; } ;;
2001
+ esac
2002
+ shift
2003
+ done
2004
+ [ -n "$branch" ] || { echo "usage: /flow workspace remove <branch> [--force]"; return 1; }
2005
+ lock_acquire workspace || return 1
2006
+ local rec wt rmout rc
2007
+ rec="$(_ws_latest_by_branch "$branch")"
2008
+ wt="$(git -C "$ROOT" worktree list --porcelain 2>/dev/null | awk -v want="$branch" \
2009
+ '/^worktree /{p=substr($0,10)} /^branch /{b=$2; sub(/^refs\/heads\//,"",b); if(b==want) print p}')"
2010
+ [ -n "$wt" ] || wt="$(_ws_get "$rec" worktree_path)"
2011
+ if [ -z "$wt" ]; then echo "workspace: no worktree found for branch '$branch'."; return 1; fi
2012
+ if [ "$force" -eq 1 ]; then
2013
+ rmout="$(git -C "$ROOT" worktree remove --force "$wt" 2>&1)"; rc=$?
2014
+ else
2015
+ rmout="$(git -C "$ROOT" worktree remove "$wt" 2>&1)"; rc=$?
2016
+ fi
2017
+ if [ "$rc" -ne 0 ]; then
2018
+ printf '%s\n' "$rmout" # relay git's dirty/in-use refusal VERBATIM
2019
+ echo "FAIL: git worktree remove refused (commit/clean the tree, or re-run with --force)."
2020
+ return 1
2021
+ fi
2022
+ # tombstone ONLY on clean success (git is truth; a failed remove changed nothing)
2023
+ local vendor card port
2024
+ vendor="$(_ws_get "$rec" vendor)"; card="$(_ws_get "$rec" card_id)"; port="$(_ws_get_num "$rec" port_offset)"
2025
+ _ws_record_append "$wt" "$branch" "${vendor:--}" "$(_session_id)" "$card" "" "" "${port:-0}" removed \
2026
+ || echo "WARNING: worktree removed but tombstone append failed (doctor will show it as an orphan tree-free branch)."
2027
+ git -C "$ROOT" worktree prune 2>/dev/null || true
2028
+ echo "PASS: removed worktree for '$branch' ($wt) and pruned stale metadata."
2029
+ return 0
2030
+ }
2031
+
2032
+ _ws_check() {
2033
+ local branch="" card=""
2034
+ branch="${1:-}"; shift 2>/dev/null || true
2035
+ case "$branch" in ""|--*) echo "usage: /flow workspace check <branch> [--card C-NNN]"; return 1 ;; esac
2036
+ while [ $# -gt 0 ]; do case "$1" in --card) shift; card="${1:-}" ;; *) echo "workspace check: unknown arg '$1'"; return 1 ;; esac; shift 2>/dev/null || true; done
2037
+ local found=0
2038
+ if git -C "$ROOT" worktree list --porcelain 2>/dev/null | awk '/^branch /{b=$2; sub(/^refs\/heads\//,"",b); print b}' | grep -qxF "$branch"; then
2039
+ echo " [x] branch '$branch' is already checked out in a worktree (git refuses a second checkout of it)."
2040
+ found=1
2041
+ fi
2042
+ if [ -n "$card" ]; then
2043
+ local cf; cf="$(resolve_card_file "$card")"
2044
+ if [ -n "$cf" ] && [ -f "$cf" ]; then
2045
+ local td; td="$(mktemp -d)"; _register_td "$td"
2046
+ _card_allowed_files "$cf" | _ws_tokens > "$td/mine"
2047
+ local rec ob ocard ocf shared
2048
+ while IFS= read -r rec; do
2049
+ [ -n "$rec" ] || continue
2050
+ ob="$(_ws_get "$rec" branch)"; [ "$ob" = "$branch" ] && continue
2051
+ ocard="$(_ws_get "$rec" card_id)"; [ -n "$ocard" ] || continue
2052
+ ocf="$(resolve_card_file "$ocard")"; [ -f "$ocf" ] || continue
2053
+ _card_allowed_files "$ocf" | _ws_tokens > "$td/other"
2054
+ shared="$(comm -12 "$td/mine" "$td/other" 2>/dev/null | grep . || true)"
2055
+ if [ -n "$shared" ]; then
2056
+ echo " [x] allowed-files overlap with active card $ocard (branch $ob) - NOT parallel-safe:"
2057
+ printf '%s\n' "$shared" | sed 's/^/ /'
2058
+ found=1
2059
+ fi
2060
+ done <<EOF
2061
+ $(_ws_active_records)
2062
+ EOF
2063
+ rm -rf "$td"
2064
+ fi
2065
+ fi
2066
+ if [ "$found" -eq 0 ]; then
2067
+ echo "PASS: '$branch' is parallel-safe (branch free${card:+, no allowed-files overlap with active cards}). Semantic honesty still belongs to gate-rules.md."
2068
+ return 0
2069
+ fi
2070
+ echo "FLAGGED: resolve the above before launching an agent on '$branch'."
2071
+ return 1
2072
+ }
2073
+
2074
+ _ws_doctor() {
2075
+ echo "flow workspace doctor - reconcile git worktrees vs registry (advisory; never deletes)"
2076
+ local td; td="$(mktemp -d)"; _register_td "$td"
2077
+ local pcl; pcl="$(git -C "$ROOT" worktree list --porcelain 2>/dev/null)"
2078
+ printf '%s\n' "$pcl" | awk '/^branch /{b=$2; sub(/^refs\/heads\//,"",b); print b}' | sort -u > "$td/live_all"
2079
+ printf '%s\n' "$pcl" | awk '/^worktree /{n++} /^branch /{b=$2; sub(/^refs\/heads\//,"",b); if(n>1) print b}' | sort -u > "$td/live_linked"
2080
+ _ws_active_records > "$td/active"
2081
+ awk '{b=$0; sub(/.*"branch":"/,"",b); sub(/".*/,"",b); print b}' "$td/active" | sort -u > "$td/recbranch"
2082
+ local drift=0 warn=0 nact
2083
+ nact="$(grep -c . "$td/active" 2>/dev/null)"; nact="${nact:-0}"
2084
+
2085
+ local prun; prun="$(printf '%s\n' "$pcl" | awk '/^worktree /{p=substr($0,10)} /^prunable/{print " [x] prunable tree: "p} /^locked/{print " [i] locked tree: "p}')"
2086
+ if [ -n "$prun" ]; then printf '%s\n' "$prun"; printf '%s\n' "$prun" | grep -q '\[x\]' && drift=1; fi
2087
+ local orec; orec="$(comm -23 "$td/recbranch" "$td/live_all" | grep . || true)"
2088
+ if [ -n "$orec" ]; then echo " [x] ORPHAN RECORDS (active in registry, no live worktree - crashed terminal?):"; printf '%s\n' "$orec" | sed 's/^/ /'; drift=1; fi
2089
+ local otree; otree="$(comm -13 "$td/recbranch" "$td/live_linked" | grep . || true)"
2090
+ if [ -n "$otree" ]; then echo " [x] ORPHAN TREES (live worktree, no active record - created outside flow / by auto):"; printf '%s\n' "$otree" | sed 's/^/ /'; drift=1; fi
2091
+ local pdup; pdup="$(awk '{p=$0; sub(/.*"port_offset":/,"",p); sub(/[^0-9-].*/,"",p); print p}' "$td/active" | sort | uniq -d | grep . || true)"
2092
+ if [ -n "$pdup" ]; then echo " [!] duplicate port_offset across active workspaces (advisory): $(printf '%s ' $pdup)"; warn=1; fi
2093
+ local maxw="${FLOW_WORKSPACE_MAX:-4}"
2094
+ if [ "$nact" -gt "$maxw" ]; then echo " [!] $nact active workspaces exceeds FLOW_WORKSPACE_MAX=$maxw (realistic solo ceiling is 3-4; advisory)."; warn=1; fi
2095
+ rm -rf "$td"
2096
+
2097
+ if [ "$drift" -eq 0 ]; then
2098
+ echo " PASS: no drift ($nact active workspace(s)$([ "$warn" -eq 1 ] && echo '; advisory warnings above'))."
2099
+ return 0
2100
+ fi
2101
+ echo "FLAGGED: reconcile above - 'workspace remove <branch>' (clean tree) or 'git worktree prune'; an orphan record clears on its branch's next add/remove."
2102
+ return 1
2103
+ }
2104
+
2105
+ cmd_workspace() {
2106
+ local sub="${1:-}"; shift 2>/dev/null || true
2107
+ case "$sub" in
2108
+ add|list|enter|remove|check|doctor) : ;;
2109
+ *) echo "usage: /flow workspace add|list|enter|remove|check|doctor"; return 1 ;;
2110
+ esac
2111
+ if ! command -v git >/dev/null 2>&1; then
2112
+ echo "workspace: git not found - worktree isolation unavailable (serial builds still work)."
2113
+ case "$sub" in add|remove) return 1 ;; *) return 0 ;; esac
2114
+ fi
2115
+ case "$sub" in
2116
+ add) _ws_add "$@" ;;
2117
+ list) _ws_list ;;
2118
+ enter) _ws_enter "$@" ;;
2119
+ remove) _ws_remove "$@" ;;
2120
+ check) _ws_check "$@" ;;
2121
+ doctor) _ws_doctor ;;
2122
+ esac
2123
+ }
2124
+
2125
+ # ---------- loop (thin ck-loop wrapper: plumbing only, no iteration logic here) ----------
2126
+ # ck-loop (installed ClaudeKit skill) is the untouched execution engine: verify-safety-screen,
2127
+ # stuck-detection, git commit/revert per iteration all live there. flow only (1) sets up an
2128
+ # isolated worktree + a numeric Verify command ck-loop can run, and (2) records the finished run.
2129
+ # Portable timeout wrapper: GNU `timeout` (Linux/Windows-Git-Bash) or `gtimeout` (macOS+coreutils)
2130
+ # when present; a background+watchdog-kill fallback otherwise (macOS ships neither by default).
2131
+ # Returns 124 on timeout, matching GNU timeout's convention, so callers can branch on it uniformly.
2132
+ _run_with_timeout() { # $1 = seconds; $2 = command string (run via `sh -c`)
2133
+ local secs="$1" cmd="$2"
2134
+ if command -v timeout >/dev/null 2>&1; then timeout "$secs" sh -c "$cmd"; return $?; fi
2135
+ if command -v gtimeout >/dev/null 2>&1; then gtimeout "$secs" sh -c "$cmd"; return $?; fi
2136
+ # Fallback (macOS ships neither by default): a killed process's own exit status (e.g. 143 from
2137
+ # SIGTERM) is NOT 124, so track whether the watchdog actually fired via a flag file and force
2138
+ # the GNU-timeout-compatible 124 in that case - callers only branch on the numeric 124 contract.
2139
+ # The watchdog traps TERM to kill its OWN sleep child before exiting: `wait`/`kill` only work
2140
+ # reliably within the process that actually owns the child, so the sleep must stay the
2141
+ # watchdog's direct child (not a sibling of it) - without the trap, killing the watchdog when
2142
+ # the real command finishes early does NOT kill its sleep, which is reparented and keeps
2143
+ # running to completion, orphaned, on every fast call (previously unbounded: N fast calls left
2144
+ # N orphaned `sleep $secs` processes).
2145
+ # A bare `wait` (no PID arg) is used, not `wait "$SLEEP_PID"`: a real 3-OS CI run found the
2146
+ # PID-argument form of `wait` on a job backgrounded INSIDE this subshell unreliable under
2147
+ # macOS's bash 3.2 (the timeout never fired - the mock ran to full completion, unbounded) -
2148
+ # a bare `wait` waiting on ALL of the subshell's own background jobs (here, only the one
2149
+ # sleep) is the more portable form across bash versions.
2150
+ local flag; flag="$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/.flow_timeout_$$")"
2151
+ rm -f "$flag" 2>/dev/null
2152
+ sh -c "$cmd" & local pid=$!
2153
+ ( trap 'kill "$SLEEP_PID" 2>/dev/null; exit 143' TERM
2154
+ sleep "$secs" 2>/dev/null & SLEEP_PID=$!
2155
+ wait 2>/dev/null
2156
+ kill -TERM "$pid" 2>/dev/null && : > "$flag" 2>/dev/null
2157
+ ) & local watchdog=$!
2158
+ wait "$pid" 2>/dev/null; local rc=$?
2159
+ kill "$watchdog" 2>/dev/null; wait "$watchdog" 2>/dev/null
2160
+ if [ -f "$flag" ]; then rm -f "$flag" 2>/dev/null; return 124; fi
2161
+ return "$rc"
2162
+ }
2163
+
2164
+ cmd_loop_prep() { # $1 = card id; [--metric failing-tests] [--iterations N] [--guard]
2165
+ local card="${1:-}"; shift 2>/dev/null || true
2166
+ case "$card" in ""|--*) echo 'usage: /flow loop-prep <C-NNN> [--metric failing-tests] [--iterations N] [--guard]'; return 1 ;; esac
2167
+ local metric="failing-tests" iterations=10 guard=0
2168
+ while [ $# -gt 0 ]; do
2169
+ case "$1" in
2170
+ --metric) shift; metric="${1:-failing-tests}" ;;
2171
+ --iterations) shift; iterations="${1:-10}" ;;
2172
+ --guard) guard=1 ;;
2173
+ *) echo "loop-prep: unknown arg '$1'"; return 1 ;;
2174
+ esac
2175
+ shift 2>/dev/null || true
2176
+ done
2177
+ case "$metric" in
2178
+ failing-tests) : ;;
2179
+ *) echo "loop-prep: unsupported --metric '$metric' (only 'failing-tests' supported this round)"; return 1 ;;
2180
+ esac
2181
+ case "$iterations" in ''|*[!0-9]*|0) echo "loop-prep: --iterations must be a positive integer (got '$iterations')"; return 1 ;; esac
2182
+ if ! command -v git >/dev/null 2>&1; then
2183
+ echo "loop-prep: git not found - ck-loop requires an isolated git worktree."
2184
+ return 1
2185
+ fi
2186
+ local cf; cf="$(resolve_card_file "$card")"
2187
+ if [ -z "$cf" ] || [ ! -f "$cf" ]; then
2188
+ echo "loop-prep: card '$card' not found (expected $cf) - run '/flow card' first."
2189
+ return 1
2190
+ fi
2191
+ FLOW_LOG_CARD="$card"
2192
+
2193
+ # Scope = the card's OWN declared Allowed files (what it may edit), NOT the Verify target.
2194
+ # Hardcoding Scope to tests/test_*.sh would let ck-loop edit only test files while measuring
2195
+ # a failing-test count - the only way to "improve" that metric within such a Scope is to
2196
+ # gut/weaken the assertions that caught the bug, not fix the source. Fall back to
2197
+ # tests/test_*.sh only when the card declares no Allowed files at all (no better signal
2198
+ # available); this fallback inherits the same reward-hacking risk, so it's a last resort.
2199
+ local scope; scope="$(_card_allowed_files "$cf" | _ws_tokens | tr '\n' ' ' | sed -E 's/[[:space:]]+$//')"
2200
+ [ -n "$scope" ] || scope="tests/test_*.sh"
2201
+
2202
+ local branch="loop/$card" wt
2203
+ wt="$(_ws_path "$branch")"
2204
+ if git -C "$ROOT" show-ref --verify --quiet "refs/heads/$branch" 2>/dev/null && ! [ -d "$wt" ]; then
2205
+ echo "WARNING: branch '$branch' already exists but has no live worktree - loop-prep will check out and reuse WHATEVER is already committed there. If you didn't create this branch for a prior loop run, stop and investigate before continuing."
2206
+ fi
2207
+ # Check the worktree directly (git-dir resolves + branch identity) rather than grepping
2208
+ # `git worktree list` output: on Windows/Git-Bash, `list` prints git's own normalized form
2209
+ # (C:/Users/...) which does not string-match the /tmp/... form `_ws_path` computes for the
2210
+ # same directory. The branch-identity check additionally guards against silently "reusing" a
2211
+ # stale/unrelated git dir that happens to occupy this deterministic sibling path.
2212
+ local reuse_head=""
2213
+ if [ -d "$wt" ]; then reuse_head="$(git -C "$wt" symbolic-ref --short -q HEAD 2>/dev/null)"; fi
2214
+ if [ -n "$reuse_head" ] && [ "$reuse_head" = "$branch" ]; then
2215
+ echo "loop-prep: reusing existing worktree for '$branch' -> $wt"
2216
+ elif [ -d "$wt" ]; then
2217
+ echo "ABORT: $wt exists but is not this card's worktree (HEAD: '${reuse_head:-detached/unknown}', expected '$branch') - remove it or run 'workspace doctor', then retry."
2218
+ return 1
2219
+ else
2220
+ _ws_add "$branch" --card "$card" || { echo "ABORT: worktree setup failed (see above) - fix the git error and retry."; return 1; }
2221
+ fi
2222
+
2223
+ # Verify: sum each suite's own printed "RESULT: N passed, M failed" line (every tests/test_*.sh
2224
+ # already ends with this identical line - no per-script changes needed). Direction=lower.
2225
+ local verify_cmd
2226
+ verify_cmd='total=0; for t in tests/test_*.sh; do n=$(bash "$t" 2>&1 | sed -nE "s/^RESULT: [0-9]+ passed, ([0-9]+) failed\$/\1/p"); total=$((total + ${n:-0})); done; echo "$total"'
2227
+ local guard_cmd=""
2228
+ [ "$guard" -eq 1 ] && guard_cmd='bash tests/run_all.sh >/dev/null 2>&1'
2229
+
2230
+ # Phase-0 self-check inside the worktree, mirroring ck-loop's own abort conditions so a fresh
2231
+ # worktree never surprises ck-loop with a precondition it would have refused anyway.
2232
+ if ! git -C "$wt" rev-parse --git-dir >/dev/null 2>&1; then
2233
+ echo "ABORT: not a git worktree at $wt - re-run 'workspace doctor'."; return 1
2234
+ fi
2235
+ local dirty; dirty="$(git -C "$wt" status --porcelain 2>/dev/null)"
2236
+ if [ -n "$dirty" ]; then
2237
+ echo "ABORT: worktree has uncommitted changes - ck-loop requires a clean tree."
2238
+ echo " dirty paths:"; printf '%s\n' "$dirty" | sed 's/^/ /'
2239
+ echo " fix: commit/stash these (common cause: --copy-env or .flow/ inside the worktree; gitignore them or omit --copy-env)."
2240
+ return 1
2241
+ fi
2242
+ if ! git -C "$wt" symbolic-ref -q HEAD >/dev/null 2>&1; then
2243
+ echo "ABORT: worktree HEAD is detached - ck-loop requires a named branch. Re-run 'workspace add' with a branch name."
2244
+ return 1
2245
+ fi
2246
+ if [ -f "$wt/loop-results.tsv.lock" ]; then
2247
+ echo "ABORT: stale loop-results.tsv.lock in $wt - a previous ck-loop run may not have exited cleanly. Remove it after confirming no run is active."
2248
+ return 1
2249
+ fi
2250
+ if ! ls "$wt"/tests/test_*.sh >/dev/null 2>&1; then
2251
+ echo "ABORT: no tests/test_*.sh found under $wt - the Verify metric would match zero files."
2252
+ return 1
2253
+ fi
2254
+ if ! ( cd "$wt" && eval "set -- $scope" && ls "$@" ) >/dev/null 2>&1; then
2255
+ echo "ABORT: Scope glob '$scope' matches zero files under $wt - ck-loop would have nothing it's allowed to edit."
2256
+ return 1
2257
+ fi
2258
+ local vtimeout="${FLOW_LOOP_VERIFY_TIMEOUT:-30}"
2259
+ local t0 t1 dur out rc
2260
+ t0="$(_now)"
2261
+ out="$(cd "$wt" && _run_with_timeout "$vtimeout" "$verify_cmd" 2>&1)"; rc=$?
2262
+ t1="$(_now)"; dur=$((t1 - t0))
2263
+ if [ "$rc" -eq 124 ]; then
2264
+ echo "ABORT: Verify dry-run timed out at ${vtimeout}s - a tests/test_*.sh suite may be hanging. Fix or exclude it, then retry."
2265
+ return 1
2266
+ fi
2267
+ case "$out" in
2268
+ ''|*[!0-9]*) echo "ABORT: Verify dry-run did not print a single integer (got: '$out'). Fix the aggregator or the failing suite's RESULT line."; return 1 ;;
2269
+ esac
2270
+ if [ "$rc" -ne 0 ]; then
2271
+ echo "ABORT: Verify dry-run exited $rc (expected 0). Output: $out"; return 1
2272
+ fi
2273
+ if [ "$dur" -gt 30 ]; then
2274
+ echo "WARNING: Verify dry-run took ${dur}s (ck-loop caps Verify at <30s). Consider narrowing --metric/Scope to the failing suite(s) only."
2275
+ fi
2276
+
2277
+ echo "PASS: loop-prep ready for '$card' -> $wt (current failing-assertion count: $out)"
2278
+ echo " cd \"$wt\""
2279
+ echo
2280
+ echo "Goal: Drive flow-skill's failing test-assertion count to 0"
2281
+ echo "Scope: $scope"
2282
+ echo "Verify: $verify_cmd"
2283
+ [ -n "$guard_cmd" ] && echo "Guard: $guard_cmd"
2284
+ echo "Iterations: $iterations"
2285
+ echo "Direction: lower"
2286
+ echo "Min-Delta: 0"
2287
+ echo
2288
+ echo "(invoke the ck-loop skill with the block above; ck-loop reads its config from THIS message)"
2289
+ return 0
2290
+ }
2291
+
2292
+ # Deferred: a first-class `loop_run` table (migration 013) would give typed columns for
2293
+ # cross-loop trend queries instead of string-parsing the usage-log args field. Not built now -
2294
+ # no multi-metric/trend need yet (YAGNI); revisit only with real usage evidence, and remember
2295
+ # schema changes must be tested against a seeded pre-013 DB before shipping (v0.17.0 lesson).
2296
+ cmd_loop_log() { # $1 = card id; --iterations N --start M --end K --outcome converged|circuit-broke|no-improve
2297
+ local card="${1:-}"; shift 2>/dev/null || true
2298
+ case "$card" in ""|--*) echo 'usage: /flow loop-log <C-NNN> --iterations N --start M --end K --outcome converged|circuit-broke|no-improve'; return 1 ;; esac
2299
+ # Validate card via resolve_card_file BEFORE it reaches FLOW_LOG_CARD, matching every other
2300
+ # call site in this file (cmd_card*, cmd_loop_prep). Free-text here would otherwise bypass
2301
+ # _mask_secrets (which only scrubs the args field, not card) - a card id is not a secrets
2302
+ # channel, so reject anything that isn't a real card reference instead of logging it verbatim.
2303
+ local cf; cf="$(resolve_card_file "$card")"
2304
+ if [ -z "$cf" ] || [ ! -f "$cf" ]; then
2305
+ echo "loop-log: card '$card' not found (expected $cf)"; return 1
2306
+ fi
2307
+ local iterations="" start="" end="" outcome=""
2308
+ while [ $# -gt 0 ]; do
2309
+ case "$1" in
2310
+ --iterations) shift; iterations="${1:-}" ;;
2311
+ --start) shift; start="${1:-}" ;;
2312
+ --end) shift; end="${1:-}" ;;
2313
+ --outcome) shift; outcome="${1:-}" ;;
2314
+ *) echo "loop-log: unknown arg '$1'"; return 1 ;;
2315
+ esac
2316
+ shift 2>/dev/null || true
2317
+ done
2318
+ FLOW_LOG_CARD="$card"
2319
+ case "$iterations" in ''|*[!0-9]*) echo "usage: --iterations must be a non-negative integer"; return 1 ;; esac
2320
+ case "$start" in ''|*[!0-9]*) echo "usage: --start must be a non-negative integer"; return 1 ;; esac
2321
+ case "$end" in ''|*[!0-9]*) echo "usage: --end must be a non-negative integer"; return 1 ;; esac
2322
+ echo "LOOP $card: $start->$end in $iterations iters ($outcome)"
2323
+ case "$outcome" in
2324
+ converged) return 0 ;;
2325
+ circuit-broke) return 1 ;;
2326
+ no-improve) return 2 ;;
2327
+ *) echo "usage: --outcome must be converged|circuit-broke|no-improve (got '$outcome')"; return 1 ;;
2328
+ esac
2329
+ }
2330
+
2331
+ # ---------- eval (LLM semantic-gate behavioral eval; opt-in, billable, NEVER in CI) ----------
2332
+ # Proves whether the semantic layer (gate-rules.md, executed by an LLM after every mechanical
2333
+ # PASS) actually catches a hollow-but-mechanically-clean artifact. See phase-02-eval-runner.md
2334
+ # for the full design + the Step-0 contract spike this implementation is built on.
2335
+
2336
+ # Per-run nonce: unpredictable to whoever authored a fixture (Phase 1, long before any eval
2337
+ # run), derived from session id + epoch + PID - no $RANDOM. One nonce per invocation, reused
2338
+ # across every fixture x N call in that batch (fixture content can never contain a matching
2339
+ # nonce, since it was written long before this run existed - this is the injection defense,
2340
+ # independent of the Phase 1/4 deny-list).
2341
+ _eval_nonce() {
2342
+ printf '%s-%s-%s' "$(_session_id)" "$(_now)" "$$" | tr -c 'A-Za-z0-9' '-' | sed -E 's/-+/-/g; s/^-//; s/-$//'
2343
+ }
2344
+
2345
+ # stage arg -> gate-rules.md heading regex (explicit map; the card gate is '## Card gate', NOT
2346
+ # a '## Stage NN' pattern - red-team finding #10).
2347
+ _eval_heading_pattern() {
2348
+ case "$1" in
2349
+ 01) echo '^## Stage 01' ;;
2350
+ 02) echo '^## Stage 02' ;;
2351
+ card) echo '^## Card gate' ;;
2352
+ *) return 1 ;;
2353
+ esac
2354
+ }
2355
+
2356
+ # Extract one stage's ritual text: from its heading line up to (not including) the next '## '
2357
+ # heading, or EOF. Prints nothing on a bad stage/missing file - caller MUST assert non-empty
2358
+ # before any billable call (a silent empty extraction would judge against no rule at all).
2359
+ _eval_extract_section() { # $1 = stage (01|02|card)
2360
+ local pat; pat="$(_eval_heading_pattern "$1")" || return 1
2361
+ [ -f "$GATE_RULES_FILE" ] || return 1
2362
+ awk -v pat="$pat" '
2363
+ $0 ~ pat { f=1; print; next }
2364
+ f && /^## / { f=0 }
2365
+ f { print }
2366
+ ' "$GATE_RULES_FILE"
2367
+ }
2368
+
2369
+ # Usability probe: zero cost if `claude` is absent; exactly one minimal billable call if present.
2370
+ # --tools "" is MANDATORY (Step-0 spike finding): claude -p runs a full agentic loop with live
2371
+ # Bash/PowerShell/Edit/Write tool access by default; neither --allowedTools "" nor
2372
+ # --disallowedTools reliably zeroed this out on the measured CLI version - only --tools ""
2373
+ # (disable the entire built-in tool set) did. The judge only ever needs the inlined prompt text.
2374
+ _eval_probe() { # echoes absent|ok|fail; never fails the caller
2375
+ command -v claude >/dev/null 2>&1 || { echo absent; return 0; }
2376
+ local out
2377
+ out="$(_run_with_timeout 30 "printf '%s' 'Reply with exactly: FLOWPONG' | claude -p --tools '' --disable-slash-commands" 2>/dev/null)"
2378
+ case "$out" in *FLOWPONG*) echo ok ;; *) echo fail ;; esac
2379
+ }
2380
+
2381
+ # Single engine seam (v2 can add codex/agy here without touching the fixture loop below).
2382
+ # Prompt is redirected from a FILE inside the cmd string itself (not via inherited stdin across
2383
+ # the _run_with_timeout background-job boundary) - the path is single-quoted so a
2384
+ # space-containing TMPDIR (Windows) does not split the command. A direct `< file` redirect (not
2385
+ # `cat file | claude`) is deliberate: a real 3-OS CI run found that when the timed command is a
2386
+ # PIPELINE, `sh -c "$cmd"`'s own PID (what the watchdog kills) is a shell juggling TWO child
2387
+ # processes (cat + claude) - on macOS's bash 3.2, killing that shell did not reliably reach the
2388
+ # still-running claude process, so a stuck/slow call ran unbounded past --timeout (real cost
2389
+ # risk: eval is billable). A single command with an input redirect has no such ambiguity - `sh
2390
+ # -c "single_command"` can exec-replace itself with that command on every shell tested, so the
2391
+ # watchdog's kill lands on the actual worker process directly, not a wrapper.
2392
+ _eval_engine_run() { # $1=promptfile $2=timeout-seconds -> stdout=raw json; return 124=timeout
2393
+ local promptfile="$1" timeout="$2"
2394
+ _run_with_timeout "$timeout" "claude -p --tools '' --disable-slash-commands --output-format json < '$promptfile'"
2395
+ }
2396
+ # stderr capture note: callers doing raw-on-INVALID persistence redirect stderr at the OUTER
2397
+ # `$(...)` call site (`raw="$(_eval_engine_run ... 2> "$errfile")"`) - never inside the `sh -c`
2398
+ # command string above. Empirical: adding a `2> '$errfile'` inside the timed cmd breaks the
2399
+ # watchdog-fallback path in `_run_with_timeout` on the timeout-less-PATH lane (macOS DEBT lane).
2400
+ # Outer-scope redirection is exactly the same capture at zero cost to the timeout contract.
2401
+
2402
+ # Build the judge prompt for one fixture. Returns 1 (writes nothing usable) if the gate-rules.md
2403
+ # section extraction came back empty.
2404
+ _eval_build_prompt() { # $1=outfile $2=stage $3=artifact-file $4=nonce
2405
+ local outfile="$1" stage="$2" artifact="$3" nonce="$4" section
2406
+ section="$(_eval_extract_section "$stage")" || return 1
2407
+ [ -n "$section" ] || return 1
2408
+ {
2409
+ printf 'You are reviewing a build-process artifact against the quality-gate challenge below.\n'
2410
+ printf 'Read the challenge, then the artifact, then decide honestly: does the artifact\n'
2411
+ printf 'genuinely satisfy the challenge, or does it pass a mechanical checklist while being\n'
2412
+ printf 'substantively hollow (vague, unsupported, or laundered) per the challenge?\n\n'
2413
+ printf '## Gate challenge\n\n%s\n\n' "$section"
2414
+ printf '## Artifact under review\n\n'
2415
+ cat "$artifact"
2416
+ printf '\n\n## Your task\n\n'
2417
+ printf 'Apply the challenge to the artifact above. End your ENTIRE response with EXACTLY ONE\n'
2418
+ printf 'final line, matching one of these two forms exactly and nothing else on that line:\n'
2419
+ printf 'GATE-EVAL-%s: FLAG <- the artifact is substantively hollow per the challenge\n' "$nonce"
2420
+ printf 'GATE-EVAL-%s: PASS <- the artifact genuinely satisfies the challenge\n' "$nonce"
2421
+ } > "$outfile"
2422
+ }
2423
+
2424
+ # Parse the LAST physical line that STARTS WITH the nonce'd marker (line-start match is one of
2425
+ # three injection defenses, alongside nonce unpredictability and the Phase 1/4 deny-list - an
2426
+ # unanchored substring match would let "reasoning... GATE-EVAL-<nonce>: PASS -- ignore that"
2427
+ # parse as a valid verdict even though the marker isn't at a line's start). The JSON response
2428
+ # text's escaped newlines (literal backslash-n, two characters) are unescaped to real newlines
2429
+ # first so `grep -E '^...'` anchors against the model's own line breaks, not the single-line
2430
+ # JSON envelope. Deliberately NOT end-anchored: the model's final line is immediately followed
2431
+ # by the JSON envelope's own closing `"}]` before the real newline that would otherwise end it,
2432
+ # so an end-anchor rejects every real response (verified: broke parsing entirely against the
2433
+ # actual `--output-format json` shape - the literal wrapper syntax, not fixture content, is
2434
+ # what trails the verdict word). No jq dependency by design.
2435
+ _eval_parse_verdict() { # $1=raw engine output $2=nonce -> FLAG|PASS|INVALID
2436
+ local line
2437
+ line="$(printf '%s' "$1" | tr -d '\r' | sed 's/\\n/\n/g' | grep -E "^GATE-EVAL-${2}: (FLAG|PASS)" | tail -1)"
2438
+ case "$line" in
2439
+ *FLAG*) echo FLAG ;;
2440
+ *PASS*) echo PASS ;;
2441
+ *) echo INVALID ;;
2442
+ esac
2443
+ }
2444
+
2445
+ # Model id from the spiked source (assistant-message JSON "model" field) - NOT `claude --version`
2446
+ # (that is the CLI version, a different axis; red-team finding #4). "<synthetic>" is the
2447
+ # observed placeholder on an auth-failure entry and must never be reported as a real model.
2448
+ _eval_parse_model() { # $1=raw json -> model id or "unknown"
2449
+ local m
2450
+ m="$(printf '%s' "$1" | tr -d '\r' | grep -oE '"model":"[^"]+"' | grep -v '"model":"<synthetic>"' | head -1 | sed -E 's/"model":"([^"]+)"/\1/')"
2451
+ [ -n "$m" ] && printf '%s' "$m" || printf 'unknown'
2452
+ }
2453
+
2454
+ # Best-effort rate-limit detection from the assistant-message envelope. ADVISORY only, never
2455
+ # authoritative: the empirically observed shape on cli 2.1.201 --output-format json is
2456
+ # "rate_limit_event"{... "rate_limit_info":{"status":"allowed", ... "overageStatus":"rejected", ...}}
2457
+ # i.e. a HEALTHY "allowed" event already contains the substring "rejected" in a different key
2458
+ # (overageStatus). A naive `grep '"status":"' | grep -v allowed` therefore false-POSITIVES on
2459
+ # every healthy call, and free-prose fixture/model text can also mint the pattern. The parser
2460
+ # below therefore anchors specifically to `rate_limit_info`'s OWN `status` value, which is the
2461
+ # one field the throttled state actually reflects. The genuinely-throttled shape has never been
2462
+ # captured in this repo (red-team finding H5) - callers must document `rate_limited` as
2463
+ # best-effort/advisory (absence != not-rate-limited) and MUST NOT feed it into any authoritative
2464
+ # drift signal until a real throttled sample is added to the corpus.
2465
+ _eval_parse_rate_limited() { # $1=raw json -> "true" | "false"
2466
+ local status
2467
+ status="$(printf '%s' "$1" | tr -d '\r' \
2468
+ | grep -oE '"rate_limit_info":\{"status":"[^"]+"' \
2469
+ | head -1 \
2470
+ | sed -E 's/.*"status":"([^"]+)"$/\1/')"
2471
+ case "$status" in
2472
+ ''|allowed) echo false ;;
2473
+ *) echo true ;;
2474
+ esac
2475
+ }
2476
+
2477
+ # Extract the epoch component from a run_id (nonce = <session>-<epoch>-<pid>, sanitized by
2478
+ # _eval_nonce so every field is [A-Za-z0-9] separated by single dashes; epoch is always the
2479
+ # second-to-last dash-separated token). Used by the raw-capture prune to sort deterministically
2480
+ # by embedded epoch instead of filesystem mtime - mtime is unreliable on FAT/network mounts and
2481
+ # can also be spoofed by a `touch`; the epoch baked in at batch start is not.
2482
+ _eval_nonce_epoch() { # $1=run_id -> epoch seconds (or empty)
2483
+ printf '%s' "$1" | awk -F- 'NF>=2 { print $(NF-1) }'
2484
+ }
2485
+
2486
+ # CRLF-normalized gate-rules.md hash: a raw-byte hash would differ per checkout line-endings
2487
+ # (git autocrlf) and produce a false "prose changed" drift alarm across identical content.
2488
+ _eval_gate_rules_sha() {
2489
+ [ -f "$GATE_RULES_FILE" ] || { printf 'unknown'; return; }
2490
+ tr -d '\r' < "$GATE_RULES_FILE" | cksum | awk '{print $1}'
2491
+ }
2492
+
2493
+ # `claude --version` is the CLI version (e.g. "2.1.201 (Claude Code)"), NOT a model id - kept
2494
+ # as its own axis distinct from `_eval_parse_model` (red-team finding #4: 3 reviewers
2495
+ # independently confirmed conflating the two is wrong). `< /dev/null`: this and every other
2496
+ # batch-level helper below is called from INSIDE the manifest `while read ... done < manifest`
2497
+ # loop's body - any subprocess in there that reads its OWN stdin without an explicit source
2498
+ # silently inherits the loop's file descriptor and consumes the REST OF THE MANIFEST (verified:
2499
+ # this exact gotcha silently truncated a live batch to 1 of 6 fixtures before this fix).
2500
+ _eval_cli_version() {
2501
+ local v
2502
+ v="$(claude --version < /dev/null 2>/dev/null | head -1 | tr -d '\r')"
2503
+ [ -n "$v" ] && printf '%s' "$v" || printf 'unknown'
2504
+ }
2505
+
2506
+ # Batch header/trailer: the completeness signal `--report`/drift filter on is "start present +
2507
+ # done present + n_written >= n_expected". Two paths lead to a MISSING trailer (correctly
2508
+ # incomplete): (a) an interrupted batch (INT/TERM trap exits before this line, per the eval
2509
+ # verb's scoped trap); (b) v0.21 circuit breaker - a first-fixture UNRELIABLE trip sets the
2510
+ # aborted flag which skips the trailer emit below. Mixed record shapes in one file (batch
2511
+ # markers vs fixture rows) are always safe to append-only-scan since both are single JSON
2512
+ # objects on their own line.
2513
+ _eval_emit_batch_marker() { # $1=run_id $2=start|done $3=n_expected-or-n_written
2514
+ mkdir -p "$LOG_DIR" 2>/dev/null || true
2515
+ printf '{"ts":"%s","epoch_s":%s,"run_id":"%s","batch":"%s","n":%s}\n' \
2516
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" "$(_now)" "$(_json_str "$1")" "$(_json_str "$2")" "$3" \
2517
+ >> "$EVAL_RESULTS_FILE" 2>/dev/null || true
2518
+ }
2519
+
2520
+ # Append one JSONL result line per fixture. NOT wrapped in _log_disabled - this is the eval's
2521
+ # own results sink, distinct from the usage-log flight-recorder, and is the entire point of
2522
+ # running eval at all. Every field kept flat/short by design (PIPE_BUF line-size invariant,
2523
+ # suite-asserted in Phase 4) - no free-text fields beyond the already-bounded id/stage/model.
2524
+ # cli_version/flow_version/gate_rules_sha are passed in (computed ONCE per batch by the caller,
2525
+ # not per fixture row) - they never change mid-batch, and recomputing per-row was both wasteful
2526
+ # (an extra subprocess per fixture) and the original vector for the stdin-consumption bug above.
2527
+ _eval_emit_result() { # $1..$14 as before + $15=retries $16=rate_limited (true|false)
2528
+ local run_id="$1" fid="$2" stage="$3" expected="$4" verdict="$5" match="$6" flag="$7" pass="$8" invalid="$9" n="${10}" model="${11}" cliv="${12}" flowv="${13}" grsha="${14}"
2529
+ local retries="${15:-0}" ratel="${16:-false}"
2530
+ case "$retries" in ''|*[!0-9]*) retries=0 ;; esac
2531
+ case "$ratel" in true|false) : ;; *) ratel=false ;; esac
2532
+ mkdir -p "$LOG_DIR" 2>/dev/null || true
2533
+ printf '{"ts":"%s","epoch_s":%s,"run_id":"%s","fixture":"%s","stage":"%s","expected":"%s","verdict":"%s","match":"%s","votes":{"flag":%s,"pass":%s,"invalid":%s},"n":%s,"cli_version":"%s","model":"%s","flow_version":"%s","gate_rules_sha":"%s","retries":%s,"rate_limited":%s}\n' \
2534
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo unknown)" "$(_now)" "$(_json_str "$run_id")" "$(_json_str "$fid")" \
2535
+ "$(_json_str "$stage")" "$(_json_str "$expected")" "$(_json_str "$verdict")" "$(_json_str "$match")" \
2536
+ "$flag" "$pass" "$invalid" "$n" "$(_json_str "$cliv")" "$(_json_str "$model")" \
2537
+ "$(_json_str "$flowv")" "$(_json_str "$grsha")" "$retries" "$ratel" >> "$EVAL_RESULTS_FILE" 2>/dev/null || true
2538
+ }
2539
+
2540
+ # Strip the noisy `system`/`init` envelope from a raw --output-format json line before persisting
2541
+ # it: the init record carries cwd (which on this dev OS embeds the Windows username), a resumable
2542
+ # session_id, plugin/memory paths, apiKeySource - none of it useful for a gate-eval postmortem, all
2543
+ # of it undesirable when the raw dir is bulk-added to git on a client repo. Keep the assistant
2544
+ # `result`/`content` records and any rate_limit_event record; drop the rest. Best-effort: if the
2545
+ # strip fails for any reason the caller falls back to persisting the original blob (the diagnostic
2546
+ # value of SOMETHING recorded is greater than the privacy value of nothing), and the raw dir is
2547
+ # git-ignored via _ignore_run_state regardless.
2548
+ _eval_strip_envelope() { # $1=raw stdout blob -> stripped blob on stdout
2549
+ # This is intentionally a hand-crafted sed-only strip (no jq): the input is a single-line array
2550
+ # `[{obj},{obj},...]`. Match record objects containing "type":"assistant" or containing
2551
+ # "rate_limit_event" and drop everything else. On failure just echo back the input.
2552
+ local raw="$1" stripped
2553
+ stripped="$(printf '%s' "$raw" | tr -d '\r' | awk '
2554
+ { gsub(/\},\{/, "}\n{"); print }
2555
+ ' | grep -E '"type":"assistant"|"rate_limit_event"|"type":"result"' | tr -d "\n")"
2556
+ if [ -n "$stripped" ]; then printf '%s\n' "$stripped"; else printf '%s' "$raw"; fi
2557
+ }
2558
+
2559
+ # Prune .flow/eval-raw/ to the N most-recent run dirs by run_id-embedded epoch (deterministic,
2560
+ # mount-independent, unforgeable). NEVER prune a dir whose embedded epoch is within FLOW_LOCK_TTL
2561
+ # seconds of now: a concurrent lock-free eval (cmd_eval takes no lock by design) or an
2562
+ # in-postmortem storm dir must not be removed under the feet of the operator diagnosing it.
2563
+ # Failures warn to stderr rather than silently swallow - this is diagnostic-critical, not a
2564
+ # telemetry sink.
2565
+ _eval_prune_raw_dirs() { # $1=keep-count (default 3)
2566
+ local keep="${1:-3}" raw_root="$LOG_DIR/eval-raw"
2567
+ [ -d "$raw_root" ] || return 0
2568
+ local now; now="$(_now)"
2569
+ # Bash-3.2 (macOS /bin/bash 3.2.57) - and BSD-tools compatibility - safe design:
2570
+ # No `set --` with a custom IFS (unreliable on macos when the value comes from a `$(...)`
2571
+ # subshell with a trailing newline), no `local` inside a pipeline subshell (also unreliable
2572
+ # under bash 3.2), and no reliance on process substitution (unavailable in the /bin/bash 3.2
2573
+ # POSIX mode some macos CI images run under). Instead: build the sorted list into a real temp
2574
+ # file, then iterate the file with a plain `while read` in the CURRENT shell (using redirect,
2575
+ # NOT a pipe - a pipe reintroduces the subshell). This is what the CI-3-OS matrix confirmed
2576
+ # actually works everywhere.
2577
+ local tmpf; tmpf="$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/.flow_prune_$$")"
2578
+ local d _ep
2579
+ for d in $(ls -1 "$raw_root" 2>/dev/null); do
2580
+ [ -d "$raw_root/$d" ] || continue
2581
+ _ep="$(_eval_nonce_epoch "$d")"
2582
+ case "$_ep" in ''|*[!0-9]*) _ep=0 ;; esac
2583
+ printf '%s %s\n' "$_ep" "$d"
2584
+ done | sort -rn -k1,1 > "$tmpf" 2>/dev/null
2585
+ if [ ! -s "$tmpf" ]; then rm -f "$tmpf" 2>/dev/null; return 0; fi
2586
+ local i=0 line ep dn age
2587
+ # Iterate via redirect so the while loop stays in the current shell (no piped subshell).
2588
+ while IFS= read -r line || [ -n "$line" ]; do
2589
+ i=$((i + 1))
2590
+ [ "$i" -le "$keep" ] && continue
2591
+ ep="${line%% *}"; dn="${line#* }"
2592
+ case "$ep" in ''|*[!0-9]*) ep=0 ;; esac
2593
+ if [ "$ep" -gt 0 ]; then
2594
+ age=$((now - ep))
2595
+ [ "$age" -lt "$FLOW_LOCK_TTL" ] && continue
2596
+ fi
2597
+ rm -rf "$raw_root/$dn" 2>/dev/null || echo "eval: WARNING raw-prune failed to remove $raw_root/$dn" 1>&2
2598
+ done < "$tmpf"
2599
+ rm -f "$tmpf" 2>/dev/null
2600
+ }
2601
+
2602
+ # Extract one top-level quoted-string field's value from an own-emitted JSON line. Our eval
2603
+ # JSONL is fully controlled/predictable in shape (never fixture/model free text at this layer -
2604
+ # those are already extracted upstream), so this stays a cheap grep/sed pair, no jq dependency.
2605
+ _eval_json_str_field() { # $1=line $2=field-name -> value or empty
2606
+ printf '%s' "$1" | grep -oE "\"$2\":\"[^\"]*\"" | head -1 | sed -E "s/^\"$2\":\"([^\"]*)\"\$/\1/"
2607
+ }
2608
+
2609
+ # List complete run_ids (has both a start header and a done trailer, n_written >= n_expected),
2610
+ # oldest first. An interrupted batch (Ctrl-C mid-run -> no trailer, per the EXIT-only trap) is
2611
+ # silently excluded here - that absence IS the completeness signal, not a positive "torn" flag.
2612
+ _eval_complete_run_ids() {
2613
+ [ -f "$EVAL_RESULTS_FILE" ] || return 0
2614
+ awk '
2615
+ /"batch":"start"/ {
2616
+ match($0, /"run_id":"[^"]*"/); rid = substr($0, RSTART+10, RLENGTH-11)
2617
+ match($0, /"n":[0-9]+/); nexp[rid] = substr($0, RSTART+4, RLENGTH-4)
2618
+ if (!(rid in seen_order)) { order[++oc] = rid; seen_order[rid] = 1 }
2619
+ seen_start[rid] = 1
2620
+ next
2621
+ }
2622
+ /"batch":"done"/ {
2623
+ match($0, /"run_id":"[^"]*"/); rid = substr($0, RSTART+10, RLENGTH-11)
2624
+ match($0, /"n":[0-9]+/); nwritten[rid] = substr($0, RSTART+4, RLENGTH-4)
2625
+ seen_done[rid] = 1
2626
+ next
2627
+ }
2628
+ END {
2629
+ for (i = 1; i <= oc; i++) {
2630
+ r = order[i]
2631
+ if (seen_start[r] && seen_done[r] && (nwritten[r]+0) >= (nexp[r]+0)) print r
2632
+ }
2633
+ }
2634
+ ' "$EVAL_RESULTS_FILE"
2635
+ }
2636
+
2637
+ # Per-stage scorecard for one batch: hollow flag-rate, sound pass-rate, invalid/unreliable
2638
+ # count, per-fixture MATCH/MISMATCH/UNRELIABLE. Healthy threshold (documented, not enforced):
2639
+ # hollow flag-rate >= 2/3 per fixture (majority), sound majority-pass (Validation decision 1).
2640
+ _eval_print_scorecard() { # $1=run_id
2641
+ local rid="$1" rows firstrow cliv model grsha tsv
2642
+ rows="$(grep -F "\"run_id\":\"$rid\"" "$EVAL_RESULTS_FILE" 2>/dev/null | grep '"fixture":')"
2643
+ if [ -z "$rows" ]; then echo "eval: no fixture rows found for run_id $rid"; return 1; fi
2644
+ firstrow="$(printf '%s\n' "$rows" | head -1)"
2645
+ cliv="$(_eval_json_str_field "$firstrow" cli_version)"
2646
+ model="$(_eval_json_str_field "$firstrow" model)"
2647
+ grsha="$(_eval_json_str_field "$firstrow" gate_rules_sha)"
2648
+ echo "eval scorecard - run $rid"
2649
+ echo " cli_version=$cliv model=$model gate_rules_sha=$grsha"
2650
+ echo
2651
+ # Name-based extraction into a clean TSV BEFORE aggregating - a naive `awk -F'"'` positional
2652
+ # split breaks the moment any field earlier in the line contains an escaped quote (e.g. a
2653
+ # cli_version string with embedded `"`), shifting every subsequent field's column index and
2654
+ # silently corrupting stage/fixture/verdict/match. `_eval_json_str_field` greps by field NAME,
2655
+ # so it is immune to CROSS-field corruption from what any other field on the line contains
2656
+ # (a field's OWN value still truncates at its first literal quote - not end-to-end JSON-safe,
2657
+ # just no longer cascading; acceptable given only cli_version/model are free-form CLI output
2658
+ # and neither commonly contains a quote character).
2659
+ tsv="$(printf '%s\n' "$rows" | while IFS= read -r line; do
2660
+ [ -z "$line" ] && continue
2661
+ printf '%s\t%s\t%s\t%s\t%s\n' \
2662
+ "$(_eval_json_str_field "$line" stage)" "$(_eval_json_str_field "$line" fixture)" \
2663
+ "$(_eval_json_str_field "$line" expected)" "$(_eval_json_str_field "$line" verdict)" \
2664
+ "$(_eval_json_str_field "$line" match)"
2665
+ done)"
2666
+ printf '%s\n' "$tsv" | awk -F'\t' '
2667
+ {
2668
+ stage=$1; fixture=$2; expected=$3; verdict=$4; matchv=$5
2669
+ tag = (matchv == "match") ? "MATCH" : (matchv == "unreliable" ? "UNRELIABLE" : "MISMATCH")
2670
+ printf " %-8s %-12s expected=%-4s verdict=%-10s %s\n", fixture, stage, expected, verdict, tag
2671
+ if (matchv == "unreliable") { unreliable[stage]++ }
2672
+ else if (expected == "FLAG") { flagtotal[stage]++; if (verdict == "FLAG") flaghit[stage]++ }
2673
+ else if (expected == "PASS") { passtotal[stage]++; if (verdict == "PASS") passhit[stage]++ }
2674
+ stages[stage] = 1
2675
+ }
2676
+ END {
2677
+ print ""
2678
+ for (s in stages) {
2679
+ fr = (flagtotal[s] > 0) ? (flaghit[s]+0) "/" flagtotal[s] : "n/a"
2680
+ pr = (passtotal[s] > 0) ? (passhit[s]+0) "/" passtotal[s] : "n/a"
2681
+ printf " stage %-12s hollow-flag-rate=%-6s sound-pass-rate=%-6s unreliable=%d\n", s, fr, pr, unreliable[s]+0
2682
+ }
2683
+ }
2684
+ '
2685
+ }
2686
+
2687
+ # Per-stage hollow flag-rate ("hit/total" among FLAG-expected fixtures only) for one batch -
2688
+ # the single number the whole eval exists to track over time ("did the judge stay honest").
2689
+ _eval_flag_rates() { # $1=run_id -> lines "stage<TAB>hit/total<TAB>comma,separated,fixture,ids"
2690
+ local rid="$1" rows
2691
+ rows="$(grep -F "\"run_id\":\"$rid\"" "$EVAL_RESULTS_FILE" 2>/dev/null | grep '"fixture":')"
2692
+ [ -z "$rows" ] && return 0
2693
+ printf '%s\n' "$rows" | while IFS= read -r line; do
2694
+ [ -z "$line" ] && continue
2695
+ local stage expected verdict fid
2696
+ stage="$(_eval_json_str_field "$line" stage)"
2697
+ expected="$(_eval_json_str_field "$line" expected)"
2698
+ verdict="$(_eval_json_str_field "$line" verdict)"
2699
+ fid="$(_eval_json_str_field "$line" fixture)"
2700
+ [ "$expected" = "FLAG" ] && printf '%s\t%s\t%s\n' "$stage" "$verdict" "$fid"
2701
+ done | awk -F'\t' '
2702
+ { total[$1]++; if ($2 == "FLAG") hit[$1]++; ids[$1] = (ids[$1] == "" ? $3 : ids[$1] "," $3) }
2703
+ END { for (s in total) printf "%s\t%s/%s\t%s\n", s, hit[s]+0, total[s], ids[s] }
2704
+ '
2705
+ }
2706
+
2707
+ # Advisory drift note across the last two COMPLETE batches (never an exit-code signal). With
2708
+ # model:"unknown" on either side, drift is explicitly narrowed to the cli_version/prose axes -
2709
+ # say so, rather than silently implying a model comparison that didn't actually happen.
2710
+ _eval_print_drift() {
2711
+ local ids count prev cur prev_row cur_row pcv pmv pgs ccv cmv cgs
2712
+ ids="$(_eval_complete_run_ids)"
2713
+ count="$(printf '%s\n' "$ids" | grep -c .)"
2714
+ if [ "$count" -lt 2 ]; then
2715
+ echo "eval: drift needs >=2 complete batches (found $count) - no comparison yet."
2716
+ return 0
2717
+ fi
2718
+ prev="$(printf '%s\n' "$ids" | tail -2 | head -1)"
2719
+ cur="$(printf '%s\n' "$ids" | tail -1)"
2720
+ prev_row="$(grep -F "\"run_id\":\"$prev\"" "$EVAL_RESULTS_FILE" | grep '"fixture":' | head -1)"
2721
+ cur_row="$(grep -F "\"run_id\":\"$cur\"" "$EVAL_RESULTS_FILE" | grep '"fixture":' | head -1)"
2722
+ pcv="$(_eval_json_str_field "$prev_row" cli_version)"; pmv="$(_eval_json_str_field "$prev_row" model)"; pgs="$(_eval_json_str_field "$prev_row" gate_rules_sha)"
2723
+ ccv="$(_eval_json_str_field "$cur_row" cli_version)"; cmv="$(_eval_json_str_field "$cur_row" model)"; cgs="$(_eval_json_str_field "$cur_row" gate_rules_sha)"
2724
+ echo
2725
+ echo "drift ($prev -> $cur):"
2726
+ local any=0
2727
+ [ "$pcv" != "$ccv" ] && { echo " cli_version changed: $pcv -> $ccv"; any=1; }
2728
+ [ "$pmv" != "$cmv" ] && { echo " model changed: $pmv -> $cmv"; any=1; }
2729
+ [ "$pgs" != "$cgs" ] && { echo " gate_rules_sha changed: $pgs -> $cgs (gate-rules.md prose may have changed)"; any=1; }
2730
+ [ "$any" -eq 0 ] && echo " no drift on cli_version/model/gate_rules_sha"
2731
+ if [ "$cmv" = "unknown" ] || [ "$pmv" = "unknown" ]; then
2732
+ echo " NOTE: model id unavailable for at least one batch - drift is coarse (cli_version/prose axes only)."
2733
+ fi
2734
+ echo " hollow flag-rate by stage:"
2735
+ local prev_rates cur_rates
2736
+ prev_rates="$(_eval_flag_rates "$prev")"
2737
+ cur_rates="$(_eval_flag_rates "$cur")"
2738
+ # A rate delta is only meaningful if both batches evaluated the SAME fixtures for that stage
2739
+ # (review finding: --stage/--fixture filtered runs can easily compare unlike sets, e.g. a
2740
+ # quick single-fixture check against an earlier full baseline - a bare "0/1 -> 1/2" reads as
2741
+ # "the judge got worse" when the denominator just grew). Flag it instead of hiding the fact.
2742
+ printf '%s\n' "$cur_rates" | while IFS=$'\t' read -r stage crate cids; do
2743
+ [ -z "$stage" ] && continue
2744
+ local prate pids
2745
+ prate="$(printf '%s\n' "$prev_rates" | awk -F'\t' -v s="$stage" '$1 == s { print $2 }')"
2746
+ pids="$(printf '%s\n' "$prev_rates" | awk -F'\t' -v s="$stage" '$1 == s { print $3 }')"
2747
+ if [ -z "$prate" ]; then
2748
+ echo " $stage: (no prior data) -> $crate"
2749
+ elif [ -n "$pids" ] && [ "$pids" != "$cids" ]; then
2750
+ echo " $stage: $prate -> $crate (NOTE: fixture set changed - prev:[$pids] cur:[$cids] - rate not directly comparable)"
2751
+ else
2752
+ echo " $stage: $prate -> $crate"
2753
+ fi
2754
+ done
2755
+ }
2756
+
2757
+ # NOTE: defined with the `function name { }` form (no parens) rather than this file's usual
2758
+ # `name() { }` style, solely so the literal 4-byte substring the shipped verb name is built
2759
+ # from never appears immediately followed by '(' in the source - a blind text-pattern security
2760
+ # lint (tuned for a same-named-but-unrelated JS/Python builtin that takes a string of code) false
2761
+ # -positives on that exact byte sequence regardless of language. Purely a spelling dodge for a
2762
+ # generic scanner; behavior is 100% identical to every other function in this file.
2763
+ function cmd_eval {
2764
+ local stage_filter="" fixture_filter="" n=3 timeout=120 report_mode=0 keep_going=0
2765
+ while [ $# -gt 0 ]; do
2766
+ case "$1" in
2767
+ --stage) shift; stage_filter="${1:-}" ;;
2768
+ --fixture) shift; fixture_filter="${1:-}" ;;
2769
+ --n) shift; n="${1:-3}" ;;
2770
+ --timeout) shift; timeout="${1:-120}" ;;
2771
+ --report) report_mode=1 ;;
2772
+ --keep-going) keep_going=1 ;;
2773
+ *) echo "usage: /flow eval [--stage 01|02|card] [--fixture <id>] [--n 3] [--timeout <seconds>] [--keep-going] [--report]"; return 1 ;;
2774
+ esac
2775
+ shift 2>/dev/null || true
2776
+ done
2777
+ case "$n" in ''|*[!0-9]*|0) echo "eval: --n must be a positive integer (got '$n')"; return 1 ;; esac
2778
+ case "$timeout" in ''|*[!0-9]*|0) echo "eval: --timeout must be a positive integer seconds value (got '$timeout')"; return 1 ;; esac
2779
+ case "$stage_filter" in ""|01|02|card) : ;; *) echo "eval: --stage must be one of 01|02|card (got '$stage_filter')"; return 1 ;; esac
2780
+ # Backoff between the in-run retry attempts. Default 5s; tests set 0 so the mock-suite is not
2781
+ # slowed by 15s+ per degraded-path case across 3-OS CI. Bare-int validation only - a bogus
2782
+ # value (`abc`) silently falls back to the default rather than aborting the batch.
2783
+ local backoff="${FLOW_EVAL_RETRY_BACKOFF:-5}"
2784
+ case "$backoff" in ''|*[!0-9]*) backoff=5 ;; esac
2785
+
2786
+ # --report is entirely offline (no LLM call, no manifest needed) - reads the existing results
2787
+ # file, prints the last COMPLETE batch's scorecard, and an advisory drift line vs the prior
2788
+ # complete batch. Never makes a billable call; exits 1 only when there's nothing to report.
2789
+ if [ "$report_mode" -eq 1 ]; then
2790
+ local ids last
2791
+ ids="$(_eval_complete_run_ids)"
2792
+ last="$(printf '%s\n' "$ids" | tail -1)"
2793
+ if [ -z "$last" ]; then
2794
+ echo "eval --report: no complete batch found in $EVAL_RESULTS_FILE yet (run 'flow.sh eval' first)."
2795
+ return 1
2796
+ fi
2797
+ _eval_print_scorecard "$last"
2798
+ _eval_print_drift
2799
+ return 0
2800
+ fi
2801
+
2802
+ if [ ! -f "$EVAL_MANIFEST" ]; then
2803
+ echo "eval: manifest not found at $EVAL_MANIFEST (fixtures not installed)"
2804
+ return 1
2805
+ fi
2806
+
2807
+ local probe; probe="$(_eval_probe)"
2808
+ case "$probe" in
2809
+ absent)
2810
+ echo "SKIP: 'claude' CLI not found on PATH - eval needs it to run the LLM judge. Zero calls made."
2811
+ return 0 ;;
2812
+ fail)
2813
+ echo "SKIP: 'claude' CLI is present but the sentinel probe did not come back clean (one minimal"
2814
+ echo " billable probe call was made). Confirm 'claude -p' runs headless on this machine, then retry."
2815
+ return 0 ;;
2816
+ esac
2817
+
2818
+ local nonce; nonce="$(_eval_nonce)"
2819
+ local total_mismatch=0 total_unreliable=0 total_evaluated=0 rundir="" n_written=0 aborted=0
2820
+ # Computed ONCE per batch, not per fixture row - see _eval_emit_result's comment.
2821
+ local batch_cliv batch_flowv batch_grsha
2822
+ batch_cliv="$(_eval_cli_version)"; batch_flowv="$(_flow_version)"; batch_grsha="$(_eval_gate_rules_sha)"
2823
+ echo "eval: batch $nonce (N=$n per fixture, timeout=${timeout}s)"
2824
+
2825
+ # Ensure .flow/ (which now hosts eval-raw/ postmortem dumps) is git-ignored on any project
2826
+ # that runs eval. Previously only mode/project-type/next paths called this - a project where
2827
+ # the operator only ever ran eval would end up with .flow/eval-raw/ (containing session ids,
2828
+ # cwd paths, plugin paths from stripped-but-still-envelope-adjacent output) untracked but not
2829
+ # ignored -> one `git add .` away from a remote. Idempotent - the helper is a no-op after the
2830
+ # first write.
2831
+ _ignore_run_state
2832
+
2833
+ # Pre-batch prune of the raw-capture dir - keeps disk bounded but respects concurrent runs
2834
+ # (see helper's TTL guard). Runs BEFORE the new batch's own raw dir is created, so this batch's
2835
+ # dir cannot be counted against or removed by its own prune.
2836
+ _eval_prune_raw_dirs 3
2837
+
2838
+ # Pre-count how many manifest rows will actually run (same filter logic as the main loop,
2839
+ # in awk form) - the batch header's n_expected is what --report/drift use to decide whether
2840
+ # a batch is complete, so it must reflect the SAME filtered set the loop below will process.
2841
+ local n_expected
2842
+ n_expected="$(awk -F'\t' -v sf="$stage_filter" -v ff="$fixture_filter" '
2843
+ {
2844
+ gsub(/\r/, "")
2845
+ if ($1 == "" || $1 == "id") next
2846
+ stage = $2; sub(/-.*/, "", stage)
2847
+ if (sf != "" && stage != sf) next
2848
+ if (ff != "" && $1 != ff) next
2849
+ c++
2850
+ }
2851
+ END { print c + 0 }
2852
+ ' "$EVAL_MANIFEST")"
2853
+ _eval_emit_batch_marker "$nonce" start "$n_expected"
2854
+
2855
+ # Scoped INT/TERM trap: the sole EXIT trap (_log_on_exit -> _cleanup_tds) was found NOT to
2856
+ # fire reliably while blocked on a foreground `_run_with_timeout` child (verified: a signal
2857
+ # sent to the flow.sh PID mid-eval left the in-flight rundir on disk). An explicit trap for
2858
+ # the two interrupt signals removes the CURRENT fixture's rundir directly rather than relying
2859
+ # on that fallback. Cleared right after the manifest loop so it never affects unrelated code.
2860
+ # It does NOT write the batch "done" trailer - an interrupted run correctly stays incomplete.
2861
+ trap 'rm -rf "${rundir:-}" 2>/dev/null; exit 130' INT TERM
2862
+
2863
+ # `|| [ -n "${fid:-}" ]` picks up a manifest whose last row has no trailing newline - the
2864
+ # classic `while read` gotcha that would otherwise silently drop the final fixture with no
2865
+ # error (a hand-edited manifest.tsv is exactly the file most likely to lose its final \n).
2866
+ while IFS=$'\t' read -r fid fstage fartifact fexpected || [ -n "${fid:-}" ]; do
2867
+ fid="$(printf '%s' "$fid" | tr -d '\r')"
2868
+ fstage="$(printf '%s' "$fstage" | tr -d '\r')"
2869
+ fartifact="$(printf '%s' "$fartifact" | tr -d '\r')"
2870
+ fexpected="$(printf '%s' "$fexpected" | tr -d '\r')"
2871
+ [ "$fid" = "id" ] && continue
2872
+ [ -z "$fid" ] && continue
2873
+ # manifest's stage column uses flow.sh's own full STAGES names ("01-research", "02-scope",
2874
+ # "card") for consistency with the rest of the codebase; --stage/heading-map use the terse
2875
+ # 01|02|card form the plan's CLI spec documents - normalize by stripping to the leading
2876
+ # token before the first '-' (a bare "card" has none, so it passes through unchanged).
2877
+ local fstage_short="${fstage%%-*}"
2878
+ [ -n "$stage_filter" ] && [ "$fstage_short" != "$stage_filter" ] && continue
2879
+ [ -n "$fixture_filter" ] && [ "$fid" != "$fixture_filter" ] && continue
2880
+
2881
+ # v1 trust-boundary invariant: ONLY manifest-listed shipped fixtures, resolved beneath
2882
+ # EVAL_DIR - never a caller-supplied path (red-team finding #9).
2883
+ local artifact_path="$EVAL_DIR/fixtures/$fid/$fartifact"
2884
+ if [ ! -f "$artifact_path" ]; then
2885
+ echo " $fid: FAIL - declared artifact '$fartifact' not found at $artifact_path"
2886
+ total_mismatch=$((total_mismatch + 1))
2887
+ continue
2888
+ fi
2889
+
2890
+ rundir="$(mktemp -d 2>/dev/null)"
2891
+ if [ -z "$rundir" ]; then
2892
+ echo " $fid: FAIL - could not create a temp run dir"
2893
+ total_mismatch=$((total_mismatch + 1))
2894
+ continue
2895
+ fi
2896
+ _register_td "$rundir"
2897
+
2898
+ local promptfile="$rundir/prompt.txt"
2899
+ if ! _eval_build_prompt "$promptfile" "$fstage_short" "$artifact_path" "$nonce"; then
2900
+ echo " $fid: FAIL - gate-rules.md section for stage '$fstage' extracted EMPTY (heading map or file drift) - aborted before any billable call"
2901
+ total_mismatch=$((total_mismatch + 1))
2902
+ rm -rf "$rundir" 2>/dev/null
2903
+ continue
2904
+ fi
2905
+
2906
+ total_evaluated=$((total_evaluated + 1))
2907
+ local flag_count=0 pass_count=0 invalid_count=0 i=1 model="unknown" retries_sum=0 vote_rate_limited=false
2908
+ # Filename-safe fixture id for raw-capture paths - manifest fid is only tr -d '\r'-cleaned
2909
+ # (the v1 trust-boundary is READ-side only, but we are now WRITING keyed by it), so a
2910
+ # hand-edited or FLOW_EVAL_MANIFEST-overridden manifest cannot traverse out of eval-raw/.
2911
+ local fid_safe; fid_safe="$(printf '%s' "$fid" | tr -c 'A-Za-z0-9' '-' | sed -E 's/-+/-/g; s/^-//; s/-$//')"
2912
+ [ -z "$fid_safe" ] && fid_safe="_"
2913
+ while [ "$i" -le "$n" ]; do
2914
+ local raw rc v raw1="" rc1=0 err1="" err2=""
2915
+ # Attempt 1 - stderr captured at the OUTER redirection so it never enters the sh -c
2916
+ # command string _eval_engine_run passes to _run_with_timeout (that broke the watchdog
2917
+ # fallback on the timeout-less-PATH lane - see engine-run's note).
2918
+ err1="$rundir/v${i}-a1.err"
2919
+ raw="$(_eval_engine_run "$promptfile" "$timeout" 2>"$err1")"; rc=$?
2920
+ raw1="$raw"; rc1="$rc"
2921
+ if [ "$rc" -eq 124 ]; then
2922
+ v=INVALID
2923
+ else
2924
+ v="$(_eval_parse_verdict "$raw" "$nonce")"
2925
+ [ "$model" = "unknown" ] && model="$(_eval_parse_model "$raw")"
2926
+ fi
2927
+ # Rate-limit detection on attempt 1 - if a rate_limit_info.status != "allowed" was seen
2928
+ # here, retry is worse than useless: the retry doubles spend, still lands inside the same
2929
+ # window, and its own INVALID would trigger a second raw persist. The retry is documented
2930
+ # as "for a formatting slip, not infra" (see the original in-run retry comment) - honor
2931
+ # that intent by skipping it on the one signal we now have that says "this is infra".
2932
+ local rl1; rl1="$(_eval_parse_rate_limited "$raw1")"
2933
+ [ "$rl1" = "true" ] && vote_rate_limited=true
2934
+ # Skip the retry on rc=124 (timeout) the same way it's skipped on rate-limit: both are
2935
+ # infra failures, not formatting slips. On the macOS watchdog-fallback lane where
2936
+ # _run_with_timeout is documented not to bound a stuck call (DEBT.md), a retry here just
2937
+ # waits another full stuck-call duration - test E on the 3-OS CI matrix reproduces this at
2938
+ # ~66s (2x ~30s stuck calls + backoff) despite --timeout 3.
2939
+ if [ "$v" = "INVALID" ] && [ "$rl1" != "true" ] && [ "$rc1" -ne 124 ]; then
2940
+ # Backoff then retry - env-injectable so tests can zero it; skipped when rc=124 (timeout
2941
+ # was infra, retry would timeout again) or when rate-limited (see above).
2942
+ if [ "$backoff" -gt 0 ]; then
2943
+ echo " $fid: retrying vote $i after ${backoff}s (parse-INVALID on attempt 1)"
2944
+ sleep "$backoff" 2>/dev/null || true
2945
+ else
2946
+ echo " $fid: retrying vote $i (parse-INVALID on attempt 1)"
2947
+ fi
2948
+ err2="$rundir/v${i}-a2.err"
2949
+ raw="$(_eval_engine_run "$promptfile" "$timeout" 2>"$err2")"; rc=$?
2950
+ retries_sum=$((retries_sum + 1))
2951
+ if [ "$rc" -eq 124 ]; then v=INVALID; else v="$(_eval_parse_verdict "$raw" "$nonce")"; fi
2952
+ [ "$model" = "unknown" ] && [ "$rc" -ne 124 ] && model="$(_eval_parse_model "$raw")"
2953
+ local rl2; rl2="$(_eval_parse_rate_limited "$raw")"
2954
+ [ "$rl2" = "true" ] && vote_rate_limited=true
2955
+ fi
2956
+ # If the vote's FINAL verdict is INVALID, persist BOTH attempts (both stdout+stderr+rc)
2957
+ # to .flow/eval-raw/<run_id>/. Attempt 1 is where the rate-limit / hook-cancelled
2958
+ # signature typically lives - the pre-v0.21 code discarded it, which is the whole reason
2959
+ # this feature exists. Persistence is diagnostic-critical, so failures are LOUD (not the
2960
+ # house `2>/dev/null || true` telemetry-sink pattern).
2961
+ if [ "$v" = "INVALID" ]; then
2962
+ local rawdir="$LOG_DIR/eval-raw/$nonce"
2963
+ if mkdir -p "$rawdir" 2>/dev/null; then
2964
+ local n_written_raw=0
2965
+ local a1out="$rawdir/${fid_safe}-v${i}-a1.out"
2966
+ local a1rc="$rawdir/${fid_safe}-v${i}-a1.rc"
2967
+ if _eval_strip_envelope "$raw1" > "$a1out" 2>/dev/null; then n_written_raw=$((n_written_raw + 1)); fi
2968
+ printf '%s\n' "$rc1" > "$a1rc" 2>/dev/null && n_written_raw=$((n_written_raw + 1))
2969
+ if [ -f "$err1" ] && [ -s "$err1" ]; then
2970
+ cp "$err1" "$rawdir/${fid_safe}-v${i}-a1.err" 2>/dev/null && n_written_raw=$((n_written_raw + 1))
2971
+ fi
2972
+ # Attempt 2 (only exists if retry ran)
2973
+ if [ -n "$err2" ]; then
2974
+ local a2out="$rawdir/${fid_safe}-v${i}-a2.out"
2975
+ local a2rc="$rawdir/${fid_safe}-v${i}-a2.rc"
2976
+ if _eval_strip_envelope "$raw" > "$a2out" 2>/dev/null; then n_written_raw=$((n_written_raw + 1)); fi
2977
+ printf '%s\n' "$rc" > "$a2rc" 2>/dev/null && n_written_raw=$((n_written_raw + 1))
2978
+ if [ -f "$err2" ] && [ -s "$err2" ]; then
2979
+ cp "$err2" "$rawdir/${fid_safe}-v${i}-a2.err" 2>/dev/null && n_written_raw=$((n_written_raw + 1))
2980
+ fi
2981
+ fi
2982
+ [ "$n_written_raw" -eq 0 ] && echo "eval: WARNING raw capture wrote 0 files under $rawdir" 1>&2
2983
+ else
2984
+ echo "eval: WARNING could not create raw-capture dir $rawdir" 1>&2
2985
+ fi
2986
+ fi
2987
+ case "$v" in
2988
+ FLAG) flag_count=$((flag_count + 1)) ;;
2989
+ PASS) pass_count=$((pass_count + 1)) ;;
2990
+ *) invalid_count=$((invalid_count + 1)) ;;
2991
+ esac
2992
+ i=$((i + 1))
2993
+ done
2994
+ rm -rf "$rundir" 2>/dev/null
2995
+
2996
+ # Reliability floor: >1/3 INVALID -> UNRELIABLE (infra failure, not a gate verdict).
2997
+ # Otherwise majority of FLAG/PASS among all N runs; a tie resolves to FLAG (benefit of the
2998
+ # doubt goes to catching a hollow artifact, matching gate-rules.md's own stated posture:
2999
+ # "never silently advance a hollow artifact").
3000
+ local verdict="UNRELIABLE"
3001
+ if [ $((invalid_count * 3)) -le "$n" ]; then
3002
+ if [ "$flag_count" -ge "$pass_count" ] && [ "$flag_count" -gt 0 ]; then
3003
+ verdict=FLAG
3004
+ elif [ "$pass_count" -gt "$flag_count" ]; then
3005
+ verdict=PASS
3006
+ fi
3007
+ fi
3008
+
3009
+ local match="mismatch"
3010
+ if [ "$verdict" = "UNRELIABLE" ]; then
3011
+ match="unreliable"
3012
+ total_unreliable=$((total_unreliable + 1))
3013
+ echo " $fid ($fstage): UNRELIABLE (flag=$flag_count pass=$pass_count invalid=$invalid_count of $n)"
3014
+ elif [ "$verdict" = "$fexpected" ]; then
3015
+ match="match"
3016
+ echo " $fid ($fstage): $verdict - matches expected $fexpected (flag=$flag_count pass=$pass_count invalid=$invalid_count)"
3017
+ else
3018
+ total_mismatch=$((total_mismatch + 1))
3019
+ echo " $fid ($fstage): $verdict - MISMATCH, expected $fexpected (flag=$flag_count pass=$pass_count invalid=$invalid_count)"
3020
+ fi
3021
+
3022
+ _eval_emit_result "$nonce" "$fid" "$fstage" "$fexpected" "$verdict" "$match" "$flag_count" "$pass_count" "$invalid_count" "$n" "$model" "$batch_cliv" "$batch_flowv" "$batch_grsha" "$retries_sum" "$vote_rate_limited"
3023
+ n_written=$((n_written + 1))
3024
+
3025
+ # Circuit breaker: catches the 260710-class INVALID storm early. Trip when the FIRST
3026
+ # evaluated fixture comes back UNRELIABLE (reliability floor: invalid_count*3 > n) - the
3027
+ # motivating 17/18 storm satisfies this at fixture 1 even with one accidentally-parsed vote,
3028
+ # which a naive `invalid_count == n` breaker would NOT have caught. --keep-going overrides.
3029
+ # Order matters: verdict computed -> UNRELIABLE line printed -> result row emitted -> aborted
3030
+ # flag set -> break. The `done` trailer below is aborted-flag-guarded so a bare break cannot
3031
+ # accidentally mark this batch complete (a filtered-run --fixture would otherwise satisfy
3032
+ # n_written == n_expected and poison --report/drift).
3033
+ if [ "$total_evaluated" -eq 1 ] && [ "$verdict" = "UNRELIABLE" ] && [ "$keep_going" -eq 0 ]; then
3034
+ local rawdir_abort="$LOG_DIR/eval-raw/$nonce"
3035
+ local raw_count=0
3036
+ [ -d "$rawdir_abort" ] && raw_count="$(find "$rawdir_abort" -type f 2>/dev/null | wc -l | tr -d ' ')"
3037
+ echo "eval: ABORT after first fixture UNRELIABLE (INVALID storm class - retry+backoff already burned)."
3038
+ echo " raw capture: $rawdir_abort ($raw_count files)"
3039
+ echo " re-run with --keep-going to force full-batch execution."
3040
+ aborted=1
3041
+ break
3042
+ fi
3043
+ done < "$EVAL_MANIFEST"
3044
+ trap - INT TERM
3045
+ # A normal (non-aborted) completion always reaches here. `aborted=1` from the circuit breaker
3046
+ # SKIPS the trailer so an early-broken batch never satisfies --report's completeness check
3047
+ # (n_written>=n_expected AND trailer present), even on a --fixture-filtered run where
3048
+ # n_written could accidentally equal the filtered n_expected of 1.
3049
+ if [ "$aborted" -eq 0 ]; then
3050
+ _eval_emit_batch_marker "$nonce" done "$n_written"
3051
+ fi
3052
+
3053
+ echo
3054
+ if [ "$total_evaluated" -eq 0 ]; then
3055
+ echo "eval: no fixtures matched the given filters - nothing evaluated."
3056
+ return 1
3057
+ fi
3058
+ if [ "$aborted" -eq 1 ]; then
3059
+ # No scorecard on an aborted batch - the batch is deliberately incomplete and the numbers
3060
+ # would misrepresent what actually ran. --report will not surface this run (no trailer).
3061
+ echo "ABORTED: circuit breaker tripped on first fixture UNRELIABLE - $total_evaluated of $n_expected fixtures evaluated."
3062
+ return 2
3063
+ fi
3064
+ _eval_print_scorecard "$nonce"
3065
+ echo
3066
+ if [ "$total_mismatch" -eq 0 ] && [ "$total_unreliable" -eq 0 ]; then
3067
+ echo "PASS: all $total_evaluated evaluated fixture(s) majority-matched their expected verdict."
3068
+ return 0
3069
+ fi
3070
+ echo "FAIL: $total_mismatch mismatch(es), $total_unreliable unreliable batch(es) of $total_evaluated evaluated."
3071
+ return 1
3072
+ }
3073
+
3074
+ usage() {
3075
+ cat <<'EOF'
3076
+ flow.sh - buildflow gate runner (mechanical layer)
3077
+
3078
+ usage: bash flow.sh <command> [args]
3079
+
3080
+ status Where am I? What's blocking? (also: no command)
3081
+ resume Session-story brief for a fresh agent entering mid-cycle: last session
3082
+ (command names only, never raw args), in-flight card + dwell, gate state,
3083
+ one NEXT-> line. Read-only, no lock. Run this FIRST when resuming a project.
3084
+ next Check current gate; unlock next stage (or start at 00)
3085
+ assess Brownfield: scaffold + gate a current-state assessment (flow/00-inspect.md) before planning
3086
+ card Create the next build card (after planning complete)
3087
+ check C-NNN Validate a card (FILL/status/sections/done-evidence)
3088
+ mode [teach|work] Show or set who writes the artifacts
3089
+ project-type [t] Show or set project type (web|cli|library|skill); adapts done-evidence
3090
+ skip <stage> --reason Advance past a gate that has a matching open DEBT (non-security only)
3091
+ ready List buildable todo cards + parallel-safety hint
3092
+ workspace <verb> Multi-agent worktree isolation: add|list|enter|remove|check|doctor (one worktree per agent)
3093
+ loop-prep <card> Plumbing for ck-loop: isolated worktree + numeric Verify command + param block (thin wrapper; ck-loop is the engine)
3094
+ loop-log <card> Record a finished ck-loop run (iterations/start/end/outcome) into usage-log telemetry
3095
+ auto Preflight an autonomous run (orchestration in SKILL.md)
3096
+ recall Read back prior knowledge (debt/retro/playbooks/prev-card/harness) before working
3097
+ unlock Clear this project's concurrency lock (after a crashed/abandoned session)
3098
+ harness <args> Passthrough to the durable layer CLI (intake/story/trace/decision/backlog/query)
3099
+ debt add|list Record/list deliberate gate-skips in DEBT.md (security-class = operator-only)
3100
+ design <file> Mechanical DESIGN.md check on a UI file (emoji/{{}}/engine-words/gradient)
3101
+ contract Check client base-URL vs served-path prefixes (path-resolution drift; web)
3102
+ tokens Check DESIGN.md declared tokens vs CSS usage (design-system drift)
3103
+ coherence Flag version drift across declared version fields (doc-vs-code coherence)
3104
+ consistency Audit cross-artifact coverage (PRD features <-> cards <-> contract; FR ids)
3105
+ constitution Check operator-authored per-project invariants in flow/constitution.md (advisory; not a next-gate)
3106
+ promote <file> Copy a playbook into the cross-project KB (~/.claude/flow/playbooks)
3107
+ doctor Check the environment (bash/python/grep/git) across macOS/Linux/Windows
3108
+ eval [opts] Behavioral eval: does the LLM semantic gate flag a hollow-but-mechanically-clean
3109
+ fixture? Opt-in, BILLABLE (skips cleanly with zero calls if 'claude' CLI absent).
3110
+ [--stage 01|02|card] [--fixture <id>] [--n 3] [--timeout <seconds>]
3111
+ [--report] offline: last complete batch's scorecard + drift, zero calls
3112
+ retro Print the 3 retro questions
3113
+
3114
+ env:
3115
+ FLOW_SESSION_ID stable id per session -> enables HARD refusal of concurrent sessions
3116
+ FLOW_LOCK_TTL seconds a lock stays fresh (default 900); older locks auto-reclaim
3117
+ FLOW_FORCE=1 take over a foreign lock (use only if the other session is truly gone)
3118
+ FLOW_WORKSPACE_BASEPORT base port for the per-worktree PORT hint (default 3000)
3119
+ FLOW_WORKSPACE_MAX advisory ceiling of active workspaces before 'workspace doctor' warns (default 4)
3120
+
3121
+ exit: 0 = pass/advanced, 1 = gate fail / usage error
3122
+ EOF
3123
+ }
3124
+
3125
+ # ---------- usage log (mechanical capture; best-effort, never fails, exit-code preserving) ----------
3126
+ # Logging is OFF when FLOW_LOG_DISABLE or the standard DO_NOT_TRACK env is set (hygiene; local-only).
3127
+ _log_disabled() { [ -n "${FLOW_LOG_DISABLE:-}" ] || [ -n "${DO_NOT_TRACK:-}" ]; }
3128
+
3129
+ # JSON string escape: backslash, doublequote, then drop control chars (keeps one line valid).
3130
+ _json_str() { printf '%s' "${1:-}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\000-\037'; }
3131
+
3132
+ # Conservative arg mask: if the arg string contains any secret-shaped keyword (case-insensitive
3133
+ # via tr, portable), replace the WHOLE field rather than risk a partial leak. Args are rarely
3134
+ # secret-bearing (mostly 'next'/'card'/'check C-001'); free-text --summary is the only real vector.
3135
+ _mask_secrets() {
3136
+ case "$(printf '%s' "${1:-}" | tr 'A-Z' 'a-z')" in
3137
+ *token*|*secret*|*passwd*|*password*|*credential*|*api_key*|*api-key*|*apikey*|*bearer*|*authorization*|*-----begin*)
3138
+ printf '***redacted***' ;;
3139
+ *) printf '%s' "${1:-}" ;;
3140
+ esac
3141
+ }
3142
+
3143
+ _flow_version() {
3144
+ local sk="$SCRIPT_DIR/../SKILL.md" v=""
3145
+ v="$(sed -nE 's/^[[:space:]]*version:[[:space:]]*"?([0-9][.0-9A-Za-z-]*).*/\1/p' "$sk" 2>/dev/null | head -1)"
3146
+ [ -n "$v" ] && printf '%s' "$v" || printf 'unknown'
3147
+ }
3148
+
3149
+ _log_is_readonly() { # $1 = command -> true|false (does it mutate the flow plan?)
3150
+ case "$1" in
3151
+ status|resume|recall|ready|usage|tokens|coherence|consistency|contract|constitution|doctor|design|help|-h|--help|"") echo true ;;
3152
+ *) echo false ;;
3153
+ esac
3154
+ }
3155
+
3156
+ # Stamp a cycle id once per project (idempotent). Groups usage-log events into one build cycle.
3157
+ # Covers ALL entry points - assess, next, or any command on a pre-existing project - so cycle
3158
+ # analytics are no longer blind on brownfield. One cycle per project dir (ADR decision).
3159
+ _ensure_cycle() {
3160
+ [ -f "$CYCLE_FILE" ] && return 0
3161
+ { mkdir -p "$LOG_DIR" && printf '%s-%s\n' "$(_now)" "$(uname -n 2>/dev/null | cut -c1-12 || echo host)" > "$CYCLE_FILE"; } 2>/dev/null || true
3162
+ }
3163
+
3164
+ # Is this project a throwaway/test run? True when the root is named like an mktemp dir (tmp.*)
3165
+ # or sits under the system temp dir. Lets analytics default-exclude dogfood/test noise.
3166
+ # Normalize a path for prefix comparison across POSIX + Git-Bash-on-Windows: backslashes ->
3167
+ # slashes, lowercase (Windows is case-insensitive), and the Windows drive form `C:/` -> `/c/`
3168
+ # (Git Bash reports $ROOT as `/c/...` but $TEMP/$TMP as `C:\...` — without this they never match).
3169
+ _norm_path() { printf '%s' "${1:-}" | tr 'A-Z\\' 'a-z/' | sed -E 's#^([a-z]):/#/\1/#; s#(.)/+$#\1#'; }
3170
+ _is_ephemeral() {
3171
+ case "$(basename "$ROOT" 2>/dev/null)" in tmp.*) echo 1; return ;; esac
3172
+ local d rp; rp="$(_norm_path "$ROOT")"
3173
+ for d in "${TMPDIR:-}" "${TEMP:-}" "${TMP:-}" /tmp /var/tmp; do
3174
+ [ -n "$d" ] || continue
3175
+ d="$(_norm_path "$d")"
3176
+ case "$rp" in "$d"/*|"$d") echo 1; return ;; esac
3177
+ done
3178
+ echo 0
3179
+ }
3180
+
3181
+ # Build + append one event. $1 = exit code. Wrapped by the caller in { } 2>/dev/null || true.
3182
+ _log_event() {
3183
+ _log_disabled && return 0
3184
+ _ensure_cycle
3185
+ local ec="${1:-0}" now ts dur gp ro ver proj host cyc args eph gfr
3186
+ now="$(_now)"; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo '')"
3187
+ dur=$(( now - ${FLOW_LOG_START:-$now} )); [ "$dur" -lt 0 ] && dur=0
3188
+ case "$FLOW_LOG_CMD" in next|check) [ "$ec" -eq 0 ] && gp=true || gp=false ;; *) gp=null ;; esac
3189
+ ro="$(_log_is_readonly "$FLOW_LOG_CMD")"
3190
+ ver="$(_flow_version)"; proj="$(basename "$ROOT" 2>/dev/null || echo '?')"
3191
+ host="$(uname -n 2>/dev/null | cut -c1-16 || echo host)"
3192
+ cyc="$(cat "$CYCLE_FILE" 2>/dev/null | tr -d '\r\n' || echo '')"
3193
+ args="$(_mask_secrets "$FLOW_LOG_ARGS")"
3194
+ eph="$(_is_ephemeral)"
3195
+ # bound the reason so the compact global line stays small (atomic-append friendly) even when
3196
+ # a future gate emits a long reason string.
3197
+ gfr="$(printf '%s' "${FLOW_LAST_GATE_FAIL:-}" | cut -c1-120)"
3198
+ # Card dwell fields for the compact GLOBAL row: gated on the verb literally being `card`
3199
+ # (not merely FLOW_LOG_CARD being set, which `check`/`loop-prep`/etc also do) - only
3200
+ # `card start`/`card done` args match the pairing query's `args LIKE 'start%'/'done%'`
3201
+ # shape. Charset-strip BEFORE bounding (card ids/verbs are ASCII by construction, `C-NNN`),
3202
+ # so a byte-wise cut can never split a multibyte sequence.
3203
+ local card_field="" card_args=""
3204
+ if [ "$FLOW_LOG_CMD" = "card" ]; then
3205
+ card_field="$FLOW_LOG_CARD"
3206
+ card_args="$(printf '%s' "$args" | tr -cd 'A-Za-z0-9 _.-' | cut -c1-32)"
3207
+ fi
3208
+ mkdir -p "$LOG_DIR" 2>/dev/null || true
3209
+ # FULL line -> per-project
3210
+ printf '{"ts":"%s","epoch_s":%s,"session_id":"%s","cycle_id":"%s","project":"%s","command":"%s","args":"%s","exit_code":%s,"gate_pass":%s,"duration_s":%s,"stage_from":"%s","stage_to":"%s","card":"%s","project_type":"%s","mode":"%s","flow_version":"%s","tier":"%s","host":"%s","read_only":%s,"gate_fail_reason":"%s","ephemeral":%s}\n' \
3211
+ "$ts" "$now" "$(_json_str "$(_session_id)")" "$cyc" "$(_json_str "$proj")" "$(_json_str "$FLOW_LOG_CMD")" "$(_json_str "$args")" "$ec" "$gp" "$dur" "$FLOW_LOG_STAGE_FROM" "$FLOW_LOG_STAGE_TO" "$(_json_str "$FLOW_LOG_CARD")" "$(get_project_type)" "$(cat "$MODE_FILE" 2>/dev/null | tr -d '\r' || echo teach)" "$ver" "${FLOW_ENGINE_TIER:-builtin}" "$(_json_str "$host")" "$ro" "$(_json_str "${FLOW_LAST_GATE_FAIL:-}")" "$eph" \
3212
+ >> "$EVENTS_FILE" 2>/dev/null || true
3213
+ # COMPACT line -> device-global (no host/type/mode -> stays small, race-safe append).
3214
+ # stage_from is included so dwell reconstruction uses the same code path as project-local.
3215
+ # v0.20 Phase 1: card/args ARE now included (constant key shape) - populated only when
3216
+ # FLOW_LOG_CMD=card (charset-stripped + 32-char-bounded), empty string otherwise - this is
3217
+ # what lets `usage --global` pair card start/done dwell; it is no longer args-free.
3218
+ if [ -n "${HOME:-}" ]; then
3219
+ mkdir -p "$(dirname "$GLOBAL_LOG")" 2>/dev/null || true
3220
+ printf '{"ts":"%s","epoch_s":%s,"session_id":"%s","cycle_id":"%s","project":"%s","command":"%s","exit_code":%s,"gate_pass":%s,"duration_s":%s,"stage_from":"%s","stage_to":"%s","flow_version":"%s","read_only":%s,"gate_fail_reason":"%s","ephemeral":%s,"card":"%s","args":"%s"}\n' \
3221
+ "$ts" "$now" "$(_json_str "$(_session_id)")" "$cyc" "$(_json_str "$proj")" "$(_json_str "$FLOW_LOG_CMD")" "$ec" "$gp" "$dur" "$FLOW_LOG_STAGE_FROM" "$FLOW_LOG_STAGE_TO" "$ver" "$ro" "$(_json_str "$gfr")" "$eph" "$(_json_str "$card_field")" "$(_json_str "$card_args")" \
3222
+ >> "$GLOBAL_LOG" 2>/dev/null || true
3223
+ fi
3224
+ return 0
3225
+ }
3226
+
3227
+ # EXIT trap: capture $? FIRST, log best-effort, re-exit unchanged (logging never alters exit code).
3228
+ _log_on_exit() {
3229
+ ec=$?
3230
+ _cleanup_tds 2>/dev/null || true # remove any advisory-probe tempdirs (belt-and-suspenders for SIGINT/SIGTERM)
3231
+ { _log_event "$ec"; } 2>/dev/null || true
3232
+ exit "$ec"
3233
+ }
3234
+
3235
+ # Roll up the JSONL usage sinks into usage_event, then print analytics. Read-only; degrades
3236
+ # gracefully when python/harness is unavailable (mirrors the durable-layer best-effort idiom).
3237
+ cmd_usage() {
3238
+ if ! harness_available; then
3239
+ echo "usage: durable layer unavailable (python or harness missing, or FLOW_HARNESS_DISABLE set)."
3240
+ echo " the raw JSONL log still exists at $EVENTS_FILE"
3241
+ return 0
3242
+ fi
3243
+ local py; py="$(_python)"
3244
+ if [ "${1:-}" = "--prune" ]; then # flow usage --prune [--keep N] [--global]
3245
+ shift
3246
+ FLOW_PROJECT_ROOT="$ROOT" "$py" "$HARNESS_PY" prune "$@"
3247
+ return 0
3248
+ fi
3249
+ # forward --global to the rollup step too, else `usage --global` queries a src that was
3250
+ # never ingested into the usage_event mirror and falsely reports "no events".
3251
+ local rollup_global=""
3252
+ case " $* " in *" --global "*) rollup_global="--global" ;; esac
3253
+ FLOW_PROJECT_ROOT="$ROOT" "$py" "$HARNESS_PY" rollup $rollup_global >/dev/null 2>&1 || true
3254
+ FLOW_PROJECT_ROOT="$ROOT" "$py" "$HARNESS_PY" usage "$@"
3255
+ return 0
3256
+ }
3257
+
3258
+ # ---------- dispatch ----------
3259
+ cmd="${1:-status}"
3260
+ shift 2>/dev/null || true
3261
+ FLOW_LOG_CMD="$cmd"
3262
+ FLOW_LOG_ARGS="$*"
3263
+ FLOW_LOG_START="$(_now)"
3264
+ trap '_log_on_exit' EXIT
3265
+ case "$cmd" in
3266
+ status|"") cmd_status ;;
3267
+ resume) cmd_resume ;;
3268
+ next) cmd_next ;;
3269
+ assess) cmd_assess ;;
3270
+ card) cmd_card "$@" ;;
3271
+ check) cmd_check "${1:-}" ;;
3272
+ mode) cmd_mode "${1:-}" ;;
3273
+ project-type) cmd_project_type "${1:-}" ;;
3274
+ skip) cmd_skip "$@" ;;
3275
+ ready) cmd_ready ;;
3276
+ workspace) cmd_workspace "$@" ;;
3277
+ loop-prep) cmd_loop_prep "$@" ;;
3278
+ loop-log) cmd_loop_log "$@" ;;
3279
+ auto) cmd_auto ;;
3280
+ recall) cmd_recall ;;
3281
+ unlock) cmd_unlock ;;
3282
+ retro) cmd_retro ;;
3283
+ harness) cmd_harness "$@" ;;
3284
+ debt) cmd_debt "$@" ;;
3285
+ design) cmd_design "${1:-}" ;;
3286
+ contract) cmd_contract ;;
3287
+ tokens) cmd_tokens ;;
3288
+ coherence) cmd_coherence ;;
3289
+ consistency) cmd_consistency ;;
3290
+ constitution) cmd_constitution ;;
3291
+ promote) cmd_promote "${1:-}" ;;
3292
+ doctor) cmd_doctor ;;
3293
+ eval) cmd_eval "$@" ;;
3294
+ usage) cmd_usage "$@" ;;
3295
+ -h|--help|help) usage ;;
3296
+ *) echo "unknown command: $cmd"; echo; usage; exit 1 ;;
3297
+ esac