sshlg-skills 1.63.2 → 1.63.3

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
@@ -1,3 +1,20 @@
1
+ ## v1.63.3 — pinned sources for every Claude member; blocked members no longer stop the run
2
+
3
+ - A Claude member whose installed bytes already equal the pin is now rebound to the hub's
4
+ pinned catalog: its marketplace source moves to the pin, with no install or uninstall, and
5
+ the run verifies that its install records did not change and its bytes still equal the pin.
6
+ A later native update therefore follows the pin, not upstream (B-191).
7
+ - A member the native contract cannot change — installed only at project or local scope,
8
+ in user plus other scopes with different bytes, or a configured plugin — is reported as
9
+ blocked and keeps its installed version, while the other members and every later channel
10
+ (plain skills, runtime, hooks, Codex) proceed; the result is partial and exits nonzero.
11
+ Malformed install records still stop the whole run (B-192).
12
+ - The hook runtime records which bytes the hub installed (`.managed.json`). A library module a
13
+ later package drops is moved to `.retired/<stamp>/` only while it still has those bytes, and
14
+ `update`/`hooks install` print what moved; hook scripts, edited, foreign or older unrecorded
15
+ files stay where they are (B-193).
16
+ - Inserting into a non-empty object of a tab-indented JSON file keeps tabs (B-194).
17
+
1
18
  ## v1.63.2 — the Claude transaction keeps settings in Claude Code's format
2
19
 
3
20
  - Replacing a marketplace source in `~/.claude/settings.json` wrote the new object on one
@@ -407,8 +407,9 @@ function withClaudePlan(steps, f) {
407
407
  steps.unshift(opres.step('claude-pins', 'home',
408
408
  'native immutable source transaction; protected catalogs/settings, scoped repair, rollback on failure, payload readback',
409
409
  { fingerprint: native.fingerprint, profile: native.profile,
410
- members: native.jobs.map(j => `${j.pluginInstall}: ${j.action} @ ${j.row.commit}; ${j.row.files.length} files`)
411
- .concat(native.equivalent.map(e => `${e.pluginInstall}: payload-equivalent, left installed (installed ${String(e.installed).slice(0, 7)}, pin ${e.pin.slice(0, 7)}; ${e.files} files equal)`)),
410
+ members: native.jobs.filter(j => j.action !== 'rebind').map(j => `${j.pluginInstall}: ${j.action} @ ${j.row.commit}; ${j.row.files.length} files`)
411
+ .concat(native.equivalent.map(e => `${e.pluginInstall}: payload-equivalent (installed ${String(e.installed).slice(0, 7)}, pin ${e.pin.slice(0, 7)}; ${e.files} files equal) — source rebind, no reinstall`))
412
+ .concat(native.blocked.map(b => `${b.pluginInstall}: blocked, left as installed — ${b.reason}`)),
412
413
  journal: path.join(native.transactionRoot, 'latest.json') }));
413
414
  }
414
415
  const result = attachWaitPlan(steps, f);
@@ -419,26 +420,31 @@ function runClaudePlan(planned, f) {
419
420
  const saved = claudePlans.get(planned);
420
421
  if (!saved) {
421
422
  log(`\n== Updating Claude Code plugins ==\n skipped: ${claudeScope(f).skippedBy} (the Claude host is outside this run's scope)`);
422
- return true;
423
+ return { ok: true, partial: false };
423
424
  }
424
- for (const e of saved.native.equivalent) log(` ${e.member || e.name}: payload equals pin ${e.pin.slice(0, 7)} (${e.files} files); installed source ${String(e.installed).slice(0, 7)} kept and its marketplace source is unchanged, so a later native update follows that source, not the pin`);
425
+ for (const e of saved.native.equivalent) log(` ${e.member || e.name}: payload equals pin ${e.pin.slice(0, 7)} (${e.files} files); no reinstall — only its marketplace source moves to the pin catalog`);
426
+ for (const b of saved.native.blocked) log(` ${b.name}: blocked, left as installed — ${b.reason}`);
427
+ const clean = !saved.native.blocked.length;
425
428
  if (!saved.native.jobs.length) {
426
429
  // Nothing is written; still describe the state at apply time, not at preview.
427
430
  let again;
428
431
  try { again = require('../lib/claude-pinned').prepare(SKILLS, saved.inventory, { home: os.homedir(), profileRoot: claudeRoot() }); }
429
- catch (e) { log(` Claude native transaction refused before execution: ${e.message}`); return false; }
430
- if (again.fingerprint !== saved.native.fingerprint) { log(' Claude native state changed since the preview; run update again'); return false; }
431
- log(' Claude native transaction: not needed — every member already carries its pinned payload');
432
- return true;
432
+ catch (e) { log(` Claude native transaction refused before execution: ${e.message}`); return { ok: false, partial: false }; }
433
+ if (again.fingerprint !== saved.native.fingerprint) { log(' Claude native state changed since the preview; run update again'); return { ok: false, partial: false }; }
434
+ log(clean ? ' Claude native transaction: not needed — every member already carries its pinned payload'
435
+ : ' Claude native transaction: nothing else to do; blocked members above keep their installed version (partial)');
436
+ return { ok: true, partial: !clean };
433
437
  }
434
438
  // Admission, version check and all backups happen before any other channel.
435
439
  let result;
436
440
  try { result = require('../lib/claude-pin-transaction').execute(saved.native, SKILLS, saved.inventory); }
437
- catch(e) { log(` Claude native transaction refused before execution: ${e.message}`); return false; }
441
+ catch(e) { log(` Claude native transaction refused before execution: ${e.message}`); return { ok: false, partial: false }; }
438
442
  log(` Claude native transaction: ${result.ok ? 'verified immutable payloads' : 'failed; inspect recovery receipt'} — ${result.receipt}`);
439
443
  for (const r of result.results) log(` ${r.member}: ${r.status}${r.rollback ? '; rollback: ' + r.rollback : ''}`);
440
444
  if (result.error) log(` ${result.error}`);
441
- return result.ok;
445
+ if (!clean) log(' Claude: partial — blocked members above keep their installed version');
446
+ // A blocked member makes the result partial; the other channels still run.
447
+ return { ok: result.ok, partial: !clean };
442
448
  }
443
449
 
444
450
  /** The same contract for `update` — every subprocess, prune, router and runtime action. */
@@ -516,8 +522,9 @@ function renderDryRun(planned, mode) {
516
522
  function cmdInstall(f) {
517
523
  const planned = frozenOperations.get(f) || planInstall(f);
518
524
  if (f.dryRun) return renderDryRun(planned, 'install');
519
- if (!runClaudePlan(planned, f)) return false;
520
- let ok = true;
525
+ const claude = runClaudePlan(planned, f);
526
+ if (!claude.ok) return false;
527
+ let ok = !claude.partial;
521
528
  if (!f.claudeOnly) {
522
529
  const agents = skillsCliAgents(f);
523
530
  log(`\n== Installing to agents via skills CLI: ${agents.join(', ')} ==`);
@@ -590,8 +597,9 @@ function scopedRoutingTargets(f) {
590
597
  function cmdUpdate(f) {
591
598
  const planned = frozenOperations.get(f) || planUpdate(f);
592
599
  if (f.dryRun) return renderDryRun(planned, 'update');
593
- if (!runClaudePlan(planned, f)) return false;
594
- let ok = true;
600
+ const claude = runClaudePlan(planned, f);
601
+ if (!claude.ok) return false;
602
+ let ok = !claude.partial;
595
603
  // Count and execute the subprocess entries from the same frozen plan.
596
604
  progress.total =
597
605
  (!f.claudeOnly && fs.existsSync(path.join(ROOT, '.gitmodules')) ? 1 : 0)
@@ -659,6 +667,8 @@ function cmdUpdate(f) {
659
667
  + `${before.differing.length} changed`
660
668
  : ' already level with this package');
661
669
  for (const m of before.missing) log(` + ${m}`);
670
+ if (r.retired && r.retired.length) log(` retired ${r.retired.length} file(s) this package dropped (kept under ${root}/.retired/): ${r.retired.join(', ')}`);
671
+ if (r.ownedError) log(` warning: ${r.ownedError}`);
662
672
  }
663
673
  } catch (e) {
664
674
  // Other update steps may finish, but failed runtime refresh makes the aggregate red.
@@ -3260,6 +3270,8 @@ function cmdHooks(argv) {
3260
3270
  function syncRuntime() {
3261
3271
  const result = require('../lib/runtime.js').sync(pkgRoot, root, { create: true });
3262
3272
  if (result.ok === false) throw new Error(result.reason);
3273
+ if (result.retired && result.retired.length) log(` runtime: retired ${result.retired.length} dropped file(s) to ${root}/.retired/: ${result.retired.join(', ')}`);
3274
+ if (result.ownedError) log(` runtime warning: ${result.ownedError}`);
3263
3275
  return root;
3264
3276
  }
3265
3277
 
@@ -58,6 +58,12 @@ function verifyForeign(plan) {
58
58
  function verify(plan,job,row=job.row,files=row.files) {
59
59
  const registry=P.parseMetadata(fs.readFileSync(plan.registryFile,'utf8')).data;
60
60
  const records=registry.plugins?.[job.pluginInstall];
61
+ if(job.action==='rebind') {
62
+ // Only the marketplace source moved: install records stay exactly as planned and
63
+ // the installed bytes still equal the pin.
64
+ if(JSON.stringify(records)!==job.records) throw Error(job.name+': native install records changed during source rebind');
65
+ return S.verifyPayload(job.old.installPath,{files});
66
+ }
61
67
  if(!Array.isArray(records) || records.length!==1 || records[0].scope!=='user' || records[0].version!==row.version || records[0].gitCommitSha!==row.commit) throw Error(job.name+': native installed source readback differs from pin');
62
68
  const install=records[0].installPath;
63
69
  if(typeof install!=='string' || !require('./visibility-state').profilePath(install).startsWith(path.join(plan.profile,'plugins/cache')+path.sep)) throw Error('native installed root is outside expected cache');
@@ -62,7 +62,7 @@ function prepare(members,inventory,opts) {
62
62
  const transactionRoot=path.join(home,'.sshlg-skills/claude-pinned',Source.sha256(profile).slice(0,16),'transactions');
63
63
  const active=read(path.join(transactionRoot,'latest.json'),'null').data;
64
64
  if(fs.existsSync(path.join(transactionRoot,'lock')) || (active && (active.status==='running' || active.status==='failed' && (!active.settingsRestored || active.uncertain)))) throw Error('incomplete Claude transaction; inspect '+path.join(transactionRoot,'latest.json'));
65
- const jobs=[],equivalent=[];
65
+ const jobs=[],equivalent=[],blocked=[];
66
66
  for(const row of rows) {
67
67
  const marketplace=row.pluginInstall.split('@')[1],dir=path.join(home,'.sshlg-skills/claude-pinned',Source.sha256(profile).slice(0,16),marketplace);
68
68
  const declaration=settings.data.extraKnownMarketplaces?.[marketplace];
@@ -73,17 +73,28 @@ function prepare(members,inventory,opts) {
73
73
  // (docs-only) source commit or extra project/local scopes need no native mutation,
74
74
  // and a reinstall would rewrite a payload directory live sessions are using.
75
75
  const user=Array.isArray(existing)?existing.filter(r=>r&&r.scope==='user'):[];
76
+ // B-191: such a member still gets the hub catalog and its marketplace source, so a
77
+ // later native update follows the pin; `rebind` never installs or uninstalls.
78
+ let rebind=null;
76
79
  if(user.length===1 && user[0].version===row.version && typeof user[0].installPath==='string'
77
80
  && !(existing.length===1 && user[0].gitCommitSha===row.commit)
78
81
  && Bindings.profilePath(user[0].installPath).startsWith(path.join(profile,'plugins/cache')+path.sep)
79
82
  && existing.every(r=>r && r.installPath===user[0].installPath && r.version===row.version)) {
80
83
  let equal=false;try {equal=Source.verifyPayload(user[0].installPath,row)>=1;}catch(_) {}
81
- if(equal) {equivalent.push({name:row.name,pluginInstall:row.pluginInstall,version:row.version,pin:row.commit,installed:user[0].gitCommitSha||null,scopes:existing.length,files:row.files.length});continue;}
84
+ if(equal) {
85
+ equivalent.push({name:row.name,pluginInstall:row.pluginInstall,version:row.version,pin:row.commit,installed:user[0].gitCommitSha||null,scopes:existing.length,files:row.files.length});
86
+ rebind=user[0];
87
+ }
82
88
  }
83
- if(existing!==undefined && (!Array.isArray(existing) || existing.length!==1 || existing[0]?.scope!=='user')) throw Error(row.name+': native immutable repair supports one user-scope install only');
89
+ if(!rebind && existing!==undefined && (!Array.isArray(existing) || !existing.length || existing.some(r=>!plain(r)) || existing.filter(r=>r.scope==='user').length>1)) throw Error(row.name+': malformed native install record');
90
+ // A supported operator configuration this contract cannot change blocks only its own
91
+ // member; the run continues for the others and reports partial.
92
+ const block=reason=>{blocked.push({name:row.name,pluginInstall:row.pluginInstall,reason});};
93
+ if(!rebind && existing!==undefined && (existing.length!==1 || existing[0].scope!=='user')) {block('native immutable repair supports one user-scope install only (installed scopes: '+existing.map(r=>r.scope).join(', ')+')');continue;}
84
94
  for(const id of Object.keys(registry.data.plugins)) if(id.endsWith('@'+marketplace) && id!==row.pluginInstall) throw Error(row.name+': marketplace has another installed plugin');
85
95
  let action='install',old=null,oldFiles=null,repairEligible=false;
86
- if(existing) {
96
+ if(rebind) {action='rebind';old=rebind;}
97
+ else if(existing) {
87
98
  old=existing[0];
88
99
  if(typeof old.installPath!=='string' || !path.isAbsolute(old.installPath) || typeof old.version!=='string') throw Error(row.name+': invalid installed plugin metadata');
89
100
  const cacheRoot=path.join(profile,'plugins/cache')+path.sep;
@@ -97,9 +108,9 @@ function prepare(members,inventory,opts) {
97
108
  if(action==='reinstall' && !/^[a-f0-9]{40}$/.test(old.gitCommitSha||'')) throw Error(row.name+': repair requires prior immutable Git identity');
98
109
  if(action==='reinstall' && (oldFiles.length!==row.files.length || oldFiles.some(f=>!row.files.some(p=>p.path===f.path)))) throw Error(row.name+': unknown same-version payload files; preserve before repair');
99
110
  repairEligible=!(row.configSurface || configured(manifest.data) || oldFiles.some(f=>/\.(mcpb|dxt)$/i.test(f.path)) || Object.prototype.hasOwnProperty.call(settings.data.pluginConfigs||{},row.pluginInstall));
100
- if(action==='reinstall' && !repairEligible) throw Error(row.name+': configured plugin repair is unsupported; options/data preserved');
111
+ if(action==='reinstall' && !repairEligible) {block('configured plugin repair is unsupported; options/data preserved');continue;}
101
112
  }
102
- if(row.configSurface && action==='install') throw Error(row.name+': configured native install is outside the verified contract');
113
+ if(row.configSurface && action==='install') {block('configured native install is outside the verified contract');continue;}
103
114
  const file=path.join(dir,'.claude-plugin/marketplace.json'),catalog=JSON.stringify(Source.catalog(row),null,2)+'\n';
104
115
  // An existing hub-owned catalog must still belong to this exact member.
105
116
  if(!Bindings.profilePath(file).startsWith(home+path.sep)) throw Error(row.name+': managed catalog escapes home');
@@ -108,9 +119,9 @@ function prepare(members,inventory,opts) {
108
119
  const p=prior.data;
109
120
  if(!plain(p) || p.name!==marketplace || !Array.isArray(p.plugins) || p.plugins.length!==1 || p.plugins[0].name!==row.pluginInstall.split('@')[0] || p.plugins[0].source?.source!=='git-subdir' || p.plugins[0].source.url!=='https://github.com/'+row.repo+'.git') throw Error(row.name+': existing catalog is not owned by this member');
110
121
  }
111
- jobs.push({name:row.name,pluginInstall:row.pluginInstall,marketplace,dir,file,catalog,action,row,old,oldFiles,repairEligible});
122
+ jobs.push({name:row.name,pluginInstall:row.pluginInstall,marketplace,dir,file,catalog,action,row,old,oldFiles,repairEligible,records:rebind?JSON.stringify(existing):null});
112
123
  }
113
- const fingerprint=Source.sha256(JSON.stringify({equivalent,settings:settings.text,registry:registry.text,known:known.text,catalogs:jobs.map(j=>({text:read(j.file,'null').text,action:j.action,oldFiles:j.oldFiles})),inventory:rows,profile,home,bindings:[settingsFile,registryFile,knownFile,...jobs.map(j=>j.file)].map(Bindings.profilePath)}));
114
- return {fingerprint,requested,profile,home,settingsFile,registryFile,knownFile,settings,registry,known,jobs,equivalent,transactionRoot};
124
+ const fingerprint=Source.sha256(JSON.stringify({equivalent,blocked,settings:settings.text,registry:registry.text,known:known.text,catalogs:jobs.map(j=>({text:read(j.file,'null').text,action:j.action,oldFiles:j.oldFiles})),inventory:rows,profile,home,bindings:[settingsFile,registryFile,knownFile,...jobs.map(j=>j.file)].map(Bindings.profilePath)}));
125
+ return {fingerprint,requested,profile,home,settingsFile,registryFile,knownFile,settings,registry,known,jobs,equivalent,blocked,transactionRoot};
115
126
  }
116
127
  module.exports={prepare,editAt,ownedSource,configured,parseMetadata};
package/lib/json-spans.js CHANGED
@@ -80,7 +80,7 @@ function edit(text, object, key, value) {
80
80
  const multiline = /[\r\n]/.test(prefix);
81
81
  const indent = multiline ? prefix.slice(prefix.lastIndexOf('\n') + 1) : '';
82
82
  const colon = first ? text.slice(first.keyEnd, first.value.start) : ': ';
83
- const encoded = multiline ? JSON.stringify(value, null, 2).replace(/\n/g, newline + indent) : JSON.stringify(value);
83
+ const encoded = multiline ? JSON.stringify(value, null, indent.includes('\t') ? '\t' : 2).replace(/\n/g, newline + indent) : JSON.stringify(value);
84
84
  const member = JSON.stringify(key) + colon + encoded;
85
85
  if (ps.length) return splice(ps[ps.length - 1].end, ps[ps.length - 1].end, ',' + (multiline ? newline + indent : '') + member);
86
86
  // Empty object has no foreign property bytes to alter. In a multi-line document an
package/lib/runtime.js CHANGED
@@ -119,6 +119,49 @@ function managedFiles(pkgRoot) {
119
119
  const crypto = require('crypto');
120
120
  const JOURNAL = '.switch-journal.json';
121
121
  const PREVIOUS = '.prev-generation';
122
+ const OWNED = '.managed.json';
123
+ const RETIRED = '.retired';
124
+
125
+ // B-193: the runtime records which bytes the hub itself installed. A later package
126
+ // that drops a module retires it to `.retired/<stamp>/` only when the file still has
127
+ // exactly those bytes; edited, foreign or unrecorded files stay. Never a deletion.
128
+ function readOwned(root) {
129
+ try {
130
+ const d = JSON.parse(fs.readFileSync(path.join(root, OWNED), 'utf8'));
131
+ if (!d || d.version !== 1 || !d.files || typeof d.files !== 'object' || Array.isArray(d.files)) return null;
132
+ for (const p of Object.values(d.files)) if (!validPrint(p)) return null;
133
+ return d;
134
+ } catch (_) { return null; }
135
+ }
136
+ function settle(runtimeRoot, rels, result) {
137
+ try {
138
+ const retired = [], old = readOwned(runtimeRoot), keep = new Set(rels);
139
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
140
+ for (const [rel, print] of Object.entries(old ? old.files : {})) {
141
+ // A hook script may still be wired in a host's settings: only library modules retire.
142
+ if (keep.has(rel) || !rel.startsWith('lib/')) continue;
143
+ let file, now;
144
+ try { file = managedPath(runtimeRoot, rel); now = fingerprint(file); } catch (_) { continue; }
145
+ if (!now || !same(now, print)) continue;
146
+ const dst = path.join(runtimeRoot, RETIRED, stamp, rel);
147
+ directory(path.join(runtimeRoot, RETIRED)); directory(path.join(runtimeRoot, RETIRED, stamp));
148
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
149
+ for (let d = path.dirname(dst); d !== path.join(runtimeRoot, RETIRED); d = path.dirname(d)) directory(d);
150
+ fs.renameSync(file, dst);
151
+ retired.push(rel);
152
+ }
153
+ const files = {};
154
+ for (const rel of rels) { const p = fingerprint(managedPath(runtimeRoot, rel)); if (p) files[rel] = p; }
155
+ const pending = path.join(runtimeRoot, `${OWNED}.${process.pid}.tmp`);
156
+ // Leftovers of a crashed writer (any pid) are ours to clear: we hold the runtime lock.
157
+ for (const n of fs.readdirSync(runtimeRoot)) if (n.startsWith(`${OWNED}.`) && n.endsWith('.tmp')) { try { fs.unlinkSync(path.join(runtimeRoot, n)); } catch (_) { /* gone */ } }
158
+ durable(pending, JSON.stringify({ version: 1, files }) + '\n');
159
+ fs.renameSync(pending, path.join(runtimeRoot, OWNED));
160
+ return { ...result, retired: retired.sort() };
161
+ } catch (e) {
162
+ return { ...result, retired: [], ownedError: `runtime ownership record not updated: ${e.message}` };
163
+ }
164
+ }
122
165
 
123
166
  function stat(file) {
124
167
  try { return fs.lstatSync(file); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
@@ -250,7 +293,7 @@ function syncLocked(pkgRoot, runtimeRoot, exists) {
250
293
  if (!rels.length) return failure('package has no managed runtime files', 'not-needed');
251
294
  const rows = rels.map(rel => ({ rel, before: fingerprint(managedPath(runtimeRoot, rel)) }));
252
295
  if (exists && rows.every(row => same(row.before, fingerprint(managedPath(pkgRoot, row.rel))))) {
253
- return { ok: true, status: 'unchanged', created: false, copied: [], reason: null };
296
+ return settle(runtimeRoot, rels, { ok: true, status: 'unchanged', created: false, copied: [], reason: null });
254
297
  }
255
298
  const staging = fs.mkdtempSync(path.join(runtimeRoot, '.staging-'));
256
299
  const prev = path.join(runtimeRoot, PREVIOUS);
@@ -298,7 +341,7 @@ function syncLocked(pkgRoot, runtimeRoot, exists) {
298
341
  fs.unlinkSync(journal);
299
342
  journaled = false;
300
343
  try { fs.rmSync(staging, { recursive: true, force: true }); } catch (_) { /* committed, inert staging */ }
301
- return { ok: true, status: 'synced', created: !exists, copied: rels, reason: null };
344
+ return settle(runtimeRoot, rels, { ok: true, status: 'synced', created: !exists, copied: rels, reason: null });
302
345
  } catch (e) {
303
346
  if (journaled) {
304
347
  const rolled = recoverUnlocked(runtimeRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sshlg-skills",
3
- "version": "1.63.2",
3
+ "version": "1.63.3",
4
4
  "skillsCli": "skills@1.7.2",
5
5
  "description": "The ssheleg harness skill layer: routing, delivery gates, specialist skills and durable handoffs for coding agents.",
6
6
  "bin": {