baldart 3.28.2 → 3.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,57 @@ 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
+ ## [3.29.0] - 2026-05-29
9
+
10
+ Closes a two-part process gap in `npx baldart update` reported from a non-TTY agent context on a shared consumer repo: (1) plumbing/collaboration merge commits were mislabeled `custom-other`, downgrading an otherwise overlay-able divergence set into `mixed`; (2) an automated agent had **no** non-destructive path to complete an update when the consumer carried local `.framework/` commits — `--yes` refused, the interactive prompt needed a TTY, and `--reset` is gated on a clean working tree. **No new `baldart.config.yml` keys** — `--on-divergence` is a CLI flag, not a config flag, so the schema-propagation rule does not apply.
11
+
12
+ ### Fixed — divergence classifier no longer poisoned by merge commits
13
+
14
+ - **[src/utils/git.js](src/utils/git.js)** `classifyDivergence()`: merge commits are now detected **structurally by parent count** (`%P` in the `git log` format → `parents.length >= 2`) instead of relying solely on the brittle subject regex `^Merge … into <branch>$`. That regex missed real `git subtree pull --squash` merges that present as a bare `Merge commit '<sha>'` (git omits the `into <branch>` suffix when the merge lands on `master`) and shared-repo collaboration merges shaped like `Merge branch 'x' of <url> into <branch>` / `Merge remote-tracking branch '…'`. Such merges fell through to the path classifier, which returns `custom-other` for the empty diff-tree of a merge — collapsing an `overlay-able` set into `mixed` and hiding the overlay-migration option. Merges are now labeled `subtree-merge` (noise) and excluded before the aggregate decision.
15
+ - **[src/utils/git.js](src/utils/git.js)**: aggregation extracted to a pure, unit-testable static `GitUtils.aggregateDivergenceClass(commits)`. Each divergence commit record now also carries `parents` and `touched` (the latter reused by the new overlay-scaffold flow below).
16
+ - **[src/utils/__tests__/classify-divergence.test.js](src/utils/__tests__/classify-divergence.test.js)**: +9 fixtures — bare-merge / `of <url>` / remote-tracking subjects (documenting the regex blind spot now covered by parent count) and an `aggregateDivergenceClass` suite asserting `overlay-able + plumbing-merge → overlay-able` (not `mixed`).
17
+
18
+ ### Added — `--on-divergence` non-interactive escape hatch for agents / CI
19
+
20
+ - **[src/commands/update.js](src/commands/update.js)** + **[bin/baldart.js](bin/baldart.js)**: `npx baldart update --on-divergence <strategy>` resolves a divergent update without a TTY:
21
+ - `scaffold-overlays` → auto-creates overlay skeleton(s) for the overlay-able framework files touched by the divergent commits (reusing `overlay.scaffoldFile`), then stops so the edits can be filled in and the update re-run. For `mixed`, scaffolds the overlay-able subset and refuses to pull while `custom-other` commits remain (would be destructive).
22
+ - `pull` → keeps the commits and lets the subtree pull create a merge (non-destructive; conflicts are surfaced, never auto-resolved). Works for `overlay-able`, `mixed`, and `real-custom`.
23
+ - `abort` → explicit no-op.
24
+ - Bare `--yes` on a divergent class still refuses (nothing destructive by accident) but now prints the exact flag to re-run with instead of dead-ending.
25
+
26
+ ### Changed — interactive `overlay` choice now actually scaffolds
27
+
28
+ - **[src/commands/update.js](src/commands/update.js)**: the interactive "migrate to overlay" option previously printed *"run `/overlay` then re-run"* and exited (a no-op). It now scaffolds the overlay skeleton(s) in-place via `overlay.scaffoldFile` and points the user to `/overlay` for guided filling.
29
+ - **[src/commands/overlay.js](src/commands/overlay.js)**: extracted a non-exiting `scaffoldFile(cwd, target, options)` (returns a structured result) from the `process.exit`-driven `scaffold()` CLI wrapper, so it can be called in a loop from `update` without a single missing/existing overlay terminating the run. Exported alongside `parseTarget`.
30
+
31
+ ### Changed — `/baldart-update` skill narration synced
32
+
33
+ - **[framework/.claude/skills/baldart-update/SKILL.md](framework/.claude/skills/baldart-update/SKILL.md)**: documents the parent-count merge detection and the new `--on-divergence` strategies; corrects the stale "real user-custom commits ABORT in --yes mode" claim.
34
+
35
+ ## [3.28.3] - 2026-05-28
36
+
37
+ Release B of the 3-release plan on `/new` fix-application authority. Closes two inline-apply violation patterns observed in real `/new` runs (doc fixes applied by the orchestrator instead of delegated to coder; Codex HIGH security findings written inline) and shuts the structural loophole in `SKILL.md`'s sub-agent failure protocol that was being used to bypass delegation even when no agent had crashed. Telemetry from v3.28.2 will measure the effectiveness of these changes; Release C (conditional threshold rule) waits for that data.
38
+
39
+ ### Added — Domain-Override Domains sub-section
40
+
41
+ - **[framework/.claude/skills/new/SKILL.md](framework/.claude/skills/new/SKILL.md)**: new sub-section under "Fix Application Log Schema" enumerates tassativamente the domains where inline orchestrator apply is **never** safe, regardless of patch size: `doc` (any `*.md` under references/, prd/, root CHANGELOG, ssot-registry), `security` (Phase 3.7 detector Triggers #2/#3 + SQL RLS policy mutations), `migration` (`supabase/migrations/*.sql` or `${paths.migrations_dir}`). The coder agent's system prompt + project overlay enforces invariants (freshness, tabular formatting, RLS structure) that orchestrator inline edits routinely break. Edge case explicit: mechanical CHANGELOG / ssot-registry append-a-row stays `doc` (uniformity > spawn cost).
42
+
43
+ ### Changed — Phase 3 doc delegation reinforced
44
+
45
+ - **[framework/.claude/skills/new/SKILL.md](framework/.claude/skills/new/SKILL.md)** Phase 3 step 15: explicit chiosa "Doc fixes are NEVER applied inline by the orchestrator, regardless of size or perceived triviality" added below the existing coder-spawn instruction. The rule already existed (line 1116, pre-v3.28.2: "invoke the coder agent once") but was being bypassed in practice — the chiosa makes the rule non-discretionary.
46
+
47
+ ### Changed — Phase 3.7 coder applies Codex patches verbatim
48
+
49
+ - **[framework/.claude/skills/new/SKILL.md](framework/.claude/skills/new/SKILL.md)** Phase 3.7 step 4 sub-bullet "spawn coder": coder now receives the report path + list of VERIFIED bugs **plus the patches Codex suggested inline in the report**, and applies them verbatim. The coder does NOT re-do the analysis, does NOT re-grep, does NOT re-read files extensively. Codex has already produced both the diagnosis and the patch; the coder's value-add is the right system prompt (project conventions, naming, testing patterns), not redoing security/correctness review. Saves an estimated 5-10k tokens per Codex-driven coder spawn.
50
+
51
+ ### Changed — Sub-agent failure protocol (loophole closed)
52
+
53
+ - **[framework/.claude/skills/new/SKILL.md](framework/.claude/skills/new/SKILL.md)** "Sub-agent failure protocol" section: replaced single bullet "attempt the work yourself directly" with a 4-step protocol. (1) log failure with full trace, (2) retry once (transient errors are common), (3) **if domain-override (doc / security / migration) and retry still fails: STOP and AskUserQuestion — never inline-fallback for these domains**, (4) non-override domains may fall back inline but MUST log `applied_by=orchestrator-fallback` for telemetry visibility. Razionale: the pre-v3.28.3 wording was being used as a shortcut to bypass delegation rules even when no agent had crashed; closing that defeats the violation pattern at the structural level.
54
+
55
+ ### Rationale
56
+
57
+ Release B addresses 4 of the 17 critiques raised in the adversarial review of the original threshold-rule plan (#4-5 domain override definition, #7 Codex re-analysis ridondante, #10 escape valve aperta). The remaining critiques are either addressed by Release A telemetry, deferred to conditional Release C, or accepted as design trade-offs documented in the plan file. **No new `baldart.config.yml` keys** — domain-override is enforced by content/path matching, not by config flag (KISS).
58
+
8
59
  ## [3.28.2] - 2026-05-28
9
60
 
10
61
  Telemetry-only release. Adds structured logging for fix-application decisions in `/new` Phases 2.55 / 3 / 3.5 / 3.7. **Zero behavior change** — the orchestrator's decisions remain identical to v3.28.1. The goal is to measure, with real data, whether the per-phase delegation rules need to change in a future release.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.28.2
1
+ 3.29.0
package/bin/baldart.js CHANGED
@@ -72,6 +72,7 @@ program
72
72
  .option('--auto-stash', 'When the working tree is dirty, stash automatically instead of prompting.')
73
73
  .option('--reset', 'Nuclear option: rm -rf .framework/ + fresh install, preserving baldart.config.yml + .baldart/ + custom .claude/{agents,skills,commands}/. Requires clean working tree.')
74
74
  .option('--i-know', 'Acknowledges --reset will wipe untracked/ignored files inside .framework/ (required to use --reset --yes).')
75
+ .option('--on-divergence <strategy>', 'Non-interactive resolution when the consumer has local commits on .framework/ (for agents / CI): "scaffold-overlays" (auto-create overlay skeletons, then stop), "pull" (keep commits + merge — non-destructive), or "abort".')
75
76
  .action(async (options) => {
76
77
  const updateCommand = require('../src/commands/update');
77
78
  await updateCommand(options);
@@ -139,7 +139,7 @@ post-flight assert:
139
139
  - **Hooks drift**: missing hooks auto-registered silently; only command-level drift prompts (and only if interactive).
140
140
  - **Schema config drift**: surfaces as a warning post-update; never blocks.
141
141
  - **BALDART-managed auto-commit**: `--yes` commits silently with `chore(baldart):` subject.
142
- - **Subtree-merge / chore divergence commits**: classified by the CLI; auto-resolved silently when `all-noise`, prompts only for `overlay-able` / `real-custom` / `mixed`.
142
+ - **Subtree-merge / chore divergence commits**: classified by the CLI (merges detected by parent count, so plumbing/collaboration merges never poison the class); auto-resolved silently when `all-noise`, prompts for `overlay-able` / `real-custom` / `mixed` — or resolve non-interactively with `--on-divergence scaffold-overlays|pull|abort`.
143
143
  - **CLI self-upgrade**: transparent relaunch via `npx baldart@latest` when global CLI is stale.
144
144
 
145
145
  > **IMPORTANT — do NOT use `git -C .framework fetch`.** `.framework/` is a git
@@ -272,10 +272,21 @@ Do NOT pass `--auto-stash` (redundant with `--yes`).
272
272
  files it touched during the reconcile with `chore(baldart): post-update
273
273
  reconcile to vX.Y.Z`. User-owned files are never staged.
274
274
  - **Subtree-merge / chore divergence commits**: when the consumer has
275
- local commits that are just `git subtree pull` plumbing or CLI-generated
276
- `[CHORE]` commits, the CLI auto-resolves silently. Real user-custom
277
- commits ABORT in `--yes` mode (the user must re-run without `--yes` to
278
- choose).
275
+ local commits that are just `git subtree pull` plumbing, ordinary
276
+ collaboration merges (detected by parent count, not just subject), or
277
+ CLI-generated `[CHORE]` commits, the CLI auto-resolves silently. Real
278
+ user-custom commits do NOT auto-resolve under bare `--yes` — but an agent
279
+ is no longer dead-ended: pass `--on-divergence` to resolve non-interactively
280
+ (since v3.29.0):
281
+ - `--on-divergence scaffold-overlays` → auto-creates overlay skeleton(s)
282
+ for the overlay-able commits, then stops so the edits can be filled in
283
+ (then re-run update). Use this when the divergence is `overlay-able`.
284
+ - `--on-divergence pull` → keeps the commits and lets the subtree pull
285
+ create a merge (non-destructive; resolve any conflicts that surface).
286
+ Works for `overlay-able`, `mixed`, and `real-custom`.
287
+ - `--on-divergence abort` → explicit no-op.
288
+ Bare `--yes` without a strategy still refuses (and now prints exactly which
289
+ flag to re-run with), so nothing destructive happens by accident.
279
290
  - **CLI self-upgrade**: if the global CLI is older than npm latest, the
280
291
  update transparently relaunches itself via `npx baldart@latest update
281
292
  --yes`. Loop-guarded by `BALDART_RELAUNCHED=1`.
@@ -961,6 +961,22 @@ Every fix-application decision in Phases 2.55 / 3 / 3.5 / 3.7 appends one row to
961
961
 
962
962
  **Cost**: ~80-120 bytes per row, ~5-15 rows per card. Negligible context impact, large analytical value.
963
963
 
964
+ #### Domain-Override Domains (since v3.28.3)
965
+
966
+ Some fix domains are **never** safe for inline orchestrator apply, regardless of size. The coder agent's system prompt + project overlay enforces invariants (doc freshness, tabular formatting, RLS policy structure, migration ordering) that orchestrator inline edits routinely break. The orchestrator MUST delegate every fix in these domains to `coder`, even when the patch is a one-liner.
967
+
968
+ Enumerated tassativamente:
969
+
970
+ | Domain | Match rule |
971
+ |---|---|
972
+ | `doc` | File path matching `*.md` under `${paths.references_dir}`, `${paths.prd_dir}`, project root `CHANGELOG.md`, or any `ssot-registry.md`. |
973
+ | `security` | File path matching the Phase 3.7 detector Triggers #2 (auth/permissions: `src/lib/auth/middleware.ts`, `src/lib/permissions.ts`, anything matching `withAuth`) or #3 (payments: `^src/lib/payments/`, `^src/app/api/v1/billing/`). Also any SQL migration whose content matches `CREATE POLICY|ALTER POLICY|DROP POLICY` (RLS policy mutations). |
974
+ | `migration` | File path matching `supabase/migrations/*.sql` (or `${paths.migrations_dir}/*.sql` if defined in `baldart.config.yml`). |
975
+
976
+ **Edge case explicit** — a mechanical append-a-row update to `CHANGELOG.md` or `ssot-registry.md` is still classified `doc` and still goes through coder. The uniformity of the rule matters more than the cost of the individual spawn. If telemetry from Release A shows that doc-mechanical-append is a large fraction of doc coder spawns, a sub-domain `doc-mechanical-append` may be carved out in a later release.
977
+
978
+ Domains NOT listed here remain governed by the per-phase rules of the corresponding phase (e.g. `simplify-*` follows Phase 2.55 inline rule).
979
+
964
980
  ### Phase 2.55 — Simplify (code cleanup before review)
965
981
 
966
982
  After completeness is verified, clean up the implementation before it reaches reviewers and E2E tests. This phase launches three parallel agents on the card's diff, then applies fixes directly.
@@ -1140,6 +1156,8 @@ skill's Phase 1 falls back to deriving Gherkin scenarios from
1140
1156
  14. **Obsidian Corpus Dispatch**: Parse section H from doc-reviewer findings. If `Trigger: YES`, dispatch the `obsidian-sync` agent (`.claude/agents/obsidian-sync.md`) with the listed paths after all fixes are applied (step 16). If `Trigger: NO`, skip. This is non-blocking -- do not wait for obsidian-sync to complete before proceeding.
1141
1157
  15. If doc findings exist, invoke the **coder** agent once to apply **ALL doc fixes in one pass**.
1142
1158
 
1159
+ **Doc fixes are NEVER applied inline by the orchestrator**, regardless of size or perceived triviality (since v3.28.3). `doc` is a domain-override domain — see "Domain-Override Domains" sub-section. A one-line `CHANGELOG.md` append and a 50-line `data-model.md` migration row update both go through coder. The freshness invariants and tabular formatting that the coder's system prompt enforces are routinely broken by orchestrator inline edits; the cost of a coder spawn is the cost of correctness.
1160
+
1143
1161
  **Telemetry** — after the coder returns, append one row per doc finding to `## Fix Application Log`: `3 | doc | est_lines=<n> | decision=coder-batch | applied_by=coder | finding=<1-line>`. If 0 findings, append one row: `3 | doc | est_lines=0 | decision=skipped | applied_by=- | reason=no-findings`.
1144
1162
  16. Run `npm run lint` and `npx tsc --noEmit` to verify nothing broke. If any check fails, apply the self-healing retry loop (up to 3 times, no user prompt).
1145
1163
  17. **Update tracker**: phase = "3-doc-review DONE", log doc findings count, fixes applied.
@@ -1274,7 +1292,7 @@ For EVERY card (no conditional skip):
1274
1292
 
1275
1293
  4. **Apply fix sub-loop** (mirror of Phase 3.5 retry pattern):
1276
1294
  - If 0 BLOCKER and 0 HIGH → log `verdict: PASS — proceeding to Phase 4` in tracker. Done.
1277
- - If 1+ BLOCKER OR 1+ HIGH → spawn `coder` agent with the report path + list of VERIFIED bugs to fix. After coder fixes, re-invoke `/codexreview <CARD-ID>` to re-validate. Repeat **max 2 times**.
1295
+ - If 1+ BLOCKER OR 1+ HIGH → spawn `coder` agent with the report path + list of VERIFIED bugs **+ the patch(es) suggested by Codex inline in the report**. The coder applies the suggested patches verbatim — **does NOT re-do the analysis, does NOT re-grep, does NOT re-read files extensively** (since v3.28.3). Codex has already produced both the diagnosis and the patch; the coder is here to apply edits with the right system prompt (project conventions, naming, testing patterns), not to redo Codex's work. This saves 5-10k tokens of redundant security/correctness review per spawn. After coder fixes, re-invoke `/codexreview <CARD-ID>` to re-validate. Repeat **max 2 times**.
1278
1296
  - If still BLOCKER/HIGH after 2 retries → log in `## Issues & Flags` and **ask the user** whether to proceed, escalate, or stop. The Phase 4 commit MUST NOT happen until High-Risk Gate verdict is PASS or user explicitly overrides.
1279
1297
  - **Telemetry** — for EVERY codex finding processed (verified BLOCKER, verified HIGH, or false-positive-filtered), append one row to `## Fix Application Log`: `3.7 | codex-<security|correctness|other> | est_lines=<n> | decision=<coder|skipped> | applied_by=<coder|-> | severity=<BLOCKER|HIGH|FALSE-POSITIVE> | retry=<n>`. Classify domain: `security` for findings touching RLS / auth / permissions / payments; `correctness` for logic / data integrity / race conditions; `other` for everything else.
1280
1298
 
@@ -1314,9 +1332,16 @@ The detector (Step A) is bash + grep — guaranteed to run, no LLM skip. The dow
1314
1332
  e. Stage BOTH the updated YAML AND ssot-registry.md, then commit (or as an immediate follow-up commit if Phase 4 commit already happened).
1315
1333
  28. **Update tracker**: move card to `## Completed Cards` with commit hash, summary, flags, **and `card_status: DONE (verified)`**.
1316
1334
 
1317
- ### Sub-agent failure protocol
1318
- - If any sub-agent **crashes or errors** during any phase: log the failure in the tracker, **attempt the work yourself directly**, and note it in the final report.
1319
- - Never block the pipeline waiting for a failed agent — recover and continue.
1335
+ ### Sub-agent failure protocol (since v3.28.3)
1336
+
1337
+ If any sub-agent **crashes or errors** during any phase, follow this 4-step protocol. The pre-v3.28.3 wording ("attempt the work yourself directly") was being used as a shortcut to bypass delegation rules even when no agent had crashed that escape valve is closed.
1338
+
1339
+ 1. **Log the failure** in the tracker with the full error trace (under `## Issues & Flags`, prefix `[AGENT-CRASH]`).
1340
+ 2. **Retry the same agent once**. Transient errors (network, rate-limit, race) are common; a single retry resolves most.
1341
+ 3. **If retry fails AND the fix is in a domain-override domain** (`doc`, `security`, `migration` — see "Domain-Override Domains" sub-section): **STOP the pipeline**, log in `## Issues & Flags`, and invoke `AskUserQuestion` with options (a) skip the finding, (b) hand off to user, (c) escalate to a different agent type. **NEVER** fall through to inline orchestrator apply for these domains. The whole point of delegation here is that the orchestrator does not have the right system prompt — bypassing on crash defeats the rule.
1342
+ 4. **If retry fails AND the fix is non-domain-override**: orchestrator MAY apply inline, but MUST log a Fix Application Log row with `applied_by=orchestrator-fallback` + `reason=<agent>-crash-retry-failed` so the telemetry surfaces frequency. If this happens often for the same agent, that agent's reliability is the problem, not the protocol.
1343
+
1344
+ Never block the pipeline indefinitely — recover and continue per the rules above. AskUserQuestion in step 3 is bounded by normal user response time.
1320
1345
 
1321
1346
  ### Phase 5 — Context Clean & Continue
1322
1347
  29. Archive the card from Active Code Context in `${paths.references_dir}/project-status.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.28.2",
3
+ "version": "3.29.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"
@@ -116,60 +116,86 @@ function buildFrontmatter({ kind, name, frameworkVersion, baseSha, mode }) {
116
116
 
117
117
  // ─── scaffold ──────────────────────────────────────────────────────────────
118
118
 
119
- async function scaffold(target, options = {}) {
120
- const cwd = process.cwd();
121
- ensureConsumer(cwd);
122
-
119
+ // Pure scaffolder performs the filesystem work and returns a structured
120
+ // result WITHOUT printing or calling process.exit. The interactive `scaffold`
121
+ // wrapper renders the result; `update --on-divergence scaffold-overlays` calls
122
+ // this in a loop (so one already-existing or missing overlay can't terminate
123
+ // the whole update). Keeping the path/frontmatter logic here honours the
124
+ // "CLI is the source of truth, never re-implement" rule.
125
+ function scaffoldFile(cwd, target, options = {}) {
123
126
  const parsed = parseTarget(target);
124
- if (!parsed) {
125
- UI.error('Invalid target. Use: skill/<name>, agents/<name>, or commands/<name>.');
126
- UI.info('Example: npx baldart overlay scaffold agents/coder');
127
- process.exit(1);
128
- }
127
+ if (!parsed) return { ok: false, reason: 'invalid-target', target };
129
128
  const { kind, name } = parsed;
130
129
 
131
130
  const baseAbs = baseFilePathFor(cwd, kind, name);
132
131
  if (!fs.existsSync(baseAbs)) {
133
- UI.error(`Base ${kind} "${name}" not found at ${path.relative(cwd, baseAbs)}.`);
134
- UI.info(`Run \`ls ${path.relative(cwd, path.dirname(baseAbs))}\` to list available ${kind}s.`);
135
- process.exit(1);
132
+ return { ok: false, reason: 'base-missing', kind, name, baseRel: path.relative(cwd, baseAbs) };
136
133
  }
137
134
 
138
135
  const overlayAbs = overlayPathFor(cwd, kind, name);
139
136
  const overlayRel = path.relative(cwd, overlayAbs);
140
-
141
137
  if (fs.existsSync(overlayAbs)) {
142
- UI.warning(`Overlay already exists: ${overlayRel}`);
143
- UI.info('Run `npx baldart overlay drift ' + overlayRel + '` to check it, or edit it directly to extend.');
144
- process.exit(0);
138
+ return { ok: false, reason: 'exists', kind, name, overlayRel };
145
139
  }
146
140
 
147
- fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
148
-
149
- const baseContent = fs.readFileSync(baseAbs, 'utf8');
150
- const baseSha = computeBaseFileSha(baseContent);
151
- const fwVersion = readFrameworkVersion(cwd);
152
141
  const mode = (options.mode || 'extend').trim();
153
142
  if (!['extend', 'override'].includes(mode)) {
154
- UI.error(`Invalid --mode "${mode}". Use "extend" or "override".`);
155
- process.exit(1);
143
+ return { ok: false, reason: 'bad-mode', mode };
156
144
  }
157
145
 
146
+ fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
147
+ const baseContent = fs.readFileSync(baseAbs, 'utf8');
148
+ const baseSha = computeBaseFileSha(baseContent);
149
+ const fwVersion = readFrameworkVersion(cwd);
158
150
  const content =
159
151
  buildFrontmatter({ kind, name, frameworkVersion: fwVersion, baseSha, mode }) +
160
152
  scaffoldBody(kind, name);
161
-
162
153
  fs.writeFileSync(overlayAbs, content);
163
- UI.success(`Created ${overlayRel}`);
164
- UI.info(`Base file: ${path.relative(cwd, baseAbs)}`);
165
- UI.info(`Targets framework v${fwVersion || 'unknown'} (base_file_sha=${baseSha}).`);
154
+
155
+ return {
156
+ ok: true, reason: 'created', kind, name, overlayRel,
157
+ baseRel: path.relative(cwd, baseAbs), fwVersion, baseSha,
158
+ };
159
+ }
160
+
161
+ async function scaffold(target, options = {}) {
162
+ const cwd = process.cwd();
163
+ ensureConsumer(cwd);
164
+
165
+ const res = scaffoldFile(cwd, target, options);
166
+ if (!res.ok) {
167
+ if (res.reason === 'invalid-target') {
168
+ UI.error('Invalid target. Use: skill/<name>, agents/<name>, or commands/<name>.');
169
+ UI.info('Example: npx baldart overlay scaffold agents/coder');
170
+ process.exit(1);
171
+ }
172
+ if (res.reason === 'base-missing') {
173
+ UI.error(`Base ${res.kind} "${res.name}" not found at ${res.baseRel}.`);
174
+ UI.info(`Run \`ls ${path.dirname(res.baseRel)}\` to list available ${res.kind}s.`);
175
+ process.exit(1);
176
+ }
177
+ if (res.reason === 'exists') {
178
+ UI.warning(`Overlay already exists: ${res.overlayRel}`);
179
+ UI.info('Run `npx baldart overlay drift ' + res.overlayRel + '` to check it, or edit it directly to extend.');
180
+ process.exit(0);
181
+ }
182
+ if (res.reason === 'bad-mode') {
183
+ UI.error(`Invalid --mode "${res.mode}". Use "extend" or "override".`);
184
+ process.exit(1);
185
+ }
186
+ process.exit(1);
187
+ }
188
+
189
+ UI.success(`Created ${res.overlayRel}`);
190
+ UI.info(`Base file: ${res.baseRel}`);
191
+ UI.info(`Targets framework v${res.fwVersion || 'unknown'} (base_file_sha=${res.baseSha}).`);
166
192
  UI.newline();
167
193
  UI.list([
168
- `Edit ${overlayRel} to add your customisations.`,
169
- `Validate the merge with: npx baldart overlay validate ${overlayRel}`,
170
- kind === 'skill'
194
+ `Edit ${res.overlayRel} to add your customisations.`,
195
+ `Validate the merge with: npx baldart overlay validate ${res.overlayRel}`,
196
+ res.kind === 'skill'
171
197
  ? `Run any /-command that uses this skill to see the overlay applied at runtime.`
172
- : `Apply with: npx baldart update (regenerates .claude/${kind}s/${name}.md from base + overlay).`,
198
+ : `Apply with: npx baldart update (regenerates .claude/${res.kind}s/${res.name}.md from base + overlay).`,
173
199
  ]);
174
200
  }
175
201
 
@@ -378,4 +404,4 @@ async function drift(target, options = {}) {
378
404
  UI.info(`Summary: ${clean} clean, ${drifted} drifted, ${unknown} unknown — of ${targets.length} overlay(s).`);
379
405
  }
380
406
 
381
- module.exports = { scaffold, validate, drift };
407
+ module.exports = { scaffold, scaffoldFile, validate, drift, parseTarget };
@@ -20,6 +20,68 @@ function readEnabledTools(cwd = process.cwd()) {
20
20
  return ['claude'];
21
21
  }
22
22
 
23
+ // Allowed values for `--on-divergence` — the non-interactive escape hatch that
24
+ // lets an agent / CI complete (or deliberately stop) an update when the
25
+ // consumer has local commits on `.framework/`. Without it a non-TTY caller has
26
+ // no non-destructive path: `--yes` refuses, the interactive prompt needs a TTY,
27
+ // and `--reset` is gated on a clean working tree (see Finding 2 in the
28
+ // agent-overlay feedback that prompted this).
29
+ const DIVERGENCE_STRATEGIES = ['abort', 'pull', 'scaffold-overlays'];
30
+
31
+ // Map a touched framework path to an `overlay scaffold` target string.
32
+ // .framework/framework/.claude/agents/coder.md → 'agents/coder'
33
+ // .framework/framework/.claude/commands/check.md → 'commands/check'
34
+ // .framework/framework/.claude/skills/bug/SKILL.md → 'skill/bug'
35
+ // Returns null for anything that isn't an overlay-able framework primitive.
36
+ function frameworkPathToOverlayTarget(p) {
37
+ const m = /(?:^|\/)\.framework\/framework\/\.claude\/(agents|commands|skills)\/(.+)$/.exec(p);
38
+ if (!m) return null;
39
+ const [, dir, rest] = m;
40
+ if (dir === 'skills') {
41
+ const name = rest.split('/')[0];
42
+ return name ? `skill/${name}` : null;
43
+ }
44
+ const name = rest.replace(/\.md$/, '');
45
+ if (!name || name.includes('/')) return null;
46
+ return `${dir}/${name}`;
47
+ }
48
+
49
+ // Scaffold overlay skeletons for every overlay-able framework file touched by
50
+ // the given commits. Reuses overlay.scaffoldFile (the CLI is the source of
51
+ // truth — we never re-implement frontmatter/path logic here). Idempotent:
52
+ // existing overlays are left untouched.
53
+ function scaffoldOverlaysForCommits(commits) {
54
+ const overlay = require('./overlay');
55
+ const cwd = process.cwd();
56
+ const targets = new Set();
57
+ for (const c of commits) {
58
+ for (const p of (c.touched || [])) {
59
+ const t = frameworkPathToOverlayTarget(p);
60
+ if (t) targets.add(t);
61
+ }
62
+ }
63
+ const created = [];
64
+ const skipped = [];
65
+ if (targets.size === 0) {
66
+ UI.warning('Could not derive any overlay target from the divergent commits.');
67
+ UI.info('They may touch files outside `.claude/{agents,skills,commands}/`. Use `/baldart-push` or `--on-divergence pull`.');
68
+ return { created, skipped };
69
+ }
70
+ for (const t of [...targets].sort()) {
71
+ const r = overlay.scaffoldFile(cwd, t);
72
+ if (r.ok) {
73
+ created.push(r.overlayRel);
74
+ UI.success(`Scaffolded overlay: ${r.overlayRel} (base: ${r.baseRel})`);
75
+ } else if (r.reason === 'exists') {
76
+ skipped.push(r.overlayRel);
77
+ UI.info(`Overlay already exists, left as-is: ${r.overlayRel}`);
78
+ } else {
79
+ UI.warning(`Could not scaffold ${t}: ${r.reason}${r.baseRel ? ` (${r.baseRel})` : ''}`);
80
+ }
81
+ }
82
+ return { created, skipped };
83
+ }
84
+
23
85
  // Path prefixes BALDART itself writes during install/update. Anything outside
24
86
  // these patterns is treated as user-owned and never auto-staged.
25
87
  const BALDART_MANAGED_PATTERNS = [
@@ -359,6 +421,14 @@ async function update(options = {}) {
359
421
  const autoStash = autoYes || options.autoStash === true;
360
422
  const confirm = async (msg, def = true) => (autoYes ? def : UI.confirm(msg, def));
361
423
 
424
+ // Non-interactive divergence strategy (since v3.29.0). Validated up-front so
425
+ // a typo fails fast instead of mid-flow after the network fetch.
426
+ const divStrategy = options.onDivergence;
427
+ if (divStrategy !== undefined && !DIVERGENCE_STRATEGIES.includes(divStrategy)) {
428
+ UI.error(`Invalid --on-divergence "${divStrategy}". Use one of: ${DIVERGENCE_STRATEGIES.join(', ')}.`);
429
+ process.exit(1);
430
+ }
431
+
362
432
  try {
363
433
  // Step 1: Verify installation
364
434
  UI.header('STEP 1/5: Verify Installation');
@@ -470,45 +540,107 @@ async function update(options = {}) {
470
540
  } else if (status.divergenceClass === 'overlay-able') {
471
541
  UI.warning('Detected custom edits to framework files inside `.framework/.claude/{agents,skills,commands}/`.');
472
542
  UI.info('These are better expressed as overlays under `.baldart/overlays/` — they survive updates without divergence.');
473
- for (const c of status.divergenceCommits.filter((c) => c.category === 'custom-overlay-able')) {
543
+ const overlayCommits = status.divergenceCommits.filter((c) => c.category === 'custom-overlay-able');
544
+ for (const c of overlayCommits) {
474
545
  UI.info(` • ${c.sha.slice(0, 7)} — ${c.subject}`);
475
546
  }
476
- if (autoYes) {
477
- UI.error('Refusing to overwrite custom edits in --yes mode. Re-run without --yes to choose.');
478
- UI.info('Recommended: use the `/overlay` skill to migrate these edits, then re-run update.');
479
- process.exit(1);
480
- }
481
- const choice = await UI.select('How would you like to proceed?', [
482
- { name: 'Migrate to overlay and pull anyway (recommended — overlay wins)', value: 'overlay' },
483
- { name: 'Keep the custom commits (subtree pull may conflict)', value: 'keep' },
484
- { name: 'Abort', value: 'abort' },
485
- ]);
486
- if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
487
- if (choice === 'overlay') {
488
- UI.info('Run `/overlay` from Claude Code to migrate the custom edits, then re-run `npx baldart update`.');
547
+
548
+ // Non-interactive resolution (agents / CI) via --on-divergence.
549
+ if (divStrategy === 'scaffold-overlays') {
550
+ UI.newline();
551
+ scaffoldOverlaysForCommits(overlayCommits);
552
+ UI.newline();
553
+ UI.info('Next steps:');
554
+ UI.list([
555
+ 'Fill in the overlay skeleton(s) above to capture your customisations (the base file shows what you changed).',
556
+ 'Then complete the update with `npx baldart update --reset --yes --i-know` — this discards the now-redundant `.framework/` edits and reinstalls clean; your `.baldart/overlays/` are preserved and re-applied.',
557
+ 'Alternatively, push the edits upstream with `/baldart-push` if they are generic improvements.',
558
+ ], 'cyan');
489
559
  process.exit(0);
490
560
  }
491
- // 'keep' fall through, subtree pull will attempt merge
561
+ if (divStrategy === 'abort') { UI.info('Update cancelled (--on-divergence abort).'); process.exit(0); }
562
+ if (divStrategy === 'pull') {
563
+ UI.info('Keeping the custom commits (--on-divergence pull). The subtree pull will attempt a merge.');
564
+ // fall through
565
+ } else if (autoYes) {
566
+ // --yes without an explicit strategy: never silently overwrite. Tell
567
+ // the (likely non-TTY) caller exactly which flag completes the update.
568
+ UI.error('Custom edits present — refusing to resolve them unattended under --yes.');
569
+ UI.info('Re-run with one of:');
570
+ UI.list([
571
+ '--on-divergence scaffold-overlays → auto-create overlay skeleton(s), then stop so you can fill them',
572
+ '--on-divergence pull → keep the commits and merge the update (non-destructive)',
573
+ '--reset --yes --i-know → discard .framework/ edits and reinstall clean (destructive)',
574
+ ], 'cyan');
575
+ process.exit(1);
576
+ } else {
577
+ const choice = await UI.select('How would you like to proceed?', [
578
+ { name: 'Scaffold overlay skeleton(s) now (recommended — then fill them in & re-run)', value: 'overlay' },
579
+ { name: 'Keep the custom commits (subtree pull may conflict)', value: 'keep' },
580
+ { name: 'Abort', value: 'abort' },
581
+ ]);
582
+ if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
583
+ if (choice === 'overlay') {
584
+ UI.newline();
585
+ scaffoldOverlaysForCommits(overlayCommits);
586
+ UI.newline();
587
+ UI.info('Next steps:');
588
+ UI.list([
589
+ 'Edit the overlay skeleton(s) above to capture your customisations (`/overlay` from Claude Code gives a guided flow).',
590
+ 'Then complete the update with `npx baldart update --reset` — discards the redundant `.framework/` edits, reinstalls clean, re-applies your overlays.',
591
+ ], 'cyan');
592
+ process.exit(0);
593
+ }
594
+ // 'keep' → fall through, subtree pull will attempt merge
595
+ }
492
596
  } else if (status.divergenceClass === 'real-custom' || status.divergenceClass === 'mixed') {
493
597
  UI.warning(`Detected ${status.divergenceCommits.filter((c) => c.category === 'custom-other').length} custom commit(s) on .framework/ that are NOT auto-classifiable as overlays.`);
494
598
  for (const c of status.divergenceCommits.filter((c) => !['subtree-merge', 'subtree-squash', 'chore-wrapper'].includes(c.category))) {
495
599
  UI.info(` • ${c.sha.slice(0, 7)} [${c.category}] — ${c.subject}`);
496
600
  }
497
- if (autoYes) {
498
- UI.error('Refusing to pull over custom commits in --yes mode. Re-run without --yes to choose.');
499
- process.exit(1);
500
- }
501
- const choice = await UI.select('How would you like to proceed?', [
502
- { name: 'Push these commits upstream first (recommended `/baldart-push`)', value: 'push' },
503
- { name: 'Pull anyway (will create a merge commit possible conflicts)', value: 'pull' },
504
- { name: 'Abort', value: 'abort' },
505
- ]);
506
- if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
507
- if (choice === 'push') {
508
- UI.info('Run `/baldart-push` from Claude Code (or `npx baldart push`) to contribute upstream, then re-run update.');
601
+ const overlayCommits = status.divergenceCommits.filter((c) => c.category === 'custom-overlay-able');
602
+
603
+ // Non-interactive resolution (agents / CI) via --on-divergence.
604
+ if (divStrategy === 'scaffold-overlays') {
605
+ if (overlayCommits.length === 0) {
606
+ UI.error('No overlay-able commits to scaffoldevery custom commit touches non-overlayable paths (src/, CHANGELOG, …).');
607
+ UI.info('Use `/baldart-push` to contribute them upstream, or `--on-divergence pull` to merge.');
608
+ process.exit(1);
609
+ }
610
+ UI.newline();
611
+ scaffoldOverlaysForCommits(overlayCommits);
612
+ UI.newline();
613
+ UI.warning('Scaffolded overlays for the overlay-able commits, but custom-other commit(s) remain — NOT pulling (would be destructive to those edits).');
614
+ UI.info('Resolve the remaining commits (push upstream with `/baldart-push`, or revert), then re-run `npx baldart update`.');
509
615
  process.exit(0);
510
616
  }
511
- // 'pull' fall through
617
+ if (divStrategy === 'abort') { UI.info('Update cancelled (--on-divergence abort).'); process.exit(0); }
618
+ if (divStrategy === 'pull') {
619
+ UI.info('Pulling over the custom commits (--on-divergence pull). A merge commit will be created — resolve any conflicts that surface.');
620
+ // fall through
621
+ } else if (autoYes) {
622
+ UI.error('Custom commits present — refusing to pull over them unattended under --yes.');
623
+ UI.info('Re-run with one of:');
624
+ UI.list([
625
+ '--on-divergence pull → create a merge commit over your edits (non-destructive)',
626
+ '--on-divergence scaffold-overlays → scaffold overlays for the overlay-able subset (custom-other still needs handling)',
627
+ '--on-divergence abort → do nothing',
628
+ ], 'cyan');
629
+ UI.info('Best practice: contribute generic improvements upstream first with `/baldart-push`.');
630
+ process.exit(1);
631
+ } else {
632
+ const choice = await UI.select('How would you like to proceed?', [
633
+ { name: 'Push these commits upstream first (recommended — `/baldart-push`)', value: 'push' },
634
+ { name: 'Pull anyway (will create a merge commit — possible conflicts)', value: 'pull' },
635
+ { name: 'Abort', value: 'abort' },
636
+ ]);
637
+ if (choice === 'abort') { UI.info('Update cancelled.'); process.exit(0); }
638
+ if (choice === 'push') {
639
+ UI.info('Run `/baldart-push` from Claude Code (or `npx baldart push`) to contribute upstream, then re-run update.');
640
+ process.exit(0);
641
+ }
642
+ // 'pull' → fall through
643
+ }
512
644
  }
513
645
  // 'unknown' → conservative: continue silently, the subtree pull will
514
646
  // surface any real conflict.
@@ -41,6 +41,65 @@ const SUBJECT_FIXTURES = [
41
41
  { subject: "Squashed 'docs/' content from abc..def", expected: null },
42
42
  // Chore without 'baldart' keyword — NOT chore-wrapper
43
43
  { subject: 'chore: update dependencies', expected: null },
44
+
45
+ // ─── subject-regex BLIND SPOTS (now covered by parent-count detection) ──
46
+ // git omits the "into <branch>" suffix when the merge lands on `master`,
47
+ // so a real subtree-pull merge can present as a bare "Merge commit 'x'".
48
+ // The subject classifier returns null here; classifyDivergence() then
49
+ // relabels it `subtree-merge` because the commit has 2+ parents. We assert
50
+ // the subject classifier's null so the gap (and why parents matter) is
51
+ // documented and regression-locked.
52
+ { subject: "Merge commit 'abc123'", expected: null },
53
+ // Shared-repo collaboration merge — the " of <url>" segment breaks the
54
+ // narrow `Merge branch 'x' into <branch>$` shape. Also null → parent count.
55
+ { subject: "Merge branch 'develop' of github.com:org/repo into develop", expected: null },
56
+ { subject: "Merge remote-tracking branch 'origin/main'", expected: null },
57
+ ];
58
+
59
+ // aggregateDivergenceClass(commits) — pure reduction of per-commit categories
60
+ // to the divergence class. The headline Finding-1 case: a single overlay-able
61
+ // commit alongside a plumbing merge must stay `overlay-able`, NOT collapse to
62
+ // `mixed`, because the merge is excluded as noise before the decision.
63
+ const AGGREGATE_FIXTURES = [
64
+ {
65
+ name: 'overlay-able commit + plumbing merge → overlay-able (not mixed)',
66
+ commits: [
67
+ { category: 'custom-overlay-able' },
68
+ { category: 'subtree-merge' },
69
+ ],
70
+ expected: 'overlay-able',
71
+ },
72
+ {
73
+ name: 'overlay-able + subtree-squash + chore-wrapper → overlay-able',
74
+ commits: [
75
+ { category: 'custom-overlay-able' },
76
+ { category: 'subtree-squash' },
77
+ { category: 'chore-wrapper' },
78
+ ],
79
+ expected: 'overlay-able',
80
+ },
81
+ {
82
+ name: 'overlay-able + custom-other → mixed (genuine mix preserved)',
83
+ commits: [
84
+ { category: 'custom-overlay-able' },
85
+ { category: 'custom-other' },
86
+ ],
87
+ expected: 'mixed',
88
+ },
89
+ {
90
+ name: 'only custom-other → real-custom',
91
+ commits: [{ category: 'custom-other' }, { category: 'subtree-merge' }],
92
+ expected: 'real-custom',
93
+ },
94
+ {
95
+ name: 'only noise → all-noise',
96
+ commits: [
97
+ { category: 'subtree-merge' },
98
+ { category: 'subtree-squash' },
99
+ { category: 'chore-wrapper' },
100
+ ],
101
+ expected: 'all-noise',
102
+ },
44
103
  ];
45
104
 
46
105
  const PATH_FIXTURES = [
@@ -116,5 +175,17 @@ for (const f of PATH_FIXTURES) {
116
175
  }
117
176
  }
118
177
 
178
+ console.log('\n─── Aggregate class ───');
179
+ for (const f of AGGREGATE_FIXTURES) {
180
+ const actual = GitUtils.aggregateDivergenceClass(f.commits);
181
+ if (actual === f.expected) {
182
+ pass++;
183
+ console.log(` ✓ ${f.name} → ${actual}`);
184
+ } else {
185
+ fail++;
186
+ console.log(` ✗ ${f.name} → ${actual} (expected ${f.expected})`);
187
+ }
188
+ }
189
+
119
190
  console.log(`\nResult: ${pass} pass, ${fail} fail`);
120
191
  process.exit(fail > 0 ? 1 : 0);
package/src/utils/git.js CHANGED
@@ -263,43 +263,64 @@ class GitUtils {
263
263
  const raw = await this.git.raw([
264
264
  'log',
265
265
  'FETCH_HEAD..HEAD',
266
- '--pretty=format:%H%x00%s',
266
+ '--pretty=format:%H%x00%P%x00%s',
267
267
  '--',
268
268
  FRAMEWORK_DIR
269
269
  ]);
270
270
  for (const line of raw.split('\n')) {
271
271
  if (!line.trim()) continue;
272
- const [sha, ...rest] = line.split('');
272
+ const [sha, parentStr, ...rest] = line.split('');
273
273
  const subject = rest.join('');
274
+ const parents = (parentStr || '').trim().split(/\s+/).filter(Boolean);
275
+ const isMerge = parents.length >= 2;
274
276
  let category = this.classifyCommitSubject(subject);
277
+ let touched;
278
+ if (!category && isMerge) {
279
+ // A merge commit is git plumbing, never a user customization. The
280
+ // real content (if any) lives in the merged commits, which appear
281
+ // in this range on their own when they aren't already upstream.
282
+ // `git subtree pull --squash` merges vary in subject across git
283
+ // versions and default-branch name ("Merge commit 'x'" with no
284
+ // "into <branch>" suffix when landing on `master`; "Merge branch
285
+ // 'x' of <url> into <branch>" on shared repos), so the subject
286
+ // regex alone is brittle. Detect merges structurally by parent
287
+ // count and treat them as noise — otherwise a single plumbing
288
+ // merge poisons an otherwise overlay-able set into `mixed`.
289
+ category = 'subtree-merge';
290
+ }
275
291
  if (!category) {
276
292
  // Need file list to disambiguate custom-overlay-able vs custom-other.
277
- let touched = [];
293
+ touched = [];
278
294
  try {
279
295
  const filesRaw = await this.git.raw(['diff-tree', '--no-commit-id', '--name-only', '-r', sha]);
280
296
  touched = filesRaw.split('\n').filter((l) => l.trim());
281
297
  } catch (_) { /* leave empty → custom-other */ }
282
298
  category = this.classifyCommitByPaths(touched);
283
299
  }
284
- commits.push({ sha, subject, category });
300
+ commits.push({ sha, subject, category, parents, touched: touched || [] });
285
301
  }
286
302
  } catch (_) {
287
303
  return { class: 'unknown', commits: [] };
288
304
  }
289
305
 
290
306
  if (commits.length === 0) return { class: 'all-noise', commits: [] };
307
+ return { class: GitUtils.aggregateDivergenceClass(commits), commits };
308
+ }
291
309
 
310
+ // Pure aggregation of per-commit categories into the divergence class.
311
+ // Extracted as a static method so it can be unit-tested without a live repo
312
+ // (see src/utils/__tests__/classify-divergence.test.js). Noise categories
313
+ // (subtree plumbing + CLI chore wrappers + merge commits) are excluded
314
+ // BEFORE deciding overlay-able vs mixed, so plumbing never downgrades the UX.
315
+ static aggregateDivergenceClass(commits) {
292
316
  const noiseCategories = new Set(['subtree-merge', 'subtree-squash', 'chore-wrapper']);
293
317
  const nonNoise = commits.filter((c) => !noiseCategories.has(c.category));
294
- if (nonNoise.length === 0) return { class: 'all-noise', commits };
295
-
318
+ if (nonNoise.length === 0) return 'all-noise';
296
319
  const hasOverlayable = nonNoise.some((c) => c.category === 'custom-overlay-able');
297
320
  const hasOther = nonNoise.some((c) => c.category === 'custom-other');
298
- let aggregate = 'real-custom';
299
- if (hasOverlayable && !hasOther) aggregate = 'overlay-able';
300
- else if (hasOverlayable && hasOther) aggregate = 'mixed';
301
-
302
- return { class: aggregate, commits };
321
+ if (hasOverlayable && !hasOther) return 'overlay-able';
322
+ if (hasOverlayable && hasOther) return 'mixed';
323
+ return 'real-custom';
303
324
  }
304
325
 
305
326
  async getUpdateStatus(repo, branch = 'main') {