baldart 4.52.4 → 4.53.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.
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # setup-worktree.sh — SSOT deterministic CODE-worktree creation + baseline.
4
+ #
5
+ # This is the single source of truth for the worktree build sequence consumed by
6
+ # three callers (no per-caller duplication — the sin CLAUDE.md forbids):
7
+ # • /nw (worktree-manager § "/nw") — interactive + slug-only + programmatic
8
+ # • /new (skill: framework/.claude/skills/new/references/setup.md step 4)
9
+ # • new2 (framework/.claude/workflows/new2.js Pre-flight)
10
+ #
11
+ # Why a deterministic script instead of a model-driven subagent: the worktree
12
+ # setup is PURE PLUMBING (git worktree add → npm install → env copy → port →
13
+ # registry → baseline). Driving it through a Skill-interpreting subagent put a
14
+ # model in the loop for mechanical work — and the model either FABRICATED a
15
+ # well-formed "baseline: pass" block with nothing on disk (haiku, 2/2), or went
16
+ # IDLE mid-execution leaving a half-built worktree (sonnet, node_modules missing).
17
+ # A deterministic script cannot fabricate or stall: it does the work or fails
18
+ # honestly with a log. Callers launch it as a BACKGROUND Bash, end the turn, and
19
+ # on resume read the structured manifest + run their own disk-verification gate.
20
+ #
21
+ # SCOPE: the FRESH-path only. The caller's git-authoritative idempotency pre-check
22
+ # (setup.md step 4a2) decides resume / reset / fresh BEFORE invoking this — this
23
+ # script assumes a clean slate and fails loud on a branch/path collision (a code
24
+ # worktree is not silently re-creatable). It does NOT repair a failing baseline
25
+ # (role boundary — the coder specialist does that downstream); it reports the
26
+ # failure with a log precise enough to act on.
27
+ #
28
+ # Usage:
29
+ # setup-worktree.sh --branch <branch> --slug <slug> --manifest <path> [opts]
30
+ #
31
+ # Required:
32
+ # --branch <branch> resolved branch name (caller derives it; e.g.
33
+ # feat/FEAT-0200-menu-ranking or feat/<slug>)
34
+ # --slug <slug> kebab-case slug (used for the log filename only;
35
+ # the worktree path derives from --branch)
36
+ # --manifest <path> file to write the structured result block to (the
37
+ # caller reads THIS at resume — never the bg stdout)
38
+ #
39
+ # Optional (each falls back to baldart.config.yml / autodetect when omitted, so
40
+ # /nw can call with just the three required flags; /new + new2 pass the values
41
+ # they already resolved in Phase 0 to avoid re-deriving / drift):
42
+ # --card <id> primary card id (default: none → null)
43
+ # --cards <a,b,c> all card ids sharing the wt (default: [])
44
+ # --group-parent <id> group.parent (default: none → null)
45
+ # --main <path> main repo root (default: resolve_main)
46
+ # --trunk <branch> git.trunk_branch (default: config/autodetect)
47
+ # --env-files <a,b> stack.env_files CSV (default: config / .env.local,.env)
48
+ # --backlog-dir <dir> paths.backlog_dir (default: config / backlog)
49
+ # --tc-typecheck <cmd> toolchain typecheck cmd (default: config / npx tsc --noEmit)
50
+ # --tc-lint <cmd> toolchain lint cmd (default: config / npx eslint --max-warnings=0 src/)
51
+ # --tc-build <cmd> toolchain build cmd (default: config / npm run build)
52
+ # --install-cmd <cmd> dependency install cmd (default: npm install)
53
+ # --log <path> install/build log sink (default: /tmp/wt-setup-<slug>.log)
54
+ # --port-min <n> port scan floor (default: 3001)
55
+ # --port-max <n> port scan ceiling (default: 3099)
56
+ # --build-timeout <s> hard build timeout seconds (default: 600)
57
+ #
58
+ # Manifest written (parsed by callers — STABLE contract):
59
+ # status: ok | error
60
+ # error: <message | ->
61
+ # worktree_path: <abs | ->
62
+ # branch: <branch>
63
+ # port: <n | ->
64
+ # created_at: <ISO-8601 | -> (stamped at worktree creation, NOT at finish)
65
+ # baseline: pass | fail | timeout | -
66
+ # baseline_log: <path | ->
67
+ #
68
+ # Exit: 0 on a VERIFIED worktree with baseline:pass; non-zero otherwise. The
69
+ # caller never trusts the exit code or the manifest as the sole evidence — it
70
+ # re-verifies the worktree on disk (setup.md step 6a). This script writes the
71
+ # manifest on EVERY exit path so a non-zero exit is always diagnosable.
72
+
73
+ set -u
74
+
75
+ err() { printf '%s\n' "$*" >&2; }
76
+
77
+ # --- args ------------------------------------------------------------------
78
+ BRANCH= SLUG= MANIFEST= CARD= CARDS= GROUP_PARENT= MAIN= TRUNK=
79
+ ENV_FILES_ARG= BACKLOG_DIR= TC_TC= TC_LINT= TC_BUILD= INSTALL_CMD=
80
+ LOG= PORT_MIN=3001 PORT_MAX=3099 BUILD_TIMEOUT=600
81
+ ENV_FILES_SET=0
82
+ while [ $# -gt 0 ]; do
83
+ case "$1" in
84
+ --branch) BRANCH="${2:-}"; shift 2 ;;
85
+ --slug) SLUG="${2:-}"; shift 2 ;;
86
+ --manifest) MANIFEST="${2:-}"; shift 2 ;;
87
+ --card) CARD="${2:-}"; shift 2 ;;
88
+ --cards) CARDS="${2:-}"; shift 2 ;;
89
+ --group-parent) GROUP_PARENT="${2:-}"; shift 2 ;;
90
+ --main) MAIN="${2:-}"; shift 2 ;;
91
+ --trunk) TRUNK="${2:-}"; shift 2 ;;
92
+ --env-files) ENV_FILES_ARG="${2:-}"; ENV_FILES_SET=1; shift 2 ;;
93
+ --backlog-dir) BACKLOG_DIR="${2:-}"; shift 2 ;;
94
+ --tc-typecheck) TC_TC="${2:-}"; shift 2 ;;
95
+ --tc-lint) TC_LINT="${2:-}"; shift 2 ;;
96
+ --tc-build) TC_BUILD="${2:-}"; shift 2 ;;
97
+ --install-cmd) INSTALL_CMD="${2:-}"; shift 2 ;;
98
+ --log) LOG="${2:-}"; shift 2 ;;
99
+ --port-min) PORT_MIN="${2:-}"; shift 2 ;;
100
+ --port-max) PORT_MAX="${2:-}"; shift 2 ;;
101
+ --build-timeout) BUILD_TIMEOUT="${2:-}"; shift 2 ;;
102
+ *) err "ERROR: unknown arg: $1"; exit 2 ;;
103
+ esac
104
+ done
105
+
106
+ # --- manifest writer (called on EVERY exit path) ---------------------------
107
+ M_STATUS="error" M_ERROR="-" M_WTPATH="-" M_PORT="-" M_CREATED="-" M_BASELINE="-" M_BLOG="-"
108
+ write_manifest() {
109
+ [ -n "$MANIFEST" ] || return 0
110
+ {
111
+ printf 'status: %s\n' "$M_STATUS"
112
+ printf 'error: %s\n' "$M_ERROR"
113
+ printf 'worktree_path: %s\n' "$M_WTPATH"
114
+ printf 'branch: %s\n' "${BRANCH:--}"
115
+ printf 'port: %s\n' "$M_PORT"
116
+ printf 'created_at: %s\n' "$M_CREATED"
117
+ printf 'baseline: %s\n' "$M_BASELINE"
118
+ printf 'baseline_log: %s\n' "$M_BLOG"
119
+ } > "$MANIFEST" 2>/dev/null || true
120
+ }
121
+ fail() { M_STATUS="error"; M_ERROR="$1"; write_manifest; err "ERROR: $1"; exit "${2:-1}"; }
122
+
123
+ [ -n "$BRANCH" ] || { err "ERROR: --branch is required"; exit 2; }
124
+ [ -n "$SLUG" ] || { err "ERROR: --slug is required"; exit 2; }
125
+ [ -n "$MANIFEST" ] || { err "ERROR: --manifest is required"; exit 2; }
126
+
127
+ # --- canonical main-repo-root resolver (mirrors allocate-id.sh resolve_main;
128
+ # inlined by the same rationale that script documents — it is not on disk
129
+ # inside a freshly-checked-out worktree, and sourcing across the freshly
130
+ # created worktree boundary is fragile). Built on --show-toplevel, NEVER
131
+ # --git-common-dir+parent (wrong under git init --separate-git-dir). -------
132
+ resolve_main() {
133
+ local top gd common
134
+ top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
135
+ gd="$(git rev-parse --git-dir 2>/dev/null)" || return 1
136
+ common="$(git rev-parse --git-common-dir 2>/dev/null)" || return 1
137
+ if [ "$gd" = "$common" ]; then
138
+ printf '%s\n' "$top"
139
+ else
140
+ (cd "$top/.." 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null) || return 1
141
+ fi
142
+ }
143
+
144
+ # --- read a paths.* / git.* / stack.* scalar from baldart.config.yml --------
145
+ config_scalar() {
146
+ local block="$1" key="$2" cfg="$MAIN/baldart.config.yml"
147
+ [ -f "$cfg" ] || return 0
148
+ grep -A60 "^${block}:" "$cfg" 2>/dev/null \
149
+ | grep -m1 "[[:space:]]*${key}:" \
150
+ | sed -E "s/.*${key}:[[:space:]]*\"?([^\"#]*)\"?.*/\1/" \
151
+ | sed -E 's/[[:space:]]+$//'
152
+ }
153
+
154
+ # --- resolve MAIN ----------------------------------------------------------
155
+ if [ -z "$MAIN" ]; then
156
+ MAIN="$(resolve_main)" || fail "cannot resolve main repo root (not inside a git repository)" 1
157
+ fi
158
+ [ -d "$MAIN/.git" ] || [ -e "$MAIN/.git" ] || fail "resolved main '$MAIN' is not a git repo" 1
159
+
160
+ # --- resolve TRUNK (config → origin/HEAD → develop/main/master) -------------
161
+ if [ -z "$TRUNK" ]; then
162
+ TRUNK="$(config_scalar git trunk_branch)"
163
+ fi
164
+ if [ -z "$TRUNK" ]; then
165
+ TRUNK="$(git -C "$MAIN" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null | sed -E 's#^refs/remotes/origin/##' || true)"
166
+ fi
167
+ if [ -z "$TRUNK" ]; then
168
+ for cand in develop main master; do
169
+ if git -C "$MAIN" show-ref --verify --quiet "refs/heads/$cand"; then TRUNK="$cand"; break; fi
170
+ done
171
+ fi
172
+ [ -n "$TRUNK" ] || fail "git.trunk_branch unresolved (config + origin/HEAD + develop/main/master all empty)" 1
173
+
174
+ # --- resolve env_files (CSV → array; default .env.local,.env when unset) ----
175
+ ENV_FILES=()
176
+ if [ "$ENV_FILES_SET" = 1 ]; then
177
+ # explicit --env-files wins, even if empty (caller may intentionally pass none)
178
+ IFS=',' read -r -a ENV_FILES <<< "$ENV_FILES_ARG"
179
+ else
180
+ # not passed → try config stack.env_files (CSV/inline-list best-effort), else default
181
+ cfg_env="$(grep -A4 '^[[:space:]]*env_files:' "$MAIN/baldart.config.yml" 2>/dev/null | tr -d '[]"' | grep -oE "\.[A-Za-z0-9._/-]+" | tr '\n' ',' || true)"
182
+ if [ -n "$cfg_env" ]; then
183
+ IFS=',' read -r -a ENV_FILES <<< "$cfg_env"
184
+ else
185
+ ENV_FILES=( ".env.local" ".env" )
186
+ fi
187
+ fi
188
+ # strip whitespace from each entry
189
+ _clean=(); for ef in "${ENV_FILES[@]:-}"; do ef="$(printf '%s' "$ef" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"; [ -n "$ef" ] && _clean+=("$ef"); done
190
+ ENV_FILES=("${_clean[@]:-}")
191
+
192
+ # --- resolve backlog dir + toolchain commands ------------------------------
193
+ [ -n "$BACKLOG_DIR" ] || { BACKLOG_DIR="$(config_scalar paths backlog_dir)"; [ -n "$BACKLOG_DIR" ] || BACKLOG_DIR="backlog"; }
194
+ [ -n "$INSTALL_CMD" ] || INSTALL_CMD="npm install"
195
+ # toolchain.commands.* only when features.has_toolchain: true; else the defaults.
196
+ _tc() {
197
+ local cfg="$MAIN/baldart.config.yml"
198
+ grep -E '^[[:space:]]*has_toolchain:[[:space:]]*true' "$cfg" >/dev/null 2>&1 || return 0
199
+ grep -A20 '^toolchain:' "$cfg" 2>/dev/null | grep -A15 '^[[:space:]]*commands:' \
200
+ | grep -E "^[[:space:]]+$1:" | head -1 \
201
+ | sed -E "s/.*$1:[[:space:]]*\"?([^\"#]*)\"?.*/\1/" | sed -E 's/[[:space:]]+$//'
202
+ }
203
+ [ -n "$TC_TC" ] || TC_TC="$(_tc typecheck)"; [ -n "$TC_TC" ] || TC_TC="npx tsc --noEmit"
204
+ [ -n "$TC_LINT" ] || TC_LINT="$(_tc lint)"; [ -n "$TC_LINT" ] || TC_LINT="npx eslint --max-warnings=0 src/"
205
+ [ -n "$TC_BUILD" ] || TC_BUILD="$(_tc build)"; [ -n "$TC_BUILD" ] || TC_BUILD="npm run build"
206
+
207
+ # --- log sink --------------------------------------------------------------
208
+ [ -n "$LOG" ] || LOG="/tmp/wt-setup-${SLUG}.log"
209
+ : > "$LOG" 2>/dev/null || true
210
+
211
+ # --- worktree path is deterministic from the branch (R8) -------------------
212
+ WT_REL=".worktrees/$(printf '%s' "$BRANCH" | tr '/' '-')"
213
+ WT_ABS="$MAIN/$WT_REL"
214
+ M_WTPATH="$WT_ABS"
215
+
216
+ # --- lock helpers (mkdir is atomic on POSIX; same pattern as allocate-id.sh) -
217
+ WT_DIR="$MAIN/.worktrees"
218
+ LOCKDIR="$WT_DIR/.wt-setup.lock"
219
+ LOCK_STALE_SECONDS=30
220
+ LOCK_MAX_TRIES=150
221
+ file_mtime() { stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || echo 0; }
222
+ acquire_lock() {
223
+ local tries=0 now m age
224
+ mkdir -p "$WT_DIR" 2>/dev/null || true
225
+ while ! mkdir "$LOCKDIR" 2>/dev/null; do
226
+ if [ -d "$LOCKDIR" ]; then
227
+ now="$(date +%s)"; m="$(file_mtime "$LOCKDIR")"
228
+ case "$m" in ''|*[!0-9]*) m=0 ;; esac
229
+ age=$(( now - m ))
230
+ if [ "$m" -gt 0 ] && [ "$age" -gt "$LOCK_STALE_SECONDS" ]; then
231
+ err "WARN: stealing stale wt-setup lock (age ${age}s)"; rm -rf "$LOCKDIR" 2>/dev/null; continue
232
+ fi
233
+ fi
234
+ tries=$(( tries + 1 ))
235
+ [ "$tries" -gt "$LOCK_MAX_TRIES" ] && { err "WARN: could not acquire wt-setup lock — proceeding without it (port race possible)"; return 1; }
236
+ sleep 0.2
237
+ done
238
+ printf '%s\n' "$$" > "$LOCKDIR/pid" 2>/dev/null || true
239
+ return 0
240
+ }
241
+ release_lock() { rm -rf "$LOCKDIR" 2>/dev/null || true; }
242
+
243
+ # --- registry upsert via node (atomic tmp+rename; no jq dependency) ---------
244
+ # Fields are passed through the environment to avoid bash→JSON quoting hell.
245
+ REG="$WT_DIR/registry.json"
246
+ registry_upsert() {
247
+ REG_FILE="$REG" \
248
+ R_CARD="$CARD" R_CARDS="$CARDS" R_GROUP="$GROUP_PARENT" R_SLUG="$SLUG" \
249
+ R_BRANCH="$BRANCH" R_TRUNK="$TRUNK" R_MAIN="$MAIN" R_PATH="$WT_ABS" \
250
+ R_PORT="$1" R_CREATED="$M_CREATED" R_ENVSYNCED="$2" R_BUILDVERIFIED="$3" \
251
+ node -e '
252
+ const fs = require("fs");
253
+ const f = process.env.REG_FILE;
254
+ let reg = { worktrees: [] };
255
+ try { const j = JSON.parse(fs.readFileSync(f, "utf8")); if (j && Array.isArray(j.worktrees)) reg = j; } catch (_) {}
256
+ const csv = (s) => (s || "").split(",").map(x => x.trim()).filter(Boolean);
257
+ const num = (s) => { const n = parseInt(s, 10); return Number.isFinite(n) ? n : null; };
258
+ const bool = (s) => s === "true" ? true : s === "false" ? false : null;
259
+ const entry = {
260
+ card: process.env.R_CARD || null,
261
+ cards: csv(process.env.R_CARDS),
262
+ groupParent: process.env.R_GROUP || null,
263
+ slug: process.env.R_SLUG || null,
264
+ branch: process.env.R_BRANCH,
265
+ trunkBranch: process.env.R_TRUNK || null,
266
+ mainRoot: process.env.R_MAIN || null,
267
+ path: process.env.R_PATH,
268
+ port: num(process.env.R_PORT),
269
+ createdAt: process.env.R_CREATED && process.env.R_CREATED !== "-" ? process.env.R_CREATED : null,
270
+ envSyncedAt: process.env.R_ENVSYNCED && process.env.R_ENVSYNCED !== "-" ? process.env.R_ENVSYNCED : null,
271
+ buildVerified: bool(process.env.R_BUILDVERIFIED),
272
+ };
273
+ const i = reg.worktrees.findIndex(w => w && w.branch === entry.branch);
274
+ if (i >= 0) reg.worktrees[i] = { ...reg.worktrees[i], ...entry };
275
+ else reg.worktrees.push(entry);
276
+ const tmp = f + ".tmp";
277
+ fs.writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n");
278
+ fs.renameSync(tmp, f);
279
+ ' 2>>"$LOG"
280
+ }
281
+ # ports currently held by registry entries (so concurrent setups never collide)
282
+ registry_used_ports() {
283
+ REG_FILE="$REG" node -e '
284
+ const fs = require("fs");
285
+ try {
286
+ const j = JSON.parse(fs.readFileSync(process.env.REG_FILE, "utf8"));
287
+ (j.worktrees || []).forEach(w => { if (w && Number.isFinite(w.port)) console.log(w.port); });
288
+ } catch (_) {}
289
+ ' 2>/dev/null
290
+ }
291
+
292
+ # ===========================================================================
293
+ # 1. .gitignore safety (auto-heal, idempotent, non-blocking)
294
+ if [ ! -f "$MAIN/.gitignore" ] || ! grep -qE '^\.worktrees/?$' "$MAIN/.gitignore"; then
295
+ printf '\n.worktrees/\n' >> "$MAIN/.gitignore"
296
+ err "WARN: appended '.worktrees/' to $MAIN/.gitignore (was missing)."
297
+ fi
298
+
299
+ # 2. Fetch trunk (read-only; never touches the main repo HEAD)
300
+ git -C "$MAIN" fetch origin "$TRUNK" --quiet 2>>"$LOG" || err "WARN: git fetch origin $TRUNK failed — branching from possibly-stale local ref"
301
+
302
+ # 3. Collision guard — a code worktree is NOT silently re-creatable. The caller's
303
+ # 4a2 pre-check should have reset any orphan; if something is still here, fail
304
+ # loud rather than clobber.
305
+ EXISTING="$(git -C "$MAIN" worktree list --porcelain | awk -v b="refs/heads/$BRANCH" '$1=="worktree"{p=$2} $1=="branch"&&$2==b{print p}')"
306
+ if [ -n "$EXISTING" ]; then
307
+ fail "branch '$BRANCH' already has a worktree at '$EXISTING' (caller's idempotency pre-check should reset/resume before invoking this script)" 3
308
+ fi
309
+ if [ -e "$WT_ABS" ]; then
310
+ fail "worktree path '$WT_ABS' already exists on disk (stale orphan — caller must reset it first)" 3
311
+ fi
312
+
313
+ # 4. Create the worktree off origin/$TRUNK + stamp created_at AT creation
314
+ git -C "$MAIN" worktree add "$WT_REL" -b "$BRANCH" "origin/$TRUNK" >>"$LOG" 2>&1 \
315
+ || fail "git worktree add failed (see $LOG)" 4
316
+ M_CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
317
+ # Provisional NON-terminal manifest — written the instant the worktree exists, so
318
+ # that if this process is KILLED mid-install/build (e.g. the caller's background
319
+ # timeout fires, or no `timeout` binary exists on this host so the build runs
320
+ # unbounded) the manifest on disk still reads status:error / baseline:- . The
321
+ # caller's disk gate (setup.md 6a) treats anything that is NOT a terminal
322
+ # `status: ok` + `baseline: pass` as NOT-verified → fallback, never a false pass
323
+ # on a half-built worktree whose node_modules merely happens to exist.
324
+ M_STATUS="error"; M_ERROR="setup incomplete (worktree created, install/build not finished — process killed or interrupted)"
325
+ write_manifest
326
+
327
+ # 4b. Sync untracked backlog cards from main (created during /prd, not yet on trunk)
328
+ if [ -d "$MAIN/$BACKLOG_DIR" ]; then
329
+ mkdir -p "$WT_ABS/$BACKLOG_DIR" 2>/dev/null || true
330
+ for CARD_FILE in "$MAIN/$BACKLOG_DIR"/*.yml; do
331
+ [ -e "$CARD_FILE" ] || continue
332
+ BN="$(basename "$CARD_FILE")"
333
+ [ -f "$WT_ABS/$BACKLOG_DIR/$BN" ] || cp "$CARD_FILE" "$WT_ABS/$BACKLOG_DIR/$BN" 2>/dev/null || true
334
+ done
335
+ fi
336
+
337
+ cd "$WT_ABS" || fail "cannot cd into worktree '$WT_ABS'" 4
338
+
339
+ # 5. Install dependencies — NON-INTERACTIVE (stdin closed, CI=1). An install that
340
+ # blocks on a prompt is the bash equivalent of the model "going idle" — the
341
+ # exact failure this script eliminates; never reintroduce it via a prompt.
342
+ err "… installing dependencies ($INSTALL_CMD)"
343
+ if ! CI=1 bash -c "$INSTALL_CMD" </dev/null >>"$LOG" 2>&1; then
344
+ M_BASELINE="fail"; M_BLOG="$LOG"
345
+ fail "dependency install failed ($INSTALL_CMD) — see $LOG" 5
346
+ fi
347
+
348
+ # 6. Copy gitignored env artifacts (stack.env_files). FILES via plain cp
349
+ # (dereferences symlinks; never cp -P), DIRS via cp -R. Missing FILE → WARN,
350
+ # never abort (the baseline build gate is the real enforcement).
351
+ primary_env=""
352
+ copied_any=0
353
+ for ef in "${ENV_FILES[@]:-}"; do
354
+ [ -n "$ef" ] || continue
355
+ src="$MAIN/$ef"; dest_dir="$(dirname "$ef")"
356
+ if [ -d "$src" ]; then
357
+ mkdir -p "$dest_dir"; rm -rf "$ef"
358
+ cp -R "$src" "$dest_dir"/ 2>/dev/null || true
359
+ [ -e "$ef" ] && copied_any=1 || err "WARN: env dir '$ef' present in main but copy failed."
360
+ elif [ -f "$src" ]; then
361
+ mkdir -p "$dest_dir"
362
+ if cp "$src" "$ef" 2>>"$LOG"; then copied_any=1; [ -z "$primary_env" ] && primary_env="$ef"; fi
363
+ else
364
+ err "WARN: env artifact '$ef' (stack.env_files) not found in $MAIN — the build may fail if it is required."
365
+ fi
366
+ done
367
+ ENVSYNCED="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
368
+ if [ "$copied_any" = 0 ] && [ "${#ENV_FILES[@]}" -gt 0 ]; then
369
+ err "WARN: NO env artifacts copied (none of: ${ENV_FILES[*]:-} exist in $MAIN)."
370
+ fi
371
+
372
+ # 7. Allocate a free port UNDER THE LOCK (registry-held ports + live listeners),
373
+ # then write a PROVISIONAL registry entry (buildVerified:false) so the port is
374
+ # reserved against concurrent /nw before the long build runs. The lock is held
375
+ # only across the cheap pick+write — never across npm install / build.
376
+ PORT=""
377
+ if acquire_lock; then trap release_lock EXIT; fi
378
+ USED="$(registry_used_ports | tr '\n' ' ')"
379
+ p="$PORT_MIN"
380
+ while [ "$p" -le "$PORT_MAX" ]; do
381
+ if printf ' %s ' "$USED" | grep -q " $p " ; then p=$(( p + 1 )); continue; fi
382
+ if lsof -i :"$p" -sTCP:LISTEN >/dev/null 2>&1; then p=$(( p + 1 )); continue; fi
383
+ PORT="$p"; break
384
+ done
385
+ if [ -z "$PORT" ]; then
386
+ release_lock; trap - EXIT
387
+ M_BASELINE="fail"; M_BLOG="$LOG"
388
+ fail "no free port in ${PORT_MIN}-${PORT_MAX} (deterministic — recreating cannot fix)" 6
389
+ fi
390
+ M_PORT="$PORT"
391
+ registry_upsert "$PORT" "$ENVSYNCED" "false"
392
+ release_lock; trap - EXIT
393
+
394
+ # 7b. Persist PORT into the primary copied env file (the dev server reads it).
395
+ PORT_ENV_FILE="${primary_env:-.env.local}"
396
+ if grep -q "^PORT=" "$PORT_ENV_FILE" 2>/dev/null; then
397
+ sed -i '' "s/^PORT=.*/PORT=$PORT/" "$PORT_ENV_FILE" 2>/dev/null \
398
+ || sed -i "s/^PORT=.*/PORT=$PORT/" "$PORT_ENV_FILE" 2>/dev/null || true
399
+ else
400
+ echo "PORT=$PORT" >> "$PORT_ENV_FILE" 2>/dev/null || true
401
+ fi
402
+
403
+ # 8. Assert git hooks are ACTIVE (best-effort WARN, never blocks)
404
+ HOOK_SRC=""
405
+ for d in .husky .githooks scripts/git-hooks githooks; do
406
+ if [ -d "$d" ] && ls "$d" 2>/dev/null | grep -qE '^(pre-commit|pre-push|commit-msg)$'; then HOOK_SRC="$d"; break; fi
407
+ done
408
+ if [ -n "$HOOK_SRC" ]; then
409
+ ACTIVE="$(git config --get core.hooksPath || echo .git/hooks)"
410
+ case "$ACTIVE" in
411
+ "$HOOK_SRC"|"$HOOK_SRC"/*|.husky/_) : ;;
412
+ *) err "WARN: git hooks INACTIVE — versioned hooks in '$HOOK_SRC' but core.hooksPath='$ACTIVE'. Fix: git config core.hooksPath $HOOK_SRC" ;;
413
+ esac
414
+ fi
415
+
416
+ # 9. Baseline — tsc/lint are report-but-continue; build is STOP-on-fail.
417
+ # The build runs under a HARD timeout so a hung/interactive build cannot stall
418
+ # the caller's pre-flight barrier forever.
419
+ err "… baseline typecheck ($TC_TC)"
420
+ CI=1 bash -c "$TC_TC" </dev/null >>"$LOG" 2>&1 || err "WARN: typecheck failed (report-but-continue — trunk should be clean; see $LOG)"
421
+ err "… baseline lint ($TC_LINT)"
422
+ CI=1 bash -c "$TC_LINT" </dev/null >>"$LOG" 2>&1 || err "WARN: lint failed (report-but-continue; see $LOG)"
423
+
424
+ # Resolve a timeout binary (coreutils 'timeout', or gtimeout on macOS). If none,
425
+ # run without one (best-effort) and WARN — better than aborting setup.
426
+ TIMEOUT_BIN=""
427
+ command -v timeout >/dev/null 2>&1 && TIMEOUT_BIN="timeout"
428
+ [ -z "$TIMEOUT_BIN" ] && command -v gtimeout >/dev/null 2>&1 && TIMEOUT_BIN="gtimeout"
429
+
430
+ err "… baseline build ($TC_BUILD, timeout ${BUILD_TIMEOUT}s)"
431
+ if [ -n "$TIMEOUT_BIN" ]; then
432
+ CI=1 "$TIMEOUT_BIN" "$BUILD_TIMEOUT" bash -c "$TC_BUILD" </dev/null >>"$LOG" 2>&1
433
+ BUILD_RC=$?
434
+ else
435
+ err "WARN: no 'timeout'/'gtimeout' binary — running build WITHOUT a hard timeout."
436
+ CI=1 bash -c "$TC_BUILD" </dev/null >>"$LOG" 2>&1
437
+ BUILD_RC=$?
438
+ fi
439
+
440
+ if [ "$BUILD_RC" -eq 124 ] && [ -n "$TIMEOUT_BIN" ]; then
441
+ M_BASELINE="timeout"; M_BLOG="$LOG"
442
+ # leave buildVerified:false in the registry (already provisional)
443
+ fail "baseline build TIMED OUT after ${BUILD_TIMEOUT}s — partial log at $LOG" 7
444
+ elif [ "$BUILD_RC" -ne 0 ]; then
445
+ M_BASELINE="fail"; M_BLOG="$LOG"
446
+ fail "baseline build FAILED (do NOT fix here — the coder repairs it) — see $LOG" 7
447
+ fi
448
+
449
+ # 10. Build passed → flip buildVerified:true in the registry and finish clean.
450
+ if acquire_lock; then trap release_lock EXIT; fi
451
+ registry_upsert "$PORT" "$ENVSYNCED" "true"
452
+ release_lock; trap - EXIT
453
+
454
+ M_STATUS="ok"; M_ERROR="-"; M_BASELINE="pass"; M_BLOG="-"
455
+ write_manifest
456
+ err "✓ worktree ready: $WT_ABS (branch $BRANCH, port $PORT, baseline pass)"
457
+ exit 0
@@ -80,6 +80,7 @@ const FINDING = {
80
80
  evidence: { type: 'string', description: 'exact file:line + code quote' },
81
81
  minimal_fix_direction: { type: 'string' },
82
82
  domain: { enum: ['doc', 'security', 'migration', 'code', 'perf', 'test'], description: 'Domain-Override routing bucket' },
83
+ requires_action: { type: 'boolean', description: 'false = a VERIFIED observation that needs NO change anywhere (concern cleared / safe-as-is / advisory) — recorded for audit, NEVER routed to a writer. Omit (⇒ actionable) when a concrete edit is needed. If a change is needed but not by you (out of scope), keep true and say so in minimal_fix_direction (it surfaces as residual).' },
83
84
  },
84
85
  }
85
86
  const FINDINGS_SCHEMA = {
@@ -200,7 +201,8 @@ const codexPrompt =
200
201
  `Return codexAvailable:false ONLY if $REVIEW_FILE ends up containing "CODEX_NOT_FOUND" or stays empty after the FULL 10-minute window — NEVER because a single Bash call returned slowly.\n\n` +
201
202
  `${waveBrief}\n\n${baselineBrief}\n\n` +
202
203
  `For each finding return: finding_id, title, severity (BLOCKER|HIGH|MEDIUM|LOW), confidence (0-100), evidence (exact file:line + code quote), minimal_fix_direction, and domain (doc|security|migration|code|perf|test). ` +
203
- `Run the mandatory false-positive check on every finding and suppress the unconvincing ones (your findings are treated as already FP-validated). Set codexAvailable:true when the review ran.`
204
+ `Run the mandatory false-positive check on every finding and suppress the unconvincing ones (your findings are treated as already FP-validated). ` +
205
+ `Then the actionability check: an observation you have VERIFIED needs NO change anywhere (the fix direction would be "no fix required" / "acceptable as-is" / "verified non-issue") is not work — set requires_action:false on it (it is recorded but never sent to a fixer), or simply do not emit it. Emit requires_action true/omitted ONLY when a concrete edit is needed. Set codexAvailable:true when the review ran.`
204
206
 
205
207
  const tcGateLines = [
206
208
  ['lint', tcCmds.lint], ['typecheck', tcCmds.typecheck], ['test', tcCmds.test],
@@ -218,12 +220,14 @@ function simplifyPrompt(c) {
218
220
  ` • Quality — redundant state, parameter sprawl, copy-paste with slight variation, leaky abstractions, stringly-typed code where enums exist, unnecessary JSX nesting, WHAT-comments / narration.\n` +
219
221
  ` • Efficiency — redundant computation, duplicate API calls, N+1, missed concurrency, hot-path bloat, missing change-detection guards, unbounded structures.\n\n` +
220
222
  `${cardScopeBrief(c)}\n\n${baselineBrief}\n\n` +
221
- `Run a false-positive check on every finding and SUPPRESS the unconvincing ones (your surviving findings are treated as validated). Return findings with domain in {code, perf}. Flag only a finding you genuinely cannot resolve as confidence < 80.`
223
+ `Run a false-positive check on every finding and SUPPRESS the unconvincing ones (your surviving findings are treated as validated). ` +
224
+ `Then the actionability check: an observation you have VERIFIED needs NO change (the fix direction would be "no fix required" / "acceptable as-is" / "optional") is not work — set requires_action:false (recorded, never sent to a fixer), or do not emit it. Return findings with domain in {code, perf}. Flag only a finding you genuinely cannot resolve as confidence < 80.`
222
225
  }
223
226
 
224
227
  function securityPrompt(c) {
225
228
  return `AppSec review (read-only) over ONE card's committed diff — auth, permissions, secrets, webhooks, file upload, infra, multi-tenant isolation, injection. Security-sensitive paths: ${highRisk.join(', ') || '(none configured)'}.\n\n${cardScopeBrief(c)}\n\n${baselineBrief}\n\n` +
226
- `You OWN the security domain end-to-end: run the mandatory false-positive check on every finding yourself and SUPPRESS the unconvincing ones — your surviving findings are treated as validated and are NOT re-judged by another agent. Flag only a finding you genuinely cannot resolve as confidence < 80. Return findings with domain in {security, migration}.`
229
+ `You OWN the security domain end-to-end: run the mandatory false-positive check on every finding yourself and SUPPRESS the unconvincing ones — your surviving findings are treated as validated and are NOT re-judged by another agent. ` +
230
+ `Then the actionability check: a control you have VERIFIED as safe-as-is needs NO change (the fix direction would be "no fix required" / "verified safe") — set requires_action:false on it (recorded for the security audit trail, never sent to a fixer), or do not emit it. Flag only a finding you genuinely cannot resolve as confidence < 80. Return findings with domain in {security, migration}.`
227
231
  }
228
232
 
229
233
  // Build the finder fan-out. Per-card: simplify (if runSimplify), security (if hasSecurityFiles).
@@ -273,12 +277,19 @@ for (const item of findResults) {
273
277
  if (!codexRan) {
274
278
  codexEngine = 'code-reviewer (fallback)'
275
279
  const fb = await agent(
276
- `Codex was unavailable for this wave's code review. Run the FULL code review yourself over the wave diff, per ${protocolRef} (Phase 3.7).\n\n${waveBrief}\n\n${baselineBrief}\n\nReturn findings using the schema fields, with a self false-positive check applied (your findings are treated as validated).`,
280
+ `Codex was unavailable for this wave's code review. Run the FULL code review yourself over the wave diff, per ${protocolRef} (Phase 3.7).\n\n${waveBrief}\n\n${baselineBrief}\n\nReturn findings using the schema fields, with a self false-positive check applied (your findings are treated as validated). An observation you VERIFIED needs NO change (fix direction "no fix required" / "acceptable as-is") is not work — set requires_action:false (recorded, never sent to a fixer), or do not emit it.`,
277
281
  { label: 'code-reviewer (fallback)', phase: 'Discovery', agentType: 'code-reviewer', schema: FINDINGS_SCHEMA }
278
282
  )
279
283
  if (fb && Array.isArray(fb.findings)) raw.push(...fb.findings.map((f) => ({ ...f, source: 'code-reviewer', preValidated: true })))
280
284
  }
281
285
 
286
+ // Globally-unique finding_id at the fan-in. Finders number F### INDEPENDENTLY, so a Codex F003 and
287
+ // a security F003 collide. The Fix-phase bookkeeping (appliedIds / unresolvedIds / codeResidual /
288
+ // bucket) keys on finding_id with FLAT Sets, so a collision cross-contaminates: one finder's
289
+ // `unresolved` id drags another finder's APPLIED same-id finding into residual (the empty-coder
290
+ // re-spawn bug). Prefix source + running index → uniqueness without losing the human-readable id.
291
+ raw.forEach((f, i) => { f.finding_id = `${f.source || 'src'}#${i}:${f.finding_id || ('F' + i)}` })
292
+
282
293
  // ───────────────────────────────────────────────────────────────────────────
283
294
  // Phase Verify — specialist-owned validation (parity with new-final-review.js F.4)
284
295
  // ───────────────────────────────────────────────────────────────────────────
@@ -330,16 +341,29 @@ const isDoc = (f) => /doc|wiki|ssot|readme/.test(String(f.domain).toLowerCase())
330
341
  // migration/test → coder), so match the exact 'security' domain, not the broader verifier regex.
331
342
  const isSecurity = (f) => String(f.domain).toLowerCase() === 'security'
332
343
  const isManual = (f) => f.classification === 'NEEDS_MANUAL_CONFIRMATION'
344
+ // Actionability gate: a VERIFIED finding that needs NO change must NOT reach a writer (it floods the
345
+ // fix pass with no-ops). Honoured ONLY for MED/LOW — a BLOCKER/HIGH is always actionable (the flag
346
+ // can never suppress a blocker; a cross-wave HIGH like "don't merge before the consumer cards" stays
347
+ // actionable → surfaces as residual). Signal: finder flag requires_action:false, OR a conservative
348
+ // regex backstop on the fix direction for when the model omitted the flag. Never a silent drop —
349
+ // no-action findings are logged + counted, just excluded from the writer partitions.
350
+ const NO_ACTION_RE = /(^|[.\s(])(no\s+(fix|change|action)\s+(is\s+)?(required|needed)|none\s+(needed|required)|not\s+a\s+(defect|bug|correctness\s+bug)|acceptable\s+as[-\s]is|verified\s+(safe|non[-\s]issue)|no\s+action\s+(needed|required))([.\s,;)]|$)/i
351
+ const isNoAction = (f) => (f.severity !== 'BLOCKER' && f.severity !== 'HIGH') &&
352
+ (f.requires_action === false || NO_ACTION_RE.test(String(f.minimal_fix_direction || '')))
333
353
  // Partition `surviving` (= VERIFIED + NEEDS_MANUAL; FALSE_POSITIVE already dropped) with NO overlap:
334
354
  // securityFix = VERIFIED security → security-reviewer applies (it owns the security invariants).
335
355
  // actionable = VERIFIED non-doc non-security → the coder fixes these.
336
356
  // docResidual = VERIFIED doc → the skill runs doc-reviewer post-E2E on final code.
337
357
  // manualResidual= NEEDS_MANUAL any → human gate, owned by the skill (a doc-manual must NOT be
338
358
  // silently auto-re-reviewed: it carries its needs-manual classification out).
339
- const securityFix = surviving.filter((f) => f.classification === 'VERIFIED' && !isDoc(f) && isSecurity(f))
340
- const actionable = surviving.filter((f) => f.classification === 'VERIFIED' && !isDoc(f) && !isSecurity(f))
341
- const docResidual = surviving.filter((f) => f.classification === 'VERIFIED' && isDoc(f))
359
+ // No-action VERIFIED findings: recorded + counted, excluded from every writer partition.
360
+ const noAction = surviving.filter((f) => f.classification === 'VERIFIED' && isNoAction(f))
361
+ const noActionSet = new Set(noAction)
362
+ const securityFix = surviving.filter((f) => f.classification === 'VERIFIED' && !isDoc(f) && isSecurity(f) && !noActionSet.has(f))
363
+ const actionable = surviving.filter((f) => f.classification === 'VERIFIED' && !isDoc(f) && !isSecurity(f) && !noActionSet.has(f))
364
+ const docResidual = surviving.filter((f) => f.classification === 'VERIFIED' && isDoc(f) && !noActionSet.has(f))
342
365
  const manualResidual = surviving.filter(isManual)
366
+ for (const f of noAction) log(`Review: no-action finding recorded (not routed to a writer): [${f.finding_id}] ${f.severity} ${f.domain} — ${f.title} (${f.requires_action === false ? 'requires_action:false' : 'no-action-inferred'})`)
343
367
 
344
368
  const SKIP_CHECKS = { lint: 'SKIP', tsc: 'SKIP', build: 'SKIP' }
345
369
 
@@ -360,7 +384,7 @@ async function applyFixPass(findings, writer, label, role) {
360
384
  findings.map((f) => `- [${f.finding_id}] (${f.card || '?'} / ${f.domain} / ${f.severity}) ${f.title}\n evidence: ${f.evidence}\n direction: ${f.minimal_fix_direction}`).join('\n') +
361
385
  `\n\nAfter applying: run \`${tc('lint', 'npm run lint')}\` and (when the project uses typescript) \`${tc('typecheck', 'npx tsc --noEmit')}\` and \`${tc('build', 'npm run build')}\` in the worktree. If a check fails because of an edit you made, fix the regression — at most 2 retries — staying within the allowed files. ` +
362
386
  `Do NOT commit. Do NOT git stash (refs/stash is shared across worktrees). ` +
363
- `Return: applied (finding_ids you fixed), unresolved (finding_ids you could NOT fix within the allowed files / 2 retries), and checks (PASS/FAIL/SKIP for lint, tsc, build).`
387
+ `Return: applied (finding_ids you fixed — list a finding here if you MADE the edit, even when the build stays red for reasons OUTSIDE this finding / your ownership, e.g. a known cross-wave cascade; the build status is reported separately in checks), unresolved (finding_ids you could NOT fix within the allowed files / 2 retries — i.e. the EDIT itself could not be made), and checks (PASS/FAIL/SKIP for lint, tsc, build).`
364
388
  const r = await agent(fixBrief, { label, phase: 'Fix', agentType: writer, schema: FIX_SCHEMA })
365
389
  // Normalize: the agent may die (null) or return a truthy object missing fields.
366
390
  const res = (r && typeof r === 'object') ? r : { applied: [], unresolved: findings.map((f) => f.finding_id), checks: { ...SKIP_CHECKS } }
@@ -407,6 +431,7 @@ const summary = makeSummary({
407
431
  cards: cards.length,
408
432
  totalFindings: raw.length,
409
433
  verified: surviving.filter((f) => f.classification === 'VERIFIED').length,
434
+ noAction: noAction.length, // VERIFIED-but-no-change findings: recorded, never sent to a writer
410
435
  falsePositive: classified.filter((f) => f.classification === 'FALSE_POSITIVE').length,
411
436
  needsManual: manualResidual.length,
412
437
  fixesApplied: appliedIds.size,
@@ -418,7 +443,7 @@ const summary = makeSummary({
418
443
  blockers: surviving.filter((f) => f.classification === 'VERIFIED' && f.severity === 'BLOCKER').length,
419
444
  highs: surviving.filter((f) => f.classification === 'VERIFIED' && f.severity === 'HIGH').length,
420
445
  })
421
- log(`Wave review done: ${summary.fixesApplied} fixed, ${summary.codeResidual} code-residual, ${summary.docResidual} doc-residual, ${summary.needsManual} needs-manual, ${summary.failingGates.length} failing gate(s)${checksFailed ? ', post-fix checks FAILED' : ''}. Engine: ${codexEngine}.`)
446
+ log(`Wave review done: ${summary.fixesApplied} fixed, ${summary.codeResidual} code-residual, ${summary.docResidual} doc-residual, ${summary.noAction} no-action (recorded), ${summary.needsManual} needs-manual, ${summary.failingGates.length} failing gate(s)${checksFailed ? ', post-fix checks FAILED' : ''}. Engine: ${codexEngine}.`)
422
447
 
423
448
  return { codexEngine, perCard, gateTable, summary }
424
449
 
@@ -426,7 +451,7 @@ return { codexEngine, perCard, gateTable, summary }
426
451
  function asArr(x) { return Array.isArray(x) ? x.filter(Boolean) : [] }
427
452
  function dedupe(xs) { return Array.from(new Set(asArr(xs))) }
428
453
  function makeSummary(o) {
429
- return Object.assign({ cards: 0, totalFindings: 0, verified: 0, falsePositive: 0, needsManual: 0, fixesApplied: 0, docResidual: 0, codeResidual: 0, qaRan: false, checksFailed: false, failingGates: [], blockers: 0, highs: 0 }, o || {})
454
+ return Object.assign({ cards: 0, totalFindings: 0, verified: 0, noAction: 0, falsePositive: 0, needsManual: 0, fixesApplied: 0, docResidual: 0, codeResidual: 0, qaRan: false, checksFailed: false, failingGates: [], blockers: 0, highs: 0 }, o || {})
430
455
  }
431
456
  function slimFinding(f) {
432
457
  return { finding_id: f.finding_id, title: f.title, severity: f.severity, domain: f.domain, evidence: f.evidence, minimal_fix_direction: f.minimal_fix_direction, classification: f.classification, card: f.card }