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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- 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 {
|
|
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)
|
|
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
|
-
|
|
132
|
-
|
|
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) {
|
package/scripts/resume.mjs
CHANGED
|
@@ -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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
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 {
|
|
19
|
-
import { loadHypoIgnore,
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
!
|
|
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(
|
|
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
|
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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.
|