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.
- package/CHANGELOG.md +86 -0
- package/VERSION +1 -1
- package/framework/.claude/agents/CHANGELOG.md +16 -0
- package/framework/.claude/agents/prd-card-writer.md +37 -3
- package/framework/.claude/skills/new/CHANGELOG.md +33 -0
- package/framework/.claude/skills/new/SKILL.md +11 -5
- package/framework/.claude/skills/new/references/commit.md +12 -8
- package/framework/.claude/skills/new/references/completeness.md +10 -0
- package/framework/.claude/skills/new/references/final-review.md +23 -0
- package/framework/.claude/skills/new/references/implement.md +14 -4
- package/framework/.claude/skills/new/references/merge-cleanup.md +35 -14
- package/framework/.claude/skills/new/references/setup.md +1 -1
- package/framework/.claude/skills/new2/CHANGELOG.md +50 -0
- package/framework/.claude/skills/new2/SKILL.md +101 -17
- package/framework/.claude/skills/prd/CHANGELOG.md +36 -0
- package/framework/.claude/skills/prd/SKILL.md +17 -5
- package/framework/.claude/skills/prd/assets/state-template.md +8 -1
- package/framework/.claude/skills/prd/references/audit-phase.md +13 -1
- package/framework/.claude/skills/prd/references/backlog-phase.md +20 -0
- package/framework/.claude/skills/prd/references/discovery-phase.md +35 -0
- package/framework/.claude/skills/prd/references/ui-design-phase.md +28 -0
- package/framework/.claude/skills/prd/references/validation-phase.md +45 -1
- package/framework/.claude/skills/worktree-manager/CHANGELOG.md +13 -0
- package/framework/.claude/skills/worktree-manager/SKILL.md +11 -1
- package/framework/.claude/skills/worktree-manager/scripts/merge-worktree.sh +82 -6
- package/framework/.claude/workflows/new-final-review.js +9 -7
- package/framework/.claude/workflows/new2-resolve.js +29 -1
- package/framework/.claude/workflows/new2.js +153 -17
- package/framework/agents/card-schema.md +18 -1
- package/framework/agents/research-protocol.md +6 -1
- package/framework/scripts/stamp-holistic-audit.js +53 -10
- 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
|
|
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
|
-
|
|
371
|
-
|
|
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
|
|
377
|
-
|
|
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.
|
|
344
|
-
` node "${codexScriptPath}" task "$(cat ${codexTaskFile})" --cwd "${a.worktreePath || '.'}" > ${codexReviewFile} 2>&1\n` +
|
|
345
|
-
`
|
|
346
|
-
`
|
|
347
|
-
` •
|
|
348
|
-
` • "
|
|
349
|
-
` •
|
|
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 finishes — that 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 (
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
|
1290
|
-
|
|
1291
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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` |
|
|
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
|
|---|---|---|---|
|