baldart 3.12.0 → 3.14.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.
@@ -130,6 +130,29 @@ async function update(options = {}) {
130
130
  const currentVersion = await git.getFrameworkVersion();
131
131
  UI.success(`Current version: ${currentVersion}`);
132
132
 
133
+ // `.framework/` MUST be trackable — heal a stale `.gitignore` before
134
+ // any `git subtree pull` / `git add` runs. Idempotent. (since v3.14.0)
135
+ try {
136
+ const heal = await git.ensureFrameworkNotIgnored();
137
+ if (heal.wasFixed) {
138
+ UI.warning(`.framework/ was ignored by ${heal.source || 'a gitignore'} — auto-healed.`);
139
+ if (heal.removedLines > 0) {
140
+ UI.info(` Removed ${heal.removedLines} stale .framework line(s) from .gitignore.`);
141
+ }
142
+ if (heal.appendedNegation) {
143
+ UI.info(' Appended `!.framework` negation block to .gitignore (idempotent).');
144
+ }
145
+ if (heal.stillIgnored) {
146
+ UI.error('.framework/ is STILL ignored after auto-heal — likely a parent-dir .gitignore outside this repo.');
147
+ UI.info(`Source: ${heal.recheck.source}:${heal.recheck.line} (${heal.recheck.pattern})`);
148
+ UI.info('Resolve by removing that rule, then re-run `baldart update`.');
149
+ process.exit(1);
150
+ }
151
+ }
152
+ } catch (err) {
153
+ UI.warning(`Could not check .gitignore for .framework: ${err.message}`);
154
+ }
155
+
133
156
  // Step 2: Check for updates
134
157
  UI.header('STEP 2/5: Check for Updates');
135
158
 
@@ -246,7 +269,21 @@ async function update(options = {}) {
246
269
 
247
270
  newVersion = await git.getFrameworkVersion();
248
271
  UI.success(`New version: ${newVersion}`);
249
- try { State.recordUpdate({ from: currentVersion, to: newVersion }); } catch (_) { /* non-fatal */ }
272
+ // Update the ledger and VERIFY the write landed. Prior to v3.13.0 this
273
+ // call used a silent catch — write failures (permissions, EBUSY) were
274
+ // hidden, and the user only noticed the drift weeks later via doctor.
275
+ // Now: visible warning + always-actionable next step.
276
+ try {
277
+ const after = State.recordUpdate({ from: currentVersion, to: newVersion });
278
+ if (after.installed_version !== newVersion) {
279
+ UI.warning(`Ledger write returned installed_version=${after.installed_version}, expected ${newVersion}.`);
280
+ UI.info('Run `baldart doctor` to reconcile the ledger.');
281
+ }
282
+ } catch (err) {
283
+ UI.warning(`Could not write .baldart/state.json: ${err.message}`);
284
+ UI.info(`Framework on disk is at v${newVersion} but the ledger still records v${currentVersion}.`);
285
+ UI.info('Run `baldart doctor` to reconcile, or check filesystem permissions on .baldart/state.json.');
286
+ }
250
287
 
251
288
  // Re-apply the pre-update stash (if any). Conflicts mean we leave the
252
289
  // stash intact and tell the user how to resolve — never silently drop.
@@ -1,6 +1,7 @@
1
1
  const GitUtils = require('../utils/git');
2
2
  const UI = require('../utils/ui');
3
3
  const State = require('../utils/state');
4
+ const UpdateNotifier = require('../utils/update-notifier');
4
5
 
5
6
  const REPO_DEFAULT = 'antbald/BALDART';
6
7
  const FRAMEWORK_DIR = '.framework';
@@ -48,6 +49,7 @@ async function version(opts = {}) {
48
49
  const frameworkVersion = await git.getFrameworkVersion();
49
50
  const state = State.load();
50
51
  const cliVersion = require('../../package.json').version;
52
+ const cliUpdate = UpdateNotifier.hint({ currentVersion: cliVersion });
51
53
 
52
54
  // Optionally fetch upstream to compute drift (default: yes).
53
55
  const offline = opts.offline === true;
@@ -75,7 +77,9 @@ async function version(opts = {}) {
75
77
  `Last update: ${fmtDate(state.last_update_date)}`,
76
78
  `Last push: v${state.last_pushed_version || '—'} ${state.last_push_date ? `(${fmtDate(state.last_push_date)})` : ''}`.trim(),
77
79
  '',
78
- `CLI: v${cliVersion}`,
80
+ cliUpdate
81
+ ? `CLI: v${cliVersion} → v${cliUpdate.latest} available (npm i -g baldart@latest)`
82
+ : `CLI: v${cliVersion}`,
79
83
  `Repository: https://github.com/${state.framework_repo || REPO_DEFAULT}`,
80
84
  ].filter((l) => l !== ''));
81
85
  UI.newline();
package/src/utils/git.js CHANGED
@@ -176,6 +176,99 @@ class GitUtils {
176
176
  return '';
177
177
  }
178
178
  }
179
+
180
+ // ────────────────────────────────────────────────────────────────────
181
+ // `.framework/` is a git subtree — it MUST be tracked. If anything in
182
+ // the gitignore chain (project .gitignore, .git/info/exclude, global
183
+ // excludes) matches `.framework`, every subsequent `git add` on files
184
+ // under that directory fails with "paths are ignored by one of your
185
+ // .gitignore files". Detect + self-heal idempotently so install /
186
+ // update / push flows never trip over this. (since v3.14.0)
187
+ // ────────────────────────────────────────────────────────────────────
188
+ async checkFrameworkIgnored() {
189
+ try {
190
+ const out = await this.git.raw([
191
+ 'check-ignore',
192
+ '-v',
193
+ '--no-index',
194
+ '--',
195
+ FRAMEWORK_DIR
196
+ ]);
197
+ const line = (out || '').trim();
198
+ if (!line) return { ignored: false };
199
+ // Format: "<source>:<line>:<pattern>\t<path>"
200
+ const m = /^(.+?):(\d+):([^\t]+)\t/.exec(line);
201
+ return {
202
+ ignored: true,
203
+ source: m ? m[1] : 'unknown',
204
+ line: m ? Number(m[2]) : null,
205
+ pattern: m ? m[3] : null,
206
+ raw: line
207
+ };
208
+ } catch (_) {
209
+ // `git check-ignore` exits 1 when the path is not ignored — simple-git
210
+ // throws on non-zero. Treat as "not ignored".
211
+ return { ignored: false };
212
+ }
213
+ }
214
+
215
+ async ensureFrameworkNotIgnored() {
216
+ const status = await this.checkFrameworkIgnored();
217
+ if (!status.ignored) return { wasFixed: false };
218
+
219
+ const gitignorePath = path.join(this.cwd, '.gitignore');
220
+ const marker = '# BALDART: .framework/ is a git subtree — never ignore (auto-managed)';
221
+ const negationBlock = `\n${marker}\n!.framework\n!.framework/**\n`;
222
+
223
+ let original = '';
224
+ if (fs.existsSync(gitignorePath)) {
225
+ original = fs.readFileSync(gitignorePath, 'utf8');
226
+ }
227
+
228
+ // 1. Strip standalone .framework rules from the project .gitignore so we
229
+ // don't leave stale "do ignore" lines that future maintainers might
230
+ // notice and try to "fix" by removing our negation.
231
+ const lines = original.split('\n');
232
+ const stripped = lines.filter((line) => {
233
+ const t = line.trim();
234
+ return !(
235
+ t === '.framework' ||
236
+ t === '.framework/' ||
237
+ t === '/.framework' ||
238
+ t === '/.framework/' ||
239
+ t === '.framework/*' ||
240
+ t === '/.framework/*'
241
+ );
242
+ });
243
+ const removedLines = lines.length - stripped.length;
244
+
245
+ // 2. Append the negation block (only once — idempotent across re-runs).
246
+ let next = stripped.join('\n');
247
+ if (!next.includes(marker)) {
248
+ if (next.length && !next.endsWith('\n')) next += '\n';
249
+ next += negationBlock;
250
+ }
251
+
252
+ if (next !== original) {
253
+ fs.writeFileSync(gitignorePath, next, 'utf8');
254
+ }
255
+
256
+ // 3. Verify the heal actually took effect. If the rule lives somewhere we
257
+ // cannot rewrite from here (a parent-dir .gitignore outside the repo,
258
+ // a non-standard core.excludesFile that overrides the project file),
259
+ // surface the leftover state to the caller instead of pretending.
260
+ const recheck = await this.checkFrameworkIgnored();
261
+
262
+ return {
263
+ wasFixed: true,
264
+ stillIgnored: recheck.ignored,
265
+ removedLines,
266
+ appendedNegation: !original.includes(marker),
267
+ source: status.source,
268
+ pattern: status.pattern,
269
+ recheck
270
+ };
271
+ }
179
272
  }
180
273
 
181
274
  module.exports = GitUtils;
@@ -19,7 +19,7 @@ class GoAdapter {
19
19
 
20
20
  installCommand() { return 'go install golang.org/x/tools/gopls@latest'; }
21
21
  verifyCommand() { return 'gopls version'; }
22
- claudePluginId() { return null; }
22
+ claudePluginId() { return 'gopls-lsp@claude-plugins-official'; }
23
23
 
24
24
  static detect(cwd = process.cwd()) {
25
25
  return fs.existsSync(path.join(cwd, 'go.mod'));
@@ -23,7 +23,7 @@ class PythonAdapter {
23
23
 
24
24
  installCommand() { return 'npm install --save-dev pyright'; }
25
25
  verifyCommand() { return 'npx --no-install pyright --version'; }
26
- claudePluginId() { return null; }
26
+ claudePluginId() { return 'pyright-lsp@claude-plugins-official'; }
27
27
 
28
28
  static detect(cwd = process.cwd()) {
29
29
  const markers = ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'Pipfile'];
@@ -19,7 +19,7 @@ class RubyAdapter {
19
19
 
20
20
  installCommand() { return 'gem install ruby-lsp'; }
21
21
  verifyCommand() { return 'ruby-lsp --version'; }
22
- claudePluginId() { return null; }
22
+ claudePluginId() { return 'ruby-lsp@claude-plugins-official'; }
23
23
 
24
24
  static detect(cwd = process.cwd()) {
25
25
  const markers = ['Gemfile', '.ruby-version', 'config.ru'];
@@ -16,7 +16,7 @@ class RustAdapter {
16
16
 
17
17
  installCommand() { return 'rustup component add rust-analyzer'; }
18
18
  verifyCommand() { return 'rust-analyzer --version'; }
19
- claudePluginId() { return null; }
19
+ claudePluginId() { return 'rust-analyzer-lsp@claude-plugins-official'; }
20
20
 
21
21
  static detect(cwd = process.cwd()) {
22
22
  return fs.existsSync(path.join(cwd, 'Cargo.toml'));
@@ -32,8 +32,14 @@ class TypeScriptAdapter {
32
32
  /** Verify the binary is reachable (no execution side effects). */
33
33
  verifyCommand() { return 'npx --no-install typescript-language-server --version'; }
34
34
 
35
- /** Claude Code code-intelligence plugin id, if any. Null = use generic LSP. */
36
- claudePluginId() { return null; }
35
+ /**
36
+ * Claude Code code-intelligence plugin id, if any. Null = no companion plugin.
37
+ * When non-null, `LspInstaller.installClaudePlugins()` installs this plugin
38
+ * from the `claude-plugins-official` marketplace (anthropics/claude-plugins-official)
39
+ * so the LSP layer is wired into Claude Code natively instead of leaving the
40
+ * user to enable it manually after `baldart configure`.
41
+ */
42
+ claudePluginId() { return 'typescript-lsp@claude-plugins-official'; }
37
43
 
38
44
  /** Autodetect whether this language is in scope for the project. */
39
45
  static detect(cwd = process.cwd()) {
@@ -1,7 +1,42 @@
1
1
  const { execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
2
5
  const { detectAll, getAdapter, listAdapters } = require('./lsp-adapters');
3
6
  const UI = require('./ui');
4
7
 
8
+ /**
9
+ * The `claude` CLI is built on Ink (React for TUIs) and writes stdout in a
10
+ * way that truncates at ~8 KB when piped directly to a node child process —
11
+ * regardless of `maxBuffer` setting. Workaround: shell-redirect to a temp
12
+ * file and read the file back. Used only for read-only `--json` queries.
13
+ *
14
+ * Returns the file contents as a string, or throws (caller catches).
15
+ */
16
+ function captureClaudeJson(args, { timeout = 10000 } = {}) {
17
+ const tmp = path.join(os.tmpdir(), `baldart-claude-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
18
+ try {
19
+ execSync(`claude ${args} > ${JSON.stringify(tmp)} 2>/dev/null`, {
20
+ stdio: ['ignore', 'ignore', 'ignore'],
21
+ timeout,
22
+ shell: '/bin/bash',
23
+ });
24
+ return fs.readFileSync(tmp, 'utf8');
25
+ } finally {
26
+ try { fs.unlinkSync(tmp); } catch (_) { /* best-effort cleanup */ }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Anthropic's official marketplace for native Claude Code LSP plugins
32
+ * (typescript-lsp, pyright-lsp, gopls-lsp, rust-analyzer-lsp, ruby-lsp, …).
33
+ * Every adapter's `claudePluginId()` returns an id namespaced under this
34
+ * marketplace, so the installer hard-codes it here. If a future adapter needs
35
+ * a different marketplace, lift this into a per-adapter field.
36
+ */
37
+ const OFFICIAL_MARKETPLACE_NAME = 'claude-plugins-official';
38
+ const OFFICIAL_MARKETPLACE_SOURCE = 'anthropics/claude-plugins-official';
39
+
5
40
  /**
6
41
  * High-level LSP installer used by `baldart configure` and the `lsp-bootstrap`
7
42
  * skill / doctor step. Owns the install + verify side effects so the command
@@ -11,6 +46,10 @@ const UI = require('./ui');
11
46
  * - Pure detection (no install) when only `detectLanguages` is called.
12
47
  * - All mutating ops respect `--non-interactive` via the `interactive` flag.
13
48
  * - Returns plain objects (no throws) so the caller can render results.
49
+ * - Claude plugin install ops gate on the `claude` CLI being on PATH AND on
50
+ * `claude` being present in `tools.enabled`; when either fails the methods
51
+ * report `{ skipped: [...], reason }` so the caller can render a hint
52
+ * instead of erroring.
14
53
  */
15
54
  class LspInstaller {
16
55
  constructor(cwd = process.cwd()) { this.cwd = cwd; }
@@ -91,6 +130,210 @@ class LspInstaller {
91
130
  return { installed, skipped, manual };
92
131
  }
93
132
 
133
+ // ──────────────────────────────────────────────────────────────────────
134
+ // Native Claude Code companion plugins (since v3.14.0)
135
+ //
136
+ // For each language whose adapter declares a `claudePluginId()`, this layer
137
+ // ensures the matching plugin from the `claude-plugins-official` marketplace
138
+ // is installed. Without it, enabling `features.has_lsp_layer: true` only
139
+ // installs the language-server binary — Claude Code itself never sees it,
140
+ // and agents fall back to Grep silently. Wiring the two together is the
141
+ // whole point of opting into LSP, so the installer treats this as part of
142
+ // the install, not an optional extra.
143
+ // ──────────────────────────────────────────────────────────────────────
144
+
145
+ /** True iff the `claude` CLI is on PATH and answers `--version`. */
146
+ isClaudeCliAvailable() {
147
+ try {
148
+ execSync('claude --version', { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
149
+ return true;
150
+ } catch { return false; }
151
+ }
152
+
153
+ /**
154
+ * Idempotently register the official marketplace. Reads the existing list
155
+ * first so we never spam `claude plugin marketplace add` on every configure
156
+ * run. Returns `{ ok, alreadyPresent, error? }`. Never throws.
157
+ */
158
+ ensureClaudeMarketplace() {
159
+ if (!this.isClaudeCliAvailable()) {
160
+ return { ok: false, error: 'claude CLI not on PATH' };
161
+ }
162
+ try {
163
+ const out = captureClaudeJson('plugin marketplace list --json', { timeout: 8000 });
164
+ const parsed = JSON.parse(out);
165
+ // Tolerate both shapes: object map keyed by name, or array of { name, … }.
166
+ const names = Array.isArray(parsed)
167
+ ? parsed.map(m => m && m.name).filter(Boolean)
168
+ : Object.keys(parsed || {});
169
+ if (names.includes(OFFICIAL_MARKETPLACE_NAME)) {
170
+ return { ok: true, alreadyPresent: true };
171
+ }
172
+ } catch (_) {
173
+ // Fall through to the add — if the add itself fails, we report that.
174
+ }
175
+ try {
176
+ execSync(`claude plugin marketplace add ${OFFICIAL_MARKETPLACE_SOURCE} --scope user`, {
177
+ stdio: ['ignore', 'pipe', 'pipe'],
178
+ timeout: 60000 // first add clones the marketplace repo; allow time
179
+ });
180
+ return { ok: true, alreadyPresent: false };
181
+ } catch (err) {
182
+ return { ok: false, error: (err.message || '').split('\n')[0] };
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Return the set of plugin ids (e.g. `typescript-lsp@claude-plugins-official`)
188
+ * currently installed at ANY scope. Used to skip already-installed plugins.
189
+ * Returns `Set<string>`; empty set on any failure (degrade open — we'd rather
190
+ * issue a redundant install than block on a parser hiccup).
191
+ */
192
+ listInstalledClaudePlugins() {
193
+ const ids = new Set();
194
+ try {
195
+ const out = captureClaudeJson('plugin list --json', { timeout: 8000 });
196
+ const parsed = JSON.parse(out);
197
+ const entries = Array.isArray(parsed) ? parsed : Object.values(parsed || {});
198
+ for (const entry of entries) {
199
+ // Be tolerant of shape drift: pull the id from `name`, `id`, or
200
+ // the object's own key.
201
+ const id = (entry && (entry.id || entry.name)) || null;
202
+ if (id) ids.add(id);
203
+ }
204
+ // Also handle the `{ "<id>@<marketplace>": [...] }` map shape that the
205
+ // on-disk `installed_plugins.json` uses, in case `list --json` mirrors it.
206
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
207
+ for (const key of Object.keys(parsed)) {
208
+ if (key.includes('@')) ids.add(key);
209
+ }
210
+ }
211
+ } catch (_) { /* swallow — empty set means "install, don't skip" */ }
212
+ return ids;
213
+ }
214
+
215
+ /**
216
+ * Install the native Claude Code companion plugins for the given language
217
+ * server names. Idempotent: skips plugins already installed, skips adapters
218
+ * whose `claudePluginId()` is null.
219
+ *
220
+ * Gating:
221
+ * - `opts.toolsEnabled` (array, default `['claude']`): when `claude` is
222
+ * not in the array, the whole method is a silent no-op (Codex-only users
223
+ * don't want Claude marketplace mutation).
224
+ * - When the `claude` CLI is missing, returns `{ skipped, reason }` so the
225
+ * caller can render a manual-install hint.
226
+ *
227
+ * Returns `{ installed: [...], skipped: [...], manual: [...], marketplace }`.
228
+ * Never throws.
229
+ */
230
+ async installClaudePlugins(serverNames, opts = {}) {
231
+ const {
232
+ toolsEnabled = ['claude'],
233
+ scope = 'user', // LSP plugins are machine-wide useful — avoid N project copies
234
+ interactive = false
235
+ } = opts;
236
+
237
+ if (!Array.isArray(toolsEnabled) || !toolsEnabled.includes('claude')) {
238
+ return { skipped: [], installed: [], manual: [], reason: 'claude not in tools.enabled' };
239
+ }
240
+ if (!this.isClaudeCliAvailable()) {
241
+ return {
242
+ skipped: serverNames.map(n => ({ name: n, reason: 'claude CLI not on PATH' })),
243
+ installed: [], manual: [],
244
+ reason: 'claude CLI not on PATH'
245
+ };
246
+ }
247
+
248
+ // Resolve <serverName> → <claudePluginId>, dropping adapters without one.
249
+ const targets = [];
250
+ for (const name of serverNames) {
251
+ try {
252
+ const a = getAdapter(name, this.cwd);
253
+ const pid = a.claudePluginId();
254
+ if (pid) targets.push({ name, label: a.label, pluginId: pid });
255
+ } catch { /* unknown adapter — ignore */ }
256
+ }
257
+ if (!targets.length) {
258
+ return { installed: [], skipped: [], manual: [], reason: 'no adapters declare a Claude plugin id' };
259
+ }
260
+
261
+ // Idempotency 1: marketplace add (no-op if already registered).
262
+ const mkt = this.ensureClaudeMarketplace();
263
+ if (!mkt.ok) {
264
+ return {
265
+ installed: [],
266
+ skipped: targets.map(t => ({ name: t.name, reason: `marketplace not registered (${mkt.error || 'unknown'})` })),
267
+ manual: targets.map(t => ({
268
+ name: t.name,
269
+ label: t.label,
270
+ command: `claude plugin marketplace add ${OFFICIAL_MARKETPLACE_SOURCE} && claude plugin install ${t.pluginId} --scope ${scope}`
271
+ })),
272
+ marketplace: mkt
273
+ };
274
+ }
275
+
276
+ // Idempotency 2: skip plugins already installed at any scope.
277
+ const already = this.listInstalledClaudePlugins();
278
+
279
+ const installed = [];
280
+ const skipped = [];
281
+ const manual = [];
282
+
283
+ for (const t of targets) {
284
+ if (already.has(t.pluginId)) {
285
+ skipped.push({ name: t.name, reason: 'plugin already installed' });
286
+ continue;
287
+ }
288
+
289
+ if (interactive) {
290
+ const ok = await UI.confirm(
291
+ `Install native Claude Code plugin \`${t.pluginId}\` (--scope ${scope})?`,
292
+ true
293
+ );
294
+ if (!ok) { skipped.push({ name: t.name, reason: 'user declined' }); continue; }
295
+ }
296
+
297
+ const spinner = UI.spinner(`Installing ${t.pluginId}…`).start();
298
+ try {
299
+ execSync(`claude plugin install ${t.pluginId} --scope ${scope}`, {
300
+ stdio: ['ignore', 'pipe', 'pipe'],
301
+ timeout: 60000
302
+ });
303
+ spinner.succeed(`Installed ${t.pluginId}`);
304
+ installed.push({ name: t.name, pluginId: t.pluginId });
305
+ } catch (err) {
306
+ spinner.fail(`Failed to install ${t.pluginId}`);
307
+ manual.push({
308
+ name: t.name,
309
+ label: t.label,
310
+ command: `claude plugin install ${t.pluginId} --scope ${scope}`
311
+ });
312
+ skipped.push({ name: t.name, reason: (err.message || '').split('\n')[0] });
313
+ }
314
+ }
315
+
316
+ return { installed, skipped, manual, marketplace: mkt };
317
+ }
318
+
319
+ /**
320
+ * For each declared server, report whether the matching Claude plugin is
321
+ * installed. Returns `[{ name, pluginId, installed }]`. Adapters without a
322
+ * `claudePluginId()` are reported as `{ pluginId: null, installed: null }`
323
+ * so callers can filter them out.
324
+ */
325
+ verifyClaudePlugins(serverNames) {
326
+ const already = this.listInstalledClaudePlugins();
327
+ return serverNames.map(name => {
328
+ let a;
329
+ try { a = getAdapter(name, this.cwd); }
330
+ catch { return { name, pluginId: null, installed: null }; }
331
+ const pid = a.claudePluginId();
332
+ if (!pid) return { name, pluginId: null, installed: null };
333
+ return { name, pluginId: pid, installed: already.has(pid) };
334
+ });
335
+ }
336
+
94
337
  /**
95
338
  * Verify each named server's binary is reachable. Returns
96
339
  * `[{ name, ok, output | error }]`. Never throws.
@@ -106,6 +106,32 @@ function recordUpdate({ from, to }, cwd = process.cwd()) {
106
106
  return state;
107
107
  }
108
108
 
109
+ /**
110
+ * Reconcile the ledger's installed_version with the actual framework version
111
+ * on disk (read from .framework/VERSION). Used by `baldart doctor` when it
112
+ * detects a mismatch between the payload and the ledger — typically caused by
113
+ * an interrupted update flow, a manual commit that excluded state.json, or a
114
+ * filesystem write failure that the silent catch in update.js used to hide.
115
+ *
116
+ * Idempotent: calling with the already-current version is a no-op (no history
117
+ * entry produced). History entry uses event "ledger-reconcile" so the source
118
+ * of the bump is auditable separately from real updates.
119
+ */
120
+ function reconcileInstalledVersion({ to, reason }, cwd = process.cwd()) {
121
+ const state = load(cwd);
122
+ const from = state.installed_version;
123
+ if (from === to) return { state, changed: false };
124
+ state.installed_version = to;
125
+ state.history = appendHistory(state, {
126
+ event: 'ledger-reconcile',
127
+ from,
128
+ to,
129
+ note: reason || 'reconcile ledger with .framework/VERSION'
130
+ });
131
+ save(state, cwd);
132
+ return { state, changed: true, from, to };
133
+ }
134
+
109
135
  function recordPush({ from, to, description }, cwd = process.cwd()) {
110
136
  const state = load(cwd);
111
137
  state.last_pushed_version = to;
@@ -137,6 +163,7 @@ module.exports = {
137
163
  recordInstall,
138
164
  recordUpdate,
139
165
  recordPush,
166
+ reconcileInstalledVersion,
140
167
  readFrameworkVersion,
141
168
  STATE_FILE,
142
169
  STATE_VERSION,