baldart 4.16.0 → 4.16.2
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 +29 -0
- package/VERSION +1 -1
- package/framework/.claude/workflows/new2-resolve.js +1 -0
- package/framework/.claude/workflows/new2.js +51 -13
- package/package.json +1 -1
- package/src/utils/__tests__/classify-divergence.test.js +21 -0
- package/src/utils/__tests__/overlay-capture.test.js +50 -0
- package/src/utils/git.js +39 -2
- package/src/utils/overlay-capture.js +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ All notable changes to BALDART will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [4.16.2] - 2026-06-06
|
|
9
|
+
|
|
10
|
+
**`baldart update` non si blocca più all'infinito su un edit `.framework/` ormai assorbito a monte.** Un edit locale a un file framework (es. `agents/ui-expert.md`) committato con un subject che *non* contiene la parola "baldart" sfuggiva alla classificazione `chore-wrapper` (rumore) e veniva marcato `custom-overlay-able`. Quel commit resta per sempre ancestor di `HEAD` ma mai di `FETCH_HEAD`, quindi `git log FETCH_HEAD..HEAD -- .framework` lo ripesca a **ogni rilascio successivo** — e il flusso seamless si fermava sul gate `divergence-capture-incomplete` perché l'overlay (additivo) non riproduceva byte-a-byte il file `.framework`, anche quando il contenuto era già identico all'upstream e la personalizzazione viveva correttamente nell'overlay. Risultato: lo stesso commit "fantasma" rifaceva muro a ogni `update`, senza via d'uscita via overlay (riconciliare un overlay additivo non può mai far passare il round-trip stretto). **PATCH** (bugfix del detector di divergenza + reset-safety degli overlay additivi; nessun cambio di capability, nessuna chiave `baldart.config.yml`).
|
|
11
|
+
|
|
12
|
+
> **Why.** Il check di fedeltà capture+verify pretendeva `merge(base_upstream, overlay) == file_.framework`. Per un overlay `extend` (additivo) questo è **strutturalmente falso** quando `.framework` è pristine-upstream: il merge aggiunge sezioni, il file no → "stale" eterno. E il classifier non distingueva un edit *vivo* (da preservare) da uno *assorbito* (contenuto già a monte → un re-sync non perde nulla). Due correzioni complementari chiudono la classe di falso positivo per tutti i consumer.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **`src/utils/git.js` — categoria `absorbed` nel classifier di divergenza.** `classifyDivergence` ora demota a rumore (`absorbed`) ogni commit `custom-*` i cui file `.framework/` toccati sono **byte-identici all'upstream (`FETCH_HEAD`)** — confronto di blob-SHA, prefisso `.framework/` strippato per mappare il path upstream; conservativo (file fuori da `.framework/`, mancante a monte, o SHA diverso → resta drift reale). `aggregateDivergenceClass` include `absorbed` tra le `noiseCategories`, così un edit ormai assorbito non spinge più la classe verso `overlay-able`/`mixed`: la batch torna `all-noise` → auto-resolve pulito.
|
|
17
|
+
- **`src/utils/overlay-capture.js` — reset-safety per overlay additivi (`captureAndVerify`).** Quando esiste già un overlay agent/command, il file `.framework` viene riconosciuto come **reset-safe** (nessun edit inline da perdere) se è pristine: (a) byte-identico alla base upstream verso cui si farebbe reset, **oppure** (b) corrisponde alla base con cui l'overlay è stato autorato (`base_file_sha` in frontmatter) — pristine anche dopo che l'upstream è andato avanti. In quel caso è `captured` (non più `stale`), invece di pretendere il round-trip stretto che un overlay additivo non può mai soddisfare. Il caso *genuinamente* stale (edit inline reale che l'overlay non riproduce) resta correttamente un blocker.
|
|
18
|
+
- **Test.** `classify-divergence.test.js`: 3 fixture per `absorbed` (da solo / con merge plumbing → `all-noise`; con overlay-able genuino → `overlay-able`). `overlay-capture.test.js`: 2 fixture per la reset-safety (overlay additivo + `.framework` == base → `captured`; upstream spostato ma `.framework` pristine vs `base_file_sha` → `captured`).
|
|
19
|
+
|
|
20
|
+
## [4.16.1] - 2026-06-05
|
|
21
|
+
|
|
22
|
+
**Review di allineamento `new` ↔ `new2`: 9 fix di fedeltà sui workflow `new2`/`new2-resolve` emersi da una review adversariale a 3 reviewer paralleli.** La v4.16.0 ha introdotto `new2` come variante workflow-hosted di `/new`; una review di allineamento (gate-coverage · child-workflow wiring · control-flow) ha refutato l'implementazione su difetti reali — di semantica/allineamento, non di esecuzione (il wiring tra i 3 workflow era contract-fedele). **PATCH** (bugfix di allineamento del nuovo `new2`, nessun cambio di capability, nessuna chiave `baldart.config.yml`).
|
|
23
|
+
|
|
24
|
+
> **Why.** `new2` deve essere un A/B equo di `/new`: ogni gate va mappato con policy *fedele* alla semantica di `/new` (meno l'AskUserQuestion), e tutti i path/child-workflow vanno cablati. La review ha trovato punti dove la policy deterministica divergeva silenziosamente dalla SSOT o dove un segnale veniva letto ma non agito.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **`framework/.claude/workflows/new2.js`**:
|
|
29
|
+
- **Final review `NEEDS_MANUAL_CONFIRMATION` non più ignorato** — il filtro blocker includeva solo `VERIFIED` mentre il commento dichiarava "treat as VERIFIED": ora include anche `NEEDS_MANUAL_CONFIRMATION` (tentato il fix, mai scartato in silenzio → ripristina l'invariante zero-drop di `final-review.md` F.4).
|
|
30
|
+
- **`status:'fatal'` di `new2-resolve` ora onorato** — `resolve()` setta `batchFatal` e sospende final review + merge, invece di degradarlo a `followup`.
|
|
31
|
+
- **Phase 7 Production Readiness aggiunta** (post-merge, non-bloccante) — ripristina la full-parity dichiarata (auto-esegue i deploy stack-matched, riporta gli item manuali).
|
|
32
|
+
- **Card crashata non più persa** — il catch del driver passa da `resolve('agent-crash')` (retry specialista, mai bypass su security/migration) e garantisce un follow-up tracciato (path `agent-crash` prima irraggiungibile).
|
|
33
|
+
- **G3 cross-card / G5 depends-on rafforzati** — il prompt pre-flight ora *applica* le azioni cross-card (force-sequential/warning in executionMode/groups), non le logga soltanto; G5 garantisce l'esclusione **transitiva** dei dipendenti.
|
|
34
|
+
- **Minori**: `fileDiffViolation` (E4) ora loggato nel gate ledger; `hasApiDataFiles` non più sempre-true (rimosso il disgiunto che rendeva irraggiungibile lo skip dell'api-perf-auditor); i resolve di `merge-blocker`/`qa-fail` passano `mayEditPaths` (confine ownership sui fix batch-wide).
|
|
35
|
+
- **`framework/.claude/workflows/new2-resolve.js`**: obbligo esplicito di **re-run E2E (Phase 2.6)** quando un fix tocca file UI (app/components/`*.css`/`*.scss`), allineato a `codex-gate.md` step 6.
|
|
36
|
+
|
|
8
37
|
## [4.16.0] - 2026-06-05
|
|
9
38
|
|
|
10
39
|
**Nuova skill sperimentale `new2` + i workflow `new2`/`new2-resolve`: l'INTERA batch di `/new` può girare come un singolo dynamic workflow autonomo in background, per fare A/B testing sul context economy.** `/new` è una skill che gira nel main loop, quindi l'output di ogni subagent (architect, coder, reviewer, Codex, QA, E2E) gonfia il contesto dell'orchestrator — il driver di costo che si sposta d'asse a ogni fix (v4.12→v4.15). I dynamic workflow spostano l'orchestrazione in un runtime **isolato**: i risultati intermedi vivono in variabili di script e nel main loop torna **solo il report finale**. `new2` è la versione workflow-hosted di `/new`, **parallela e additiva** (NON sostituisce `/new`), pensata per misurare il delta di `usage` reale a parità di semantica. **MINOR** (nuova skill + 2 workflow, opt-in, Claude-only; nessun breaking change, nessuna chiave `baldart.config.yml` — il gating è runtime via presenza del tool `Workflow`, come v4.14.0).
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.16.
|
|
1
|
+
4.16.2
|
|
@@ -67,6 +67,7 @@ const brief = [
|
|
|
67
67
|
`Scope files for re-verify: ${scopeFiles.join(', ') || '(diff)'}`,
|
|
68
68
|
`Domain: ${domain}${protectedDomain ? ' (PROTECTED — security/migration: never inline-bypass; the right specialist MUST apply)' : ''}`,
|
|
69
69
|
`Reference modules (Read for exact semantics): ${REF}/`,
|
|
70
|
+
`UI re-verify obligation: if your fix touches a UI file (app dir / components / *.css / *.scss), you MUST re-run the Phase 2.6 E2E review (per ${REF}/review-cycle.md) before declaring verified — a code-review/Codex fix can reintroduce visual/functional drift (mirrors codex-gate.md step 6).`,
|
|
70
71
|
].join('\n')
|
|
71
72
|
|
|
72
73
|
// The agent that OWNS a fix in this domain (Domain-Override): doc → doc-reviewer.
|
|
@@ -7,6 +7,7 @@ export const meta = {
|
|
|
7
7
|
{ title: 'Implement', detail: 'per-card implement+review pipeline; team-mode runs coders in parallel per wave' },
|
|
8
8
|
{ title: 'Final', detail: 'cross-batch final review (delegates to new-final-review)' },
|
|
9
9
|
{ title: 'Merge', detail: 'auto-merge to trunk via git.merge_strategy + cleanup' },
|
|
10
|
+
{ title: 'Production', detail: 'post-merge production-readiness checklist (Phase 7, non-blocking)' },
|
|
10
11
|
],
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -46,6 +47,8 @@ const firstCard = cardIds[0] || 'BATCH'
|
|
|
46
47
|
const gateLedger = [] // { card, gate, decision, detail }
|
|
47
48
|
const residualFollowups = []// { card, kind, followupCard, reason }
|
|
48
49
|
const perCardResults = [] // structured per-card outcome
|
|
50
|
+
let batchFatal = false // set when a resolve() returns 'fatal' (unrecoverable)
|
|
51
|
+
let prodReadiness = null // Phase 7 result (post-merge, non-blocking)
|
|
49
52
|
|
|
50
53
|
function ledger(card, gate, decision, detail) {
|
|
51
54
|
gateLedger.push({ card, gate, decision, detail: detail || '' })
|
|
@@ -120,9 +123,9 @@ const preflight = await agent(
|
|
|
120
123
|
`DETERMINISTIC GATE POLICIES (NO user prompts):\n` +
|
|
121
124
|
`• G1 dirty-tree (main repo ${MAIN}): partition framework-managed noise exactly as setup.md step 3 ($METRICS=${METRICS}, .baldart/generated|state.json|skill-conflicts.json — NOT overlays/). If genuine user work remains → auto-stash 'baldart-new2-${firstCard}-<ts>' (main checkout, safe) and record the MESSAGE label for restore at merge. Never commit/abort/prompt.\n` +
|
|
122
125
|
`• G2 divergence vs origin/${TRUNK}: behind-only AND HEAD==${TRUNK} → ff-pull; ahead-genuine or diverged → DO NOT push/rebase/reset; proceed (the worktree is isolated) and put a one-line note in workspaceNote. Framework-management commits are auto-ignored (setup.md step 4 reclassification).\n` +
|
|
123
|
-
`• G3 cross-card grounding (setup.md 3d): run the Codex cross-card check with the provenance skip; on CODEX_NOT_FOUND fall back to code-reviewer; if BOTH unavailable → proceed and set crossCard='CAPABILITY_UNAVAILABLE'. Read the audit file ONLY via the [codex]-stripping filter; return DISTILLED findings.\n` +
|
|
126
|
+
`• G3 cross-card grounding (setup.md 3d): run the Codex cross-card check with the provenance skip; on CODEX_NOT_FOUND fall back to code-reviewer; if BOTH unavailable → proceed and set crossCard='CAPABILITY_UNAVAILABLE'. Read the audit file ONLY via the [codex]-stripping filter; return DISTILLED findings. CRITICAL — if conflicts are FOUND you MUST APPLY them, not just report: FILE_CONFLICT / ORDER_RISK → force the conflicting cards SEQUENTIAL (encode this in executionMode/groups AND the ownership map); STATE_MUTATION → fold a warning into the affected cards via the ownership map. The returned executionMode/groups MUST already reflect these constraints.\n` +
|
|
124
127
|
`• G4 card-field validation (setup.md 1b/1c): a card missing requirements/acceptance_criteria/files_likely_touched → EXCLUDE it (add to excluded[] with reason); continue with valid cards. Never HALT the batch for one bad card.\n` +
|
|
125
|
-
`• G5 depends-on (implement.md Phase 1 step 2): if a card's depends_on names a card that is NOT DONE and is NOT in this batch → exclude that card AND
|
|
128
|
+
`• G5 depends-on (implement.md Phase 1 step 2): if a card's depends_on names a card that is NOT DONE and is NOT in this batch → exclude that card AND, TRANSITIVELY, every in-batch card that depends on it (excluded[], reason 'depends_on <dep> not DONE'). GUARANTEE: the returned cards[] must contain NO card that transitively depends on an excluded card. If the dep IS in this batch, keep it and order it first.\n` +
|
|
126
129
|
`• Worktree (setup.md step 4): create ONE code worktree off ${TRUNK} (slug from git_strategy.branch or the parent/lead card title), install deps, assign a port, run the baseline (tsc+lint+build). Use the git-authoritative idempotency pre-check (step 4a2). E2: if baseline FAILS, fix-attempt once (the trunk itself may not build); if still failing return baseline:'fail' with baselineLog (the caller treats it as batch-fatal).\n` +
|
|
127
130
|
`• Complexity (setup.md 3c): decide executionMode sequential|team and, for team, the groups [{level,cards}]. Build the file-ownership map; write it to /tmp and return ownershipMapPath.\n` +
|
|
128
131
|
`• Persist per-card architecture baselines you gather to /tmp/arch-baseline-<CARD>.md and return archBaselinePaths (final review reuses them).\n\n` +
|
|
@@ -165,6 +168,7 @@ async function resolve(kind, card, evidence, extra) {
|
|
|
165
168
|
config: cfg,
|
|
166
169
|
}).catch((e) => ({ status: 'followup', reason: 'resolve workflow error: ' + (e && e.message) }))
|
|
167
170
|
const status = (res && res.status) || 'followup'
|
|
171
|
+
if (status === 'fatal') { batchFatal = true; ledger(card, 'resolve:' + kind, 'FATAL', (res && res.reason) || ''); return status }
|
|
168
172
|
if (status === 'followup' && res && res.followupCard) {
|
|
169
173
|
residualFollowups.push({ card, kind, followupCard: res.followupCard, reason: res.reason || '' })
|
|
170
174
|
}
|
|
@@ -172,6 +176,15 @@ async function resolve(kind, card, evidence, extra) {
|
|
|
172
176
|
return status
|
|
173
177
|
}
|
|
174
178
|
|
|
179
|
+
// A crashed runCard (E1) routes through new2-resolve(agent-crash) for a specialist
|
|
180
|
+
// retry, then ALWAYS lands as a tracked followup — never a silent drop.
|
|
181
|
+
async function crashFollowup(id, e) {
|
|
182
|
+
const msg = String(e && e.message)
|
|
183
|
+
const s = await resolve('agent-crash', id, 'runCard crashed: ' + msg, { mayEditPaths: [], scopeFiles: [] })
|
|
184
|
+
if (s !== 'followup') residualFollowups.push({ card: id, kind: 'agent-crash', followupCard: '(specialist retry attempted — manual check)', reason: msg })
|
|
185
|
+
return { card: id, status: 'followup', gates: [{ gate: 'runCard', decision: 'ERROR', detail: msg }], commit: '-', telemetry: {} }
|
|
186
|
+
}
|
|
187
|
+
|
|
175
188
|
// ───────────────────────────────────────────────────────────────────────────
|
|
176
189
|
// Per-card pipeline — sequences /new Phases 1→4 via agents that Read the modules.
|
|
177
190
|
// Each phase agent returns a structured verdict; this function applies the
|
|
@@ -200,6 +213,7 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
200
213
|
|
|
201
214
|
const mayEdit = (impl && impl.mayEditPaths) || []
|
|
202
215
|
const scopeFiles = (impl && impl.scopeFiles) || []
|
|
216
|
+
if (impl && impl.fileDiffViolation) g('E4-file-diff', 'AUTO-REVERTED', 'coder touched files outside ownership — reverted per implement.md 11b')
|
|
203
217
|
|
|
204
218
|
// G26 — build gates not converging → self-heal.
|
|
205
219
|
if (impl && impl.buildBlocked) {
|
|
@@ -291,13 +305,13 @@ if (preflight.executionMode === 'team' && Array.isArray(preflight.groups) && pre
|
|
|
291
305
|
const ids = (grp.cards || []).filter((id) => runnableCards.includes(id))
|
|
292
306
|
if (!ids.length) continue
|
|
293
307
|
log(`Team wave level=${grp.level}: ${ids.length} card(s) in parallel.`)
|
|
294
|
-
const waveResults = await parallel(ids.map((id) => () => runCard(id, pathById[id], lessons).catch((e) => (
|
|
308
|
+
const waveResults = await parallel(ids.map((id) => () => runCard(id, pathById[id], lessons).catch((e) => crashFollowup(id, e))))
|
|
295
309
|
for (const r of waveResults.filter(Boolean)) { perCardResults.push(r); if (r.note) lessons.push(`${r.card}: ${r.note}`) }
|
|
296
310
|
}
|
|
297
311
|
} else {
|
|
298
312
|
// Sequential — cards share one worktree and run one at a time.
|
|
299
313
|
for (const id of runnableCards) {
|
|
300
|
-
const r = await runCard(id, pathById[id], lessons).catch((e) => (
|
|
314
|
+
const r = await runCard(id, pathById[id], lessons).catch((e) => crashFollowup(id, e))
|
|
301
315
|
perCardResults.push(r)
|
|
302
316
|
if (r && r.note) lessons.push(`${r.card}: ${r.note}`)
|
|
303
317
|
}
|
|
@@ -313,12 +327,12 @@ const committed = perCardResults.filter((r) => r.status === 'committed')
|
|
|
313
327
|
// ───────────────────────────────────────────────────────────────────────────
|
|
314
328
|
phase('Final')
|
|
315
329
|
let finalSummary = null
|
|
316
|
-
let mergeBlocked =
|
|
317
|
-
if (committed.length) {
|
|
330
|
+
let mergeBlocked = batchFatal
|
|
331
|
+
if (committed.length && !batchFatal) {
|
|
318
332
|
const reviewScopeFiles = dedupe(committed.flatMap((r) => r.scopeFiles || []))
|
|
319
333
|
const archPaths = committed.map((r) => r.archBaselinePath).filter(Boolean)
|
|
320
334
|
const allArch = archPaths.length === committed.length ? archPaths : null
|
|
321
|
-
const hasApiDataFiles = reviewScopeFiles.some((f) => /api\/|data-model|\.sql$|migrations?\//i.test(f))
|
|
335
|
+
const hasApiDataFiles = reviewScopeFiles.some((f) => /api\/|data-model|\.sql$|migrations?\//i.test(f))
|
|
322
336
|
|
|
323
337
|
const final = await workflow('new-final-review', {
|
|
324
338
|
firstCardId: firstCard,
|
|
@@ -334,21 +348,23 @@ if (committed.length) {
|
|
|
334
348
|
if (final) {
|
|
335
349
|
finalSummary = final.summary || null
|
|
336
350
|
ledger(firstCard, 'final-review', 'DONE', `engine=${final.codexEngine}; verified=${final.summary ? final.summary.verified : '?'}`)
|
|
337
|
-
// G18 — apply
|
|
338
|
-
|
|
351
|
+
// G18 — apply BLOCKER/HIGH fixes; unresolved → block merge. G17: a workflow
|
|
352
|
+
// cannot ask the user, so NEEDS_MANUAL_CONFIRMATION is treated as VERIFIED
|
|
353
|
+
// (attempt the fix) — never silently ignored (the count-only path was the bug).
|
|
354
|
+
const blockers = (final.findings || []).filter((f) => (f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && (f.severity === 'BLOCKER' || f.severity === 'HIGH'))
|
|
339
355
|
for (const f of blockers) {
|
|
340
|
-
const s = await resolve('merge-blocker', f.finding_id || firstCard, `${f.severity} ${f.title}: ${f.evidence}`, { scopeFiles: reviewScopeFiles, domain: f.domain || 'code' })
|
|
356
|
+
const s = await resolve('merge-blocker', f.finding_id || firstCard, `${f.classification} ${f.severity} ${f.title}: ${f.evidence}`, { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: f.domain || 'code' })
|
|
341
357
|
if (s !== 'resolved') mergeBlocked = true
|
|
342
358
|
}
|
|
343
359
|
if ((final.summary && final.summary.failingGates && final.summary.failingGates.length)) {
|
|
344
|
-
const s = await resolve('qa-fail', firstCard, `final gates failing: ${final.summary.failingGates.join(', ')}`, { scopeFiles: reviewScopeFiles, domain: 'code' })
|
|
360
|
+
const s = await resolve('qa-fail', firstCard, `final gates failing: ${final.summary.failingGates.join(', ')}`, { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: 'code' })
|
|
345
361
|
if (s !== 'resolved') mergeBlocked = true
|
|
346
362
|
}
|
|
347
363
|
} else {
|
|
348
364
|
ledger(firstCard, 'final-review', 'SKIPPED', 'workflow returned null')
|
|
349
365
|
}
|
|
350
366
|
} else {
|
|
351
|
-
ledger(firstCard, 'final-review', 'SKIPPED', 'no committed cards')
|
|
367
|
+
ledger(firstCard, 'final-review', 'SKIPPED', batchFatal ? 'batch fatal — review/merge halted' : 'no committed cards')
|
|
352
368
|
}
|
|
353
369
|
|
|
354
370
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -372,6 +388,22 @@ if (!committed.length) {
|
|
|
372
388
|
if (mergeResult && mergeResult.reconciliation) ledger(firstCard, 'G19-23-reconcile', 'AUTO', mergeResult.reconciliation)
|
|
373
389
|
}
|
|
374
390
|
|
|
391
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
392
|
+
// Phase Production (Phase 7) — post-merge readiness checklist (non-blocking, full
|
|
393
|
+
// parity with /new). Auto-executes stack-matched deploys; reports the manual items.
|
|
394
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
395
|
+
phase('Production')
|
|
396
|
+
if (mergeResult && mergeResult.merged) {
|
|
397
|
+
prodReadiness = await agent(
|
|
398
|
+
`Run the post-merge Production Readiness checklist per ${REF}/production-readiness.md (Phase 7) over the batch's changed files. Auto-EXECUTE only stack-matched index / access-rule / cron deploys; REPORT (do not execute) env vars, feature flags, DB migrations, third-party secrets, DNS changes. NON-BLOCKING — never fail the batch.\n\n${projectBrief}\nChanged files: ${dedupe(committed.flatMap((r) => r.filesChanged || [])).join(', ') || '(derive from git)'}\n\nReturn: { autoExecuted:[...], manualItems:[...], note }`,
|
|
399
|
+
{ label: 'production-readiness', phase: 'Production', agentType: 'general-purpose',
|
|
400
|
+
schema: { type: 'object', required: ['manualItems'], additionalProperties: true, properties: { autoExecuted: { type: 'array', items: { type: 'string' } }, manualItems: { type: 'array', items: { type: 'string' } }, note: { type: 'string' } } } }
|
|
401
|
+
)
|
|
402
|
+
ledger(firstCard, 'phase7-production', 'DONE', `auto=${((prodReadiness && prodReadiness.autoExecuted) || []).length} manual=${((prodReadiness && prodReadiness.manualItems) || []).length}`)
|
|
403
|
+
} else {
|
|
404
|
+
ledger(firstCard, 'phase7-production', 'SKIPPED', 'not merged')
|
|
405
|
+
}
|
|
406
|
+
|
|
375
407
|
// ───────────────────────────────────────────────────────────────────────────
|
|
376
408
|
// Return — assemble the report + Phase-8 telemetry (variant new2).
|
|
377
409
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -421,8 +453,14 @@ function buildReport(o) {
|
|
|
421
453
|
}
|
|
422
454
|
L.push(``, `## Merge`)
|
|
423
455
|
if (mergeResult && mergeResult.merged) L.push(`Merged → ${TRUNK} @ ${mergeResult.mergeCommit || '?'}${mergeResult.reconciliation ? ' · ' + mergeResult.reconciliation : ''}`)
|
|
424
|
-
else if (mergeBlocked) L.push(`Merge BLOCKED — unresolved final BLOCKER/HIGH. Worktree left intact at ${sharedCtx.worktreePath}.`)
|
|
456
|
+
else if (mergeBlocked) L.push(`Merge BLOCKED — ${batchFatal ? 'batch fatal (new2-resolve)' : 'unresolved final BLOCKER/HIGH'}. Worktree left intact at ${sharedCtx.worktreePath}.`)
|
|
425
457
|
else L.push(`Not merged (no committed cards or incomplete).`)
|
|
458
|
+
if (prodReadiness) {
|
|
459
|
+
const man = prodReadiness.manualItems || []
|
|
460
|
+
L.push(``, `## Production readiness (Phase 7)`)
|
|
461
|
+
L.push(`Auto-eseguiti: ${(prodReadiness.autoExecuted || []).length} · Da fare a mano: ${man.length}`)
|
|
462
|
+
for (const m of man) L.push(`- [ ] ${m}`)
|
|
463
|
+
}
|
|
426
464
|
if (residualFollowups.length) {
|
|
427
465
|
L.push(``, `## ⚠️ Follow-up auto-creati (residui non risolti autonomamente — nulla perso)`)
|
|
428
466
|
for (const f of residualFollowups) L.push(`- **${f.followupCard}** ← ${f.card} (${f.kind})${f.reason ? ': ' + f.reason : ''}`)
|
package/package.json
CHANGED
|
@@ -100,6 +100,27 @@ const AGGREGATE_FIXTURES = [
|
|
|
100
100
|
],
|
|
101
101
|
expected: 'all-noise',
|
|
102
102
|
},
|
|
103
|
+
{
|
|
104
|
+
name: 'absorbed commit alone → all-noise (content now == upstream)',
|
|
105
|
+
commits: [{ category: 'absorbed' }],
|
|
106
|
+
expected: 'all-noise',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'absorbed + plumbing merge → all-noise (absorbed is noise)',
|
|
110
|
+
commits: [
|
|
111
|
+
{ category: 'absorbed' },
|
|
112
|
+
{ category: 'subtree-merge' },
|
|
113
|
+
],
|
|
114
|
+
expected: 'all-noise',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'absorbed + genuine overlay-able → overlay-able (absorbed excluded)',
|
|
118
|
+
commits: [
|
|
119
|
+
{ category: 'absorbed' },
|
|
120
|
+
{ category: 'custom-overlay-able' },
|
|
121
|
+
],
|
|
122
|
+
expected: 'overlay-able',
|
|
123
|
+
},
|
|
103
124
|
];
|
|
104
125
|
|
|
105
126
|
const PATH_FIXTURES = [
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
buildOverlayBody, verifyCapture, kindNameFromPath, toRepoPath, captureAndVerify,
|
|
21
21
|
} = require('../overlay-capture');
|
|
22
22
|
const { buildFrontmatter } = require('../../commands/overlay');
|
|
23
|
+
const { computeBaseFileSha } = require('../overlay-merger');
|
|
23
24
|
|
|
24
25
|
const BASE = `---
|
|
25
26
|
name: ui-expert
|
|
@@ -198,6 +199,55 @@ test('captureAndVerify: STALE existing agent overlay (does not reproduce edit)
|
|
|
198
199
|
}
|
|
199
200
|
});
|
|
200
201
|
|
|
202
|
+
test('captureAndVerify: ADDITIVE overlay + pristine .framework (== base) → captured, not stale (Fix B)', async () => {
|
|
203
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
|
|
204
|
+
try {
|
|
205
|
+
const fwRel = '.framework/framework/.claude/agents/ui-expert.md';
|
|
206
|
+
const fwAbs = path.join(tmp, fwRel);
|
|
207
|
+
fs.mkdirSync(path.dirname(fwAbs), { recursive: true });
|
|
208
|
+
// `.framework` is pristine upstream base — the customization lives ONLY in
|
|
209
|
+
// an additive (append-a-new-section) overlay, so reset+reapply loses
|
|
210
|
+
// nothing. The strict round-trip would call this "stale" (merge=base+append
|
|
211
|
+
// ≠ base); Fix B recognizes the pristine file as reset-safe → captured.
|
|
212
|
+
fs.writeFileSync(fwAbs, BASE);
|
|
213
|
+
const overlayAbs = path.join(tmp, '.baldart/overlays/agents/ui-expert.md');
|
|
214
|
+
fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
|
|
215
|
+
fs.writeFileSync(overlayAbs, overlayFor('\n## Mayo Stack Locks\nCSS Modules only.\n'));
|
|
216
|
+
|
|
217
|
+
const git = { git: { raw: async (a) => (a[0] === 'show' ? BASE : '') } };
|
|
218
|
+
const res = await captureAndVerify({ cwd: tmp, git, files: [fwRel], frameworkVersion: '4.16.1' });
|
|
219
|
+
assert.deepStrictEqual(res.blockers, []);
|
|
220
|
+
assert.deepStrictEqual(res.captured, ['.baldart/overlays/agents/ui-expert.md']);
|
|
221
|
+
} finally {
|
|
222
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('captureAndVerify: upstream moved but .framework pristine wrt overlay base_file_sha → captured (Fix B)', async () => {
|
|
227
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
|
|
228
|
+
try {
|
|
229
|
+
const fwRel = '.framework/framework/.claude/agents/ui-expert.md';
|
|
230
|
+
const OLD = BASE; // base the overlay was authored against
|
|
231
|
+
const NEW = BASE.replace('You are a UI expert.', 'You are a UI expert (upstream v2).'); // upstream moved on
|
|
232
|
+
const fwAbs = path.join(tmp, fwRel);
|
|
233
|
+
fs.mkdirSync(path.dirname(fwAbs), { recursive: true });
|
|
234
|
+
fs.writeFileSync(fwAbs, OLD); // .framework still pristine-at-OLD (no inline edit)
|
|
235
|
+
const overlayAbs = path.join(tmp, '.baldart/overlays/agents/ui-expert.md');
|
|
236
|
+
fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
|
|
237
|
+
const overlay = buildFrontmatter({
|
|
238
|
+
kind: 'agent', name: 'ui-expert', frameworkVersion: '4.16.1', baseSha: computeBaseFileSha(OLD), mode: 'extend',
|
|
239
|
+
}) + '\n## Mayo Locks\nCSS Modules only.\n';
|
|
240
|
+
fs.writeFileSync(overlayAbs, overlay);
|
|
241
|
+
|
|
242
|
+
const git = { git: { raw: async (a) => (a[0] === 'show' ? NEW : '') } }; // FETCH_HEAD base = NEW (differs from .framework)
|
|
243
|
+
const res = await captureAndVerify({ cwd: tmp, git, files: [fwRel], frameworkVersion: '4.16.1' });
|
|
244
|
+
assert.deepStrictEqual(res.blockers, []);
|
|
245
|
+
assert.deepStrictEqual(res.captured, ['.baldart/overlays/agents/ui-expert.md']);
|
|
246
|
+
} finally {
|
|
247
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
201
251
|
test('captureAndVerify: unmappable overlay-able path (nested agent) → blocker, never silently dropped', async () => {
|
|
202
252
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'baldart-capt-'));
|
|
203
253
|
try {
|
package/src/utils/git.js
CHANGED
|
@@ -209,12 +209,16 @@ class GitUtils {
|
|
|
209
209
|
// - subtree-merge: `git subtree pull --squash` merge commits
|
|
210
210
|
// - subtree-squash: the squash payload commits themselves
|
|
211
211
|
// - chore-wrapper: CLI-generated [CHORE]/chore(baldart): commits
|
|
212
|
+
// - absorbed: a would-be custom commit whose touched .framework/ files
|
|
213
|
+
// are now byte-identical to upstream (FETCH_HEAD) — the edit
|
|
214
|
+
// has been absorbed into upstream (or reset away), so a
|
|
215
|
+
// re-sync loses nothing. Pure history, not live drift → noise.
|
|
212
216
|
// - custom-overlay-able: user edited a framework agent/skill/command
|
|
213
217
|
// → should migrate to .baldart/overlays/
|
|
214
218
|
// - custom-other: anything else (src/, CHANGELOG, ad-hoc fixes)
|
|
215
219
|
//
|
|
216
220
|
// Aggregate `class`:
|
|
217
|
-
// - all-noise: every commit is subtree-* / chore-wrapper → auto-resolve
|
|
221
|
+
// - all-noise: every commit is subtree-* / chore-wrapper / absorbed → auto-resolve
|
|
218
222
|
// - overlay-able: every non-noise commit is overlay-able → suggest /overlay
|
|
219
223
|
// - real-custom: every non-noise commit is custom-other → prompt 3-way
|
|
220
224
|
// - mixed: non-noise commits span both → prompt 3-way
|
|
@@ -293,6 +297,30 @@ class GitUtils {
|
|
|
293
297
|
});
|
|
294
298
|
}
|
|
295
299
|
|
|
300
|
+
// True when EVERY touched `.framework/` file's content at HEAD is already
|
|
301
|
+
// byte-identical to the upstream (FETCH_HEAD) blob — i.e. the commit's edit
|
|
302
|
+
// has been fully absorbed into upstream (or reset away) and a re-sync would
|
|
303
|
+
// lose nothing. Compares blob SHAs (cheap, exact). The subtree prefix
|
|
304
|
+
// `.framework/` is stripped to reach the upstream path (FETCH_HEAD has no
|
|
305
|
+
// `.framework/` prefix). Conservative: any file outside `.framework/`, missing
|
|
306
|
+
// upstream, or whose SHA differs → not absorbed (real drift, keep flagging).
|
|
307
|
+
async isAbsorbedAgainstUpstream(touched) {
|
|
308
|
+
if (!touched || !touched.length) return false;
|
|
309
|
+
const prefix = `${FRAMEWORK_DIR}/`;
|
|
310
|
+
for (const p of touched) {
|
|
311
|
+
if (!p.startsWith(prefix)) return false;
|
|
312
|
+
const upstreamPath = p.slice(prefix.length);
|
|
313
|
+
let headSha;
|
|
314
|
+
let upSha;
|
|
315
|
+
try { headSha = (await this.git.raw(['rev-parse', `HEAD:${p}`])).trim(); }
|
|
316
|
+
catch (_) { return false; }
|
|
317
|
+
try { upSha = (await this.git.raw(['rev-parse', `FETCH_HEAD:${upstreamPath}`])).trim(); }
|
|
318
|
+
catch (_) { return false; }
|
|
319
|
+
if (!headSha || headSha !== upSha) return false;
|
|
320
|
+
}
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
|
|
296
324
|
async classifyDivergence() {
|
|
297
325
|
// Inspect aheadScoped commits — those not on FETCH_HEAD that touch .framework/.
|
|
298
326
|
const commits = [];
|
|
@@ -334,6 +362,15 @@ class GitUtils {
|
|
|
334
362
|
} catch (_) { /* leave empty → custom-other */ }
|
|
335
363
|
category = this.classifyCommitByPaths(touched);
|
|
336
364
|
}
|
|
365
|
+
// A would-be custom commit whose touched files are now identical to
|
|
366
|
+
// upstream is absorbed history, not live drift — demote it to noise so
|
|
367
|
+
// a long-resolved local edit (e.g. one whose content later landed in or
|
|
368
|
+
// matched upstream) stops re-triggering the divergence gate on every
|
|
369
|
+
// release. Only runs for custom-* (where `touched` is populated).
|
|
370
|
+
if ((category === 'custom-overlay-able' || category === 'custom-other')
|
|
371
|
+
&& await this.isAbsorbedAgainstUpstream(touched)) {
|
|
372
|
+
category = 'absorbed';
|
|
373
|
+
}
|
|
337
374
|
const overlayCovered = category === 'custom-overlay-able'
|
|
338
375
|
? this.overlayCoversTouched(touched)
|
|
339
376
|
: false;
|
|
@@ -353,7 +390,7 @@ class GitUtils {
|
|
|
353
390
|
// (subtree plumbing + CLI chore wrappers + merge commits) are excluded
|
|
354
391
|
// BEFORE deciding overlay-able vs mixed, so plumbing never downgrades the UX.
|
|
355
392
|
static aggregateDivergenceClass(commits) {
|
|
356
|
-
const noiseCategories = new Set(['subtree-merge', 'subtree-squash', 'chore-wrapper']);
|
|
393
|
+
const noiseCategories = new Set(['subtree-merge', 'subtree-squash', 'chore-wrapper', 'absorbed']);
|
|
357
394
|
const nonNoise = commits.filter((c) => !noiseCategories.has(c.category));
|
|
358
395
|
if (nonNoise.length === 0) return 'all-noise';
|
|
359
396
|
const hasOverlayable = nonNoise.some((c) => c.category === 'custom-overlay-able');
|
|
@@ -181,6 +181,26 @@ async function captureAndVerify({ cwd, git, files, frameworkVersion }) {
|
|
|
181
181
|
let overlayContent;
|
|
182
182
|
try { overlayContent = fs.readFileSync(overlayAbs, 'utf8'); }
|
|
183
183
|
catch (_) { blockers.push({ file, reason: 'existing overlay unreadable' }); continue; }
|
|
184
|
+
|
|
185
|
+
// Reset-safety shortcut: when the `.framework/` file carries NO inline
|
|
186
|
+
// edit to lose, a reset+re-apply preserves everything (the customization
|
|
187
|
+
// lives in the overlay and is re-applied). Two pristine signals:
|
|
188
|
+
// (a) it is byte-identical to the upstream base we'd reset to, or
|
|
189
|
+
// (b) it matches the base the overlay was authored against
|
|
190
|
+
// (`base_file_sha`) — pristine even after upstream moved on.
|
|
191
|
+
// This is what makes an ADDITIVE (extend) overlay survive: the strict
|
|
192
|
+
// round-trip below would otherwise demand merge(base, overlay) == file,
|
|
193
|
+
// which never holds when the overlay only appends — flagging a perfectly
|
|
194
|
+
// healthy overlay as "stale" on every release.
|
|
195
|
+
const overlayBaseSha = (/^\s*base_file_sha\s*:\s*"?([a-f0-9]+)"?/m.exec(overlayContent) || [])[1];
|
|
196
|
+
const editedPristine =
|
|
197
|
+
norm(editedContent) === norm(baseContent)
|
|
198
|
+
|| (overlayBaseSha && computeBaseFileSha(editedContent) === overlayBaseSha);
|
|
199
|
+
if (editedPristine) {
|
|
200
|
+
captured.push(overlayRel);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
184
204
|
if (verifyCapture({ kind: kn.kind, name: kn.name, baseContent, overlayContent, editedContent, frameworkVersion })) {
|
|
185
205
|
captured.push(overlayRel);
|
|
186
206
|
} else {
|