baldart 5.4.1 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/VERSION +1 -1
  3. package/framework/.claude/agents/CHANGELOG.md +16 -0
  4. package/framework/.claude/agents/prd-card-writer.md +37 -3
  5. package/framework/.claude/skills/new/CHANGELOG.md +33 -0
  6. package/framework/.claude/skills/new/SKILL.md +11 -5
  7. package/framework/.claude/skills/new/references/commit.md +12 -8
  8. package/framework/.claude/skills/new/references/completeness.md +10 -0
  9. package/framework/.claude/skills/new/references/final-review.md +23 -0
  10. package/framework/.claude/skills/new/references/implement.md +14 -4
  11. package/framework/.claude/skills/new/references/merge-cleanup.md +35 -14
  12. package/framework/.claude/skills/new/references/setup.md +1 -1
  13. package/framework/.claude/skills/new2/CHANGELOG.md +50 -0
  14. package/framework/.claude/skills/new2/SKILL.md +101 -17
  15. package/framework/.claude/skills/prd/CHANGELOG.md +36 -0
  16. package/framework/.claude/skills/prd/SKILL.md +17 -5
  17. package/framework/.claude/skills/prd/assets/state-template.md +8 -1
  18. package/framework/.claude/skills/prd/references/audit-phase.md +13 -1
  19. package/framework/.claude/skills/prd/references/backlog-phase.md +20 -0
  20. package/framework/.claude/skills/prd/references/discovery-phase.md +35 -0
  21. package/framework/.claude/skills/prd/references/ui-design-phase.md +28 -0
  22. package/framework/.claude/skills/prd/references/validation-phase.md +45 -1
  23. package/framework/.claude/skills/worktree-manager/CHANGELOG.md +13 -0
  24. package/framework/.claude/skills/worktree-manager/SKILL.md +11 -1
  25. package/framework/.claude/skills/worktree-manager/scripts/merge-worktree.sh +82 -6
  26. package/framework/.claude/workflows/new-final-review.js +9 -7
  27. package/framework/.claude/workflows/new2-resolve.js +29 -1
  28. package/framework/.claude/workflows/new2.js +153 -17
  29. package/framework/agents/card-schema.md +18 -1
  30. package/framework/agents/research-protocol.md +6 -1
  31. package/framework/scripts/stamp-holistic-audit.js +53 -10
  32. package/package.json +1 -1
@@ -48,6 +48,21 @@
48
48
  # --tc-build <cmd> toolchain build cmd (default: config / npm run build)
49
49
  # --log <path> build/merge log sink (default: /tmp/wt-merge-<slug>.log)
50
50
  # --build-timeout <s> hard build timeout sec (default: 600)
51
+ # --safety-sweep <mode> what the pre-rebase safety commit may stage (v5.6.0):
52
+ # all — every changed+untracked file (default;
53
+ # the historical /mw interactive behavior)
54
+ # allowlist:<globs> — comma-separated shell globs (e.g.
55
+ # "backlog/*.yml,docs/metrics/*,.baldart/*").
56
+ # Matching files → committed with an honest message that
57
+ # ENUMERATES them. Non-matching TRACKED changes → preserved
58
+ # byte-for-byte into a labeled quarantine dir, then restored
59
+ # to HEAD (so the rebase can start). Non-matching UNTRACKED
60
+ # files → left in place. Both reported in the manifest.
61
+ # Rationale (FEAT-0068 5f618347d / FEAT-0067 5a5f1c135): an
62
+ # unconditional sweep contradicts the caller's F-030 "never
63
+ # commit ungated code" by construction — dirty code at merge
64
+ # time did NOT pass the per-card gates and must never ride an
65
+ # opaque "[safety]" commit onto the trunk.
51
66
  #
52
67
  # Status file written (parsed by callers — STABLE contract):
53
68
  # status: success | code_conflict | build_fail | sync_needs_decision | error
@@ -63,6 +78,8 @@
63
78
  # sync_marker: <none | SYNC-DEFERRED | SYNC-NEEDS-DECISION>
64
79
  # sync_detail: <message | ->
65
80
  # build_log: <path | ->
81
+ # uncommitted_left: <csv | -> (allowlist mode — untracked files left in place, NOT committed)
82
+ # quarantined: <dir | -> (allowlist mode — tracked changes preserved here, then reset to HEAD)
66
83
  #
67
84
  # Exit: 0 ONLY on status:success. Non-zero otherwise (the exit code mirrors the
68
85
  # status: 10=code_conflict, 11=build_fail, 12=sync_needs_decision, 1/2=error). The
@@ -75,7 +92,7 @@ err() { printf '%s\n' "$*" >&2; }
75
92
 
76
93
  # --- args ------------------------------------------------------------------
77
94
  WORKTREE= MANIFEST= CONTINUE=0 BRANCH= TRUNK= MAIN= STRATEGY= SKIP_CHECKS=0
78
- METRICS_DIR= TC_TC= TC_LINT= TC_BUILD= LOG= BUILD_TIMEOUT=600
95
+ METRICS_DIR= TC_TC= TC_LINT= TC_BUILD= LOG= BUILD_TIMEOUT=600 SAFETY_SWEEP=all
79
96
  while [ $# -gt 0 ]; do
80
97
  case "$1" in
81
98
  --worktree) WORKTREE="${2:-}"; shift 2 ;;
@@ -92,6 +109,7 @@ while [ $# -gt 0 ]; do
92
109
  --tc-build) TC_BUILD="${2:-}"; shift 2 ;;
93
110
  --log) LOG="${2:-}"; shift 2 ;;
94
111
  --build-timeout) BUILD_TIMEOUT="${2:-}"; shift 2 ;;
112
+ --safety-sweep) SAFETY_SWEEP="${2:-all}"; shift 2 ;;
95
113
  *) err "ERROR: unknown arg: $1"; exit 2 ;;
96
114
  esac
97
115
  done
@@ -99,6 +117,7 @@ done
99
117
  # --- status writer (called on EVERY exit path) -----------------------------
100
118
  M_STATUS="error" M_PHASE="identify" M_ERROR="-" M_MERGE="-" M_MERGETS="-"
101
119
  M_CONFLICTS="-" M_SYNCMARK="none" M_SYNCDETAIL="-" M_BLOG="-"
120
+ M_UNCOMMITTED="-" M_QUARANTINE="-"
102
121
  write_status() {
103
122
  [ -n "$MANIFEST" ] || return 0
104
123
  {
@@ -115,6 +134,8 @@ write_status() {
115
134
  printf 'sync_marker: %s\n' "$M_SYNCMARK"
116
135
  printf 'sync_detail: %s\n' "$M_SYNCDETAIL"
117
136
  printf 'build_log: %s\n' "$M_BLOG"
137
+ printf 'uncommitted_left: %s\n' "$M_UNCOMMITTED"
138
+ printf 'quarantined: %s\n' "$M_QUARANTINE"
118
139
  } > "$MANIFEST" 2>/dev/null || true
119
140
  }
120
141
  fail() { M_STATUS="error"; M_ERROR="$1"; write_status; err "ERROR: $1"; exit "${2:-1}"; }
@@ -362,19 +383,74 @@ else
362
383
  # -------------------------------------------------------------------------
363
384
  # FRESH path: safety commit → (checks) → rebase
364
385
  # -------------------------------------------------------------------------
365
- # 1. Safety commit (ALWAYS never skippable; files lost after rebase are unrecoverable)
386
+ # 1. Safety commit. Mode `all` (default, /mw interactive): every changed+untracked file
387
+ # files lost after rebase are unrecoverable. Mode `allowlist:<globs>` (v5.6.0, passed by
388
+ # orchestrated callers like new2): only batch bookkeeping is committed (with an honest,
389
+ # enumerating message); non-matching TRACKED changes are quarantined byte-for-byte then
390
+ # reset to HEAD (a dirty tracked file would block the rebase), non-matching UNTRACKED
391
+ # files stay in place. Nothing is silently lost, nothing ungated rides to the trunk.
366
392
  M_PHASE="safety"
367
393
  CHANGED="$(git -C "$WORKTREE" diff --name-only HEAD 2>/dev/null)"
368
394
  UNTRACKED="$(git -C "$WORKTREE" ls-files --others --exclude-standard 2>/dev/null)"
369
395
  if [ -n "$CHANGED" ] || [ -n "$UNTRACKED" ]; then
370
- for f in $CHANGED $UNTRACKED; do git -C "$WORKTREE" add "$f" 2>>"$LOG"; done
371
- git -C "$WORKTREE" commit -m "[safety] Auto-commit uncommitted files before merge
396
+ case "$SAFETY_SWEEP" in
397
+ allowlist:*)
398
+ SWEEP_GLOBS="${SAFETY_SWEEP#allowlist:}"
399
+ sweep_match() { # POSIX case-glob match of $1 against any comma-separated pattern
400
+ _f="$1"; _rest="$SWEEP_GLOBS"
401
+ while [ -n "$_rest" ]; do
402
+ _pat="${_rest%%,*}"
403
+ [ "$_pat" = "$_rest" ] && _rest= || _rest="${_rest#*,}"
404
+ # shellcheck disable=SC2254 — unquoted on purpose: $_pat is a glob
405
+ case "$_f" in $_pat) return 0 ;; esac
406
+ done
407
+ return 1
408
+ }
409
+ SWEPT= LEFT_UNTRACKED= QUAR_TRACKED=
410
+ for f in $CHANGED; do
411
+ if sweep_match "$f"; then git -C "$WORKTREE" add "$f" 2>>"$LOG"; SWEPT="$SWEPT $f"
412
+ else QUAR_TRACKED="$QUAR_TRACKED $f"; fi
413
+ done
414
+ for f in $UNTRACKED; do
415
+ if sweep_match "$f"; then git -C "$WORKTREE" add "$f" 2>>"$LOG"; SWEPT="$SWEPT $f"
416
+ else LEFT_UNTRACKED="${LEFT_UNTRACKED:+$LEFT_UNTRACKED,}$f"; fi
417
+ done
418
+ if [ -n "$QUAR_TRACKED" ]; then
419
+ QDIR="/tmp/wt-merge-quarantine-$(basename "$WORKTREE")-$(date +%s)"
420
+ for f in $QUAR_TRACKED; do
421
+ mkdir -p "$QDIR/$(dirname "$f")" 2>>"$LOG"
422
+ cp -p "$WORKTREE/$f" "$QDIR/$f" 2>>"$LOG" || err "WARN: quarantine copy failed for $f"
423
+ done
424
+ # restore ONLY after the byte-for-byte copy — the rebase needs a clean tracked tree
425
+ for f in $QUAR_TRACKED; do git -C "$WORKTREE" checkout -- "$f" 2>>"$LOG"; done
426
+ M_QUARANTINE="$QDIR"
427
+ err "WARN: $(echo "$QUAR_TRACKED" | wc -w | tr -d ' ') tracked change(s) OUTSIDE the sweep allowlist quarantined to $QDIR + reset to HEAD (ungated code never rides a safety commit — F-030)"
428
+ fi
429
+ [ -z "$LEFT_UNTRACKED" ] || M_UNCOMMITTED="$LEFT_UNTRACKED"
430
+ write_status
431
+ if [ -n "$SWEPT" ]; then
432
+ git -C "$WORKTREE" commit -m "[safety] Materialize batch bookkeeping before merge (sweep allowlist)
433
+
434
+ Allowlist-matched files that were uncommitted when merge-worktree.sh started:
435
+ $(for f in $SWEPT; do printf ' - %s\n' "$f"; done)
436
+
437
+ Non-matching files were NOT committed (quarantined/left + reported in the manifest).
438
+
439
+ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" --quiet >>"$LOG" 2>&1 \
440
+ || err "WARN: allowlist safety commit produced no commit (nothing staged?)"
441
+ fi
442
+ ;;
443
+ *)
444
+ for f in $CHANGED $UNTRACKED; do git -C "$WORKTREE" add "$f" 2>>"$LOG"; done
445
+ git -C "$WORKTREE" commit -m "[safety] Auto-commit uncommitted files before merge
372
446
 
373
447
  Files were uncommitted when merge-worktree.sh started. This safety commit
374
448
  prevents file loss during rebase.
375
449
 
376
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>" --quiet >>"$LOG" 2>&1 \
377
- || err "WARN: safety commit produced no commit (nothing staged?)"
450
+ Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>" --quiet >>"$LOG" 2>&1 \
451
+ || err "WARN: safety commit produced no commit (nothing staged?)"
452
+ ;;
453
+ esac
378
454
  fi
379
455
 
380
456
  # 2. Pre-merge checks (skipped when --skip-checks / checksAlreadyPassed)
@@ -340,13 +340,15 @@ const codexPrompt =
340
340
  `The companion script is ALREADY CONFIRMED PRESENT at:\n ${codexScriptPath}\n\n` +
341
341
  `Run EXACTLY these steps and nothing else:\n` +
342
342
  ` 1. Write the task text (everything after "TASK PROMPT:" at the end of this message) to ${codexTaskFile} using the Write tool — do NOT inline it into the shell command (avoids quote breakage).\n` +
343
- ` 2. Launch Codex in the BACKGROUND (run_in_background:true a sync run hits the Bash timeout):\n` +
344
- ` node "${codexScriptPath}" task "$(cat ${codexTaskFile})" --cwd "${a.worktreePath || '.'}" > ${codexReviewFile} 2>&1\n` +
345
- ` 3. POLL ${codexReviewFile} (BashOutput / repeated reads) until ONE of these appears whichever is FIRST ends the poll (do NOT keep waiting once one is present). Set \`marker\` accordingly:\n` +
346
- ` • "<<<END_FINDINGS_JSON>>>" marker = "END_JSON"\n` +
347
- ` • "Turn completed." → marker = "TURN_COMPLETED" (Codex finished; it may have written prose instead of the JSON block)\n` +
348
- ` • "CODEX_NOT_FOUND" → marker = "CODEX_NOT_FOUND" (companion missing)\n` +
349
- ` • the full 10-minute window elapses with none of the above → marker = "TIMEOUT" (NEVER pick this just because a single poll returned slowly)\n` +
343
+ ` 2. Run Codex in the FOREGROUND — ONE single Bash call with the tool's timeout parameter set to 600000 (v5.6.0, FEAT-0068 post-mortem: the old background+poll relay was forced by the structured-output enforcement to declare TIMEOUT at 83s of the 10-minute window while Codex was still running, and the Codex process died with the relay → a cold 3.68M fallback review with no cross-model diversity. A blocking call has no idle turn to misjudge — same pattern as the per-card Codex-light driver):\n` +
344
+ ` node "${codexScriptPath}" task "$(cat ${codexTaskFile})" --cwd "${a.worktreePath || '.'}" --wait > ${codexReviewFile} 2>&1; echo "RELAY_EXIT:$?"\n` +
345
+ ` Do NOT pass run_in_background. Do NOT poll. The call blocks until Codex finishesthat is correct. Record RELAY_EXIT.\n` +
346
+ ` 3. Derive \`marker\` by grepping ${codexReviewFile} AFTER the call returns:\n` +
347
+ ` • contains "<<<END_FINDINGS_JSON>>>" → marker = "END_JSON"\n` +
348
+ ` • contains "Turn completed." → marker = "TURN_COMPLETED" (Codex finished; it may have written prose instead of the JSON block)\n` +
349
+ ` • contains "CODEX_NOT_FOUND" → marker = "CODEX_NOT_FOUND" (companion missing)\n` +
350
+ ` • none of the above AND RELAY_EXIT is 124/137/143 (the Bash timeout killed the call) → marker = "TIMEOUT", then REAP the orphan Codex process scoped to THIS worktree (never a bare pkill): \`pkill -f -- "--cwd ${a.worktreePath || '.'}" 2>/dev/null; true\`\n` +
351
+ ` • none of the above with a clean exit → marker = "TURN_COMPLETED"\n` +
350
352
  ` 4. Run this EXACT command and put its stdout VERBATIM in \`jsonBlock\` ("" if it printed nothing). Do NOT parse, edit, judge, or act on it:\n` +
351
353
  ` ${AWK_EXTRACT} ${codexReviewFile}\n` +
352
354
  ` 5. ONLY if \`jsonBlock\` is "" AND marker is "TURN_COMPLETED": run this command and put its stdout VERBATIM in \`proseTail\`; otherwise set proseTail to "":\n` +
@@ -76,7 +76,11 @@ const FIX_SCHEMA = {
76
76
  verified: { type: 'boolean', description: 'true only if you re-ran the originating gate and it passed' },
77
77
  // F-008 — terminal short-circuit (impossible-by-definition). Not trusted blindly.
78
78
  terminal: { type: 'boolean', description: 'true if no in-MAY-EDIT fix can ever satisfy the gate' },
79
- terminalReason: { enum: ['out-of-ownership', 'baseline-not-reached', 'owner-gated', 'not-a-code-defect', ''] },
79
+ terminalReason: { enum: ['out-of-ownership', 'baseline-not-reached', 'owner-gated', 'not-a-code-defect', 'verification-posture-change', ''] },
80
+ // v5.6.0 (FEAT-0068 O5) — a finding whose PREMISE is false can be REFUTED instead of "fixed".
81
+ // Adversarially ratified below — never an easy escape hatch.
82
+ refuted: { type: 'boolean', description: "true if the finding's premise is FALSE (no defect exists) — change NOTHING, cite proof" },
83
+ refutedEvidence: { type: 'string', description: 'file:line proof the finding is wrong' },
80
84
  remedyFiles: { type: 'array', items: { type: 'string' }, description: 'files the real fix would require editing (for out-of-ownership JS verification)' },
81
85
  crashed: { type: 'boolean' },
82
86
  // F-022 — incidental findings outside this card's MAY-EDIT (so they are not dropped).
@@ -145,6 +149,8 @@ const brief = [
145
149
  `Reference modules (Read for exact semantics): ${REF}/`,
146
150
  `UI re-verify obligation: if your fix touches a UI file, re-run the Phase 2.6 E2E review (per ${REF}/review-cycle.md) before declaring verified.`,
147
151
  `If the fix requires editing files OUTSIDE MAY-EDIT, return terminal:true terminalReason:'out-of-ownership' remedyFiles:[...] (do NOT edit them). If the remedy is an owner-gated infra action, terminal:true terminalReason:'owner-gated'. Report any incidental defect outside MAY-EDIT in outOfScopeFindings.`,
152
+ `VALIDATE THE PREMISE FIRST (v5.6.0): before touching any file, check the finding against the actual code. If its premise is FALSE (misread code, condition already satisfied, documented-by-design), return refuted:true + refutedEvidence:'<file:line proof>' and change NOTHING — NEVER edit code to appease a wrong finding.`,
153
+ `VERIFICATION-POSTURE RULE (v5.6.0): a "fix" that disables, skips, or opt-in-gates a test/check/suite that an acceptance criterion of this batch mandates is FORBIDDEN — it erodes the DoD (FEAT-0068: a repair flag-gated the epic's visual-regression suite to silence a blocker). If that looks like the only available fix, return terminal:true terminalReason:'verification-posture-change' instead.`,
148
154
  ].filter(Boolean).join('\n')
149
155
 
150
156
  // JS-deterministic ownership check (F-008 anti-escape-hatch): are remedy files ⊆ MAY-EDIT?
@@ -213,6 +219,28 @@ try {
213
219
  )
214
220
  } catch (e) { if (e && e.transientExhausted) return { status: 'followup', reason: 'outage during tier-1', deferralClass: 'outage', outOfScopeFindings: [] }; throw e }
215
221
 
222
+ // v5.6.0 (FEAT-0068 O5 — false-positive BLOCKER): refuted short-circuit, adversarially ratified.
223
+ // The FEAT-0068 CR-001 blocker had a false premise ("baselines missing" = the documented bootstrap
224
+ // state) and was "resolved" by flag-gating the epic's mandated visual-regression suite — 4.73M
225
+ // spent eroding the DoD to appease a wrong finding. A ratified refutation resolves with ZERO edits.
226
+ if (attempt && attempt.refuted && !attempt.terminal) {
227
+ let refConfirmed = selfJudges // reviewer-writer specialists self-verify (same trust model as terminal)
228
+ if (!selfJudges) {
229
+ try {
230
+ const rj = await agentSafe(
231
+ `A repair agent claims this finding's PREMISE IS FALSE (refuted) — no defect exists, nothing was changed. Claimed proof: ${attempt.refutedEvidence || attempt.note || '(none)'}\n\n${brief}\n\nIndependently verify the refutation against the actual files (grep/read them yourself; do NOT trust the claim). Return confirmed:true ONLY if the finding is genuinely wrong.`,
232
+ { label: `resolve:refute-judge:${card}`, phase: 'Verify', agentType: judgeAgent, schema: TERMINAL_JUDGE_SCHEMA }
233
+ )
234
+ refConfirmed = !!(rj && rj.confirmed)
235
+ } catch (_) { refConfirmed = false }
236
+ }
237
+ if (refConfirmed) {
238
+ log(`${kind} REFUTED (false premise${selfJudges ? '' : ', judge-ratified'}) — resolved with zero edits.`)
239
+ return { status: 'resolved', reason: `refuted: ${attempt.refutedEvidence || attempt.note || 'false premise'}`, outOfScopeFindings: collectOOS(attempt) }
240
+ }
241
+ log('refutation NOT ratified — treating as a real finding.')
242
+ }
243
+
216
244
  // F-008 — terminal short-circuit, verified not trusted.
217
245
  if (attempt && attempt.terminal) {
218
246
  const tr = attempt.terminalReason || ''
@@ -85,7 +85,13 @@ const schemaDeployFromTrunkOnly = stack.schema_deploy_from_trunk_only === true
85
85
  const firstCard = cardIds[0] || 'BATCH'
86
86
  const gateLedger = [] // { card, gate, decision, detail }
87
87
  const residualFollowups = [] // { card, kind, followupCard, reason }
88
- const residuals = [] // F-020 OFFLINE-SAFE ledger: { card, kind, evidence, materialized, deferralClass, domain, remedyFiles }
88
+ const residuals = [] // F-020 OFFLINE-SAFE ledger: { card, kind, evidence, materialized, deferralClass, domain, remedyFiles, severity? }
89
+ // v5.6.0 (FEAT-0068 post-mortem) — epic-closure integrity state. closureInvalidating collects final
90
+ // findings that assert an epic goal/AC is NOT met (a STATE defect: a doc fix resolves the text, not
91
+ // the epic). closureBlockedEpics is the JS-computed deny-list the merge agent must honor: the
92
+ // workflow sees the residual ledger; the merge agent's grep cannot see unmaterialized residuals.
93
+ const closureInvalidating = [] // strings: "SEVERITY title: evidence"
94
+ const closureBlockedEpics = new Set()
89
95
  const cardMayEdit = {} // v4.30.0 — per-card MAY-EDIT, for the cross-card integration union
90
96
  const perCardResults = []
91
97
  let prodReadiness = null
@@ -209,6 +215,10 @@ const PREFLIGHT_SCHEMA = {
209
215
  designSrcDir: { type: 'string', description: "abs path of links.design_src / mockups/_src dir ('' when absent)" },
210
216
  designHtml: { type: 'string', description: "abs path of an .html links.design mockup ('' when absent)" },
211
217
  hasBindings: { type: 'boolean', description: 'card carries a non-empty component_bindings map' },
218
+ // v5.6.0 (FEAT-0068 post-mortem) — the card's parent epic id (group.parent / parent:).
219
+ // Drives the epic-closure blocklist (an epic with open scope residuals/follow-ups must
220
+ // not close) and the Epic goal section of the final report.
221
+ parentEpic: { type: 'string', description: "parent epic id from group.parent or parent: ('' when none)" },
212
222
  },
213
223
  },
214
224
  },
@@ -237,7 +247,10 @@ const MERGE_SCHEMA = {
237
247
  reconciliation: { type: 'string' },
238
248
  forcedDone: { type: 'array', items: { type: 'string' }, description: 'MUST be empty — false-DONE is forbidden (F-029)' },
239
249
  deferredLeftOpen: { type: 'array', items: { type: 'string' }, description: 'F-040 — committed cards left NON-DONE (open owner-gated AC); the skill marks them DONE post-run' },
240
- epicsClosed: { type: 'array', items: { type: 'string' }, description: 'Epic/parent cards marked DONE by Phase 6b step 5e (all children DONE) — NOT a forcedDone violation' },
250
+ epicsClosed: { type: 'array', items: { type: 'string' }, description: 'Epic/parent cards marked DONE by Phase 6b step 5e (children DONE + no open follow-up + epic ACs verified) — NOT a forcedDone violation' },
251
+ // v5.6.0 (FEAT-0068) — epics deliberately NOT closed, each with the concrete reason (workflow
252
+ // blocklist / open child or follow-up / unverified epic AC). Feeds the Epic goal report section.
253
+ epicsLeftOpen: { type: 'array', items: { type: 'object', additionalProperties: true, properties: { epic: { type: 'string' }, reason: { type: 'string' } } }, description: 'epics left open by the closure predicate, with reasons' },
241
254
  uncommittedLeft: { type: 'boolean', description: 'true if dirty code was left (NOT committed) + reported (F-030)' },
242
255
  status: { type: 'string', description: 'merge-worktree.sh terminal status: success|code_conflict|build_fail|sync_needs_decision|error' },
243
256
  conflictFiles: { type: 'array', items: { type: 'string' }, description: 'on status:code_conflict — the UNRESOLVED code/test files the script paused on (left for a human / the resolver; new2 OPS agent does NOT hand-resolve)' },
@@ -292,7 +305,7 @@ try {
292
305
  g3Bullet +
293
306
  `• G4 card-field validation (setup.md 1b/1c, card-schema.md consumer contract): card missing acceptance_criteria/files_likely_touched/scope → EXCLUDE (excluded[] + reason). \`requirements\` is CONDITIONAL, not an auto-exclude: if it is missing BUT acceptance_criteria + scope are both present and non-empty, FAITHFULLY DERIVE it (a concrete restatement/decomposition of the existing AC + scope — faithful, never inventing new scope) and WRITE it back to the card YAML on disk in ${MAIN}/${paths.backlog_dir || 'backlog'} (F-040: main repo, not the worktree copy; the card artifact is NOT a source/doc file, so this is allowed despite the role boundary), logging \`[BACKFILL] <id>: requirements=<N items, AC+scope-derived>\`. Only EXCLUDE for missing requirements when acceptance_criteria OR scope is ALSO empty (a genuinely thin card). Never HALT for one bad card.\n` +
294
307
  `• G5 depends-on: a card whose depends_on names a non-DONE card NOT in this batch → EXCLUDE it AND every in-batch card that transitively depends on it.\n` +
295
- `• cardGraph (REQUIRED, F-021): for every runnable card return { id, dependsOn:[IN-BATCH deps only], ownerAgent (the card's owner_agent; G25 unknown→'coder'), hasMockup (BOOLEAN — true IFF the card has a non-empty links.design OR links.design_src; drives the mockup-first build override — implement.md §6a), designSrcDir (ABS path of links.design_src / the PRD mockups/_src dir; '' when absent), designHtml (ABS path when links.design is an .html file; '' otherwise), hasBindings (BOOLEAN — non-empty component_bindings), reviewProfile (the card's review_profile; default 'balanced'), policyDeferredACs, alreadyCommitted, alreadyCommittedSha, isEpic (implement.md §6b epic guard: id ends '-00' OR filename ends '-epic.yml' OR group.is_epic:true OR review_profile 'skip' with no requirements), filesLikelyTouched (verbatim from the YAML) }.\n` +
308
+ `• cardGraph (REQUIRED, F-021): for every runnable card return { id, dependsOn:[IN-BATCH deps only], ownerAgent (the card's owner_agent; G25 unknown→'coder'), hasMockup (BOOLEAN — true IFF the card BUILDS the mocked UI: links.design_src non-empty, OR links.design non-empty AND (component_bindings non-empty OR files_likely_touched intersects component/style/UI source paths). A bare links.design on a docs/test/meta-only card is a POINTER to the epic's explainer, not a build target — v5.6.0, FEAT-0068: the bare test clamped a docs+ADR card to ui-expert; drives the mockup-first build override — implement.md §6a), parentEpic (the card's parent epic id from group.parent or a top-level parent: field; '' when none — drives the epic-closure blocklist + the Epic goal report section), designSrcDir (ABS path of links.design_src / the PRD mockups/_src dir; '' when absent), designHtml (ABS path when links.design is an .html file; '' otherwise), hasBindings (BOOLEAN — non-empty component_bindings), reviewProfile (the card's review_profile; default 'balanced'), policyDeferredACs, alreadyCommitted, alreadyCommittedSha, isEpic (implement.md §6b epic guard: id ends '-00' OR filename ends '-epic.yml' OR group.is_epic:true OR review_profile 'skip' with no requirements), filesLikelyTouched (verbatim from the YAML) }.\n` +
296
309
  `• B1/F-026 idempotency (per card, AFTER the worktree exists): set alreadyCommitted:true (+ alreadyCommittedSha) IFF ALL hold: (a) a commit referencing the card id exists in ${TRUNK}..HEAD of the worktree; (b) the card's validation_commands re-run GREEN right now; (c) NO open follow-up card for it exists in ${paths.backlog_dir || 'backlog'}. On a FRESH worktree ${TRUNK}..HEAD is empty → all false, zero extra work.\n` +
297
310
  `• F-016 AC↔ownership consistency: for each acceptance_criterion, derive the file(s) it requires editing. If those files are NOT a subset of the card's MAY-EDIT/files_likely_touched → add the AC to policyDeferredACs:[{n,text,owningCard|owningFile,reason}] (it will become ONE follow-up, never a resolve). Do the same for any AC whose remedy is an owner-gated infra action (remote db push / deploy / secret / DNS).\n` +
298
311
  (migrationApplied
@@ -944,19 +957,28 @@ async function runCard(cardId, cardPath) {
944
957
  // must NOT be marked DONE here (its own DoD isn't met yet — e.g. the remote db:push is pending).
945
958
  // The new2 SKILL marks it DONE post-run, ONLY after the deferral's follow-up exists on disk in the
946
959
  // main repo (so a card is never DONE with a silently-dropped requirement — F-029).
960
+ // v5.6.0 (FEAT-0068 post-mortem) — step 4b (epic closure) REMOVED from the commit agent: closing
961
+ // at the last card's commit ran structurally BEFORE the final review could disprove the goal, and
962
+ // from a context that could not see the worktree's untracked follow-up YAMLs. final-review.md:451
963
+ // already assigns closure to the merge phase ONLY — new2 now honors its own SSOT (Phase 6b 5e).
947
964
  const doneStep = deferredOpen
948
965
  ? `(4) DO NOT mark the card DONE: it has an OPEN owner-gated/policy-deferred AC. Keep status IN_PROGRESS and add an implementation_note "deferred — DONE pending follow-up (new2 skill reconciles post-run)". STILL add the ssot-registry row for the committed code.`
949
- : `(4) mark the card DONE in its YAML + add the ssot-registry row.${provLine} (4b) epic-closure gate (deterministic): if the card YAML has a \`parent:\` field, count sibling YAMLs in the backlog dir with that same parent whose status is NOT DONE; when the count is zero, flip the parent epic's ssot-registry entry to DONE (and the parent's own YAML status + completed_date, if one exists) in the SAME commit — these bookkeeping edits are in-scope for staging even if outside MAY-EDIT. Idempotent — skip when no parent, siblings still pending, or the parent entry is already DONE.`
966
+ : `(4) mark the card DONE in its YAML + add the ssot-registry row.${provLine}`
967
+ // The card YAML the agent edits MUST be the WORKTREE copy — the FEAT-0068 commit agent `find`-ed
968
+ // the main-repo copies and flipped stale siblings there (79 turns of detour, main checkout dirtied).
969
+ const wtCardPath = `${sharedCtx.worktreePath}/${paths.backlog_dir || 'backlog'}/${String(cardPath).split('/').pop()}`
950
970
  let commitRes
951
971
  try {
952
972
  commitRes = await agentSafe(
953
- `Commit card ${cardId} in worktree ${sharedCtx.worktreePath}. MECHANICAL — do NOT re-read reference modules. ROLE BOUNDARY: you NEVER modify file contents except the card YAML status/note fields, the parent-epic YAML status (epic-closure gate only), and the ssot-registry entries — source/doc changes are not yours.\n` +
973
+ `Commit card ${cardId} in worktree ${sharedCtx.worktreePath}. MECHANICAL — do NOT re-read reference modules. ROLE BOUNDARY: you NEVER modify file contents except THIS card's YAML status/note fields and the ssot-registry entries — source/doc changes are not yours; sibling and parent-epic card YAMLs are READ-ONLY (epic closure happens in the merge phase, NOT here).\n` +
974
+ `Card YAML (edit THIS copy and no other): ${wtCardPath}\n` +
954
975
  `HARD RULE — cwd: EVERY shell command MUST begin with \`cd ${sharedCtx.worktreePath} && \` IN THE SAME Bash invocation. The shell cwd RESETS between Bash calls to the MAIN repo: a bare \`git commit\` executes against the main checkout's current branch and corrupts it (observed in FEAT-0067 — a card-DONE commit landed on the trunk mid-run). No exceptions, including 'quick' status/log commands.\n` +
955
- `Steps: (1) \`git status --porcelain\`; (2) stage = MAY-EDIT (${JSON.stringify(mayEdit)}) dirty NEVER \`git add -A\`, NEVER \`git stash\`; if dirty has files OUTSIDE MAY-EDIT, do NOT stage them and set reconcileNote; (3) commit message \`[${cardId}] <concise>\`; ${doneStep} (5) 'nothing to commit' = already committed (record HEAD); (6) VERIFY placement: \`cd ${sharedCtx.worktreePath} && git log -1 --format=%H\` MUST equal the sha you return if it does not, your commit landed outside the worktree: report committed:false with reconcileNote naming the stray sha, do NOT retry blindly.\n` +
976
+ `WORKTREE ISOLATION TOOLS TOO (v5.6.0): every path you pass to Read/Edit/Write MUST be under ${sharedCtx.worktreePath}. The cwd rule does NOT protect tool calls with absolute paths NEVER Read/Edit/Write a path under the main repo ${MAIN}, never \`find\` files there, never cp/rsync between checkouts. A file you need that is missing from the worktree is a reconcileNote, not a fetch.\n` +
977
+ `Steps: (0) isolation evidence: run \`git -C ${MAIN} status --porcelain\` and record its LITERAL output as mainStatusBefore; (1) \`git status --porcelain\`; (2) stage = MAY-EDIT (${JSON.stringify(mayEdit)}) ∩ dirty — NEVER \`git add -A\`, NEVER \`git stash\` (anywhere, worktree or main); if dirty has files OUTSIDE MAY-EDIT, do NOT stage them and set reconcileNote; (3) commit message \`[${cardId}] <concise>\`; ${doneStep} (5) 'nothing to commit' = already committed (record HEAD); (6) VERIFY placement: \`cd ${sharedCtx.worktreePath} && git log -1 --format=%H\` MUST equal the sha you return — if it does not, your commit landed outside the worktree: report committed:false with reconcileNote naming the stray sha, do NOT retry blindly; (7) LAST action: re-run \`git -C ${MAIN} status --porcelain\` and record it VERBATIM as mainStatusAfter.\n` +
956
978
  `On COMMIT_LOCK: clear stale lock + retry once. Still locked → committed:false.\n\n` +
957
- `Return: { committed, commit, filesChanged, reconcileNote }`,
979
+ `Return: { committed, commit, filesChanged, reconcileNote, mainStatusBefore, mainStatusAfter }`,
958
980
  { label: `commit:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'sonnet',
959
- schema: { type: 'object', required: ['committed'], additionalProperties: true, properties: { committed: { type: 'boolean' }, commit: { type: 'string' }, filesChanged: { type: 'array', items: { type: 'string' } }, reconcileNote: { type: 'string' } } } }
981
+ schema: { type: 'object', required: ['committed'], additionalProperties: true, properties: { committed: { type: 'boolean' }, commit: { type: 'string' }, filesChanged: { type: 'array', items: { type: 'string' } }, reconcileNote: { type: 'string' }, mainStatusBefore: { type: 'string', description: 'literal `git -C <main> status --porcelain` BEFORE any other action' }, mainStatusAfter: { type: 'string', description: 'same command as the LAST action' } } } }
960
982
  )
961
983
  } catch (e) {
962
984
  if (e && e.transientExhausted) { noteDegraded('outage'); await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'pending', gates } }
@@ -970,6 +992,20 @@ async function runCard(cardId, cardPath) {
970
992
  }
971
993
  if (commitRes && commitRes.reconcileNote) g('commit-reconcile', 'NOTE', commitRes.reconcileNote)
972
994
 
995
+ // v5.6.0 isolation guard (FEAT-0068 #60: prompts alone did not stop the main-repo detour) — the
996
+ // porcelain evidence is mechanical literal output (same trust model as E2.5): new lines in the
997
+ // main repo's status after the spawn = the agent dirtied the live checkout → VIOLATION + a HIGH
998
+ // residual so the skill reconciles the contamination post-run (it can run git; this JS cannot).
999
+ {
1000
+ const msb = String((commitRes && commitRes.mainStatusBefore) || '').split('\n').filter(Boolean)
1001
+ const msa = String((commitRes && commitRes.mainStatusAfter) || '').split('\n').filter(Boolean)
1002
+ const dirtied = msa.filter((l) => !msb.includes(l))
1003
+ if (dirtied.length) {
1004
+ g('isolation-guard', 'VIOLATION', `main checkout dirtied during commit spawn: ${dirtied.slice(0, 5).join(' | ')}${dirtied.length > 5 ? ` (+${dirtied.length - 5})` : ''}`)
1005
+ residuals.push({ card: cardId, kind: 'main-repo-contamination', severity: 'HIGH', evidence: `commit agent dirtied the main checkout (${dirtied.join(', ')}) — reconcile: restore/inspect these paths in ${MAIN}, never stash-and-forget`, materialized: false })
1006
+ }
1007
+ }
1008
+
973
1009
  g('commit', 'COMMITTED', `${(commitRes && commitRes.commit) || ''}${deferredOpen ? ' (NON-DONE — deferred, skill reconciles)' : ''}`)
974
1010
  return {
975
1011
  card: cardId, status: 'committed',
@@ -1200,6 +1236,18 @@ if (committed.length && !degraded && !skipFinalLight) {
1200
1236
  finalSummary = final.summary || null
1201
1237
  ledger(firstCard, 'final-review', 'DONE', `engine=${final.codexEngine}; verified=${final.summary ? final.summary.verified : '?'}`)
1202
1238
  const all = (final.findings || []).filter((f) => (f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && (f.severity === 'BLOCKER' || f.severity === 'HIGH'))
1239
+ // v5.6.0 — closure-invalidating detection (final-review.md § closure-invalidating): a finding
1240
+ // asserting an epic goal/AC is unmet blocks/reopens the epic even after its text fix resolves.
1241
+ // FEAT-0068: DOC-F001 ("SSOT overclaim: epic goal not achieved") was fixed as prose 13 minutes
1242
+ // AFTER the epic closed DONE — the state contradiction merged. Scans ALL verified findings
1243
+ // (any severity): an overclaim is state-relevant regardless of the reviewer's severity call.
1244
+ const CLOSURE_INVALIDATING_RE = /goal[^.\n]{0,60}not[^.\n]{0,25}(achiev|met\b|reach|complete)|not[^.\n]{0,25}achiev[^.\n]{0,40}goal|overclaim|DONE[^.\n]{0,40}(unmet|not[^.\n]{0,20}(achiev|implement|complete))|epic[^.\n]{0,40}(unmet|not[^.\n]{0,30}(achiev|complete|reach))/i
1245
+ for (const f of (final.findings || [])) {
1246
+ if ((f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && CLOSURE_INVALIDATING_RE.test(`${f.title || ''} ${f.evidence || ''}`)) {
1247
+ closureInvalidating.push(`${f.severity} ${f.title}: ${String(f.evidence || '').slice(0, 200)}`)
1248
+ ledger(f.finding_id || firstCard, 'closure-invalidating', 'EPIC-CLOSURE-BLOCKED', `${f.title} — the text fix resolves the finding, NOT the epic state`)
1249
+ }
1250
+ }
1203
1251
  // F-035 — drop merge-artifact doc findings (content exists in worktree, the merge carries it).
1204
1252
  const actionable = all.filter((f) => !/in (the )?main repo|not (in|on) (the )?main|merge[- ]?time|worktree.*(not|missing).*main/i.test(`${f.title} ${f.evidence}`))
1205
1253
  // G3 — the merge-artifact regex is loose; a BLOCKER/HIGH it over-matches must NOT vanish.
@@ -1250,6 +1298,26 @@ if (committed.length && !degraded && !skipFinalLight) {
1250
1298
  // ───────────────────────────────────────────────────────────────────────────
1251
1299
  phase('Merge')
1252
1300
  let mergeResult = null
1301
+ // v5.6.0 — epic-closure deny-list (FEAT-0068 post-mortem): an epic must NOT close while (a) a
1302
+ // closure-invalidating finding stands, (b) a child carries an open scope-expansion/unresolved/
1303
+ // ac-unmet/out-of-ownership residual, or (c) a residual follow-up was minted for a child. Computed
1304
+ // in JS from the ledger (the merge agent's disk grep cannot see unmaterialized residuals).
1305
+ // Conservative by design: a later-retracted residual keeps the epic open this run — the skill's
1306
+ // post-run reconciliation (REOPEN/close branch) settles it against the disk.
1307
+ {
1308
+ const epicOf = (cid) => (graphById[cid] && graphById[cid].parentEpic) || ''
1309
+ const BLOCKING_RESIDUAL = new Set(['scope-expansion', 'unresolved', 'ac-unmet', 'out-of-ownership', 'main-repo-contamination'])
1310
+ if (closureInvalidating.length) for (const n of cardGraph) { const e = epicOf(n.id); if (e) closureBlockedEpics.add(e) }
1311
+ for (const r of residuals) { if (BLOCKING_RESIDUAL.has(r.deferralClass || r.kind)) { const e = epicOf(r.card); if (e) closureBlockedEpics.add(e) } }
1312
+ for (const fu of residualFollowups) { const e = epicOf(fu.card); if (e) closureBlockedEpics.add(e) }
1313
+ if (closureBlockedEpics.size) ledger(firstCard, 'epic-closure-blocklist', 'ACTIVE', `${[...closureBlockedEpics].join(' ')} — open residuals/follow-ups/closure-invalidating findings; merge MUST leave these epics open`)
1314
+ }
1315
+ // v5.6.0 — safety-sweep allowlist (F-030 by construction): at merge time the only legitimately
1316
+ // uncommitted files are batch bookkeeping (follow-up YAMLs materialized in the worktree, metrics,
1317
+ // run artifacts). Dirty CODE here by definition did not pass the per-card gates → the script must
1318
+ // leave+quarantine it (manifest `uncommitted_left`/`quarantined`), never sweep it into the trunk
1319
+ // under an opaque "[safety]" commit (FEAT-0068 5f618347d, FEAT-0067 5a5f1c135).
1320
+ const sweepAllowlist = dedupe([`${paths.backlog_dir || 'backlog'}/*.yml`, `${METRICS}/*`, '.baldart/*']).join(',')
1253
1321
  // A2 — 'followup'/'blocked' cards were rolled back and their residuals live in the offline-safe
1254
1322
  // ledger: the worktree holds ONLY gate-passing committed work, so they must not strand the batch
1255
1323
  // (the old filter contradicted the gate's own comment and orphaned the worktree with no resume
@@ -1274,8 +1342,9 @@ if (!committed.length) {
1274
1342
  `Auto-merge the batch worktree to ${TRUNK} per ${REF}/merge-cleanup.md: Phase 6 via the DETERMINISTIC script merge-worktree.sh, then Phase 6b status reconciliation + Phase 6c hygiene. Run git yourself.\n\n${projectBrief}\nWorktree: ${sharedCtx.worktreePath}\nBranch: ${sharedCtx.branch}\nmerge_strategy: ${mergeStrategy}\nCommitted cards: ${committed.map((r) => r.card).join(' ')}\nPhase-0 stash to restore (if any): see "$(git -C "${MAIN}" rev-parse --git-common-dir)/baldart/run/batch-tracker-${firstCard}.md".\n\n` +
1275
1343
  `PHASE 6 — run the SSOT script (the merge analogue of setup-worktree.sh; NOT a hand-rolled merge, NOT /mw inline):\n` +
1276
1344
  ` MERGE_SH="$(ls .framework/framework/.claude/skills/worktree-manager/scripts/merge-worktree.sh .claude/skills/worktree-manager/scripts/merge-worktree.sh 2>/dev/null | head -1)"\n` +
1277
- ` bash "$MERGE_SH" --worktree ${sharedCtx.worktreePath} --manifest /tmp/new2-merge-status-${firstCard}.txt --skip-checks\n` +
1278
- ` Read /tmp/new2-merge-status-${firstCard}.txt and set status:<its status:>. \n` +
1345
+ ` bash "$MERGE_SH" --worktree ${sharedCtx.worktreePath} --manifest /tmp/new2-merge-status-${firstCard}.txt --skip-checks --safety-sweep "allowlist:${sweepAllowlist}"\n` +
1346
+ ` (--safety-sweep allowlist, v5.6.0: the script commits ONLY matching uncommitted files — batch bookkeeping — with an honest enumerating message; non-matching tracked changes are QUARANTINED to a labeled dir + restored, untracked left in place, both reported in the manifest keys uncommitted_left/quarantined. If the resolved script predates the flag it errors on the unknown arg → re-run WITHOUT the flag and treat any "[safety] Auto-commit" it then produces as reconciliation to report. F-030 stays binding either way.)\n` +
1347
+ ` Read /tmp/new2-merge-status-${firstCard}.txt and set status:<its status:>. Also read uncommitted_left:/quarantined: and, when non-empty, set uncommittedLeft:true + note them.\n` +
1279
1348
  ` • status:success → set merged:true + mergeCommit/mergeTs from the file, then do Phase 6b/6c below. Read sync_marker:/sync_detail: from the FILE (not stdout) for the G19-23 hygiene gate.\n` +
1280
1349
  ` • status:code_conflict → set merged:false, status:'code_conflict', conflictFiles:[the file's conflict_files split on comma]. The script ALREADY resolved every additive doc/registry/JSONL conflict and PAUSED the rebase on the code/test files. Per your ROLE BOUNDARY you do NOT hand-resolve code — leave the worktree paused (do NOT abort), SKIP 6b/6c, and return (the workflow tracks it as a merge blocker → follow-up).\n` +
1281
1350
  ` • status:build_fail/error/sync_needs_decision → merged:false, note:<the file's error:>, leave the worktree intact, return.\n` +
@@ -1286,9 +1355,13 @@ if (!committed.length) {
1286
1355
  `• F-030 HARD RULE: NEVER \`git add\`/commit code that did not pass the per-card gates. If the worktree is dirty with uncommitted code → DO NOT commit it; leave it, set uncommittedLeft:true, and report. NO "safety commit". Security/migration code is NEVER swept in.\n` +
1287
1356
  `• F-029 HARD RULE: Phase 6b reconciliation marks a card DONE ONLY if it has a real commit in ${TRUNK}..HEAD AND its gates are green. NEVER force a non-implemented card to DONE. Return forcedDone:[] (must be empty).\n` +
1288
1357
  `• F-040 DEFERRED CARDS — leave NON-DONE (do NOT force to DONE in Phase 6b): ${deferredCards.length ? deferredCards.join(' ') : '(none)'}. These committed their code but carry an OPEN owner-gated/policy-deferred AC (e.g. a pending remote db:push). Their YAML is INTENTIONALLY IN_PROGRESS; the new2 skill marks them DONE post-run after materialising the deferral's follow-up. They ARE part of the merge — just skip them in the DONE-reconciliation. Return deferredLeftOpen:[the ones you left non-DONE].\n` +
1289
- `• EPIC CLOSURE (Phase 6b step 5e): the epic/parent card (group.is_epic:true) is NOT in the batch and stays TODO unless closed here. For each distinct group.parent of the batch cards (and any epic card in the batch itself): if EVERY child of that epic — \`grep -l "parent: <EPIC-ID>" ${paths.backlog_dir || 'backlog'}/*.yml | xargs grep -L "status: DONE"\` prints nothing — set the epic card status:DONE + completed_date + note "epic-closure gate — all children DONE" and fold into the reconciliation commit. If any child is still open → leave the epic untouched. This is NOT a forcedDone violation (the epic is a tracker, gated on all-children-DONE, not on its own commit). Return epicsClosed:[<EPIC-IDs marked DONE>].\n` +
1290
- `• G19 sync-deferred HEAD==${TRUNK} ff-pull, else leave+report. G20 leave+report. G21 post-batch dirty → partition-ignore framework artifacts; leave the rest + report (do NOT commit). G22 divergence → behind: ff-pull; ahead/both: leave+report; NEVER reset --hard/force-push. G23 stash restore conflict leave intact + report.\n\n` +
1291
- `Return: { merged, mergeCommit, mergeTs, status, conflictFiles, reconciliation, forcedDone:[], deferredLeftOpen:[], uncommittedLeft, note }`,
1358
+ `• EPIC CLOSURE (Phase 6b step 5e — the ONLY place an epic may close; v5.6.0 predicate, FEAT-0068 post-mortem): the epic/parent card is NOT in the batch and stays open unless closed here. For each distinct parent epic of the batch cards (and any epic card in the batch itself), close it ONLY when ALL of (a)(b)(c) hold:\n` +
1359
+ ` (a) WORKFLOW BLOCKLIST these epics have open scope residuals / pending follow-ups / closure-invalidating findings and MUST NOT be closed regardless of what the greps below say: ${closureBlockedEpics.size ? [...closureBlockedEpics].join(' ') : '(none)'}. A blocklisted epic goes in epicsLeftOpen with reason 'workflow blocklist (open residuals/follow-ups)'.\n` +
1360
+ ` (b) NO OPEN CHILD — run \`grep -RIl -- "parent: <EPIC-ID>" ${paths.backlog_dir || 'backlog'}/\` in the WORKTREE (where in-batch follow-up YAMLs materialize — include untracked: also \`git ls-files --others --exclude-standard -- ${paths.backlog_dir || 'backlog'}/\`) AND in the main repo ${MAIN}; every hit (follow-up cards INCLUDED — a follow-up with that parent IS an open child) must have \`status: DONE\`. Any open hit → epicsLeftOpen with reason 'open child: <id>'.\n` +
1361
+ ` (c) EPIC ACs VERIFIED — Read the epic YAML's acceptance_criteria: for each item run its ac_verification oracle when present (else a targeted grep/check that proves it) and flip \`[ ]\`→\`[x]\` ONLY with concrete evidence; an AC you cannot verify stays \`[ ]\` and the epic stays OPEN (epicsLeftOpen with reason 'unverified epic AC: <text>'). Never close an epic whose own checklist still has \`[ ]\` items.\n` +
1362
+ ` Close = status:DONE + completed_date + note "epic-closure gate — children DONE + epic ACs verified", folded into the reconciliation commit. This is NOT a forcedDone violation (the epic is a tracker). Return epicsClosed:[<ids>] AND epicsLeftOpen:[{epic, reason}].\n` +
1363
+ `• G19 sync-deferred → HEAD==${TRUNK} ff-pull, else leave+report. G20 → leave+report. G21 post-batch dirty → partition-ignore framework artifacts; leave the rest + report (do NOT commit). G22 divergence → behind: ff-pull; ahead/both: leave+report; NEVER reset --hard/force-push. G23 stash restore conflict → leave intact + report. NEVER \`git stash\` — in the worktree OR the main checkout: an ff-pull blocked by dirty files is leave+report (v5.6.0 — the FEAT-0068 run fabricated a dangling pre-ffpull stash from an unscoped sync mandate; a constraint with no escape hatch breeds fabrication).\n\n` +
1364
+ `Return: { merged, mergeCommit, mergeTs, status, conflictFiles, reconciliation, forcedDone:[], deferredLeftOpen:[], epicsClosed:[], epicsLeftOpen:[], uncommittedLeft, note }`,
1292
1365
  // Sonnet, not the inherited opus: this is a deterministic OPS/GIT executor (git merge +
1293
1366
  // YAML status reconciliation + grep-based epic closure + leave-and-report hygiene gates), and
1294
1367
  // the correctness-critical checks (F-029 forcedDone guard, F-040 deferred guard) are enforced
@@ -1299,7 +1372,19 @@ if (!committed.length) {
1299
1372
  } catch (e) { if (e && e.transientExhausted) noteDegraded('outage'); mergeResult = null }
1300
1373
  if (mergeResult && (mergeResult.forcedDone || []).length) { noteDegraded('false_done'); ledger(firstCard, 'F029-guard', 'VIOLATION', `forcedDone: ${mergeResult.forcedDone.join(' ')}`) }
1301
1374
  if (mergeResult && mergeResult.uncommittedLeft) ledger(firstCard, 'F030-guard', 'LEFT-UNCOMMITTED', 'dirty code left (not swept) + reported')
1302
- if (mergeResult && (mergeResult.epicsClosed || []).length) ledger(firstCard, 'epic-closure', 'CLOSED', `epics marked DONE (all children DONE): ${mergeResult.epicsClosed.join(' ')}`)
1375
+ // v5.6.0 epic-closure guard (the F-029 twin `epicsClosed` was trusted blindly): an epic closed
1376
+ // against the workflow deny-list is a false DONE → degrade + HIGH residual so the skill REOPENs it
1377
+ // in the post-run reconciliation (this JS cannot run git; the skill can).
1378
+ if (mergeResult && (mergeResult.epicsClosed || []).length) {
1379
+ const wrongly = (mergeResult.epicsClosed || []).filter((e) => closureBlockedEpics.has(e))
1380
+ if (wrongly.length) {
1381
+ noteDegraded('false_done')
1382
+ ledger(firstCard, 'epic-closure-guard', 'VIOLATION', `epic(s) closed against the blocklist: ${wrongly.join(' ')} — open residuals/follow-ups exist; skill must REOPEN`)
1383
+ for (const e of wrongly) residuals.push({ card: e, kind: 'false-epic-closure', severity: 'HIGH', evidence: `epic ${e} was marked DONE despite open scope residuals/follow-ups — REOPEN it (status IN_PROGRESS) in the post-run reconciliation commit`, materialized: false })
1384
+ }
1385
+ ledger(firstCard, 'epic-closure', 'CLOSED', `epics marked DONE (children DONE + ACs verified): ${mergeResult.epicsClosed.join(' ')}`)
1386
+ }
1387
+ if (mergeResult && (mergeResult.epicsLeftOpen || []).length) ledger(firstCard, 'epic-closure', 'LEFT-OPEN', mergeResult.epicsLeftOpen.map((x) => `${x.epic}: ${x.reason}`).join(' · '))
1303
1388
  if (deferredCards.length) {
1304
1389
  ledger(firstCard, 'F040-deferred', 'LEFT-NON-DONE', `${deferredCards.join(' ')} — skill marks DONE post-run after follow-up materialises`)
1305
1390
  // F-040 guard — catch a merge agent that ignored the instruction and force-DONE'd a deferred card.
@@ -1543,20 +1628,67 @@ function buildTelemetry() {
1543
1628
  }
1544
1629
  }
1545
1630
 
1631
+ // v5.6.0 — residual severity (FEAT-0068 §3.2: residuals carried no severity, so a goal-void run
1632
+ // and a clean run produced the same headline). Explicit severity wins; else class-derived.
1633
+ function residualSeverity(r) {
1634
+ if (r.severity) return String(r.severity).toUpperCase()
1635
+ const cls = r.deferralClass || r.kind || ''
1636
+ if (/^(scope-expansion|unresolved|ac-unmet|false-epic-closure|main-repo-contamination)$/.test(cls)) return 'HIGH'
1637
+ if (/^\s*BLOCKER\b/i.test(String(r.evidence || ''))) return 'BLOCKER'
1638
+ if (/^\s*HIGH\b/i.test(String(r.evidence || ''))) return 'HIGH'
1639
+ if (/^(out-of-ownership|merge-artifact-skipped|out-of-scope|baseline-not-reached|outage)$/.test(cls)) return 'MEDIUM'
1640
+ return 'LOW' // owner-gated / not-a-code-defect / policy-deferred-ac — external actions, tracked
1641
+ }
1642
+
1546
1643
  function buildReport(o) {
1547
1644
  const L = []
1548
1645
  L.push(`# new2 batch — ${cardIds.join(' ')}`)
1549
1646
  L.push(`Variant: **new2** · Mode: sequential · Trunk: ${TRUNK}${degraded ? ' · ⚠️ DEGRADED (' + degradationReasons.join(',') + ')' : ''}`)
1550
1647
  if (o.fatal) { L.push(``, `## ⛔ BATCH FATAL`, o.reason || 'workspace unworkable'); return L.join('\n') }
1648
+ // v5.6.0 PARTIAL verdict (FEAT-0068 §3.2) — the HARD headline rule: any BLOCKER/HIGH residual,
1649
+ // any epic left open / blocklisted / falsely closed, or a false_done degradation ⇒ the FIRST
1650
+ // line after the title says ⚠️ PARTIAL and names the gap. ✅ is RESERVED for zero-HIGH +
1651
+ // no open epic. The FEAT-0068 report said "✅ 6/6 DONE" over a disclosed-but-buried HIGH gap;
1652
+ // the defect was headline framing, and this is where the headline is built.
1653
+ const sevRank = { BLOCKER: 3, HIGH: 2, MEDIUM: 1, LOW: 0 }
1654
+ const highResiduals = residuals.filter((r) => (sevRank[residualSeverity(r)] || 0) >= 2)
1655
+ const epicsInPlay = dedupe(cardGraph.map((n) => n.parentEpic || '').filter(Boolean).concat(cardGraph.filter((n) => n.isEpic).map((n) => n.id)))
1656
+ const closedEpics = (mergeResult && mergeResult.epicsClosed) || []
1657
+ const leftOpen = ((mergeResult && mergeResult.epicsLeftOpen) || []).filter((x) => x && x.epic)
1658
+ const openEpics = epicsInPlay.filter((e) => !closedEpics.includes(e) || closureBlockedEpics.has(e))
1659
+ const falseDone = degradationReasons.includes('false_done')
1660
+ const partial = highResiduals.length > 0 || openEpics.length > 0 || falseDone
1661
+ if (partial) {
1662
+ const top = falseDone ? 'epic chiusa contro la blocklist (false DONE — da riaprire)'
1663
+ : highResiduals.length ? `${residualSeverity(highResiduals[0])} residuo aperto: ${String(highResiduals[0].evidence || highResiduals[0].kind).slice(0, 140)}`
1664
+ : `epic ${openEpics.join(' ')} NON chiusa (goal non ancora raggiunto/verificato)`
1665
+ L.push(``, `## ⚠️ PARTIAL — obiettivo NON completamente raggiunto`)
1666
+ L.push(`**${top}**`)
1667
+ if (highResiduals.length) L.push(`Il lavoro sostanziale rimanente vive in ${highResiduals.filter((r) => r.materialized).length}/${highResiduals.length} follow-up già su disco — vedi § Residui. NON considerare la feature consegnata finché le follow-up HIGH non sono implementate.`)
1668
+ } else {
1669
+ L.push(``, `## ✅ COMPLETO`)
1670
+ L.push(`Tutte le card committate, epic ${epicsInPlay.length ? 'chiuse con AC verificati' : 'non presenti'}, nessun residuo HIGH.`)
1671
+ }
1551
1672
  L.push(``, `## Esito card`)
1552
1673
  L.push(`| Card | Status | Commit | File |`)
1553
1674
  L.push(`|------|--------|--------|------|`)
1554
1675
  for (const r of perCardResults) L.push(`| ${r.card} | ${r.status}${r.deferred ? ' (NON-DONE: deferred)' : ''} | ${r.commit || '-'} | ${(r.filesChanged || []).length} |`)
1555
1676
  const blockedIds = runnableCards.filter((id) => state[id] === 'blocked' || state[id] === 'pending')
1556
1677
  for (const id of blockedIds) L.push(`| ${id} | ${state[id]} | - | 0 |`)
1678
+ // v5.6.0 Epic goal section — per-epic truth: closed/open + why + the open work attached to it.
1679
+ if (epicsInPlay.length) {
1680
+ L.push(``, `## Epic goal`)
1681
+ for (const e of epicsInPlay) {
1682
+ const reason = (leftOpen.find((x) => x.epic === e) || {}).reason
1683
+ const epicHigh = residuals.filter((r) => ((graphById[r.card] && graphById[r.card].parentEpic) === e || r.card === e) && (sevRank[residualSeverity(r)] || 0) >= 2)
1684
+ if (closedEpics.includes(e) && !closureBlockedEpics.has(e)) L.push(`- **${e}: CHIUSA** (children DONE + AC epic verificati dal merge agent)`)
1685
+ else if (closedEpics.includes(e)) L.push(`- **${e}: ⚠️ CHIUSA CONTRO BLOCKLIST (false DONE)** — riaprire: ${epicHigh.length} residuo/i HIGH aperti`)
1686
+ else L.push(`- **${e}: APERTA** — ${reason || (closureBlockedEpics.has(e) ? 'blocklist: residui/follow-up aperti' : 'merge non eseguito o children aperti')}${epicHigh.length ? ` · ${epicHigh.length} residuo/i HIGH` : ''}`)
1687
+ }
1688
+ }
1557
1689
  if (finalSummary) {
1558
1690
  L.push(``, `## Final review`)
1559
- L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) · failing gates: ${(finalSummary.failingGates || []).join(', ') || 'none'}`)
1691
+ L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) · failing gates: ${(finalSummary.failingGates || []).join(', ') || 'none'}${closureInvalidating.length ? ` · closure-invalidating: ${closureInvalidating.length}` : ''}`)
1560
1692
  }
1561
1693
  L.push(``, `## Merge`)
1562
1694
  if (mergeResult && mergeResult.merged) L.push(`Merged → ${TRUNK} @ ${mergeResult.mergeCommit || '?'}${mergeResult.reconciliation ? ' · ' + mergeResult.reconciliation : ''}`)
@@ -1567,10 +1699,14 @@ function buildReport(o) {
1567
1699
  for (const m of man) L.push(`- [ ] ${m}`)
1568
1700
  }
1569
1701
  if (residuals.length) {
1570
- L.push(``, `## ⚠️ Residui (il skill materializza le follow-up mancanti nulla perso)`)
1702
+ // v5.6.0 "nulla perso" is a tracking claim, not a completion claim: it may only headline
1703
+ // when no HIGH residual exists (a HIGH follow-up is real work NOT delivered, not bookkeeping).
1704
+ L.push(``, highResiduals.length
1705
+ ? `## ⚠️ Residui — ATTENZIONE: ${highResiduals.length} HIGH+ = lavoro sostanziale NON consegnato (le follow-up sono il lavoro, non burocrazia)`
1706
+ : `## Residui (bassa severità — il skill materializza le follow-up mancanti; nulla perso)`)
1571
1707
  const bd = residuals.reduce((b, x) => { const k = x.deferralClass || x.kind || 'unknown'; b[k] = (b[k] || 0) + 1; return b }, {})
1572
1708
  L.push(`Ripartizione per classe: ${Object.entries(bd).map(([k, n]) => `${k}=${n}`).join(' · ')} — una classe dominante è il segnale di causa (out-of-ownership → MAY-EDIT troppo strette · unresolved → fix difficili · scope-expansion → card sotto-specificate).`)
1573
- for (const f of residuals) L.push(`- ${f.card} (${f.kind})${f.materialized ? ' ✓' : ' — DA MATERIALIZZARE'}: ${f.evidence}`)
1709
+ for (const f of residuals.slice().sort((a, b) => (sevRank[residualSeverity(b)] || 0) - (sevRank[residualSeverity(a)] || 0))) L.push(`- [${residualSeverity(f)}] ${f.card} (${f.kind})${f.materialized ? ' ✓' : ' — DA MATERIALIZZARE'}: ${f.evidence}`)
1574
1710
  }
1575
1711
  const excluded = gateLedger.filter((x) => x.decision === 'EXCLUDED')
1576
1712
  if (excluded.length) { L.push(``, `## Card escluse in pre-flight`); for (const e of excluded) L.push(`- ${e.card}: ${e.detail}`) }
@@ -57,7 +57,7 @@ Legend: **R** = required, present **and non-empty** · **E** = required key, val
57
57
  | `scope_boundaries` | R | C | C | "Omit for standalone cards with no siblings" (`prd-card-writer.md`) |
58
58
  | `requirements` | C | R | R | EPIC tracks via AC-EPIC; CHILD/STANDALONE: faithfully derivable from AC+scope when both present — see consumer contract |
59
59
  | `acceptance_criteria` | R (AC-EPIC) | R | R | CHILD/STANDALONE: EARS grammar — see § "Acceptance-criteria grammar (EARS)" |
60
- | `ac_verification` | | C | C | OPTIONAL map `AC-N → executable oracle` (or `manual: <check>`); authored by `/prd`, executed by `/new` Phase 2.5 — see § below |
60
+ | `ac_verification` | R (≥1 per goal AC, v5.6.0) | C | C | EPIC: REQUIRED — ≥1 executable oracle (or `manual:`) per top-level goal AC, consumed by the epic-closure gate; CHILD/STANDALONE: OPTIONAL map `AC-N → executable oracle` (or `manual: <check>`); authored by `/prd`, executed by `/new` Phase 2.5 — see § below |
61
61
  | `definition_of_done` | R | R | R | |
62
62
  | `depends_on` / `blocks` | E (`[]`) | E | E | |
63
63
  | `estimated_complexity` | C | R | R | |
@@ -131,6 +131,23 @@ downstream cost in a real batch:
131
131
  when most cards omit it). The back-fill stays as the safety net for legacy cards; a freshly
132
132
  authored `/prd` card must not trigger it.
133
133
 
134
+ - **Epic goal ACs are machine-checkable (v5.6.0 — FEAT-0068).** The epic's `[AC-EPIC-N]`
135
+ items: (1) NEVER hardcode a child count ("Tutte le 6 sub-card" broke the moment follow-up
136
+ cards appeared — write "all child cards including any added later"); (2) an AC with a
137
+ **universal quantifier over a surface** ("every screen", "tutte le route", "nessun seam su
138
+ ogni pagina") must carry `invariant_owners:` — the surface's owner components resolved from
139
+ the fresh registry/ISA census — so the epic-closure gate can verify the owners' coverage
140
+ instead of trusting the child count; (3) the epic carries `ac_verification` with ≥1
141
+ executable oracle per goal AC (field-state matrix above). An epic whose goal cannot be
142
+ checked is an epic that closes on bookkeeping — the FEAT-0068 epic closed DONE with all 7
143
+ ACs unchecked and the goal unmet on ~90% of its surface.
144
+ - **Snapshot-suite lifecycle (v5.6.0 — FEAT-0068).** A card that CREATES a visual-regression /
145
+ snapshot / screenshot-diff suite MUST declare the baseline lifecycle: EITHER "baselines
146
+ generated and committed in-card" (the generate command in `validation_commands`, the baseline
147
+ dir in `files_likely_touched`) OR "assertion-based, screenshots advisory". "Baselines arrive
148
+ later", undeclared, is an authoring BLOCKER — it detonates as a merge-blocker and invites a
149
+ repair that disables the suite (`/new` completeness Phase 2.5 step 1b enforces the read-side).
150
+
134
151
  A back-fill never rescues a card missing a HALT field: a card without `scope` blocks regardless
135
152
  of how many derivable fields were filled.
136
153
 
@@ -38,7 +38,12 @@ dependency.
38
38
  The `PROFILE=<decision|deep|compare|regulatory>` token in the spawn prompt
39
39
  selects the research contract. **Token absent → `deep`** (preserves the legacy
40
40
  full-report semantics for direct invocation and for existing spawners that
41
- pass no token).
41
+ pass no token). **Defense-in-depth (v5.6.0):** when spawned WITHOUT a `PROFILE`
42
+ token, the agent runs the SECTION: reuse library pre-flight ITSELF before
43
+ searching (a caller that forgot the token also forgot the pre-flight — on a
44
+ real `/prd` session the research ran contract-less and the library reuse loop
45
+ silently no-oped), and states in the report where the output landed
46
+ (`${paths.research_dir}` path) so the caller never hunts for it.
42
47
 
43
48
  | Profile | Typical consumer | Output contract | Depth |
44
49
  |---|---|---|---|