baldart 5.5.0 → 5.7.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 (65) hide show
  1. package/CHANGELOG.md +119 -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/capture/CHANGELOG.md +4 -0
  6. package/framework/.claude/skills/capture/SKILL.md +2 -1
  7. package/framework/.claude/skills/design-sync/CHANGELOG.md +4 -0
  8. package/framework/.claude/skills/design-sync/SKILL.md +2 -1
  9. package/framework/.claude/skills/ds-edit/CHANGELOG.md +5 -0
  10. package/framework/.claude/skills/ds-edit/SKILL.md +9 -10
  11. package/framework/.claude/skills/ds-handoff/CHANGELOG.md +5 -0
  12. package/framework/.claude/skills/ds-handoff/SKILL.md +3 -2
  13. package/framework/.claude/skills/ds-new/CHANGELOG.md +4 -0
  14. package/framework/.claude/skills/ds-new/SKILL.md +2 -1
  15. package/framework/.claude/skills/ds-render/CHANGELOG.md +4 -0
  16. package/framework/.claude/skills/ds-render/SKILL.md +2 -1
  17. package/framework/.claude/skills/e2e-review/CHANGELOG.md +4 -0
  18. package/framework/.claude/skills/e2e-review/SKILL.md +2 -1
  19. package/framework/.claude/skills/gamification-design/CHANGELOG.md +4 -0
  20. package/framework/.claude/skills/gamification-design/SKILL.md +2 -1
  21. package/framework/.claude/skills/graph-align/CHANGELOG.md +4 -0
  22. package/framework/.claude/skills/graph-align/SKILL.md +2 -1
  23. package/framework/.claude/skills/i18n/CHANGELOG.md +4 -0
  24. package/framework/.claude/skills/i18n/SKILL.md +2 -1
  25. package/framework/.claude/skills/motion-design/CHANGELOG.md +4 -0
  26. package/framework/.claude/skills/motion-design/SKILL.md +2 -1
  27. package/framework/.claude/skills/new/CHANGELOG.md +27 -0
  28. package/framework/.claude/skills/new/SKILL.md +2 -1
  29. package/framework/.claude/skills/new/references/commit.md +12 -8
  30. package/framework/.claude/skills/new/references/completeness.md +10 -0
  31. package/framework/.claude/skills/new/references/final-review.md +23 -0
  32. package/framework/.claude/skills/new/references/implement.md +14 -4
  33. package/framework/.claude/skills/new/references/merge-cleanup.md +35 -14
  34. package/framework/.claude/skills/new2/CHANGELOG.md +40 -0
  35. package/framework/.claude/skills/new2/SKILL.md +53 -12
  36. package/framework/.claude/skills/prd/CHANGELOG.md +40 -0
  37. package/framework/.claude/skills/prd/SKILL.md +18 -5
  38. package/framework/.claude/skills/prd/assets/state-template.md +8 -1
  39. package/framework/.claude/skills/prd/references/audit-phase.md +13 -1
  40. package/framework/.claude/skills/prd/references/backlog-phase.md +20 -0
  41. package/framework/.claude/skills/prd/references/discovery-phase.md +35 -0
  42. package/framework/.claude/skills/prd/references/ui-design-phase.md +28 -0
  43. package/framework/.claude/skills/prd/references/validation-phase.md +45 -1
  44. package/framework/.claude/skills/prd-add/CHANGELOG.md +4 -0
  45. package/framework/.claude/skills/prd-add/SKILL.md +2 -1
  46. package/framework/.claude/skills/skill-creator/references/skill-structure.md +31 -2
  47. package/framework/.claude/skills/skill-creator/scripts/quick_validate.py +25 -3
  48. package/framework/.claude/skills/ui-design/CHANGELOG.md +4 -0
  49. package/framework/.claude/skills/ui-design/SKILL.md +2 -1
  50. package/framework/.claude/skills/ui-implement/CHANGELOG.md +4 -0
  51. package/framework/.claude/skills/ui-implement/SKILL.md +2 -1
  52. package/framework/.claude/skills/worktree-manager/CHANGELOG.md +13 -0
  53. package/framework/.claude/skills/worktree-manager/SKILL.md +11 -1
  54. package/framework/.claude/skills/worktree-manager/scripts/merge-worktree.sh +82 -6
  55. package/framework/.claude/workflows/new-final-review.js +9 -7
  56. package/framework/.claude/workflows/new2-resolve.js +29 -1
  57. package/framework/.claude/workflows/new2.js +158 -18
  58. package/framework/agents/card-schema.md +18 -1
  59. package/framework/agents/research-protocol.md +6 -1
  60. package/framework/scripts/stamp-holistic-audit.js +53 -10
  61. package/package.json +1 -1
  62. package/src/commands/configure.js +9 -2
  63. package/src/commands/doctor.js +65 -0
  64. package/src/utils/skill-gate.js +139 -0
  65. package/src/utils/symlinks.js +72 -7
@@ -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
@@ -621,7 +634,11 @@ async function runCard(cardId, cardPath) {
621
634
  : `Phase 1 fallback: the architect specialist was unavailable — do the Phase 1 claim+architect exploration yourself per ${REF}/implement.md and persist the baseline to ${baselinePath} before coding.`
622
635
  // T1.A (v4.90.0) — mockup cards: binding-compliance is verified deterministically by the
623
636
  // check-bindings.mjs script (ui-implement Step 5b is the SSOT; this is the new2 parallel).
624
- const bindingBit = node.hasMockup && node.hasBindings
637
+ // Guard on has_design_system: component_bindings only exist when a /prd design
638
+ // phase (which requires the design system) produced them, so absent-DS + bindings
639
+ // is a malformed hand-authored card — don't emit a gate whose script path could
640
+ // resolve empty. Mirrors the has_design_system ds-gate guard above.
641
+ const bindingBit = features.has_design_system && node.hasMockup && node.hasBindings
625
642
  ? `\nBINDING GATE (deterministic — ui-implement Step 5b parallel): after building, run \`node "$(ls .claude/skills/ui-implement/scripts/check-bindings.mjs .framework/framework/.claude/skills/ui-implement/scripts/check-bindings.mjs 2>/dev/null | head -1)" --bindings-json '<the card component_bindings as JSON>' --files <your touched implementation files>\`. Exit 3 = a reuse/reuse-variant binding is NOT used in the code: the map is AUTHORITATIVE — fix it before returning (one pass). Include the script's one-line JSON output as bindingCheck in your return.\n`
626
643
  : ''
627
644
  let impl
@@ -944,19 +961,28 @@ async function runCard(cardId, cardPath) {
944
961
  // must NOT be marked DONE here (its own DoD isn't met yet — e.g. the remote db:push is pending).
945
962
  // The new2 SKILL marks it DONE post-run, ONLY after the deferral's follow-up exists on disk in the
946
963
  // main repo (so a card is never DONE with a silently-dropped requirement — F-029).
964
+ // v5.6.0 (FEAT-0068 post-mortem) — step 4b (epic closure) REMOVED from the commit agent: closing
965
+ // at the last card's commit ran structurally BEFORE the final review could disprove the goal, and
966
+ // from a context that could not see the worktree's untracked follow-up YAMLs. final-review.md:451
967
+ // already assigns closure to the merge phase ONLY — new2 now honors its own SSOT (Phase 6b 5e).
947
968
  const doneStep = deferredOpen
948
969
  ? `(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.`
970
+ : `(4) mark the card DONE in its YAML + add the ssot-registry row.${provLine}`
971
+ // The card YAML the agent edits MUST be the WORKTREE copy — the FEAT-0068 commit agent `find`-ed
972
+ // the main-repo copies and flipped stale siblings there (79 turns of detour, main checkout dirtied).
973
+ const wtCardPath = `${sharedCtx.worktreePath}/${paths.backlog_dir || 'backlog'}/${String(cardPath).split('/').pop()}`
950
974
  let commitRes
951
975
  try {
952
976
  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` +
977
+ `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` +
978
+ `Card YAML (edit THIS copy and no other): ${wtCardPath}\n` +
954
979
  `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` +
980
+ `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` +
981
+ `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
982
  `On COMMIT_LOCK: clear stale lock + retry once. Still locked → committed:false.\n\n` +
957
- `Return: { committed, commit, filesChanged, reconcileNote }`,
983
+ `Return: { committed, commit, filesChanged, reconcileNote, mainStatusBefore, mainStatusAfter }`,
958
984
  { 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' } } } }
985
+ 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
986
  )
961
987
  } catch (e) {
962
988
  if (e && e.transientExhausted) { noteDegraded('outage'); await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'pending', gates } }
@@ -970,6 +996,20 @@ async function runCard(cardId, cardPath) {
970
996
  }
971
997
  if (commitRes && commitRes.reconcileNote) g('commit-reconcile', 'NOTE', commitRes.reconcileNote)
972
998
 
999
+ // v5.6.0 isolation guard (FEAT-0068 #60: prompts alone did not stop the main-repo detour) — the
1000
+ // porcelain evidence is mechanical literal output (same trust model as E2.5): new lines in the
1001
+ // main repo's status after the spawn = the agent dirtied the live checkout → VIOLATION + a HIGH
1002
+ // residual so the skill reconciles the contamination post-run (it can run git; this JS cannot).
1003
+ {
1004
+ const msb = String((commitRes && commitRes.mainStatusBefore) || '').split('\n').filter(Boolean)
1005
+ const msa = String((commitRes && commitRes.mainStatusAfter) || '').split('\n').filter(Boolean)
1006
+ const dirtied = msa.filter((l) => !msb.includes(l))
1007
+ if (dirtied.length) {
1008
+ g('isolation-guard', 'VIOLATION', `main checkout dirtied during commit spawn: ${dirtied.slice(0, 5).join(' | ')}${dirtied.length > 5 ? ` (+${dirtied.length - 5})` : ''}`)
1009
+ 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 })
1010
+ }
1011
+ }
1012
+
973
1013
  g('commit', 'COMMITTED', `${(commitRes && commitRes.commit) || ''}${deferredOpen ? ' (NON-DONE — deferred, skill reconciles)' : ''}`)
974
1014
  return {
975
1015
  card: cardId, status: 'committed',
@@ -1200,6 +1240,18 @@ if (committed.length && !degraded && !skipFinalLight) {
1200
1240
  finalSummary = final.summary || null
1201
1241
  ledger(firstCard, 'final-review', 'DONE', `engine=${final.codexEngine}; verified=${final.summary ? final.summary.verified : '?'}`)
1202
1242
  const all = (final.findings || []).filter((f) => (f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && (f.severity === 'BLOCKER' || f.severity === 'HIGH'))
1243
+ // v5.6.0 — closure-invalidating detection (final-review.md § closure-invalidating): a finding
1244
+ // asserting an epic goal/AC is unmet blocks/reopens the epic even after its text fix resolves.
1245
+ // FEAT-0068: DOC-F001 ("SSOT overclaim: epic goal not achieved") was fixed as prose 13 minutes
1246
+ // AFTER the epic closed DONE — the state contradiction merged. Scans ALL verified findings
1247
+ // (any severity): an overclaim is state-relevant regardless of the reviewer's severity call.
1248
+ 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
1249
+ for (const f of (final.findings || [])) {
1250
+ if ((f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && CLOSURE_INVALIDATING_RE.test(`${f.title || ''} ${f.evidence || ''}`)) {
1251
+ closureInvalidating.push(`${f.severity} ${f.title}: ${String(f.evidence || '').slice(0, 200)}`)
1252
+ ledger(f.finding_id || firstCard, 'closure-invalidating', 'EPIC-CLOSURE-BLOCKED', `${f.title} — the text fix resolves the finding, NOT the epic state`)
1253
+ }
1254
+ }
1203
1255
  // F-035 — drop merge-artifact doc findings (content exists in worktree, the merge carries it).
1204
1256
  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
1257
  // G3 — the merge-artifact regex is loose; a BLOCKER/HIGH it over-matches must NOT vanish.
@@ -1250,6 +1302,26 @@ if (committed.length && !degraded && !skipFinalLight) {
1250
1302
  // ───────────────────────────────────────────────────────────────────────────
1251
1303
  phase('Merge')
1252
1304
  let mergeResult = null
1305
+ // v5.6.0 — epic-closure deny-list (FEAT-0068 post-mortem): an epic must NOT close while (a) a
1306
+ // closure-invalidating finding stands, (b) a child carries an open scope-expansion/unresolved/
1307
+ // ac-unmet/out-of-ownership residual, or (c) a residual follow-up was minted for a child. Computed
1308
+ // in JS from the ledger (the merge agent's disk grep cannot see unmaterialized residuals).
1309
+ // Conservative by design: a later-retracted residual keeps the epic open this run — the skill's
1310
+ // post-run reconciliation (REOPEN/close branch) settles it against the disk.
1311
+ {
1312
+ const epicOf = (cid) => (graphById[cid] && graphById[cid].parentEpic) || ''
1313
+ const BLOCKING_RESIDUAL = new Set(['scope-expansion', 'unresolved', 'ac-unmet', 'out-of-ownership', 'main-repo-contamination'])
1314
+ if (closureInvalidating.length) for (const n of cardGraph) { const e = epicOf(n.id); if (e) closureBlockedEpics.add(e) }
1315
+ for (const r of residuals) { if (BLOCKING_RESIDUAL.has(r.deferralClass || r.kind)) { const e = epicOf(r.card); if (e) closureBlockedEpics.add(e) } }
1316
+ for (const fu of residualFollowups) { const e = epicOf(fu.card); if (e) closureBlockedEpics.add(e) }
1317
+ if (closureBlockedEpics.size) ledger(firstCard, 'epic-closure-blocklist', 'ACTIVE', `${[...closureBlockedEpics].join(' ')} — open residuals/follow-ups/closure-invalidating findings; merge MUST leave these epics open`)
1318
+ }
1319
+ // v5.6.0 — safety-sweep allowlist (F-030 by construction): at merge time the only legitimately
1320
+ // uncommitted files are batch bookkeeping (follow-up YAMLs materialized in the worktree, metrics,
1321
+ // run artifacts). Dirty CODE here by definition did not pass the per-card gates → the script must
1322
+ // leave+quarantine it (manifest `uncommitted_left`/`quarantined`), never sweep it into the trunk
1323
+ // under an opaque "[safety]" commit (FEAT-0068 5f618347d, FEAT-0067 5a5f1c135).
1324
+ const sweepAllowlist = dedupe([`${paths.backlog_dir || 'backlog'}/*.yml`, `${METRICS}/*`, '.baldart/*']).join(',')
1253
1325
  // A2 — 'followup'/'blocked' cards were rolled back and their residuals live in the offline-safe
1254
1326
  // ledger: the worktree holds ONLY gate-passing committed work, so they must not strand the batch
1255
1327
  // (the old filter contradicted the gate's own comment and orphaned the worktree with no resume
@@ -1274,8 +1346,9 @@ if (!committed.length) {
1274
1346
  `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
1347
  `PHASE 6 — run the SSOT script (the merge analogue of setup-worktree.sh; NOT a hand-rolled merge, NOT /mw inline):\n` +
1276
1348
  ` 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` +
1349
+ ` bash "$MERGE_SH" --worktree ${sharedCtx.worktreePath} --manifest /tmp/new2-merge-status-${firstCard}.txt --skip-checks --safety-sweep "allowlist:${sweepAllowlist}"\n` +
1350
+ ` (--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` +
1351
+ ` 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
1352
  ` • 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
1353
  ` • 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
1354
  ` • status:build_fail/error/sync_needs_decision → merged:false, note:<the file's error:>, leave the worktree intact, return.\n` +
@@ -1286,9 +1359,13 @@ if (!committed.length) {
1286
1359
  `• 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
1360
  `• 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
1361
  `• 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 }`,
1362
+ `• 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` +
1363
+ ` (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` +
1364
+ ` (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` +
1365
+ ` (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` +
1366
+ ` 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` +
1367
+ `• 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` +
1368
+ `Return: { merged, mergeCommit, mergeTs, status, conflictFiles, reconciliation, forcedDone:[], deferredLeftOpen:[], epicsClosed:[], epicsLeftOpen:[], uncommittedLeft, note }`,
1292
1369
  // Sonnet, not the inherited opus: this is a deterministic OPS/GIT executor (git merge +
1293
1370
  // YAML status reconciliation + grep-based epic closure + leave-and-report hygiene gates), and
1294
1371
  // the correctness-critical checks (F-029 forcedDone guard, F-040 deferred guard) are enforced
@@ -1299,7 +1376,19 @@ if (!committed.length) {
1299
1376
  } catch (e) { if (e && e.transientExhausted) noteDegraded('outage'); mergeResult = null }
1300
1377
  if (mergeResult && (mergeResult.forcedDone || []).length) { noteDegraded('false_done'); ledger(firstCard, 'F029-guard', 'VIOLATION', `forcedDone: ${mergeResult.forcedDone.join(' ')}`) }
1301
1378
  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(' ')}`)
1379
+ // v5.6.0 epic-closure guard (the F-029 twin `epicsClosed` was trusted blindly): an epic closed
1380
+ // against the workflow deny-list is a false DONE → degrade + HIGH residual so the skill REOPENs it
1381
+ // in the post-run reconciliation (this JS cannot run git; the skill can).
1382
+ if (mergeResult && (mergeResult.epicsClosed || []).length) {
1383
+ const wrongly = (mergeResult.epicsClosed || []).filter((e) => closureBlockedEpics.has(e))
1384
+ if (wrongly.length) {
1385
+ noteDegraded('false_done')
1386
+ ledger(firstCard, 'epic-closure-guard', 'VIOLATION', `epic(s) closed against the blocklist: ${wrongly.join(' ')} — open residuals/follow-ups exist; skill must REOPEN`)
1387
+ 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 })
1388
+ }
1389
+ ledger(firstCard, 'epic-closure', 'CLOSED', `epics marked DONE (children DONE + ACs verified): ${mergeResult.epicsClosed.join(' ')}`)
1390
+ }
1391
+ if (mergeResult && (mergeResult.epicsLeftOpen || []).length) ledger(firstCard, 'epic-closure', 'LEFT-OPEN', mergeResult.epicsLeftOpen.map((x) => `${x.epic}: ${x.reason}`).join(' · '))
1303
1392
  if (deferredCards.length) {
1304
1393
  ledger(firstCard, 'F040-deferred', 'LEFT-NON-DONE', `${deferredCards.join(' ')} — skill marks DONE post-run after follow-up materialises`)
1305
1394
  // F-040 guard — catch a merge agent that ignored the instruction and force-DONE'd a deferred card.
@@ -1543,20 +1632,67 @@ function buildTelemetry() {
1543
1632
  }
1544
1633
  }
1545
1634
 
1635
+ // v5.6.0 — residual severity (FEAT-0068 §3.2: residuals carried no severity, so a goal-void run
1636
+ // and a clean run produced the same headline). Explicit severity wins; else class-derived.
1637
+ function residualSeverity(r) {
1638
+ if (r.severity) return String(r.severity).toUpperCase()
1639
+ const cls = r.deferralClass || r.kind || ''
1640
+ if (/^(scope-expansion|unresolved|ac-unmet|false-epic-closure|main-repo-contamination)$/.test(cls)) return 'HIGH'
1641
+ if (/^\s*BLOCKER\b/i.test(String(r.evidence || ''))) return 'BLOCKER'
1642
+ if (/^\s*HIGH\b/i.test(String(r.evidence || ''))) return 'HIGH'
1643
+ if (/^(out-of-ownership|merge-artifact-skipped|out-of-scope|baseline-not-reached|outage)$/.test(cls)) return 'MEDIUM'
1644
+ return 'LOW' // owner-gated / not-a-code-defect / policy-deferred-ac — external actions, tracked
1645
+ }
1646
+
1546
1647
  function buildReport(o) {
1547
1648
  const L = []
1548
1649
  L.push(`# new2 batch — ${cardIds.join(' ')}`)
1549
1650
  L.push(`Variant: **new2** · Mode: sequential · Trunk: ${TRUNK}${degraded ? ' · ⚠️ DEGRADED (' + degradationReasons.join(',') + ')' : ''}`)
1550
1651
  if (o.fatal) { L.push(``, `## ⛔ BATCH FATAL`, o.reason || 'workspace unworkable'); return L.join('\n') }
1652
+ // v5.6.0 PARTIAL verdict (FEAT-0068 §3.2) — the HARD headline rule: any BLOCKER/HIGH residual,
1653
+ // any epic left open / blocklisted / falsely closed, or a false_done degradation ⇒ the FIRST
1654
+ // line after the title says ⚠️ PARTIAL and names the gap. ✅ is RESERVED for zero-HIGH +
1655
+ // no open epic. The FEAT-0068 report said "✅ 6/6 DONE" over a disclosed-but-buried HIGH gap;
1656
+ // the defect was headline framing, and this is where the headline is built.
1657
+ const sevRank = { BLOCKER: 3, HIGH: 2, MEDIUM: 1, LOW: 0 }
1658
+ const highResiduals = residuals.filter((r) => (sevRank[residualSeverity(r)] || 0) >= 2)
1659
+ const epicsInPlay = dedupe(cardGraph.map((n) => n.parentEpic || '').filter(Boolean).concat(cardGraph.filter((n) => n.isEpic).map((n) => n.id)))
1660
+ const closedEpics = (mergeResult && mergeResult.epicsClosed) || []
1661
+ const leftOpen = ((mergeResult && mergeResult.epicsLeftOpen) || []).filter((x) => x && x.epic)
1662
+ const openEpics = epicsInPlay.filter((e) => !closedEpics.includes(e) || closureBlockedEpics.has(e))
1663
+ const falseDone = degradationReasons.includes('false_done')
1664
+ const partial = highResiduals.length > 0 || openEpics.length > 0 || falseDone
1665
+ if (partial) {
1666
+ const top = falseDone ? 'epic chiusa contro la blocklist (false DONE — da riaprire)'
1667
+ : highResiduals.length ? `${residualSeverity(highResiduals[0])} residuo aperto: ${String(highResiduals[0].evidence || highResiduals[0].kind).slice(0, 140)}`
1668
+ : `epic ${openEpics.join(' ')} NON chiusa (goal non ancora raggiunto/verificato)`
1669
+ L.push(``, `## ⚠️ PARTIAL — obiettivo NON completamente raggiunto`)
1670
+ L.push(`**${top}**`)
1671
+ 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.`)
1672
+ } else {
1673
+ L.push(``, `## ✅ COMPLETO`)
1674
+ L.push(`Tutte le card committate, epic ${epicsInPlay.length ? 'chiuse con AC verificati' : 'non presenti'}, nessun residuo HIGH.`)
1675
+ }
1551
1676
  L.push(``, `## Esito card`)
1552
1677
  L.push(`| Card | Status | Commit | File |`)
1553
1678
  L.push(`|------|--------|--------|------|`)
1554
1679
  for (const r of perCardResults) L.push(`| ${r.card} | ${r.status}${r.deferred ? ' (NON-DONE: deferred)' : ''} | ${r.commit || '-'} | ${(r.filesChanged || []).length} |`)
1555
1680
  const blockedIds = runnableCards.filter((id) => state[id] === 'blocked' || state[id] === 'pending')
1556
1681
  for (const id of blockedIds) L.push(`| ${id} | ${state[id]} | - | 0 |`)
1682
+ // v5.6.0 Epic goal section — per-epic truth: closed/open + why + the open work attached to it.
1683
+ if (epicsInPlay.length) {
1684
+ L.push(``, `## Epic goal`)
1685
+ for (const e of epicsInPlay) {
1686
+ const reason = (leftOpen.find((x) => x.epic === e) || {}).reason
1687
+ const epicHigh = residuals.filter((r) => ((graphById[r.card] && graphById[r.card].parentEpic) === e || r.card === e) && (sevRank[residualSeverity(r)] || 0) >= 2)
1688
+ if (closedEpics.includes(e) && !closureBlockedEpics.has(e)) L.push(`- **${e}: CHIUSA** (children DONE + AC epic verificati dal merge agent)`)
1689
+ else if (closedEpics.includes(e)) L.push(`- **${e}: ⚠️ CHIUSA CONTRO BLOCKLIST (false DONE)** — riaprire: ${epicHigh.length} residuo/i HIGH aperti`)
1690
+ 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` : ''}`)
1691
+ }
1692
+ }
1557
1693
  if (finalSummary) {
1558
1694
  L.push(``, `## Final review`)
1559
- L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) · failing gates: ${(finalSummary.failingGates || []).join(', ') || 'none'}`)
1695
+ L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) · failing gates: ${(finalSummary.failingGates || []).join(', ') || 'none'}${closureInvalidating.length ? ` · closure-invalidating: ${closureInvalidating.length}` : ''}`)
1560
1696
  }
1561
1697
  L.push(``, `## Merge`)
1562
1698
  if (mergeResult && mergeResult.merged) L.push(`Merged → ${TRUNK} @ ${mergeResult.mergeCommit || '?'}${mergeResult.reconciliation ? ' · ' + mergeResult.reconciliation : ''}`)
@@ -1567,10 +1703,14 @@ function buildReport(o) {
1567
1703
  for (const m of man) L.push(`- [ ] ${m}`)
1568
1704
  }
1569
1705
  if (residuals.length) {
1570
- L.push(``, `## ⚠️ Residui (il skill materializza le follow-up mancanti nulla perso)`)
1706
+ // v5.6.0 "nulla perso" is a tracking claim, not a completion claim: it may only headline
1707
+ // when no HIGH residual exists (a HIGH follow-up is real work NOT delivered, not bookkeeping).
1708
+ L.push(``, highResiduals.length
1709
+ ? `## ⚠️ Residui — ATTENZIONE: ${highResiduals.length} HIGH+ = lavoro sostanziale NON consegnato (le follow-up sono il lavoro, non burocrazia)`
1710
+ : `## Residui (bassa severità — il skill materializza le follow-up mancanti; nulla perso)`)
1571
1711
  const bd = residuals.reduce((b, x) => { const k = x.deferralClass || x.kind || 'unknown'; b[k] = (b[k] || 0) + 1; return b }, {})
1572
1712
  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}`)
1713
+ 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
1714
  }
1575
1715
  const excluded = gateLedger.filter((x) => x.decision === 'EXCLUDED')
1576
1716
  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
  |---|---|---|---|
@@ -22,7 +22,17 @@
22
22
  * Usage:
23
23
  * node framework/scripts/stamp-holistic-audit.js \
24
24
  * --at "2026-07-02T10:00:00Z" --commit "<sha-or-empty>" \
25
- * --set "FEAT-0070-01,FEAT-0070-02" <card1.yml> [<card2.yml> ...]
25
+ * --set "FEAT-0070-01,FEAT-0070-02" \
26
+ * [--audit-report /tmp/prd-audit-<slug>.md] <card1.yml> [<card2.yml> ...]
27
+ *
28
+ * EVIDENCE GATE (v5.6.0 — the FEAT-0068 phantom stamp): the stamp is only worth
29
+ * anything if the audit team actually ran. `--audit-report <path>` names the
30
+ * audit-phase report artifact; the script reads its `agents_run:` line (falling
31
+ * back to counting `agent:`/`###` sections) and writes `agents_run: [...]` +
32
+ * `findings_count` into the stamp. WITHOUT the flag — or when the file does not
33
+ * exist / names no agents — the script writes `status: "skipped"` and NO
34
+ * `agents_run`, which `/new` (implement.md Phase 1 step 4 P1) treats as ABSENT:
35
+ * the plan-auditor runs. A stamp can no longer claim an audit that never happened.
26
36
  *
27
37
  * Exit 0 = every non-epic card carries the stamp afterwards; exit 1 = a write or
28
38
  * parse failed (per-file errors printed). Zero-dep, node-core only.
@@ -61,19 +71,49 @@ function stripExistingBlock(text) {
61
71
  return lines.slice(0, start).concat(lines.slice(end)).join('\n');
62
72
  }
63
73
 
64
- function buildBlock(at, commit, set, childIndent) {
74
+ /**
75
+ * v5.6.0 — extract audit evidence from the audit-phase report artifact.
76
+ * Returns { agentsRun: string[], findingsCount: number|null } or null when the
77
+ * report is absent/unreadable/empty of agents (→ the stamp degrades to skipped).
78
+ */
79
+ function readAuditEvidence(reportPath) {
80
+ if (!reportPath) return null;
81
+ let text;
82
+ try { text = fs.readFileSync(reportPath, 'utf8'); } catch (_) { return null; }
83
+ // primary: an explicit `agents_run: a, b, c` (or YAML list) line in the report
84
+ const m = text.match(/^agents_run:\s*\[?([^\]\n]+)\]?/m);
85
+ let agents = m ? m[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean) : [];
86
+ if (!agents.length) {
87
+ // fallback: agent names cited as report sections/spawn lines
88
+ const KNOWN = ['plan-auditor', 'codebase-architect', 'security-reviewer', 'api-perf-cost-auditor', 'qa-sentinel', 'code-reviewer', 'doc-reviewer'];
89
+ agents = KNOWN.filter((a) => new RegExp(`\\b${a}\\b`).test(text));
90
+ }
91
+ if (!agents.length) return null;
92
+ const fc = text.match(/^findings(?:_count)?:\s*(\d+)/m);
93
+ return { agentsRun: Array.from(new Set(agents)), findingsCount: fc ? Number(fc[1]) : null };
94
+ }
95
+
96
+ function buildBlock(at, commit, set, childIndent, evidence) {
65
97
  const pad = ' '.repeat(childIndent);
66
98
  const inner = ' '.repeat(childIndent + 2);
67
99
  const ids = set.map((s) => `"${s}"`).join(', ');
68
- return [
100
+ const lines = [
69
101
  `${pad}holistic_audit:`,
70
102
  `${inner}audited_at: "${at}"`,
71
103
  `${inner}audited_commit: "${commit}"`,
72
104
  `${inner}audited_set: [${ids}]`,
73
- ].join('\n');
105
+ ];
106
+ if (evidence) {
107
+ lines.push(`${inner}agents_run: [${evidence.agentsRun.map((a) => `"${a}"`).join(', ')}]`);
108
+ if (evidence.findingsCount !== null) lines.push(`${inner}findings_count: ${evidence.findingsCount}`);
109
+ } else {
110
+ // no audit artifact → the stamp is explicit about it; /new P1 treats this as ABSENT
111
+ lines.push(`${inner}status: "skipped"`);
112
+ }
113
+ return lines.join('\n');
74
114
  }
75
115
 
76
- function stampFile(file, at, commit, set) {
116
+ function stampFile(file, at, commit, set, evidence) {
77
117
  let text = fs.readFileSync(file, 'utf8');
78
118
  let card;
79
119
  try { card = parseCardYaml(text); } catch (e) { return { file, status: 'error', detail: `YAML parse error — ${e.message}` }; }
@@ -94,11 +134,11 @@ function stampFile(file, at, commit, set) {
94
134
  if (li > 0) childIndent = li;
95
135
  break;
96
136
  }
97
- lines.splice(metaIdx + 1, 0, buildBlock(at, commit, set, childIndent));
137
+ lines.splice(metaIdx + 1, 0, buildBlock(at, commit, set, childIndent, evidence));
98
138
  text = lines.join('\n');
99
139
  } else {
100
140
  if (!text.endsWith('\n')) text += '\n';
101
- text += `\nmetadata:\n${buildBlock(at, commit, set, 2)}\n`;
141
+ text += `\nmetadata:\n${buildBlock(at, commit, set, 2, evidence)}\n`;
102
142
  }
103
143
  fs.writeFileSync(file, text);
104
144
  // verify the write landed (the 5b "re-read one card" contract, made universal)
@@ -113,20 +153,23 @@ function main(argv) {
113
153
  const at = argValue(args, '--at');
114
154
  const commit = argValue(args, '--commit'); // may legitimately be "" — must still be PASSED
115
155
  const setRaw = argValue(args, '--set');
156
+ const auditReport = argValue(args, '--audit-report'); // v5.6.0 evidence gate (optional flag, binding semantics)
116
157
  const files = [];
117
158
  for (let i = 0; i < args.length; i++) {
118
- if (args[i] === '--at' || args[i] === '--commit' || args[i] === '--set') { i++; continue; }
159
+ if (args[i] === '--at' || args[i] === '--commit' || args[i] === '--set' || args[i] === '--audit-report') { i++; continue; }
119
160
  files.push(args[i]);
120
161
  }
121
162
  if (!at || commit === null || setRaw === null || !files.length) {
122
- process.stderr.write('Usage: stamp-holistic-audit.js --at <iso8601> --commit <sha-or-empty> --set "ID1,ID2" <card.yml> [...]\n');
163
+ process.stderr.write('Usage: stamp-holistic-audit.js --at <iso8601> --commit <sha-or-empty> --set "ID1,ID2" [--audit-report <path>] <card.yml> [...]\n');
123
164
  return 2;
124
165
  }
125
166
  const set = setRaw.split(',').map((s) => s.trim()).filter(Boolean).sort();
167
+ const evidence = readAuditEvidence(auditReport);
168
+ if (!evidence) process.stdout.write(`⚠ no audit evidence (${auditReport ? 'report unreadable/agent-less: ' + auditReport : '--audit-report not passed'}) → stamping status:"skipped" (no agents_run; /new will RUN the plan-auditor)\n`);
126
169
  let failed = 0, stamped = 0, already = 0, epics = 0;
127
170
  for (const f of files) {
128
171
  let r;
129
- try { r = stampFile(f, at, commit, set); } catch (e) { r = { file: f, status: 'error', detail: e.message }; }
172
+ try { r = stampFile(f, at, commit, set, evidence); } catch (e) { r = { file: f, status: 'error', detail: e.message }; }
130
173
  if (r.status === 'error') { failed++; process.stdout.write(`✖ ${f}: ${r.detail}\n`); }
131
174
  else if (r.status === 'stamped') { stamped++; process.stdout.write(`✓ ${f}: stamped\n`); }
132
175
  else if (r.status === 'already-stamped') { already++; process.stdout.write(`· ${f}: already stamped\n`); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "5.5.0",
3
+ "version": "5.7.0",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -1508,10 +1508,17 @@ async function configure(opts = {}) {
1508
1508
  const enabledTools = (merged.tools && Array.isArray(merged.tools.enabled) && merged.tools.enabled.length)
1509
1509
  ? merged.tools.enabled : ['claude'];
1510
1510
  if (fs.existsSync(path.join(cwd, '.framework'))) {
1511
- new SymlinkUtils(cwd).mergeAgents({ tools: enabledTools });
1511
+ const symlinks = new SymlinkUtils(cwd);
1512
+ symlinks.mergeAgents({ tools: enabledTools });
1513
+ // Also re-merge SKILLS: the feature-gate (src/utils/skill-gate.js) depends
1514
+ // on the `features.*` just written. On first install `mergeSkills` ran
1515
+ // fail-open BEFORE this config existed (linking everything), so this pass
1516
+ // is what actually prunes the gated-off skills. Without it, gating never
1517
+ // takes effect until the next `baldart update`.
1518
+ symlinks.mergeSkills({ tools: enabledTools });
1512
1519
  }
1513
1520
  } catch (err) {
1514
- UI.warning(`Agent re-merge after configure failed (${err.message}) — run \`npx baldart doctor\` to heal.`);
1521
+ UI.warning(`Agent/skill re-merge after configure failed (${err.message}) — run \`npx baldart doctor\` to heal.`);
1515
1522
  }
1516
1523
 
1517
1524
  // v5.1.0 — research library: create + seed ${paths.research_dir} right after
@@ -409,6 +409,47 @@ async function detectState(cwd, opts = {}) {
409
409
  }
410
410
  } catch (_) { /* never block doctor on probe */ }
411
411
 
412
+ // ---- Feature-gated skill drift (skill-gate) ------------------------
413
+ // A skill whose `requires_feature` frontmatter is no longer satisfied
414
+ // (feature flipped off in baldart.config.yml) may still be linked from a
415
+ // prior install. `configure` and `update` both re-merge (which prunes), but
416
+ // a user who edits the config and runs ONLY `doctor` should be told. The
417
+ // reverse (a feature just turned ON, gated skill not yet linked) is covered
418
+ // by the missing-skills path. Advisory only (fix = `reconcile-skills`, a
419
+ // plain re-merge); never blocks. Fully fail-safe.
420
+ state.gatedSkillsLinked = [];
421
+ try {
422
+ if (state.frameworkPresent) {
423
+ const { resolveSkillGate } = require('../utils/skill-gate');
424
+ const { getAdapter } = require('../utils/tool-adapters');
425
+ const frameworkSkillsDir = path.join(cwd, '.framework', 'framework', '.claude', 'skills');
426
+ const gate = resolveSkillGate(frameworkSkillsDir, cwd);
427
+ if (!gate.failOpen && gate.gatedOff.length) {
428
+ const toolsEnabled = (config && !config.__malformed && config.tools && Array.isArray(config.tools.enabled))
429
+ ? config.tools.enabled : ['claude'];
430
+ const seen = new Set();
431
+ for (const toolName of toolsEnabled) {
432
+ let adapter;
433
+ try { adapter = getAdapter(toolName, cwd); } catch (_) { continue; }
434
+ const skillsRel = adapter.skillsDir();
435
+ for (const name of gate.gatedOff) {
436
+ if (seen.has(name)) continue;
437
+ const linkPath = path.join(cwd, skillsRel, name);
438
+ try {
439
+ const lst = fs.lstatSync(linkPath);
440
+ if (!lst.isSymbolicLink()) continue;
441
+ const resolved = path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
442
+ if (resolved === path.join(frameworkSkillsDir, name)) {
443
+ seen.add(name);
444
+ state.gatedSkillsLinked.push(name);
445
+ }
446
+ } catch (_) { /* absent */ }
447
+ }
448
+ }
449
+ }
450
+ }
451
+ } catch (_) { /* never block doctor on probe */ }
452
+
412
453
  // ---- Workflow symlink integrity (since v4.14.0) --------------------
413
454
  // Dynamic workflows (.js) are Claude-only and per-item symlinked into
414
455
  // `.claude/workflows/`. Unlike agents there is NO hard discovery gate —
@@ -1086,6 +1127,30 @@ function planActions(state) {
1086
1127
  });
1087
1128
  }
1088
1129
 
1130
+ // Feature-gated skill drift (skill-gate). Advisory — these skills' gating
1131
+ // feature is now off, but they're still linked from a prior install; they'd
1132
+ // only refuse at runtime and waste the Codex skill-description budget. A plain
1133
+ // re-merge prunes them (same rationale as the agent/workflow repairs).
1134
+ if (state.gatedSkillsLinked && state.gatedSkillsLinked.length > 0) {
1135
+ actions.push({
1136
+ key: 'reconcile-skills',
1137
+ label: `Unlink ${state.gatedSkillsLinked.length} feature-gated skill(s)`,
1138
+ why: `These skills are still linked but their gating feature is now off in baldart.config.yml: ${state.gatedSkillsLinked.join(', ')}. They refuse at runtime and consume Codex's ~2% skill-description budget. Re-merging prunes them (re-enable the feature to bring them back).`,
1139
+ autoOk: true,
1140
+ run: async () => {
1141
+ const SymlinkUtils = require('../utils/symlinks');
1142
+ const cfg = loadConfig(process.cwd());
1143
+ const enabledTools = (cfg && !cfg.__malformed
1144
+ && cfg.tools && Array.isArray(cfg.tools.enabled))
1145
+ ? cfg.tools.enabled
1146
+ : ['claude'];
1147
+ const symlinks = new SymlinkUtils();
1148
+ symlinks.mergeSkills({ tools: enabledTools });
1149
+ UI.success(`Reconciled feature-gated skills for tools: ${enabledTools.join(', ')}`);
1150
+ },
1151
+ });
1152
+ }
1153
+
1089
1154
  // Root primitives integrity (since v4.83.0). Regenerates AGENTS.md / CLAUDE.md
1090
1155
  // from the versioned skeleton + config + overlay when missing, still a legacy
1091
1156
  // symlink, or drifted. Offline + idempotent. A handwritten file is NOT touched