axstack 0.9.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/bin/axstack.js +396 -0
  4. package/docs/installation.md +239 -0
  5. package/docs/workflows.md +220 -0
  6. package/package.json +40 -0
  7. package/profiles/presets/claude-only.json +194 -0
  8. package/profiles/presets/codex-only.json +194 -0
  9. package/profiles/presets/mixed.json +194 -0
  10. package/skills/axstack/SKILL.md +81 -0
  11. package/skills/axstack/references/automations.md +368 -0
  12. package/skills/axstack/references/candidate-publication.md +45 -0
  13. package/skills/axstack/references/contracts.md +102 -0
  14. package/skills/axstack/references/lifecycle.md +137 -0
  15. package/skills/axstack/references/orca-runtime.md +109 -0
  16. package/skills/axstack/references/pr-shape.md +39 -0
  17. package/skills/axstack/references/routing.md +129 -0
  18. package/skills/axstack/references/run-record.md +109 -0
  19. package/skills/axstack-align/SKILL.md +121 -0
  20. package/skills/axstack-audit/SKILL.md +137 -0
  21. package/skills/axstack-audit/references/record.md +28 -0
  22. package/skills/axstack-debug/SKILL.md +157 -0
  23. package/skills/axstack-debug/references/packet.md +80 -0
  24. package/skills/axstack-explain/SKILL.md +66 -0
  25. package/skills/axstack-explain/references/visual-qa.md +15 -0
  26. package/skills/axstack-implement/SKILL.md +164 -0
  27. package/skills/axstack-improve/SKILL.md +69 -0
  28. package/skills/axstack-relay/SKILL.md +102 -0
  29. package/skills/axstack-research/SKILL.md +57 -0
  30. package/skills/axstack-research/references/checklist.md +25 -0
  31. package/skills/axstack-review/SKILL.md +343 -0
  32. package/skills/axstack-spec/SKILL.md +67 -0
  33. package/skills/axstack-tickets/SKILL.md +86 -0
  34. package/skills/axstack-watch/SKILL.md +160 -0
  35. package/skills/axstack-watch/references/repair-publication.md +69 -0
  36. package/skills/axstack-watch/references/watch-runtime.md +60 -0
  37. package/src/capabilities.js +138 -0
  38. package/src/claude-settings.js +230 -0
  39. package/src/installer.js +980 -0
  40. package/src/instructions.js +100 -0
  41. package/src/locations.js +43 -0
  42. package/src/manifest.js +251 -0
  43. package/src/posixpath.js +108 -0
  44. package/src/roles.js +142 -0
@@ -0,0 +1,980 @@
1
+ // Safe install/uninstall bookkeeping for the Axstack skill bundle.
2
+ //
3
+ // Frozen bundle contract (from the workflow author):
4
+ // <bundle>/skills/axstack-*/SKILL.md (+ supporting files, no symlinks)
5
+ // <bundle>/profiles/presets/<preset>.json ({ version: 1, roles: [...] })
6
+ //
7
+ // Safety rules:
8
+ // - Validate the whole bundle BEFORE any target mutation.
9
+ // - Reject symlinks, absolute paths and `..` escapes in bundle entries.
10
+ // - Never overwrite unknown pre-existing files or blindly remove
11
+ // directories without explicit --force.
12
+ // - Track every installed byte in an ownership manifest (sha256); updates
13
+ // and uninstalls only touch unchanged owned assets. User edits are
14
+ // preserved and reported; pristine owned assets follow bundle upgrades
15
+ // without --force.
16
+ // - Partial failures roll back files created in that run and never write
17
+ // the manifest, so a retry starts from a known state.
18
+ import {
19
+ chmod,
20
+ lstat,
21
+ mkdir,
22
+ readdir,
23
+ readFile,
24
+ realpath,
25
+ rename,
26
+ rm,
27
+ rmdir,
28
+ stat,
29
+ } from 'node:fs/promises';
30
+ import { realpathSync } from 'node:fs';
31
+ import { basename, dirname, isAbsolute, join, relative, resolve } from './posixpath.js';
32
+ import {
33
+ MANIFEST_VERSION,
34
+ assertSafeRel,
35
+ hashContent,
36
+ readManifest,
37
+ writeFileExclusive,
38
+ writeManifest,
39
+ } from './manifest.js';
40
+ import { planInstallClaudeSettings, planUninstallClaudeSettings } from './claude-settings.js';
41
+ import {
42
+ applyInstructionPlan,
43
+ findLegacyRoutingLines,
44
+ locateInstructionBlock,
45
+ planInstruction,
46
+ renderInstructionBlock,
47
+ stripInstructionBlock,
48
+ } from './instructions.js';
49
+ import {
50
+ assertBundleRoles,
51
+ assessInstalledRoleSnapshot,
52
+ assessRoleReadiness,
53
+ installedRoleBytes,
54
+ } from './roles.js';
55
+
56
+ export const PRESET_ALIASES = Object.freeze({
57
+ mixed: 'mixed',
58
+ codex: 'codex-only',
59
+ 'codex-only': 'codex-only',
60
+ claude: 'claude-only',
61
+ 'claude-only': 'claude-only',
62
+ });
63
+ export const PRESET_CHOICES = Object.freeze(['mixed', 'codex-only', 'claude-only']);
64
+
65
+ export function normalizePreset(preset) {
66
+ const normalized = PRESET_ALIASES[preset];
67
+ if (!normalized) {
68
+ throw new Error(
69
+ `install requires --preset <${PRESET_CHOICES.join('|')}> ` +
70
+ `(aliases: codex, claude); got ${preset ?? 'nothing'}`,
71
+ );
72
+ }
73
+ return normalized;
74
+ }
75
+
76
+ function withinRoot(target, root) {
77
+ const rel = relative(root, target);
78
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
79
+ }
80
+
81
+ // Canonicalize a target directory through the nearest existing ancestor.
82
+ // A symlinked target root itself is resolved (not refused); symlinks BELOW
83
+ // the root are refused separately so bundle payloads can never escape.
84
+ async function canonicalTargetDir(dir) {
85
+ let cur = resolve(dir);
86
+ const tail = [];
87
+ for (;;) {
88
+ try {
89
+ return join(await realpath(cur), ...tail);
90
+ } catch (err) {
91
+ if (err?.code !== 'ENOENT') throw err;
92
+ const parent = dirname(cur);
93
+ if (parent === cur) throw new Error(`cannot resolve target directory: ${dir}`);
94
+ tail.unshift(basename(cur));
95
+ cur = parent;
96
+ }
97
+ }
98
+ }
99
+
100
+ // Refuse symlinks in every existing component from root (exclusive) down to
101
+ // destDir (inclusive). Called before any mutation so install and uninstall
102
+ // never write, read-for-delete, or remove through an escape link.
103
+ async function assertNoSymlinksBelow(root, destDir) {
104
+ const rel = relative(root, destDir);
105
+ if (rel === '') return;
106
+ if (rel.startsWith('..') || isAbsolute(rel)) {
107
+ throw new Error(`unsafe target: ${destDir} escapes ${root}; refusing`);
108
+ }
109
+ let cur = root;
110
+ for (const part of rel.split('/')) {
111
+ cur = join(cur, part);
112
+ let st = null;
113
+ try {
114
+ st = await lstat(cur);
115
+ } catch (err) {
116
+ if (err?.code === 'ENOENT') return; // nothing below can exist either
117
+ throw err;
118
+ }
119
+ if (st.isSymbolicLink()) {
120
+ throw new Error(`unsafe target: symlink in install path at ${cur}; refusing`);
121
+ }
122
+ }
123
+ }
124
+
125
+ // Shared ownership guard: resolve a manifest-owned rel to its destination,
126
+ // refuse escapes and symlinks, and read current bytes (null when missing)
127
+ // plus the existing mode. Read-only, so callers run it for every entry
128
+ // before mutating anything.
129
+ async function readOwnedTarget(skillsRoot, rel) {
130
+ const dest = join(skillsRoot, rel);
131
+ if (!withinRoot(dest, skillsRoot)) {
132
+ throw new Error(`unsafe target: ${rel} escapes ${skillsRoot}; refusing`);
133
+ }
134
+ await assertNoSymlinksBelow(skillsRoot, dirname(dest));
135
+ const destStat = await lstat(dest).catch((err) => {
136
+ if (err?.code === 'ENOENT') return null;
137
+ throw err;
138
+ });
139
+ if (destStat?.isSymbolicLink()) {
140
+ throw new Error(`unsafe target: destination is a symlink at ${dest}; refusing`);
141
+ }
142
+ let current = null;
143
+ try {
144
+ current = await readFile(dest);
145
+ } catch (err) {
146
+ if (err?.code !== 'ENOENT') throw err;
147
+ }
148
+ let mode = null;
149
+ if (current !== null) {
150
+ try {
151
+ mode = (await stat(dest)).mode & 0o777;
152
+ } catch (err) {
153
+ if (err?.code !== 'ENOENT') throw err;
154
+ }
155
+ }
156
+ return { dest, current, mode };
157
+ }
158
+
159
+ async function canonicalInstructionFile(instructionsPath) {
160
+ const abs = resolve(instructionsPath);
161
+ const parent = await canonicalTargetDir(dirname(abs));
162
+ const file = join(parent, basename(abs));
163
+ const st = await lstat(file).catch((err) => {
164
+ if (err?.code === 'ENOENT') return null;
165
+ throw err;
166
+ });
167
+ if (st?.isSymbolicLink()) {
168
+ throw new Error(`unsafe target: instruction path is a symlink at ${file}; refusing`);
169
+ }
170
+ return file;
171
+ }
172
+
173
+ function homeDir() {
174
+ // Bun has no homedir() API; $HOME is the POSIX source of truth. A missing,
175
+ // empty, or non-absolute HOME yields null so callers fail closed instead
176
+ // of guessing (no passwd/FFI layer). A resolvable HOME is canonicalized
177
+ // (realpath) so symlink aliases (/var -> /private/var on macOS, or any
178
+ // aliased HOME) compare equal to canonicalized targets; an unresolvable
179
+ // HOME also yields null and fails closed rather than bypassing the guard.
180
+ const home = Bun.env.HOME;
181
+ if (!home || !home.startsWith('/')) return null;
182
+ try {
183
+ return realpathSync(home);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ export function assertOutsideHome(target, { yes = false, kind = 'target' } = {}) {
190
+ const home = homeDir();
191
+ if (home === null) {
192
+ // Home boundary cannot be checked: treat every target as potentially
193
+ // inside home and require explicit confirmation.
194
+ if (!yes) {
195
+ throw new Error(
196
+ `refusing to touch ${kind} (${target}) without explicit confirmation: HOME is missing, empty, or not absolute, so the home boundary cannot be checked; re-run with --yes`,
197
+ );
198
+ }
199
+ return;
200
+ }
201
+ if (home && (target === home || withinRoot(target, home)) && !yes) {
202
+ throw new Error(
203
+ `refusing to touch ${kind} inside your home (${target}) without explicit confirmation; re-run with --yes`,
204
+ );
205
+ }
206
+ }
207
+
208
+ // Validate the bundle directory and return its payload. Throws before any
209
+ // target mutation on any problem.
210
+ export async function validateBundle(bundleDir, selectedPreset = null) {
211
+ const root = resolve(bundleDir);
212
+ let rootStat;
213
+ try {
214
+ rootStat = await lstat(root);
215
+ } catch {
216
+ throw new Error(`bundle not found: ${root}`);
217
+ }
218
+ if (rootStat.isSymbolicLink()) {
219
+ throw new Error('bundle path is a symlink; pass the real bundle directory');
220
+ }
221
+ if (!rootStat.isDirectory()) throw new Error(`bundle is not a directory: ${root}`);
222
+
223
+ const skillsRoot = join(root, 'skills');
224
+ const skillsStat = await lstat(skillsRoot).catch((err) => {
225
+ if (err?.code === 'ENOENT') throw new Error(`bundle has no skills/ directory: ${skillsRoot}`);
226
+ throw err;
227
+ });
228
+ if (skillsStat.isSymbolicLink() || !skillsStat.isDirectory()) {
229
+ throw new Error('bundle skills/ must be a real directory, not a symlink');
230
+ }
231
+ let entries = await readdir(skillsRoot, { withFileTypes: true });
232
+ // Symlinked (or non-directory) top-level skill entries must be rejected,
233
+ // not silently skipped: Dirent.isDirectory() is false for symlinks.
234
+ for (const entry of entries) {
235
+ if (!entry.name.startsWith('axstack')) continue;
236
+ const entryStat = await lstat(join(skillsRoot, entry.name));
237
+ if (entryStat.isSymbolicLink() || !entryStat.isDirectory()) {
238
+ throw new Error(
239
+ `unsafe bundle: skill entry ${entry.name} must be a real directory, not a symlink`,
240
+ );
241
+ }
242
+ }
243
+ const skillDirs = entries.filter((e) => e.isDirectory() && e.name.startsWith('axstack'));
244
+ if (skillDirs.length === 0) {
245
+ throw new Error('bundle contains no axstack-* skill directories under skills/');
246
+ }
247
+
248
+ const files = [];
249
+ for (const dir of skillDirs) {
250
+ const skillMark = join(skillsRoot, dir.name, 'SKILL.md');
251
+ try {
252
+ const s = await stat(skillMark);
253
+ if (!s.isFile()) throw new Error();
254
+ } catch {
255
+ throw new Error(`skill ${dir.name} is missing SKILL.md`);
256
+ }
257
+ await walkSkills(join(skillsRoot, dir.name), skillsRoot, files);
258
+ }
259
+ if (files.length === 0) throw new Error('bundle contains no installable skill files');
260
+
261
+ const presets = {};
262
+ const profilesRoot = join(root, 'profiles');
263
+ const profilesStat = await lstat(profilesRoot).catch((err) => {
264
+ if (err?.code === 'ENOENT') return null;
265
+ throw err;
266
+ });
267
+ if (profilesStat !== null && (profilesStat.isSymbolicLink() || !profilesStat.isDirectory())) {
268
+ throw new Error('bundle profiles must be a real directory, not a symlink');
269
+ }
270
+ const presetsRoot = join(profilesRoot, 'presets');
271
+ const presetsStat = profilesStat === null ? null : await lstat(presetsRoot).catch((err) => {
272
+ if (err?.code === 'ENOENT') return null;
273
+ throw err;
274
+ });
275
+ if (presetsStat !== null) {
276
+ if (presetsStat.isSymbolicLink() || !presetsStat.isDirectory()) {
277
+ throw new Error('bundle profiles/presets must be a real directory, not a symlink');
278
+ }
279
+ const entries = (await readdir(presetsRoot, { withFileTypes: true }))
280
+ .filter((entry) => entry.name.endsWith('.json'))
281
+ .sort((a, b) => a.name.localeCompare(b.name));
282
+ for (const entry of entries) {
283
+ const presetPath = join(presetsRoot, entry.name);
284
+ const presetStat = await lstat(presetPath);
285
+ if (presetStat.isSymbolicLink() || !presetStat.isFile()) {
286
+ throw new Error(`bundle preset ${entry.name} must be a real file, not a symlink`);
287
+ }
288
+ let parsed;
289
+ try {
290
+ parsed = JSON.parse(await readFile(presetPath, 'utf8'));
291
+ } catch {
292
+ throw new Error(`bundle preset ${entry.name} is not valid JSON`);
293
+ }
294
+ const stem = entry.name.slice(0, -'.json'.length);
295
+ if (parsed?.version !== 1) {
296
+ throw new Error(`bundle preset ${entry.name} must declare version 1`);
297
+ }
298
+ if (!Array.isArray(parsed.roles) || parsed.roles.length === 0) {
299
+ throw new Error(`bundle preset ${entry.name} must define a non-empty roles array`);
300
+ }
301
+ const keys = Object.keys(parsed).sort();
302
+ if (JSON.stringify(keys) !== JSON.stringify(['roles', 'version'])) {
303
+ throw new Error(`bundle preset ${entry.name} must contain exactly version and roles`);
304
+ }
305
+ assertBundleRoles(parsed.roles);
306
+ presets[stem] = parsed.roles;
307
+ }
308
+
309
+ const idSets = Object.entries(presets).map(([name, roles]) => [
310
+ name,
311
+ roles.map((role) => role.id).sort(),
312
+ ]);
313
+ const [reference] = idSets;
314
+ for (const [name, ids] of idSets.slice(1)) {
315
+ if (JSON.stringify(ids) !== JSON.stringify(reference[1])) {
316
+ throw new Error(
317
+ `bundle presets must expose an identical role ID set; ${reference[0]} and ${name} differ`,
318
+ );
319
+ }
320
+ }
321
+ if (selectedPreset && !(selectedPreset in presets)) {
322
+ throw new Error(`selected preset ${selectedPreset} not found in bundle profiles/presets`);
323
+ }
324
+ }
325
+
326
+ const bundleRoles = selectedPreset ? (presets[selectedPreset] ?? null) : null;
327
+ if (bundleRoles) {
328
+ if (files.some((file) => file.rel === 'axstack/roles.json')) {
329
+ throw new Error('bundle skills must not provide axstack/roles.json; it is generated from the selected preset');
330
+ }
331
+ files.push({ rel: 'axstack/roles.json', content: installedRoleBytes(selectedPreset, bundleRoles) });
332
+ files.sort((a, b) => a.rel.localeCompare(b.rel));
333
+ }
334
+
335
+ return {
336
+ root,
337
+ skillsRoot,
338
+ files,
339
+ presets,
340
+ selectedPreset,
341
+ bundleRoles,
342
+ readiness: bundleRoles ? assessRoleReadiness(bundleRoles, selectedPreset) : null,
343
+ };
344
+ }
345
+
346
+ async function walkSkills(dir, skillsRoot, out) {
347
+ const entries = await readdir(dir, { withFileTypes: true });
348
+ // Deterministic order for stable reports.
349
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
350
+ for (const entry of entries) {
351
+ const abs = join(dir, entry.name);
352
+ const lst = await lstat(abs);
353
+ if (lst.isSymbolicLink()) {
354
+ throw new Error(`unsafe bundle: symlink rejected at ${relative(skillsRoot, abs)}`);
355
+ }
356
+ if (lst.isDirectory()) {
357
+ await walkSkills(abs, skillsRoot, out);
358
+ } else if (lst.isFile()) {
359
+ const rel = relative(skillsRoot, abs);
360
+ assertSafeRel(rel);
361
+ out.push({ rel, abs });
362
+ }
363
+ }
364
+ }
365
+
366
+ // Atomic file replace with a pid-namespaced temp file. Temp creation is
367
+ // exclusive and restrictive (0600): pre-existing temp entries are refused,
368
+ // never followed or truncated. After the rename, existing permissions are
369
+ // preserved; new files take an explicit `mode` (e.g. 0600 for configs) or
370
+ // the umask default. Guards pre-existing entries, not active races.
371
+ export async function writeAtomic(dest, bytes, { mode } = {}) {
372
+ await mkdir(dirname(dest), { recursive: true });
373
+ let existingMode = null;
374
+ try {
375
+ existingMode = (await stat(dest)).mode & 0o777;
376
+ } catch (err) {
377
+ if (err?.code !== 'ENOENT') throw err;
378
+ }
379
+ const tmp = `${dest}.tmp-${process.pid}`;
380
+ await writeFileExclusive(tmp, bytes);
381
+ try {
382
+ await rename(tmp, dest);
383
+ } catch (err) {
384
+ try {
385
+ await rm(tmp); // only unlink: this run created it exclusively
386
+ } catch {
387
+ // best effort cleanup of a temp file this run created
388
+ }
389
+ throw err;
390
+ }
391
+ const finalMode = mode ?? existingMode ?? (0o666 & ~process.umask());
392
+ try {
393
+ await chmod(dest, finalMode);
394
+ } catch (err) {
395
+ throw new Error(`wrote ${dest} but could not set permissions: ${err.message}`);
396
+ }
397
+ }
398
+
399
+ export async function checkInstructionBinding({ skillsDir, instructionsPath } = {}) {
400
+ if (!skillsDir) throw new Error('instruction check requires --skills-dir <dir> or --harness <name>');
401
+ if (!instructionsPath) throw new Error('instruction check requires --instructions <file> or a supported harness');
402
+ const skillsRoot = await canonicalTargetDir(resolve(skillsDir));
403
+ const instructionsFile = await canonicalInstructionFile(instructionsPath);
404
+ const manifest = await readManifest(skillsRoot);
405
+ const binding = manifest?.instructions ?? { path: null, hash: null };
406
+ if (binding.path !== null && binding.path !== instructionsFile) {
407
+ return {
408
+ status: 'conflict',
409
+ path: instructionsFile,
410
+ reason: `manifest is bound to ${binding.path}`,
411
+ };
412
+ }
413
+ const text = await readFile(instructionsFile, 'utf8').catch((err) => {
414
+ if (err?.code === 'ENOENT') return null;
415
+ throw err;
416
+ });
417
+ if (text === null) return { status: 'missing', path: instructionsFile };
418
+ const located = locateInstructionBlock(text);
419
+ if (!located) {
420
+ return { status: 'missing', path: instructionsFile, reason: 'Axstack marker block is absent' };
421
+ }
422
+ if (binding.path === null) {
423
+ return { status: 'unowned', path: instructionsFile, reason: 'marker block is not manifest-owned' };
424
+ }
425
+ if (hashContent(located.block) !== binding.hash) {
426
+ return { status: 'conflict', path: instructionsFile, reason: 'owned marker block was edited' };
427
+ }
428
+ return { status: 'owned', path: instructionsFile };
429
+ }
430
+
431
+ async function assertFileSnapshot(dest, expected) {
432
+ let current = null;
433
+ try {
434
+ current = await readFile(dest, 'utf8');
435
+ } catch (err) {
436
+ if (err?.code !== 'ENOENT') throw err;
437
+ }
438
+ if (current !== expected) {
439
+ throw new Error(`refusing: ${dest} changed since planning`);
440
+ }
441
+ }
442
+
443
+ export async function installBundle({
444
+ bundleDir,
445
+ skillsDir,
446
+ preset,
447
+ instructionsPath = null,
448
+ force = false,
449
+ yes = false,
450
+ claude = null,
451
+ log = () => {},
452
+ } = {}) {
453
+ const selectedPreset = normalizePreset(preset);
454
+ if (!bundleDir) throw new Error('install requires --bundle <dir>');
455
+ if (!skillsDir) throw new Error('install requires --skills-dir <dir> (explicit target directories only)');
456
+
457
+ // Phase 0: validate everything before mutating anything.
458
+ const bundle = await validateBundle(bundleDir, selectedPreset);
459
+ const skillsRoot = await canonicalTargetDir(resolve(skillsDir));
460
+ const instructionsFile = instructionsPath
461
+ ? await canonicalInstructionFile(instructionsPath)
462
+ : null;
463
+ assertOutsideHome(skillsRoot, { yes, kind: 'skills directory' });
464
+ if (instructionsFile) assertOutsideHome(instructionsFile, { yes, kind: 'instruction file' });
465
+ const claudePlan = await planInstallClaudeSettings({ claude, skillsRoot });
466
+ if (claudePlan.report.path) {
467
+ assertOutsideHome(claudePlan.report.path, { yes, kind: 'Claude settings file' });
468
+ }
469
+
470
+ const prevManifest = (await readManifest(skillsRoot)) ?? {
471
+ version: MANIFEST_VERSION,
472
+ files: {},
473
+ profiles: { path: null, preset: null, entries: {} },
474
+ claudeSettings: { path: null },
475
+ instructions: { path: null, hash: null },
476
+ };
477
+ const boundClaudeSettingsPath = prevManifest.claudeSettings?.path ?? null;
478
+ if (
479
+ claudePlan.settingsPath && boundClaudeSettingsPath &&
480
+ claudePlan.settingsPath !== boundClaudeSettingsPath
481
+ ) {
482
+ throw new Error(
483
+ `Claude settings ownership is bound to ${boundClaudeSettingsPath}; ` +
484
+ `refusing target ${claudePlan.settingsPath}`,
485
+ );
486
+ }
487
+ const ownedFiles = prevManifest.files ?? {};
488
+ const legacyProfiles = prevManifest.profiles;
489
+ const legacyProfileNote = Object.keys(legacyProfiles?.entries ?? {}).length > 0
490
+ ? 'legacy Paseo profile provenance retained inert; see legacy cleanup guidance in docs/installation.md'
491
+ : null;
492
+
493
+ const boundInstructions = prevManifest.instructions ?? { path: null, hash: null };
494
+ let existingInstructionsRaw = null;
495
+ let instructionPlan = null;
496
+ let legacyInstructionNote = null;
497
+ if (instructionsFile) {
498
+ if (boundInstructions.path !== null && boundInstructions.path !== instructionsFile) {
499
+ throw new Error(
500
+ `refusing: owned instruction block is bound to a different file (${boundInstructions.path}); ` +
501
+ `uninstall with --instructions ${boundInstructions.path} first`,
502
+ );
503
+ }
504
+ try {
505
+ existingInstructionsRaw = await readFile(instructionsFile, 'utf8');
506
+ } catch (err) {
507
+ if (err?.code !== 'ENOENT') throw err;
508
+ }
509
+ instructionPlan = planInstruction({
510
+ text: existingInstructionsRaw,
511
+ block: renderInstructionBlock(skillsRoot),
512
+ ownership: boundInstructions.path === instructionsFile ? boundInstructions : null,
513
+ force,
514
+ });
515
+ if (findLegacyRoutingLines(existingInstructionsRaw ?? '').length > 0) {
516
+ legacyInstructionNote =
517
+ 'legacy Haoshoku routing text remains outside the Axstack block; preserved for manual migration';
518
+ }
519
+ }
520
+
521
+ // Phase 1: pre-scan unknown pre-existing files so conflicts fail pre-write.
522
+ // Destination ancestry is checked for symlink escapes here, before mutation.
523
+ // The stale plan below is computed in the same read-only pass: every stale
524
+ // manifest entry resolves its destination, current bytes, and pristine
525
+ // flag through the same guard before Phase 2 mutates anything.
526
+ const desired = [];
527
+ for (const { rel, abs, content: inlineContent } of bundle.files) {
528
+ const { dest, current, mode } = await readOwnedTarget(skillsRoot, rel);
529
+ const content = inlineContent ?? await readFile(abs);
530
+ desired.push({ rel, dest, content, current, mode });
531
+ if (
532
+ current !== null &&
533
+ hashContent(current) !== hashContent(content) &&
534
+ !(rel in ownedFiles) &&
535
+ !force
536
+ ) {
537
+ throw new Error(
538
+ `unknown pre-existing file would be overwritten: ${rel}; re-run with --force to take ownership`,
539
+ );
540
+ }
541
+ }
542
+
543
+ const bundleRels = new Set(bundle.files.map((file) => file.rel));
544
+ const stalePlan = [];
545
+ for (const rel of Object.keys(ownedFiles)) {
546
+ if (bundleRels.has(rel)) continue;
547
+ const { dest, current, mode } = await readOwnedTarget(skillsRoot, rel);
548
+ stalePlan.push({
549
+ rel,
550
+ dest,
551
+ current,
552
+ mode,
553
+ pristine: current !== null && hashContent(current) === ownedFiles[rel],
554
+ });
555
+ }
556
+
557
+ // Phase 2: write files. Existing overwritten bytes are backed up in memory
558
+ // and restored on any later failure (skill write, settings write, manifest
559
+ // write), so a failed run leaves pre-run state behind and the untouched
560
+ // manifest still describes it: a retry converges.
561
+ const created = [];
562
+ const backups = new Map();
563
+ const backupExisting = (dest, current, mode) => {
564
+ // Capture bytes and mode together: rollback restores through writeAtomic,
565
+ // which falls back to the umask default when the destination no longer
566
+ // exists (as after a stale deletion), so the mode must be explicit.
567
+ if (current !== null && !backups.has(dest)) backups.set(dest, { bytes: current, mode });
568
+ };
569
+ const summary = { added: [], updated: [], unchanged: [], preserved: [], stale: [], removed: [] };
570
+ const installedHashes = {};
571
+ const claudeWritten = [];
572
+ let instructionWritten = false;
573
+ log('plan complete');
574
+ try {
575
+ if (instructionsFile) {
576
+ await assertFileSnapshot(instructionsFile, existingInstructionsRaw);
577
+ }
578
+ await mkdir(skillsRoot, { recursive: true });
579
+ for (const { rel, dest, content, current, mode } of desired) {
580
+ const wanted = hashContent(content);
581
+ if (current === null) {
582
+ await writeAtomic(dest, content);
583
+ created.push(dest);
584
+ summary.added.push(rel);
585
+ installedHashes[rel] = wanted;
586
+ } else if (hashContent(current) === wanted) {
587
+ summary.unchanged.push(rel);
588
+ // Never adopt unrelated pre-existing files: only previously owned
589
+ // entries keep a manifest record.
590
+ if (rel in ownedFiles) installedHashes[rel] = wanted;
591
+ } else if (rel in ownedFiles && hashContent(current) === ownedFiles[rel]) {
592
+ backupExisting(dest, current, mode);
593
+ await writeAtomic(dest, content); // pristine owned follows bundle upgrades
594
+ summary.updated.push(rel);
595
+ installedHashes[rel] = wanted;
596
+ } else if (force) {
597
+ backupExisting(dest, current, mode);
598
+ await writeAtomic(dest, content);
599
+ summary.updated.push(`${rel} (overwrote user edit with --force)`);
600
+ installedHashes[rel] = wanted;
601
+ } else {
602
+ summary.preserved.push(rel); // user-edited owned asset: hands off
603
+ // Retain the prior install hash so uninstall still recognizes the
604
+ // edit; never record the edited bytes as owned.
605
+ if (rel in ownedFiles) installedHashes[rel] = ownedFiles[rel];
606
+ }
607
+ }
608
+
609
+ // Stale manifest entries (owned files the bundle no longer ships):
610
+ // the phase-1 plan validated every stale destination read-only
611
+ // (escapes and symlinks fail closed before any write), but each delete
612
+ // decision below re-reads its target through the same ownership guard
613
+ // immediately before its rm: a copy edited or removed after planning is
614
+ // preserved/reported, never deleted from a stale snapshot. Only
615
+ // manifest-owned pristine paths are ever deleted, guarded by the same
616
+ // ownership/hash check uninstall uses.
617
+ for (const { rel } of stalePlan) {
618
+ const { dest, current, mode } = await readOwnedTarget(skillsRoot, rel);
619
+ if (current === null || hashContent(current) !== ownedFiles[rel]) {
620
+ summary.stale.push(rel);
621
+ installedHashes[rel] = ownedFiles[rel];
622
+ continue;
623
+ }
624
+ backupExisting(dest, current, mode);
625
+ await rm(dest);
626
+ await pruneEmptyParents(dest, skillsRoot);
627
+ summary.removed.push(rel);
628
+ }
629
+
630
+ let instructionReport = null;
631
+ let nextInstructions = boundInstructions;
632
+ if (instructionPlan) {
633
+ instructionReport = {
634
+ status: instructionPlan.action,
635
+ path: instructionsFile,
636
+ ...(instructionPlan.reason ? { reason: instructionPlan.reason } : {}),
637
+ };
638
+ if (instructionPlan.action === 'created' || instructionPlan.action === 'updated') {
639
+ const nextRaw = applyInstructionPlan(existingInstructionsRaw, instructionPlan);
640
+ await writeAtomic(instructionsFile, nextRaw);
641
+ instructionWritten = true;
642
+ nextInstructions = {
643
+ path: instructionsFile,
644
+ hash: hashContent(instructionPlan.block),
645
+ separation: instructionPlan.separation,
646
+ };
647
+ } else if (instructionPlan.action === 'unchanged') {
648
+ nextInstructions = {
649
+ path: instructionsFile,
650
+ hash: hashContent(instructionPlan.block),
651
+ separation: instructionPlan.separation,
652
+ };
653
+ }
654
+ }
655
+
656
+ for (const write of claudePlan.writes) {
657
+ await writeAtomic(write.path, write.after, write.before === null ? { mode: 0o600 } : {});
658
+ claudeWritten.push(write);
659
+ }
660
+
661
+ const roleReadiness = bundle.bundleRoles
662
+ ? assessInstalledRoleSnapshot(
663
+ await readFile(join(skillsRoot, 'axstack', 'roles.json')),
664
+ selectedPreset,
665
+ bundle.bundleRoles,
666
+ )
667
+ : null;
668
+
669
+ await writeManifest(skillsRoot, {
670
+ version: MANIFEST_VERSION,
671
+ files: installedHashes,
672
+ profiles: legacyProfiles,
673
+ claudeSettings: {
674
+ path: claudePlan.settingsPath ?? boundClaudeSettingsPath,
675
+ },
676
+ instructions: nextInstructions,
677
+ });
678
+ if (legacyProfileNote || legacyInstructionNote) {
679
+ summary.notes = [
680
+ ...(summary.notes ?? []),
681
+ ...[legacyProfileNote, legacyInstructionNote].filter(Boolean),
682
+ ];
683
+ }
684
+ return {
685
+ ...summary,
686
+ preset: selectedPreset,
687
+ roles: roleReadiness,
688
+ claudeSettings: claudePlan.report,
689
+ instructions: instructionReport,
690
+ };
691
+ } catch (err) {
692
+ // Restore updated files, remove creations, and restore Claude settings so
693
+ // pre-run state (which the untouched manifest describes) holds again.
694
+ // Restore failures are collected and reported: a swallowed restore would
695
+ // leave ownership claims describing bytes that are not on disk.
696
+ const rollbackErrors = [];
697
+ for (const write of claudeWritten.reverse()) {
698
+ try {
699
+ if (write.before === null) await rm(write.path);
700
+ else await writeAtomic(write.path, write.before);
701
+ } catch (restoreErr) {
702
+ rollbackErrors.push(`${write.path}: ${restoreErr?.message ?? restoreErr}`);
703
+ }
704
+ }
705
+ if (instructionWritten) {
706
+ try {
707
+ if (existingInstructionsRaw === null) await rm(instructionsFile);
708
+ else await writeAtomic(instructionsFile, existingInstructionsRaw);
709
+ } catch (restoreErr) {
710
+ rollbackErrors.push(`${instructionsFile}: ${restoreErr?.message ?? restoreErr}`);
711
+ }
712
+ }
713
+ for (const [dest, { bytes, mode }] of backups) {
714
+ try {
715
+ await writeAtomic(dest, bytes, mode === null ? {} : { mode });
716
+ } catch (restoreErr) {
717
+ rollbackErrors.push(`${dest}: ${restoreErr?.message ?? restoreErr}`);
718
+ }
719
+ }
720
+ for (const createdFile of created) {
721
+ try {
722
+ await rm(createdFile);
723
+ } catch (restoreErr) {
724
+ rollbackErrors.push(`${createdFile}: ${restoreErr?.message ?? restoreErr}`);
725
+ }
726
+ }
727
+ if (rollbackErrors.length > 0) {
728
+ err.message += ` (incomplete rollback; manual repair needed: ${rollbackErrors.join('; ')})`;
729
+ }
730
+ throw err;
731
+ }
732
+ }
733
+
734
+ export async function uninstallBundle({
735
+ skillsDir,
736
+ instructionsPath = null,
737
+ force = false,
738
+ yes = false,
739
+ claude = null,
740
+ log = () => {},
741
+ } = {}) {
742
+ if (!skillsDir) throw new Error('uninstall requires --skills-dir <dir>');
743
+ const skillsRoot = await canonicalTargetDir(resolve(skillsDir));
744
+ const instructionsFile = instructionsPath
745
+ ? await canonicalInstructionFile(instructionsPath)
746
+ : null;
747
+ assertOutsideHome(skillsRoot, { yes, kind: 'skills directory' });
748
+ if (instructionsFile) assertOutsideHome(instructionsFile, { yes, kind: 'instruction file' });
749
+
750
+ const manifest = (await readManifest(skillsRoot)) ?? {
751
+ version: MANIFEST_VERSION,
752
+ files: {},
753
+ profiles: { path: null, preset: null, entries: {} },
754
+ claudeSettings: { path: null },
755
+ instructions: { path: null, hash: null },
756
+ };
757
+ const boundClaudeSettingsPath = manifest.claudeSettings?.path ?? null;
758
+ const claudePlan = await planUninstallClaudeSettings({
759
+ claude,
760
+ skillsRoot,
761
+ boundPath: boundClaudeSettingsPath,
762
+ });
763
+ if (claudePlan.report.path) {
764
+ assertOutsideHome(claudePlan.report.path, { yes, kind: 'Claude settings file' });
765
+ }
766
+ const remainingClaudeSettingsPath = claudePlan.unbind ? null : boundClaudeSettingsPath;
767
+ const summary = { removed: [], preserved: [], missing: [], instructions: null };
768
+ if (Object.keys(manifest.files).length === 0 && Object.keys(manifest.profiles.entries).length === 0) {
769
+ summary.note = 'no Axstack ownership manifest; nothing to remove';
770
+ }
771
+ const ownedFiles = manifest.files ?? {};
772
+ const legacyProfiles = manifest.profiles;
773
+ const hasLegacyProfiles = Object.keys(legacyProfiles?.entries ?? {}).length > 0;
774
+ if (hasLegacyProfiles) {
775
+ summary.note = 'legacy Paseo profile provenance retained inert; see legacy cleanup guidance in docs/installation.md';
776
+ }
777
+ const remainingFiles = { ...ownedFiles };
778
+ const boundInstructions = manifest.instructions ?? { path: null, hash: null };
779
+ let remainingInstructions = boundInstructions;
780
+
781
+ if (!instructionsFile && boundInstructions.path !== null) {
782
+ summary.instructions = {
783
+ status: 'preserved',
784
+ path: boundInstructions.path,
785
+ reason: `re-run uninstall with --instructions ${boundInstructions.path}`,
786
+ };
787
+ }
788
+
789
+ let existingInstructionsRaw = null;
790
+ let strippedInstructionsRaw = null;
791
+ if (instructionsFile) {
792
+ if (boundInstructions.path === null) {
793
+ summary.instructions = {
794
+ status: 'preserved',
795
+ path: instructionsFile,
796
+ reason: 'instruction file is not owned by this manifest',
797
+ };
798
+ } else if (boundInstructions.path !== instructionsFile) {
799
+ throw new Error(
800
+ `refusing: owned instruction block is bound to a different file (${boundInstructions.path}); ` +
801
+ `re-run uninstall with --instructions ${boundInstructions.path}`,
802
+ );
803
+ } else {
804
+ try {
805
+ existingInstructionsRaw = await readFile(instructionsFile, 'utf8');
806
+ } catch (err) {
807
+ if (err?.code !== 'ENOENT') throw err;
808
+ }
809
+ if (existingInstructionsRaw === null) {
810
+ summary.instructions = { status: 'missing', path: instructionsFile };
811
+ remainingInstructions = { path: null, hash: null };
812
+ } else {
813
+ const stripped = stripInstructionBlock(existingInstructionsRaw, boundInstructions, { force });
814
+ if (stripped.removed) {
815
+ strippedInstructionsRaw = stripped.text;
816
+ summary.instructions = {
817
+ status: 'removed',
818
+ path: instructionsFile,
819
+ ...(force && hashContent(stripped.block) !== boundInstructions.hash ? { forced: true } : {}),
820
+ };
821
+ remainingInstructions = { path: null, hash: null };
822
+ } else {
823
+ summary.instructions = {
824
+ status: 'conflict',
825
+ path: instructionsFile,
826
+ reason: 'owned instruction block was edited or is missing',
827
+ };
828
+ }
829
+ }
830
+ }
831
+ }
832
+
833
+ // Check every destination for symlink escapes BEFORE deleting anything:
834
+ // one unsafe entry refuses the whole uninstall with skills still on disk.
835
+ // Each delete decision below then re-reads its target through the same
836
+ // guard immediately before its own rm, so a file edited after validation
837
+ // is preserved rather than deleted from a stale snapshot.
838
+ const uninstallPlan = [];
839
+ for (const rel of Object.keys(ownedFiles)) {
840
+ await readOwnedTarget(skillsRoot, rel);
841
+ uninstallPlan.push(rel);
842
+ }
843
+
844
+ log('plan complete');
845
+ const manifestFile = join(skillsRoot, '.axstack-manifest.json');
846
+ const manifestBefore = await readFile(manifestFile).catch((err) => {
847
+ if (err?.code === 'ENOENT') return null;
848
+ throw err;
849
+ });
850
+ const manifestMode = manifestBefore === null ? null : (await stat(manifestFile)).mode & 0o777;
851
+ const instructionMode = existingInstructionsRaw === null
852
+ ? null
853
+ : (await stat(instructionsFile)).mode & 0o777;
854
+ const claudeModes = new Map();
855
+ for (const write of claudePlan.writes) {
856
+ const mode = await stat(write.path).then((value) => value.mode & 0o777).catch((err) => {
857
+ if (err?.code === 'ENOENT') return null;
858
+ throw err;
859
+ });
860
+ claudeModes.set(write.path, mode);
861
+ }
862
+
863
+ const deletedFiles = [];
864
+ const claudeWritten = [];
865
+ let instructionWritten = false;
866
+ try {
867
+ for (const rel of uninstallPlan) {
868
+ const { dest, current, mode } = await readOwnedTarget(skillsRoot, rel);
869
+ const ownedHash = ownedFiles[rel];
870
+ if (current === null) {
871
+ summary.missing.push(rel);
872
+ delete remainingFiles[rel];
873
+ continue;
874
+ }
875
+ if (hashContent(current) === ownedHash || force) {
876
+ deletedFiles.push({ dest, bytes: current, mode });
877
+ await rm(dest);
878
+ delete remainingFiles[rel];
879
+ summary.removed.push(force && hashContent(current) !== ownedHash ? `${rel} (removed user edit with --force)` : rel);
880
+ await pruneEmptyParents(dest, skillsRoot);
881
+ } else {
882
+ summary.preserved.push(rel); // user-edited owned asset survives
883
+ }
884
+ }
885
+
886
+ if (strippedInstructionsRaw !== null) {
887
+ await assertFileSnapshot(instructionsFile, existingInstructionsRaw);
888
+ await writeAtomic(instructionsFile, strippedInstructionsRaw);
889
+ instructionWritten = true;
890
+ }
891
+
892
+ for (const write of claudePlan.writes) {
893
+ if (write.after === null) {
894
+ await rm(write.path).catch((err) => {
895
+ if (err?.code !== 'ENOENT') throw err;
896
+ });
897
+ } else {
898
+ await writeAtomic(write.path, write.after, write.before === null ? { mode: 0o600 } : {});
899
+ }
900
+ claudeWritten.push(write);
901
+ }
902
+ summary.claudeSettings = claudePlan.report;
903
+
904
+ if (
905
+ Object.keys(remainingFiles).length === 0 &&
906
+ !hasLegacyProfiles &&
907
+ remainingClaudeSettingsPath === null &&
908
+ remainingInstructions.path === null
909
+ ) {
910
+ await rm(manifestFile).catch((err) => {
911
+ if (err?.code !== 'ENOENT') throw err;
912
+ });
913
+ summary.manifestRemoved = true;
914
+ } else {
915
+ await writeManifest(skillsRoot, {
916
+ version: MANIFEST_VERSION,
917
+ files: remainingFiles,
918
+ profiles: legacyProfiles,
919
+ claudeSettings: { path: remainingClaudeSettingsPath },
920
+ instructions: remainingInstructions,
921
+ });
922
+ }
923
+ return summary;
924
+ } catch (err) {
925
+ const rollbackErrors = [];
926
+ for (const write of claudeWritten.reverse()) {
927
+ try {
928
+ if (write.before === null) await rm(write.path);
929
+ else await writeAtomic(write.path, write.before, claudeModes.get(write.path) === null
930
+ ? {}
931
+ : { mode: claudeModes.get(write.path) });
932
+ } catch (restoreErr) {
933
+ rollbackErrors.push(`${write.path}: ${restoreErr?.message ?? restoreErr}`);
934
+ }
935
+ }
936
+ if (instructionWritten) {
937
+ try {
938
+ await writeAtomic(instructionsFile, existingInstructionsRaw, instructionMode === null
939
+ ? {}
940
+ : { mode: instructionMode });
941
+ } catch (restoreErr) {
942
+ rollbackErrors.push(`${instructionsFile}: ${restoreErr?.message ?? restoreErr}`);
943
+ }
944
+ }
945
+ for (const { dest, bytes, mode } of deletedFiles.reverse()) {
946
+ try {
947
+ await writeAtomic(dest, bytes, mode === null ? {} : { mode });
948
+ } catch (restoreErr) {
949
+ rollbackErrors.push(`${dest}: ${restoreErr?.message ?? restoreErr}`);
950
+ }
951
+ }
952
+ try {
953
+ const currentManifest = await readFile(manifestFile).catch((readErr) => {
954
+ if (readErr?.code === 'ENOENT') return null;
955
+ throw readErr;
956
+ });
957
+ if (manifestBefore !== null && currentManifest?.equals(manifestBefore) !== true) {
958
+ await writeAtomic(manifestFile, manifestBefore, manifestMode === null ? {} : { mode: manifestMode });
959
+ }
960
+ } catch (restoreErr) {
961
+ rollbackErrors.push(`${manifestFile}: ${restoreErr?.message ?? restoreErr}`);
962
+ }
963
+ if (rollbackErrors.length > 0) {
964
+ err.message += ` (incomplete rollback; manual repair needed: ${rollbackErrors.join('; ')})`;
965
+ }
966
+ throw err;
967
+ }
968
+ }
969
+
970
+ async function pruneEmptyParents(file, stopDir) {
971
+ let dir = dirname(file);
972
+ while (dir !== stopDir && withinRoot(dir, stopDir)) {
973
+ try {
974
+ await rmdir(dir);
975
+ } catch {
976
+ break; // non-empty or missing: stop pruning
977
+ }
978
+ dir = dirname(dir);
979
+ }
980
+ }