sshlg-skills 1.63.0 → 1.63.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
1
+ ## v1.63.2 — the Claude transaction keeps settings in Claude Code's format
2
+
3
+ - Replacing a marketplace source in `~/.claude/settings.json` wrote the new object on one
4
+ line inside a 2-space file. The same `update` then refused to wire hooks ("not in the
5
+ 2-space JSON format Claude Code writes"). A replaced object or array now keeps the
6
+ layout of its line: pretty inside a multi-line object, compact inside a single-line one.
7
+ Inserting a source — no marketplace map yet, an empty map, a new member or an empty
8
+ member entry — writes the missing branch in the file's own layout too.
9
+ - Found by the 1.63.1 installed update on a real profile: the native transaction verified
10
+ its three members, then hook wiring degraded. Running `update` again with 1.63.2
11
+ rewrites those sources in the file's own layout and wires hooks.
12
+
13
+ ## v1.63.1 — Claude update works on hosts with live sessions
14
+
15
+ - `update` no longer refuses a Claude member whose installed payload already equals its
16
+ pin. Such a member (same version, a docs-only newer source commit, or extra
17
+ project/local scopes) is reported as payload-equivalent and left installed: no
18
+ uninstall or reinstall rewrites a payload directory that running sessions are using.
19
+ Members with different bytes keep the repair/update contract and its refusals.
20
+ - Python bytecode caches (`__pycache__/*.pyc`) written beside hook scripts are runtime
21
+ artifacts, not payload; payload readback ignores them and still refuses any other
22
+ file in such a directory.
23
+ - Known limits (B-191, B-192): a payload-equivalent member keeps its upstream marketplace
24
+ source, so a later native update follows upstream rather than the pin; and a member
25
+ installed in several scopes still blocks the Claude update once its version changes.
26
+ A `.pyc` is ignored only beside its pinned `.py`, and bytecode stays outside readback.
27
+ - Found by the installed readback of 1.63.0 on a real profile: 1.63.0's preview
28
+ refused on agent-sync's bytecode cache, then on members installed in more than one
29
+ scope. No settings or payload were changed by those refused previews.
30
+
1
31
  ## v1.63.0 — native update safety and managed instruction previews
2
32
 
3
33
  - Claude Code installation now uses exact member commits through its native plugin
@@ -407,7 +407,8 @@ 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`),
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)`)),
411
412
  journal: path.join(native.transactionRoot, 'latest.json') }));
412
413
  }
413
414
  const result = attachWaitPlan(steps, f);
@@ -420,6 +421,16 @@ function runClaudePlan(planned, f) {
420
421
  log(`\n== Updating Claude Code plugins ==\n skipped: ${claudeScope(f).skippedBy} (the Claude host is outside this run's scope)`);
421
422
  return true;
422
423
  }
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
+ if (!saved.native.jobs.length) {
426
+ // Nothing is written; still describe the state at apply time, not at preview.
427
+ let again;
428
+ 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;
433
+ }
423
434
  // Admission, version check and all backups happen before any other channel.
424
435
  let result;
425
436
  try { result = require('../lib/claude-pin-transaction').execute(saved.native, SKILLS, saved.inventory); }
@@ -34,7 +34,8 @@ function editAt(text,keys,value) {
34
34
  const parsed=J.parse(text);let node=parsed.root;
35
35
  for(let i=0;i<keys.length-1;i++) {
36
36
  let entry=node.props && node.props.find(p=>p.key===keys[i]);
37
- if(!entry) return editAt(J.edit(text,node,keys[i],{}),keys,value);
37
+ // Insert the whole missing branch in one edit so it takes the file's layout.
38
+ if(!entry) return J.edit(text,node,keys[i],keys.slice(i+1).reduceRight((v,k)=>({[k]:v}),value));
38
39
  if(!entry.value.props) throw Error('native Claude settings field is not an object');
39
40
  node=entry.value;
40
41
  }
@@ -61,13 +62,24 @@ function prepare(members,inventory,opts) {
61
62
  const transactionRoot=path.join(home,'.sshlg-skills/claude-pinned',Source.sha256(profile).slice(0,16),'transactions');
62
63
  const active=read(path.join(transactionRoot,'latest.json'),'null').data;
63
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'));
64
- const jobs=[];
65
+ const jobs=[],equivalent=[];
65
66
  for(const row of rows) {
66
67
  const marketplace=row.pluginInstall.split('@')[1],dir=path.join(home,'.sshlg-skills/claude-pinned',Source.sha256(profile).slice(0,16),marketplace);
67
68
  const declaration=settings.data.extraKnownMarketplaces?.[marketplace];
68
69
  if(declaration && (!plain(declaration) || !ownedSource(declaration.source,row,dir))) throw Error(row.name+': conflicting marketplace declaration');
69
70
  if(known.data[marketplace] && !ownedSource(known.data[marketplace].source,row,dir)) throw Error(row.name+': conflicting known marketplace source');
70
71
  const existing=registry.data.plugins[row.pluginInstall];
72
+ // Same version, pinned bytes already installed: nothing to deliver. A different
73
+ // (docs-only) source commit or extra project/local scopes need no native mutation,
74
+ // and a reinstall would rewrite a payload directory live sessions are using.
75
+ const user=Array.isArray(existing)?existing.filter(r=>r&&r.scope==='user'):[];
76
+ if(user.length===1 && user[0].version===row.version && typeof user[0].installPath==='string'
77
+ && !(existing.length===1 && user[0].gitCommitSha===row.commit)
78
+ && Bindings.profilePath(user[0].installPath).startsWith(path.join(profile,'plugins/cache')+path.sep)
79
+ && existing.every(r=>r && r.installPath===user[0].installPath && r.version===row.version)) {
80
+ 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;}
82
+ }
71
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');
72
84
  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');
73
85
  let action='install',old=null,oldFiles=null,repairEligible=false;
@@ -81,7 +93,7 @@ function prepare(members,inventory,opts) {
81
93
  if(!manifest.exists || manifest.data?.name!==row.pluginInstall.split('@')[0]) throw Error(row.name+': unknown installed manifest');
82
94
  let exact=false;try {exact=old.version===row.version && old.gitCommitSha===row.commit && Source.verifyPayload(old.installPath,row)>=1;}catch(_) {}
83
95
  action=exact?'verify':old.version===row.version?'reinstall':'update';
84
- oldFiles=Source.snapshotPayload(old.installPath);
96
+ oldFiles=Source.snapshotPayload(old.installPath,'present');
85
97
  if(action==='reinstall' && !/^[a-f0-9]{40}$/.test(old.gitCommitSha||'')) throw Error(row.name+': repair requires prior immutable Git identity');
86
98
  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');
87
99
  repairEligible=!(row.configSurface || configured(manifest.data) || oldFiles.some(f=>/\.(mcpb|dxt)$/i.test(f.path)) || Object.prototype.hasOwnProperty.call(settings.data.pluginConfigs||{},row.pluginInstall));
@@ -98,7 +110,7 @@ function prepare(members,inventory,opts) {
98
110
  }
99
111
  jobs.push({name:row.name,pluginInstall:row.pluginInstall,marketplace,dir,file,catalog,action,row,old,oldFiles,repairEligible});
100
112
  }
101
- const fingerprint=Source.sha256(JSON.stringify({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)}));
102
- return {fingerprint,requested,profile,home,settingsFile,registryFile,knownFile,settings,registry,known,jobs,transactionRoot};
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};
103
115
  }
104
116
  module.exports={prepare,editAt,ownedSource,configured,parseMetadata};
@@ -73,7 +73,13 @@ function catalog(row) {
73
73
  return {name:row.pluginInstall.split('@')[1],owner:{name:'ssheleg'},plugins:[{
74
74
  ...row.entry,name:row.pluginInstall.split('@')[0],source:{source:'git-subdir',url:'https://github.com/'+row.repo+'.git',path:row.pluginPath,sha:row.commit}}]};
75
75
  }
76
- function snapshotPayload(root) {
76
+ function snapshotPayload(root,pinned) {
77
+ // `pinned`: the set of pinned paths. A bytecode cache is ignored only for a .pyc whose
78
+ // source sits pinned beside it; it stays outside this readback either way.
79
+ // `pinned === 'present'`: an installed payload being replaced, where a .pyc may
80
+ // belong to its own (old) .py rather than to the new pin.
81
+ const hasSource=pinned==='present'?rel=>{try {return fs.lstatSync(path.join(root,rel)).isFile();} catch(_) {return false;}}
82
+ :(s=>rel=>s.has(rel))(pinned instanceof Set?pinned:new Set());
77
83
  const files=[];
78
84
  function walk(dir,rel='') {
79
85
  for(const e of fs.readdirSync(dir,{withFileTypes:true})) {
@@ -82,6 +88,15 @@ function snapshotPayload(root) {
82
88
  if(!e.isDirectory() || fs.readdirSync(full,{withFileTypes:true}).some(x=>!x.isFile() || !/^[0-9]+$/.test(x.name))) throw Error('unexpected native lease-marker shape');
83
89
  continue;
84
90
  }
91
+ // Python writes bytecode caches beside scripts that hooks run; they are
92
+ // regenerated runtime artifacts, never pinned payload. Anything else there refuses.
93
+ if(e.name==='__pycache__' && e.isDirectory()) {
94
+ for(const x of fs.readdirSync(full,{withFileTypes:true})) {
95
+ const m=/^([^.]+)\.[A-Za-z0-9_-]+(?:\.opt-\d+)?\.pyc$/.exec(x.name);
96
+ if(!x.isFile() || !m || !hasSource((rel?rel+'/':'')+m[1]+'.py')) throw Error('unexpected bytecode-cache shape: '+name+'/'+x.name);
97
+ }
98
+ continue;
99
+ }
85
100
  if(e.isDirectory()) walk(full,name);
86
101
  else if(e.isFile()) files.push(name);
87
102
  else throw Error('non-regular installed plugin payload');
@@ -92,7 +107,7 @@ function snapshotPayload(root) {
92
107
  return files.sort().map(file=>{const b=fs.readFileSync(path.join(root,file));return {path:file,sha256:sha256(b),bytes:b.length};});
93
108
  }
94
109
  function verifyPayload(root,row) {
95
- const actual=snapshotPayload(root),expected=new Map(row.files.map(f=>[f.path,f]));
110
+ const expected=new Map(row.files.map(f=>[f.path,f])),actual=snapshotPayload(root,new Set(expected.keys()));
96
111
  if(actual.length!==expected.size || actual.some(f=>!expected.has(f.path))) throw Error('installed plugin file inventory differs from pin');
97
112
  for(const f of actual) {const e=expected.get(f.path);if(f.bytes!==e.bytes || f.sha256!==e.sha256) throw Error('installed plugin bytes differ from pin: '+f.path);}
98
113
  return actual.length;
package/lib/json-spans.js CHANGED
@@ -53,7 +53,18 @@ function edit(text, object, key, value) {
53
53
  if (!object || !object.props) throw new Error('target is not an object');
54
54
  const ps = object.props, index = ps.findIndex(p => p.key === key), p = ps[index];
55
55
  const splice = (start, end, insert = '') => text.slice(0, start) + insert + text.slice(end);
56
- if (p && value !== undefined) return splice(p.value.start, p.value.end, JSON.stringify(value));
56
+ if (p && value !== undefined) {
57
+ // A replaced object or array keeps the layout of its line: pretty in a multi-line
58
+ // object (the format Claude Code itself writes), compact in a single-line one.
59
+ const nl = text.includes('\r\n') ? '\r\n' : '\n';
60
+ const lineStart = text.lastIndexOf('\n', p.start - 1) + 1;
61
+ const lead = text.slice(lineStart, p.start);
62
+ const multiline = /[\r\n]/.test(text.slice(object.start + 1, ps[0].start)) && /^[ \t]*$/.test(lead);
63
+ const unit = lead.includes('\t') ? '\t' : 2;
64
+ const encoded = multiline && value !== null && typeof value === 'object'
65
+ ? JSON.stringify(value, null, unit).replace(/\n/g, nl + lead) : JSON.stringify(value);
66
+ return splice(p.value.start, p.value.end, encoded);
67
+ }
57
68
  if (p) {
58
69
  if (ps.length === 1) {
59
70
  // Insertion into an empty object precedes its original whitespace.
@@ -72,7 +83,17 @@ function edit(text, object, key, value) {
72
83
  const encoded = multiline ? JSON.stringify(value, null, 2).replace(/\n/g, newline + indent) : JSON.stringify(value);
73
84
  const member = JSON.stringify(key) + colon + encoded;
74
85
  if (ps.length) return splice(ps[ps.length - 1].end, ps[ps.length - 1].end, ',' + (multiline ? newline + indent : '') + member);
75
- // Empty object has no foreign property bytes to alter. Keep its existing gap.
86
+ // Empty object has no foreign property bytes to alter. In a multi-line document an
87
+ // empty `{}` that starts its own line opens onto lines of its own, one level deeper,
88
+ // which is the layout Claude Code writes; otherwise its existing gap is kept.
89
+ const lineStart = text.lastIndexOf('\n', object.start - 1) + 1;
90
+ const before = text.slice(lineStart, object.start);
91
+ const opens = /\n/.test(text) && text.slice(object.start, object.end) === '{}' && /^[ \t]*(?:"(?:[^"\\]|\\.)*"[ \t]*:[ \t]*)?$/.test(before);
92
+ if (opens) {
93
+ const base = before.match(/^[ \t]*/)[0], step = base.includes('\t') ? '\t' : ' ', inner = base + step;
94
+ const pretty = JSON.stringify(key) + ': ' + JSON.stringify(value, null, step === '\t' ? '\t' : 2).replace(/\n/g, newline + inner);
95
+ return splice(object.start + 1, object.start + 1, newline + inner + pretty + newline + base);
96
+ }
76
97
  return splice(object.start + 1, object.start + 1, member);
77
98
  }
78
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sshlg-skills",
3
- "version": "1.63.0",
3
+ "version": "1.63.2",
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": {