@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.
- package/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/README_VN.md +134 -0
- package/SECURITY.md +69 -0
- package/bin/cli.mjs +382 -0
- package/package.json +52 -0
- package/skills/flow/SKILL.md +234 -0
- package/skills/flow/_templates/00-idea.md +16 -0
- package/skills/flow/_templates/00-inspect.md +42 -0
- package/skills/flow/_templates/01-research.md +59 -0
- package/skills/flow/_templates/02-scope.md +71 -0
- package/skills/flow/_templates/03-prd.md +57 -0
- package/skills/flow/_templates/04-adr.md +21 -0
- package/skills/flow/_templates/05-contract.md +59 -0
- package/skills/flow/_templates/card.md +26 -0
- package/skills/flow/_templates/constitution.md +22 -0
- package/skills/flow/eval/fixtures/f01a/flow/01-research.md +77 -0
- package/skills/flow/eval/fixtures/f01b/flow/01-research.md +75 -0
- package/skills/flow/eval/fixtures/f02a/flow/02-scope.md +56 -0
- package/skills/flow/eval/fixtures/f02b/flow/02-scope.md +65 -0
- package/skills/flow/eval/fixtures/fcda/.flow/.gitkeep +0 -0
- package/skills/flow/eval/fixtures/fcda/cards/C-001.md +46 -0
- package/skills/flow/eval/fixtures/fcdb/.flow/.gitkeep +0 -0
- package/skills/flow/eval/fixtures/fcdb/cards/C-001.md +35 -0
- package/skills/flow/eval/manifest.tsv +7 -0
- package/skills/flow/harness/README.md +117 -0
- package/skills/flow/harness/_db.py +228 -0
- package/skills/flow/harness/_domain.py +161 -0
- package/skills/flow/harness/_presence.py +109 -0
- package/skills/flow/harness/flow_harness.py +1103 -0
- package/skills/flow/harness/repo_map.py +93 -0
- package/skills/flow/harness/schema/001-init.sql +129 -0
- package/skills/flow/harness/schema/002-story-verify.sql +9 -0
- package/skills/flow/harness/schema/003-tool-registry.sql +15 -0
- package/skills/flow/harness/schema/004-intervention.sql +15 -0
- package/skills/flow/harness/schema/005-tool-extensions.sql +20 -0
- package/skills/flow/harness/schema/009-accessed-count.sql +11 -0
- package/skills/flow/harness/schema/010-usage-event.sql +42 -0
- package/skills/flow/harness/schema/011-usage-gate-reason.sql +8 -0
- package/skills/flow/harness/schema/012-usage-ephemeral.sql +10 -0
- package/skills/flow/law/CLAUDE.md +140 -0
- package/skills/flow/law/DESIGN.md +141 -0
- package/skills/flow/law/RETRO.md +13 -0
- package/skills/flow/playbooks/README.md +28 -0
- package/skills/flow/playbooks/cloudflare-ai-gateway-qwen.md +346 -0
- package/skills/flow/playbooks/docker-deploy-stale-cache.md +32 -0
- package/skills/flow/playbooks/rag-lite-postgres-cloudflare.md +192 -0
- package/skills/flow/playbooks/windows-gitbash-jsonl-telemetry.md +57 -0
- package/skills/flow/references/adversarial-review.md +197 -0
- package/skills/flow/references/agent-detection.md +88 -0
- package/skills/flow/references/agent-stage-mapping.md +114 -0
- package/skills/flow/references/antigravity-integration.md +92 -0
- package/skills/flow/references/auto-run.md +81 -0
- package/skills/flow/references/claudekit-skills.md +128 -0
- package/skills/flow/references/codex-integration.md +104 -0
- package/skills/flow/references/command-dispatch.md +54 -0
- package/skills/flow/references/debt-and-halts.md +41 -0
- package/skills/flow/references/design-review-checklist.md +42 -0
- package/skills/flow/references/gate-eval.md +186 -0
- package/skills/flow/references/gate-rules.md +171 -0
- package/skills/flow/references/ground-truth-gates.md +49 -0
- package/skills/flow/references/loop-harness-2026-principles.md +52 -0
- package/skills/flow/references/mode-work.md +37 -0
- package/skills/flow/references/project-types.md +42 -0
- package/skills/flow/references/stage-state-machine.md +47 -0
- package/skills/flow/references/ui-patterns-tcr.md +49 -0
- package/skills/flow/runner/flow.cmd +18 -0
- package/skills/flow/runner/flow.sh +3297 -0
- package/skills-manifest.json +70 -0
- package/src/constants.mjs +65 -0
- package/src/detect.mjs +36 -0
- package/src/help.mjs +42 -0
- package/src/installer.mjs +231 -0
- package/src/prompt.mjs +60 -0
- 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
|