hypomnema 1.6.2 → 1.7.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.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
package/scripts/query.mjs CHANGED
@@ -18,8 +18,9 @@
18
18
 
19
19
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
20
20
  import { join, relative, extname } from 'path';
21
- import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
+ import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
22
22
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
23
+ import { currentDevice, scopeVisible, readVisibilityScope } from '../hooks/hypo-shared.mjs';
23
24
 
24
25
  // ── arg parsing ──────────────────────────────────────────────────────────────
25
26
 
@@ -31,7 +32,11 @@ function parseArgs(argv) {
31
32
  else if (arg.startsWith('--limit=')) args.limit = parseInt(arg.slice(8), 10) || 10;
32
33
  else if (arg === '--json') args.json = true;
33
34
  }
34
- if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
35
+ if (!args.hypoDir) {
36
+ const info = resolveHypoRootInfo();
37
+ args.hypoDir = info.root;
38
+ args.hypoDirSource = info.source;
39
+ }
35
40
  return args;
36
41
  }
37
42
 
@@ -95,12 +100,19 @@ if (!args.query) {
95
100
  process.exit(1);
96
101
  }
97
102
 
103
+ // Only validate the auto-resolved path (env/marker/default). An explicit
104
+ // --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
105
+ const vaultMissing = args.hypoDirSource
106
+ ? checkVaultOrExit(args.hypoDir, args.hypoDirSource)
107
+ : false;
108
+
98
109
  const terms = args.query.toLowerCase().split(/\s+/).filter(Boolean);
99
110
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
100
111
  const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
101
112
  const files = scanDirs.flatMap((d) => collectMdFiles(d, args.hypoDir, [], ignorePatterns));
102
113
 
103
114
  const results = [];
115
+ const device = currentDevice();
104
116
 
105
117
  for (const { path, rel } of files) {
106
118
  let content;
@@ -112,6 +124,7 @@ for (const { path, rel } of files) {
112
124
  const { score, excerpt } = scoreAndExcerpt(content, terms);
113
125
  if (score === 0) continue;
114
126
  const fm = parseFrontmatter(content);
127
+ if (!scopeVisible(readVisibilityScope(content), device)) continue;
115
128
  results.push({
116
129
  slug: rel.replace(/\.md$/, ''),
117
130
  title: fm.title || rel,
@@ -128,8 +141,16 @@ if (args.json) {
128
141
  console.log(JSON.stringify(top, null, 2));
129
142
  } else {
130
143
  if (top.length === 0) {
131
- console.log(`No results for: ${args.query}`);
132
- console.log(`→ 관련 페이지가 없습니다. 지식이 있다면 /hypo:ingest 추가해 보세요.`);
144
+ // vaultMissing: nothing was actually scanned (no HYPO_DIR / no vault
145
+ // found). Both "No results for: …" and the "관련 페이지가 없습니다" CTA would
146
+ // misreport real knowledge as absent, so suppress the whole no-results
147
+ // block. The stderr notice from checkVaultOrExit already told the user why
148
+ // this run found nothing, and it did not search a vault to invite them to
149
+ // /hypo:ingest into one that may already hold the answer.
150
+ if (!vaultMissing) {
151
+ console.log(`No results for: ${args.query}`);
152
+ console.log(`→ 관련 페이지가 없습니다. 새 지식이 있다면 /hypo:ingest 로 추가해 보세요.`);
153
+ }
133
154
  } else {
134
155
  console.log(`Found ${results.length} result(s) for "${args.query}" (showing ${top.length}):\n`);
135
156
  for (const r of top) {
@@ -30,6 +30,7 @@ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'f
30
30
  import { join } from 'path';
31
31
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
32
32
  import { pickProjectByCwd, collectProjectWorkingDirs } from './lib/wd-match.mjs';
33
+ import { currentDevice, scopeVisible, readVisibilityScope } from '../hooks/hypo-shared.mjs';
33
34
 
34
35
  // ── arg parsing ──────────────────────────────────────────────────────────────
35
36
 
@@ -189,16 +190,27 @@ function resolveActiveProject(hypoDir, cwd = null) {
189
190
 
190
191
  // ── read session state ────────────────────────────────────────────────────────
191
192
 
192
- function readSessionState(hypoDir, project) {
193
- const ssPath = join(hypoDir, 'projects', project, 'session-state.md');
194
- if (!existsSync(ssPath)) return null;
195
- return readFileSync(ssPath, 'utf-8');
193
+ // Visibility guard: same contract as hypo-file-watch / hypo-session-start /
194
+ // hypo-cwd-change. A machine-scoped page (visibility_scope: machine:<owner>) is
195
+ // not surfaced on any machine but its owner, and /hypo:resume feeds its stdout
196
+ // straight into the model, so it is one of those injection paths.
197
+ //
198
+ // `hidden` is kept distinct from "file absent" on purpose: reporting a scoped-out
199
+ // state file as "no session-state.md found" would read as a broken vault. The
200
+ // caller says which one it was.
201
+ function readVisible(path, device) {
202
+ if (!existsSync(path)) return { content: null, hidden: false };
203
+ const raw = readFileSync(path, 'utf-8');
204
+ if (!scopeVisible(readVisibilityScope(raw), device)) return { content: null, hidden: true };
205
+ return { content: raw, hidden: false };
196
206
  }
197
207
 
198
- function readHot(hypoDir, project) {
199
- const hotPath = join(hypoDir, 'projects', project, 'hot.md');
200
- if (!existsSync(hotPath)) return null;
201
- return readFileSync(hotPath, 'utf-8');
208
+ function readSessionState(hypoDir, project, device) {
209
+ return readVisible(join(hypoDir, 'projects', project, 'session-state.md'), device);
210
+ }
211
+
212
+ function readHot(hypoDir, project, device) {
213
+ return readVisible(join(hypoDir, 'projects', project, 'hot.md'), device);
202
214
  }
203
215
 
204
216
  // ── main ─────────────────────────────────────────────────────────────────────
@@ -212,13 +224,23 @@ if (!project) {
212
224
  process.exit(1);
213
225
  }
214
226
 
215
- const sessionState = readSessionState(args.hypoDir, project);
216
- if (!sessionState) {
217
- console.error(`Error: no session-state.md found for project "${project}"`);
227
+ const device = currentDevice();
228
+
229
+ const state = readSessionState(args.hypoDir, project, device);
230
+ if (!state.content) {
231
+ if (state.hidden) {
232
+ console.error(
233
+ `Error: session-state.md for "${project}" is scoped to another machine ` +
234
+ `(visibility_scope). Nothing to resume on this machine (${device}).`,
235
+ );
236
+ } else {
237
+ console.error(`Error: no session-state.md found for project "${project}"`);
238
+ }
218
239
  process.exit(1);
219
240
  }
241
+ const sessionState = state.content;
220
242
 
221
- const hotContent = readHot(args.hypoDir, project);
243
+ const hotContent = readHot(args.hypoDir, project, device).content;
222
244
 
223
245
  if (args.json) {
224
246
  console.log(JSON.stringify({ project, sessionState, hot: hotContent }, null, 2));
package/scripts/stats.mjs CHANGED
@@ -15,8 +15,8 @@
15
15
 
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
17
17
  import { join, extname } from 'path';
18
- import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
19
- import { loadHypoIgnore, isIgnored, isScanIgnored } from './lib/hypo-ignore.mjs';
18
+ import { resolveHypoRootInfo, checkVaultOrExit, expandHome } from './lib/hypo-root.mjs';
19
+ import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
20
20
 
21
21
  // ── arg parsing ──────────────────────────────────────────────────────────────
22
22
 
@@ -26,7 +26,11 @@ function parseArgs(argv) {
26
26
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
27
27
  else if (arg === '--json') args.json = true;
28
28
  }
29
- if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
29
+ if (!args.hypoDir) {
30
+ const info = resolveHypoRootInfo();
31
+ args.hypoDir = info.root;
32
+ args.hypoDirSource = info.source;
33
+ }
30
34
  return args;
31
35
  }
32
36
 
@@ -69,11 +73,15 @@ function parseFrontmatter(content) {
69
73
  return fm;
70
74
  }
71
75
 
72
- function listDirs(dir) {
76
+ // hypoDir/ignorePatterns are optional so callers that don't need scan-ignore
77
+ // filtering (none left in this file) still work unchanged.
78
+ function listDirs(dir, hypoDir = '', ignorePatterns = []) {
73
79
  if (!existsSync(dir)) return [];
74
80
  return readdirSync(dir).filter((e) => {
75
81
  if (e.startsWith('.')) return false;
76
- return statSync(join(dir, e)).isDirectory();
82
+ const full = join(dir, e);
83
+ if (hypoDir && isScanIgnored(full, hypoDir, ignorePatterns)) return false;
84
+ return statSync(full).isDirectory();
77
85
  });
78
86
  }
79
87
 
@@ -88,15 +96,24 @@ function getLastActivity(hypoDir) {
88
96
  // ── main ─────────────────────────────────────────────────────────────────────
89
97
 
90
98
  const args = parseArgs(process.argv);
99
+ // Only validate the auto-resolved path (env/marker/default). An explicit
100
+ // --hypo-dir=<path> (tests, other tooling) is trusted as-is, valid or not.
101
+ if (args.hypoDirSource) checkVaultOrExit(args.hypoDir, args.hypoDirSource);
91
102
 
92
103
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
93
104
  const pageFiles = collectMdFiles(join(args.hypoDir, 'pages'), [], args.hypoDir, ignorePatterns);
94
- const projects = listDirs(join(args.hypoDir, 'projects'));
105
+ // isScanIgnored (via listDirs' hypoDir arg): a .hyposcanignore-listed project
106
+ // dir drops out of the project count too, same rationale as sources below.
107
+ const projects = listDirs(join(args.hypoDir, 'projects'), args.hypoDir, ignorePatterns);
108
+ // isScanIgnored (not isIgnored): sources counted here feed a scan-aggregation
109
+ // report, not the commit/hook privacy gate, so a .hyposcanignore-only match
110
+ // should drop out of the count too. Privacy semantics are preserved — an
111
+ // .hypoignore match is still excluded, isScanIgnored only ever widens that.
95
112
  const sources = existsSync(join(args.hypoDir, 'sources'))
96
113
  ? readdirSync(join(args.hypoDir, 'sources')).filter(
97
114
  (e) =>
98
115
  !e.startsWith('.') &&
99
- !isIgnored(join(args.hypoDir, 'sources', e), args.hypoDir, ignorePatterns),
116
+ !isScanIgnored(join(args.hypoDir, 'sources', e), args.hypoDir, ignorePatterns),
100
117
  )
101
118
  : [];
102
119
 
@@ -127,7 +144,10 @@ let adrCount = 0;
127
144
  for (const p of projects) {
128
145
  const decisionsDir = join(args.hypoDir, 'projects', p, 'decisions');
129
146
  if (existsSync(decisionsDir)) {
130
- adrCount += readdirSync(decisionsDir).filter((f) => f.endsWith('.md')).length;
147
+ adrCount += readdirSync(decisionsDir).filter(
148
+ (f) =>
149
+ f.endsWith('.md') && !isScanIgnored(join(decisionsDir, f), args.hypoDir, ignorePatterns),
150
+ ).length;
131
151
  }
132
152
  }
133
153
 
@@ -39,6 +39,12 @@ import {
39
39
  CODEX_TYPES,
40
40
  readExtensionPkgStateNoMutate,
41
41
  parseExtKey,
42
+ parseSkillKey,
43
+ parseSkillShaValue,
44
+ isFlatShaValue,
45
+ emptyShaMap,
46
+ isContainedUnder,
47
+ hasSymlinkAncestor,
42
48
  buildHookCommand,
43
49
  } from './lib/extensions.mjs';
44
50
 
@@ -105,8 +111,8 @@ function removeCommands(apply, force) {
105
111
  // map empties out, drop the target key; when the whole `extensions` object empties,
106
112
  // drop it too. Other targets' records (e.g. codex map during a Claude-only uninstall)
107
113
  // are never touched.
108
- function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply) {
109
- if (!existsSync(pkgPath) || removedKeys.length === 0) return false;
114
+ function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply, rewrittenKeys = new Map()) {
115
+ if (!existsSync(pkgPath) || (removedKeys.length === 0 && rewrittenKeys.size === 0)) return false;
110
116
  let pkg;
111
117
  try {
112
118
  pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
@@ -126,6 +132,15 @@ function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply) {
126
132
  changed = true;
127
133
  }
128
134
  }
135
+ // A partially-uninstalled skill keeps only the files it still owns. Leaving the
136
+ // removed paths in the map would let a later --force run delete whatever the user
137
+ // has since put back at those paths.
138
+ for (const [key, nested] of rewrittenKeys) {
139
+ if (key in perTarget) {
140
+ perTarget[key] = nested;
141
+ changed = true;
142
+ }
143
+ }
129
144
  if (Object.keys(perTarget).length === 0) {
130
145
  delete extensions[target];
131
146
  changed = true;
@@ -157,6 +172,46 @@ function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply) {
157
172
  // file whose on-disk SHA differs from the recorded one is user-modified and
158
173
  // preserved unless --force-extensions; a symlink/non-regular target is always
159
174
  // preserved (force does not follow them, matching install/upgrade E3's guard).
175
+ // Remove the directories a skill removal emptied, deepest first, and finally the
176
+ // skill root itself. Anything still holding files (user-added, or copies we
177
+ // refused to remove) is left standing — rmdir on a non-empty directory throws and
178
+ // we swallow it, which is exactly the "never destroy what we don't own" behavior.
179
+ //
180
+ // Every rmdir is guarded by the same containment + symlink-ancestor walk the file
181
+ // removals use. Without it, a corrupt `"skills/x": {}` record plus a symlinked
182
+ // skill dir was enough to rmdir empty directories anywhere on disk: the record
183
+ // granted no file ownership, yet the prune still ran (codex pre-commit BLOCKER).
184
+ function pruneSkillDirs(skillsRoot, skillRoot) {
185
+ if (hasSymlinkAncestor(skillsRoot, skillRoot)) return;
186
+ const dirs = [];
187
+ const collect = (dir, depth) => {
188
+ if (depth > 16) return;
189
+ let entries;
190
+ try {
191
+ entries = readdirSync(dir, { withFileTypes: true });
192
+ } catch {
193
+ return;
194
+ }
195
+ for (const e of entries) {
196
+ if (!e.isDirectory()) continue; // isDirectory() is false for a symlink here
197
+ const full = join(dir, e.name);
198
+ collect(full, depth + 1);
199
+ dirs.push(full);
200
+ }
201
+ };
202
+ collect(skillRoot, 1);
203
+ dirs.push(skillRoot);
204
+ for (const dir of dirs) {
205
+ if (!isContainedUnder(skillsRoot, dir)) continue;
206
+ if (hasSymlinkAncestor(skillsRoot, dir)) continue;
207
+ try {
208
+ rmdirSync(dir);
209
+ } catch {
210
+ // not empty (or gone) — leave it
211
+ }
212
+ }
213
+ }
214
+
160
215
  function removeExtensions(target, apply, force) {
161
216
  const targetRoot = target === 'codex' ? join(HOME, '.codex') : join(HOME, '.claude');
162
217
  const types = target === 'codex' ? CODEX_TYPES : EXT_TYPES;
@@ -167,11 +222,77 @@ function removeExtensions(target, apply, force) {
167
222
 
168
223
  const removed = [];
169
224
  const removedKeys = [];
225
+ // Skill keys that survive in reduced form: key → the nested map of files we still
226
+ // own after this run.
227
+ const rewrittenKeys = new Map();
170
228
  const skippedUserModified = [];
171
229
  const skippedNonRegular = [];
172
230
 
173
231
  for (const [key, recordedSHA] of Object.entries(recorded)) {
174
232
  if (!recordedSHA) continue; // no ownership baseline → nothing to remove
233
+
234
+ // A directory skill records one key (`skills/<name>`) whose value is a map of
235
+ // per-file SHAs. parseExtKey rejects that key by design (it demands the type's
236
+ // file extension), so without this branch every installed skill file would be
237
+ // silently stranded: owned on paper, unreachable by uninstall.
238
+ const skillKey = types.includes('skills') ? parseSkillKey(key) : null;
239
+ if (skillKey) {
240
+ const nested = parseSkillShaValue(recordedSHA);
241
+ // A corrupt value grants no ownership: remove nothing, and do NOT prune —
242
+ // pruning on an empty/invalid record is destructive power the record never
243
+ // earned.
244
+ if (!nested || Object.keys(nested).length === 0) continue;
245
+ const skillsRoot = join(targetRoot, 'skills');
246
+ const skillRoot = join(skillsRoot, skillKey.installDir);
247
+ // What the record must still claim after this run. A file we removed drops
248
+ // out; a file we preserved stays. Keeping a removed path in the record is not
249
+ // cosmetic: a later --force run deletes whatever now sits at that path,
250
+ // including a brand-new file the user created there (codex fix-verify BLOCKER).
251
+ const remaining = emptyShaMap();
252
+ let removedAny = false;
253
+ const preserve = (rel, sha, path, bucket) => {
254
+ remaining[rel] = sha;
255
+ bucket.push(path);
256
+ };
257
+ for (const [rel, sha] of Object.entries(nested)) {
258
+ const fullPath = join(skillRoot, ...rel.split('/'));
259
+ // Same containment + symlink-ancestor guard the sync path uses: a lexical
260
+ // check alone cannot see a symlinked directory in the middle of the path.
261
+ if (!isContainedUnder(skillRoot, fullPath) || hasSymlinkAncestor(skillsRoot, fullPath)) {
262
+ preserve(rel, sha, fullPath, skippedNonRegular);
263
+ continue;
264
+ }
265
+ if (!existsSync(fullPath)) continue; // already gone: the claim goes too
266
+ if (!isRegularFile(fullPath)) {
267
+ preserve(rel, sha, fullPath, skippedNonRegular);
268
+ continue;
269
+ }
270
+ const buf = readFileIfRegular(fullPath);
271
+ const fileSha = buf ? sha256(buf) : null;
272
+ if (fileSha === sha || force) {
273
+ if (apply) rmSync(fullPath);
274
+ removed.push(fullPath);
275
+ removedAny = true;
276
+ } else {
277
+ preserve(rel, sha, fullPath, skippedUserModified);
278
+ }
279
+ }
280
+ // Prune the directories we emptied, deepest first. A skill dir still holding
281
+ // user files (or files we refused to remove) is left in place.
282
+ if (apply && removedAny) pruneSkillDirs(skillsRoot, skillRoot);
283
+ // Retire the key outright only when nothing is left to claim. Otherwise rewrite
284
+ // it down to just the preserved files: dropping the whole key would disown them
285
+ // forever, and keeping the whole key would leave stale claims on paths we no
286
+ // longer own.
287
+ if (Object.keys(remaining).length === 0) removedKeys.push(key);
288
+ else rewrittenKeys.set(key, remaining);
289
+ continue;
290
+ }
291
+
292
+ // A flat key's value must be a plain hex SHA. Anything else (an object parked
293
+ // there by a corrupt pkg-json) grants no ownership — otherwise --force would
294
+ // remove the file on the strength of a value we never wrote.
295
+ if (!isFlatShaValue(recordedSHA)) continue;
175
296
  const parsed = parseExtKey(key, types);
176
297
  if (!parsed) continue; // untrusted / cross-target key → never join+rm
177
298
  const fullPath = join(targetRoot, parsed.type, parsed.installFile);
@@ -193,7 +314,7 @@ function removeExtensions(target, apply, force) {
193
314
  skippedUserModified.push(fullPath);
194
315
  }
195
316
  }
196
- return { target, removed, removedKeys, skippedUserModified, skippedNonRegular };
317
+ return { target, removed, removedKeys, rewrittenKeys, skippedUserModified, skippedNonRegular };
197
318
  }
198
319
 
199
320
  // True iff `pkg.json` still holds extensions state for a target the current
@@ -478,6 +599,7 @@ let codexExtResult = {
478
599
  target: 'codex',
479
600
  removed: [],
480
601
  removedKeys: [],
602
+ rewrittenKeys: new Map(),
481
603
  skippedUserModified: [],
482
604
  skippedNonRegular: [],
483
605
  };
@@ -497,10 +619,23 @@ if (args.codex) {
497
619
  );
498
620
  }
499
621
 
500
- // Surgical per-target ext SHA strip — only for files we actually removed.
501
- stripExtensionsFromPkg(pkgJsonPath, 'claude', claudeExtResult.removedKeys, args.apply);
622
+ // Surgical per-target ext SHA strip — only for files we actually removed. A skill
623
+ // we uninstalled only partway is rewritten down to the files we still own.
624
+ stripExtensionsFromPkg(
625
+ pkgJsonPath,
626
+ 'claude',
627
+ claudeExtResult.removedKeys,
628
+ args.apply,
629
+ claudeExtResult.rewrittenKeys,
630
+ );
502
631
  if (args.codex) {
503
- stripExtensionsFromPkg(pkgJsonPath, 'codex', codexExtResult.removedKeys, args.apply);
632
+ stripExtensionsFromPkg(
633
+ pkgJsonPath,
634
+ 'codex',
635
+ codexExtResult.removedKeys,
636
+ args.apply,
637
+ codexExtResult.rewrittenKeys,
638
+ );
504
639
  }
505
640
 
506
641
  // pkg.json metadata file removal — only when no user-tracked state remains.