@dtlabs/ai-toolkit 0.10.0 → 0.11.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 (79) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/bin/cli.js +1178 -64
  3. package/docs/installation.md +74 -8
  4. package/docs/reference.md +60 -12
  5. package/lib/asset-catalog.js +52 -0
  6. package/package.json +9 -2
  7. package/{.claude → src/claude}/agents/install-toolkit.md +30 -103
  8. package/{.claude → src/claude}/workflows/am-phase1.js +9 -12
  9. package/{.claude → src/claude}/workflows/pm-phase2.js +2 -2
  10. package/.claude/.ai-toolkit-version +0 -1
  11. package/.claude/scripts/.gitkeep +0 -0
  12. package/.claude/scripts/tests/fixtures/ac-table-format.md +0 -48
  13. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-Work-Breakdown.json +0 -234
  14. package/.claude/scripts/tests/fixtures/test-feature/TEST-001-semantic-output.json +0 -4
  15. package/.claude/scripts/tests/fixtures/test-feature/feature.md +0 -45
  16. package/.claude/scripts/tests/fixtures/wb-invalid-bad-domain.json +0 -39
  17. package/.claude/scripts/tests/fixtures/wb-invalid-cycle.json +0 -61
  18. package/.claude/scripts/tests/fixtures/wb-invalid-duplicate-id.json +0 -72
  19. package/.claude/scripts/tests/fixtures/wb-invalid-missing-field.json +0 -38
  20. package/.claude/scripts/tests/fixtures/wb-valid.json +0 -189
  21. package/.claude/scripts/tests/helpers/buildWaves.js +0 -21
  22. package/.claude/scripts/tests/helpers/pm-phase2-flow.js +0 -29
  23. package/.claude/scripts/tests/helpers/pm-phase2-gate2.js +0 -55
  24. package/.claude/scripts/tests/helpers/pm-phase2-ledger.js +0 -108
  25. package/.claude/scripts/tests/installer.scripts-distribution.test.js +0 -131
  26. package/.claude/scripts/tests/pm-phase2.gate2-payload.test.js +0 -353
  27. package/.claude/scripts/tests/pm-phase2.ledger-transitions.test.js +0 -346
  28. package/.claude/scripts/tests/pm-phase2.orchestration-flow.test.js +0 -276
  29. package/.claude/scripts/tests/verify-us-01-t03.js +0 -288
  30. package/.claude/scripts/tests/verify-us-03-t04.js +0 -262
  31. package/.claude/scripts/tests/wb-render.checks-md-csv-deps.test.js +0 -337
  32. package/.claude/scripts/tests/wb-render.checks-sanitize-commit-paths.test.js +0 -352
  33. package/.claude/scripts/tests/wb-render.regression-pm-phase3-csv.test.js +0 -219
  34. package/.claude/scripts/tests/wb-validate.checks-1-7.test.js +0 -330
  35. package/.claude/scripts/tests/wb-validate.checks-11.test.js +0 -166
  36. package/.claude/scripts/tests/wb-validate.checks-12-14.test.js +0 -222
  37. package/.claude/scripts/tests/wb-validate.checks-15-18.test.js +0 -359
  38. package/.claude/scripts/tests/wb-validate.checks-19-21.test.js +0 -362
  39. package/.claude/scripts/tests/wb-validate.checks-22-23-report.test.js +0 -297
  40. package/.claude/scripts/tests/wb-validate.checks-8-10.test.js +0 -243
  41. package/.claude/settings.json +0 -5
  42. /package/{.claude → src/claude}/agents/concurrency-safety-assessment.md +0 -0
  43. /package/{.claude → src/claude}/agents/define-feature.md +0 -0
  44. /package/{.claude → src/claude}/agents/dependency-injection-refactoring.md +0 -0
  45. /package/{.claude → src/claude}/agents/dependency-supply-chain-security.md +0 -0
  46. /package/{.claude → src/claude}/agents/developer-backend.md +0 -0
  47. /package/{.claude → src/claude}/agents/developer-frontend.md +0 -0
  48. /package/{.claude → src/claude}/agents/developer-testing.md +0 -0
  49. /package/{.claude → src/claude}/agents/domain-model-refactoring.md +0 -0
  50. /package/{.claude → src/claude}/agents/generate-requirements.md +0 -0
  51. /package/{.claude → src/claude}/agents/generate-tech-spec.md +0 -0
  52. /package/{.claude → src/claude}/agents/generate-work-breakdown.md +0 -0
  53. /package/{.claude → src/claude}/agents/generic-software-assessment.md +0 -0
  54. /package/{.claude → src/claude}/agents/god-class-decomposition.md +0 -0
  55. /package/{.claude → src/claude}/agents/init-agents-md.md +0 -0
  56. /package/{.claude → src/claude}/agents/intervention-documentation-standard.md +0 -0
  57. /package/{.claude → src/claude}/agents/layered-architecture-assessment.md +0 -0
  58. /package/{.claude → src/claude}/agents/review-solution.md +0 -0
  59. /package/{.claude → src/claude}/agents/security-hardening.md +0 -0
  60. /package/{.claude → src/claude}/agents/validate-feature-docs.md +0 -0
  61. /package/{.claude → src/claude}/agents/validate-work-breakdown-semantic.md +0 -0
  62. /package/{.claude → src/claude}/commands/assessment-status.md +0 -0
  63. /package/{.claude → src/claude}/commands/check-docs.md +0 -0
  64. /package/{.claude → src/claude}/commands/check-interventions.md +0 -0
  65. /package/{.claude → src/claude}/commands/feature-status.md +0 -0
  66. /package/{.claude → src/claude}/commands/next-intervention.md +0 -0
  67. /package/{.claude → src/claude}/commands/next-task.md +0 -0
  68. /package/{.claude → src/claude}/commands/pr-description.md +0 -0
  69. /package/{.claude → src/claude}/scripts/wb-render.js +0 -0
  70. /package/{.claude → src/claude}/scripts/wb-validate.js +0 -0
  71. /package/{.claude → src/claude}/skills/assess-codebase/SKILL.md +0 -0
  72. /package/{.claude → src/claude}/skills/define-feature/SKILL.md +0 -0
  73. /package/{.claude → src/claude}/skills/hi-gaia/SKILL.md +0 -0
  74. /package/{.claude → src/claude}/skills/implement-feature/SKILL.md +0 -0
  75. /package/{.claude → src/claude}/skills/init-agents/SKILL.md +0 -0
  76. /package/{.claude → src/claude}/skills/install-toolkit/SKILL.md +0 -0
  77. /package/{.claude → src/claude}/workflows/am-phase2.js +0 -0
  78. /package/{.claude → src/claude}/workflows/pm-phase1.js +0 -0
  79. /package/{.claude → src/claude}/workflows/pm-phase3.js +0 -0
package/bin/cli.js CHANGED
@@ -40,9 +40,6 @@ function banner() {
40
40
 
41
41
  // ── helpers ──────────────────────────────────────────────────────────────────
42
42
 
43
- // User-owned config that must NEVER be copied into a destination. We only verify
44
- // and advise on these — copying would clobber the user's existing settings.
45
- const NEVER_COPY = new Set(['settings.json', 'settings.local.json']);
46
43
 
47
44
  function fileHash(filePath) {
48
45
  return crypto.createHash('md5').update(fs.readFileSync(filePath)).digest('hex');
@@ -161,12 +158,10 @@ function expandMappings(mappings) {
161
158
  if (!fs.existsSync(src)) continue;
162
159
  if (fs.statSync(src).isDirectory()) {
163
160
  for (const entry of walkDir(src)) {
164
- if (NEVER_COPY.has(path.basename(entry))) continue; // never clobber user config
165
161
  const rel = path.relative(src, entry);
166
162
  files.push({ src: entry, dest: path.join(dest, rel) });
167
163
  }
168
164
  } else {
169
- if (NEVER_COPY.has(path.basename(src))) continue;
170
165
  files.push({ src, dest });
171
166
  }
172
167
  }
@@ -266,10 +261,20 @@ function computeOrphans(oldFiles, newFiles) {
266
261
  .filter(f => !newSet.has(f));
267
262
  }
268
263
 
264
+ // Timestamp used for the current install/upgrade session — set once so all orphans
265
+ // from the same run land in the same subdirectory, making the trash auditable.
266
+ let _trashTimestamp = null;
267
+ function trashTimestamp() {
268
+ if (!_trashTimestamp) _trashTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
269
+ return _trashTimestamp;
270
+ }
271
+
269
272
  function moveToTrash(destRoot, relativePath) {
270
273
  const source = path.join(destRoot, relativePath);
271
274
  if (!fs.existsSync(source)) return;
272
- const trashPath = path.join(destRoot, '.claude', '.ai-toolkit-trash', relativePath);
275
+ // Each upgrade session gets its own timestamped subdirectory so repeated upgrades
276
+ // never overwrite previously trashed files. The timestamp is stable for the session.
277
+ const trashPath = path.join(destRoot, '.claude', '.ai-toolkit-trash', trashTimestamp(), relativePath);
273
278
  ensureDir(trashPath);
274
279
  try {
275
280
  fs.renameSync(source, trashPath);
@@ -283,7 +288,7 @@ function moveToTrash(destRoot, relativePath) {
283
288
  }
284
289
  }
285
290
 
286
- function writeManifest(destRoot, fileList) {
291
+ function writeManifest(destRoot, fileList, installationMode) {
287
292
  const manifestPath = path.join(destRoot, '.claude', MANIFEST_FILE);
288
293
  const trashDir = path.join(destRoot, '.claude', '.ai-toolkit-trash');
289
294
  const filtered = fileList.filter(rel => {
@@ -293,6 +298,7 @@ function writeManifest(destRoot, fileList) {
293
298
  const manifest = {
294
299
  version: TOOLKIT_VERSION,
295
300
  installedAt: new Date().toISOString(),
301
+ installationMode: installationMode,
296
302
  files: filtered.map(f => f.replace(/\\/g, '/')),
297
303
  };
298
304
  try {
@@ -305,7 +311,12 @@ function writeManifest(destRoot, fileList) {
305
311
 
306
312
  // ── install ───────────────────────────────────────────────────────────────────
307
313
 
308
- async function runInstall(label, mappings, force, destRoot) {
314
+ async function runInstall(label, mappings, force, destRoot, dryRun = false, installationMode) {
315
+ const { getAssetCategories } = require('../lib/asset-catalog');
316
+ // Catalog runtime-dir prefixes (e.g. '.claude/agents/') — only these paths
317
+ // belong in the manifest. Documentation and CLAUDE.md are excluded.
318
+ const catalogPrefixes = getAssetCategories().map(c => c.runtimeDir.replace(/\\/g, '/') + '/');
319
+
309
320
  const files = expandMappings(mappings);
310
321
  const entries = categorize(files);
311
322
 
@@ -315,17 +326,26 @@ async function runInstall(label, mappings, force, destRoot) {
315
326
 
316
327
  // ── prune phase (before file copy) ───────────────────────────────────────────
317
328
  const oldManifest = readManifest(destRoot);
318
- const newFileSet = files.map(f => path.relative(destRoot, f.dest).replace(/\\/g, '/'));
319
- const orphans = computeOrphans(oldManifest.files, newFileSet);
329
+ const allFileSet = files.map(f => path.relative(destRoot, f.dest).replace(/\\/g, '/'));
330
+ // Restrict manifest entries to catalog runtime assets only (no docs, no CLAUDE.md).
331
+ const newFileSet = allFileSet.filter(rel => catalogPrefixes.some(p => rel.startsWith(p)));
332
+ // Filter orphans to catalog prefixes only: entries from old manifests outside catalog dirs
333
+ // (e.g. docs/, CLAUDE.md written by pre-hotfix installers) must never be treated as orphans.
334
+ const orphans = computeOrphans(oldManifest.files, newFileSet)
335
+ .filter(o => catalogPrefixes.some(p => o.replace(/\\/g, '/').startsWith(p)));
320
336
 
321
337
  const existingOrphans = orphans.filter(o => fs.existsSync(path.join(destRoot, o)));
322
338
 
323
339
  if (existingOrphans.length > 0) {
324
340
  console.log();
325
- console.log(`${bold('📦 Orphan cleanup')} ${clr('gray', '→')} ${clr('red', `${existingOrphans.length} stale file(s) found`)}`);
341
+ const orphanLabel = dryRun
342
+ ? clr('red', `${existingOrphans.length} stale file(s) would be removed`)
343
+ : clr('red', `${existingOrphans.length} stale file(s) found`);
344
+ console.log(`${bold('📦 Orphan cleanup')} ${clr('gray', '→')} ${orphanLabel}`);
326
345
  console.log(divider());
327
346
  for (const orphan of existingOrphans) {
328
- console.log(` ${clr('red', '∅')} ${clr('red', 'REMOVED ')} ${orphan}`);
347
+ const marker = dryRun ? clr('red', 'WOULD REMOVE') : clr('red', 'REMOVED ');
348
+ console.log(` ${clr('red', '∅')} ${marker} ${orphan}`);
329
349
  }
330
350
  console.log(divider());
331
351
  }
@@ -333,40 +353,43 @@ async function runInstall(label, mappings, force, destRoot) {
333
353
  let removedCount = 0;
334
354
  let keptCount = 0;
335
355
 
336
- if (force) {
337
- for (const orphan of existingOrphans) {
338
- moveToTrash(destRoot, orphan);
339
- removedCount++;
340
- console.log(` ${clr('red', '∅')} ${dim(orphan)}`);
341
- }
342
- } else {
343
- for (const orphan of existingOrphans) {
344
- const fullPath = path.join(destRoot, orphan);
345
- if (!fs.existsSync(fullPath)) continue;
346
- const ok = await askConfirm(` Move to trash ${clr('red', orphan)}?`);
347
- if (ok) {
356
+ if (!dryRun) {
357
+ if (force) {
358
+ for (const orphan of existingOrphans) {
348
359
  moveToTrash(destRoot, orphan);
349
- console.log(` ${clr('green', '✔')} Moved to trash\n`);
350
360
  removedCount++;
351
- } else {
352
- console.log(` ${clr('gray', '✖')} Kept as-is\n`);
353
- keptCount++;
361
+ console.log(` ${clr('red', '∅')} ${dim(orphan)}`);
362
+ }
363
+ } else {
364
+ for (const orphan of existingOrphans) {
365
+ const fullPath = path.join(destRoot, orphan);
366
+ if (!fs.existsSync(fullPath)) continue;
367
+ const ok = await askConfirm(` Move to trash ${clr('red', orphan)}?`);
368
+ if (ok) {
369
+ moveToTrash(destRoot, orphan);
370
+ console.log(` ${clr('green', '✔')} Moved to trash\n`);
371
+ removedCount++;
372
+ } else {
373
+ console.log(` ${clr('gray', '✖')} Kept as-is\n`);
374
+ keptCount++;
375
+ }
354
376
  }
355
377
  }
356
- }
357
378
 
358
- if (existingOrphans.length > 0) {
359
- console.log(divider());
360
- console.log(
361
- ` ${clr('red', `∅ Moved: ${removedCount}`)}` +
362
- ` ${clr('gray', `↪ .claude/.ai-toolkit-trash/`)}` +
363
- ` ${clr('gray', `✖ Kept: ${keptCount}`)}\n`
364
- );
379
+ if (existingOrphans.length > 0) {
380
+ console.log(divider());
381
+ console.log(
382
+ ` ${clr('red', `∅ Moved: ${removedCount}`)}` +
383
+ ` ${clr('gray', `↪ .claude/.ai-toolkit-trash/`)}` +
384
+ ` ${clr('gray', `✖ Kept: ${keptCount}`)}\n`
385
+ );
386
+ }
365
387
  }
366
388
 
367
389
  // ── install plan display ──────────────────────────────────────────────────────
368
390
  console.log();
369
- console.log(`${bold('📦 Install plan')} ${clr('gray', '→')} ${clr('cyan', label)}`);
391
+ const planTitle = dryRun ? bold('📦 Dry run — install plan') : bold('📦 Install plan');
392
+ console.log(`${planTitle} ${clr('gray', '→')} ${clr('cyan', label)}`);
370
393
  console.log(divider());
371
394
  for (const e of newFiles) console.log(` ${clr('green', '✚')} ${clr('green', 'NEW ')} ${dim(path.relative(process.cwd(), e.dest))}`);
372
395
  for (const e of modified) console.log(` ${clr('yellow', '~')} ${clr('yellow', 'MODIFIED')} ${path.relative(process.cwd(), e.dest)}`);
@@ -379,6 +402,11 @@ async function runInstall(label, mappings, force, destRoot) {
379
402
  );
380
403
  console.log();
381
404
 
405
+ if (dryRun) {
406
+ console.log(` ${clr('cyan', 'ℹ')} Dry run — no files were written.\n`);
407
+ return;
408
+ }
409
+
382
410
  for (const e of newFiles) {
383
411
  ensureDir(e.dest);
384
412
  fs.copyFileSync(e.src, e.dest);
@@ -386,7 +414,7 @@ async function runInstall(label, mappings, force, destRoot) {
386
414
 
387
415
  if (modified.length === 0) {
388
416
  console.log(` ${clr('green', '✔')} All new files copied. No conflicts.\n`);
389
- writeManifest(destRoot, newFileSet);
417
+ writeManifest(destRoot, newFileSet, installationMode);
390
418
  return;
391
419
  }
392
420
 
@@ -397,7 +425,7 @@ async function runInstall(label, mappings, force, destRoot) {
397
425
  fs.copyFileSync(e.src, e.dest);
398
426
  console.log(` ${clr('yellow', '↺')} ${dim(path.relative(process.cwd(), e.dest))}`);
399
427
  }
400
- writeManifest(destRoot, newFileSet);
428
+ writeManifest(destRoot, newFileSet, installationMode);
401
429
  return;
402
430
  }
403
431
 
@@ -426,7 +454,7 @@ async function runInstall(label, mappings, force, destRoot) {
426
454
  ` ${clr('gray', `✖ Kept as-is: ${skipped}`)}\n`
427
455
  );
428
456
 
429
- writeManifest(destRoot, newFileSet);
457
+ writeManifest(destRoot, newFileSet, installationMode);
430
458
  }
431
459
 
432
460
  // ── subagent spawn-depth check (verify & advise only — never write) ────────────
@@ -644,19 +672,912 @@ function updateGitignore(destDir) {
644
672
  }
645
673
  }
646
674
 
675
+ // ── payload & detection ───────────────────────────────────────────────────────
676
+
677
+ // Returns file-level {src, dest} mappings (absolute paths) for ALL distributable
678
+ // catalog assets, applying TOOLKIT_INTERNAL_ASSETS exclusions.
679
+ // This is the single authoritative source used by the installer (copy plan),
680
+ // resolver, doctor, and list-assets so that ALL four agree on exactly what a valid
681
+ // installation contains. effectiveRoot must be the .claude/ runtime directory.
682
+ function buildPayloadFileMappings(effectiveRoot) {
683
+ const { getAssetCategories } = require('../lib/asset-catalog');
684
+ const mappings = [];
685
+ for (const cat of getAssetCategories()) {
686
+ const srcDir = path.join(packageRoot, cat.sourceDir);
687
+ if (!fs.existsSync(srcDir)) continue;
688
+ const internalItems = TOOLKIT_INTERNAL_ASSETS[cat.name];
689
+ const catSubDir = cat.runtimeDir.replace(/^\.claude\//, '');
690
+ const destCatDir = path.join(effectiveRoot, catSubDir);
691
+ let entries;
692
+ try { entries = fs.readdirSync(srcDir); } catch (_) { continue; }
693
+ for (const item of entries) {
694
+ if (internalItems && internalItems.has(item)) continue;
695
+ const itemSrc = path.join(srcDir, item);
696
+ let stat;
697
+ try { stat = fs.statSync(itemSrc); } catch (_) { continue; }
698
+ if (stat.isDirectory()) {
699
+ try {
700
+ for (const f of walkDir(itemSrc)) {
701
+ const rel = path.relative(itemSrc, f);
702
+ mappings.push({ src: f, dest: path.join(destCatDir, item, rel) });
703
+ }
704
+ } catch (_) { /* ignore */ }
705
+ } else {
706
+ mappings.push({ src: itemSrc, dest: path.join(destCatDir, item) });
707
+ }
708
+ }
709
+ }
710
+ return mappings;
711
+ }
712
+
713
+ // Returns the Set of absolute runtime dest-paths derived from buildPayloadFileMappings.
714
+ // Used by resolver, doctor, and list-assets for completeness checks.
715
+ function buildExpectedPayload(effectiveRoot) {
716
+ return new Set(buildPayloadFileMappings(effectiveRoot).map(m => path.resolve(m.dest)));
717
+ }
718
+
719
+ // ── validateRuntimeRelativePath ───────────────────────────────────────────────
720
+ // Single source of truth for runtime-path safety and canonicalization. Used by
721
+ // BOTH resolveClaudeRuntimeAsset() (lenient: normalizes and accepts) and
722
+ // validateManifestFilesField() (strict: rejects any non-canonical form). The
723
+ // two callers format their own diagnostics, but traversal, absolute/drive paths,
724
+ // null bytes, confinement, and the canonical computation live here only.
725
+ //
726
+ // A path is safe when it is a non-empty string with no surrounding whitespace,
727
+ // no null byte, is neither a Unix/Windows absolute path nor drive-relative
728
+ // (e.g. 'C:foo'), and contains no '..' traversal segment.
729
+ // Its canonical form uses '/' as the sole separator with no '.'/empty segments,
730
+ // no double separators, and no trailing slash.
731
+ //
732
+ // options:
733
+ // root — absolute installation root; when given, the returned
734
+ // `resolved` is confined within it. NOTE: after the
735
+ // absolute/drive/traversal rejections above, a surviving
736
+ // value can never escape root, so the 'escapes-root' branch
737
+ // is unreachable defence in depth (retained against future
738
+ // OS/symlink normalization surprises).
739
+ // requireCanonical — when true, a value that is not already canonical is an
740
+ // error (code 'non-canonical'); the manifest validator uses
741
+ // this so doctor flags incoherent manifests instead of
742
+ // silently normalizing them.
743
+ //
744
+ // Returns on success: { ok: true, canonical, resolved, canonicalDiffers }
745
+ // Returns on failure: { ok: false, code, reason, canonical? }
746
+ function validateRuntimeRelativePath(value, options) {
747
+ const opts = options || {};
748
+ const fail = (code, reason, extra) => Object.assign({ ok: false, code, reason }, extra);
749
+
750
+ if (typeof value !== 'string') {
751
+ const found = value === null ? 'null' : value === undefined ? 'undefined' : typeof value;
752
+ return fail('not-string', `must be a non-empty string (found ${found})`);
753
+ }
754
+ if (value.length === 0) {
755
+ return fail('empty', 'must be a non-empty string (found empty string)');
756
+ }
757
+ if (value.includes('\0')) {
758
+ return fail('null-byte', 'must not contain null bytes');
759
+ }
760
+ if (value.trim().length === 0) {
761
+ return fail('whitespace-only', 'must not be a whitespace-only string');
762
+ }
763
+ if (value !== value.trim()) {
764
+ return fail('surrounding-whitespace', 'must not have leading or trailing whitespace');
765
+ }
766
+ // Normalize separators BEFORE the absolute-path checks. Otherwise a rooted
767
+ // Windows path ('\Windows\x.md') or a UNC path ('\\server\share\x.md') would
768
+ // slip past a raw startsWith('/') test and be reinterpreted as relative once
769
+ // the backslashes were converted. Per the Tech Spec: normalize '\' → '/' first,
770
+ // then reject anything that begins with '/'.
771
+ const unified = value.replace(/\\/g, '/');
772
+ if (unified.startsWith('/')) {
773
+ // Covers Unix absolute ('/etc/passwd'), rooted Windows ('\Windows\x.md' → '/…'),
774
+ // and UNC ('\\server\share' → '//server/share', or already-'/'-form '//server').
775
+ return fail('absolute-unix', 'must be relative (got Unix absolute path)');
776
+ }
777
+ if (/^[A-Za-z]:\//.test(unified)) {
778
+ return fail('absolute-windows', 'must be relative (got Windows absolute path)');
779
+ }
780
+ if (/^[A-Za-z]:/.test(unified)) {
781
+ return fail('drive-relative', 'must be relative (got Windows drive-relative path)');
782
+ }
783
+ if (unified.split('/').includes('..')) {
784
+ return fail('traversal', 'must not contain path traversal (..)');
785
+ }
786
+ // Canonical form: forward slashes, no '.'/empty segments, no double separators,
787
+ // no trailing slash. '..' is already rejected above, so it cannot survive here.
788
+ const canonical = unified.split('/').filter(s => s !== '' && s !== '.').join('/');
789
+ if (canonical.length === 0) {
790
+ return fail('empty-after-canonical', 'must resolve to a non-empty canonical path');
791
+ }
792
+ let resolved = null;
793
+ if (opts.root !== undefined && opts.root !== null) {
794
+ const rootResolved = path.resolve(opts.root);
795
+ resolved = path.resolve(rootResolved, canonical);
796
+ if (resolved !== rootResolved && !resolved.startsWith(rootResolved + path.sep)) {
797
+ return fail('escapes-root', 'escapes the installation root', { canonical });
798
+ }
799
+ }
800
+ const canonicalDiffers = value !== canonical;
801
+ if (opts.requireCanonical && canonicalDiffers) {
802
+ return fail('non-canonical', `is not canonical; expected '${canonical}'`, { canonical });
803
+ }
804
+ return { ok: true, canonical, resolved, canonicalDiffers };
805
+ }
806
+
807
+ // Validate the shape of a manifest 'files' field for the doctor's schema check.
808
+ // Returns an array of human-readable error strings; an empty array means valid.
809
+ // A valid 'files' field is an array whose every element is a canonical,
810
+ // destination-relative path (forward slashes only, no '.'/'..'/empty segments,
811
+ // no trailing slash) confined to destRoot and free of null bytes. Non-canonical
812
+ // entries are reported — never silently normalized — so doctor requires the
813
+ // installer to regenerate an incoherent manifest.
814
+ // destRoot is the installation destination (parent of .claude) paths resolve against.
815
+ function validateManifestFilesField(files, destRoot) {
816
+ const errors = [];
817
+ if (!Array.isArray(files)) {
818
+ errors.push(`'files' must be an array (found ${files === null ? 'null' : typeof files})`);
819
+ return errors;
820
+ }
821
+ const rootResolved = path.resolve(destRoot);
822
+ files.forEach((entry, i) => {
823
+ const check = validateRuntimeRelativePath(entry, { root: rootResolved, requireCanonical: true });
824
+ if (check.ok) return;
825
+ // Format the diagnostic inline — this is the only caller, so no shared helper.
826
+ // The entry is echoed (when a string) so operators can spot the culprit.
827
+ const shown = typeof entry === 'string' ? ` ('${entry}')` : '';
828
+ let msg;
829
+ switch (check.code) {
830
+ case 'traversal':
831
+ msg = `'files[${i}]' must not contain '..' segments (path traversal)${shown}`;
832
+ break;
833
+ case 'non-canonical':
834
+ msg = `'files[${i}]' is not canonical; expected '${check.canonical}'${shown}`;
835
+ break;
836
+ case 'escapes-root':
837
+ msg = `'files[${i}]' escapes the installation root${shown}`;
838
+ break;
839
+ default:
840
+ msg = `'files[${i}]' ${check.reason}${shown}`;
841
+ }
842
+ errors.push(msg);
843
+ });
844
+ return errors;
845
+ }
846
+
847
+ // Shared presence-detection helpers — used by resolver, doctor, and list-assets.
848
+ // condC: at least one catalog category directory inside claudeDir has files.
849
+ function hasToolkitPayloadFiles(claudeDir) {
850
+ const { getAssetCategories } = require('../lib/asset-catalog');
851
+ for (const cat of getAssetCategories()) {
852
+ const catDir = path.join(claudeDir, cat.runtimeDir.replace(/^\.claude\//, ''));
853
+ if (!fs.existsSync(catDir)) continue;
854
+ try { if (fs.statSync(catDir).isDirectory() && walkDir(catDir).length > 0) return true; }
855
+ catch (_) { /* ignore unreadable dirs */ }
856
+ }
857
+ return false;
858
+ }
859
+
860
+ // A toolkit installation is PRESENT at claudeDir when any of:
861
+ // condA: .ai-toolkit-manifest.json exists (content may be corrupt)
862
+ // condB: .ai-toolkit-version exists
863
+ // condC: at least one catalog category dir contains one or more files
864
+ // settings.json / settings.local.json alone do NOT satisfy any condition.
865
+ function isToolkitInstalled(claudeDir) {
866
+ if (fs.existsSync(path.join(claudeDir, '.ai-toolkit-manifest.json'))) return true;
867
+ if (fs.existsSync(path.join(claudeDir, '.ai-toolkit-version'))) return true;
868
+ return hasToolkitPayloadFiles(claudeDir);
869
+ }
870
+
871
+ // ── resolveClaudeRuntimeAsset ─────────────────────────────────────────────────
872
+
873
+ // INFRA-TASK-BE-02 (FTR-015):
874
+ // Resolve a runtime asset path using a six-phase algorithm:
875
+ // Phase 0: Input validation (no filesystem access)
876
+ // Phase A: Presence detection — local vs global installation
877
+ // Phase B: Mode decision — local-only | global-only | both (error) | none (error)
878
+ // Phase C: Metadata warnings — manifest, version stamp; emit to stderr; never abort
879
+ // Phase D: Catalog membership + completeness check
880
+ // Phase E: Confinement check + return absolute path
881
+ //
882
+ // Parameters:
883
+ // relativePath (string): path relative to .claude/, e.g. 'agents/agent-name.md'
884
+ // options (object): { projectDir, home }
885
+ // projectDir — target project directory (defaults to process.cwd())
886
+ // home — override for os.homedir() to enable test isolation
887
+ //
888
+ // Returns: absolute path string
889
+ // Throws: Error with diagnostics on validation failure, ambiguity, or incompleteness
890
+ function resolveClaudeRuntimeAsset(relativePath, options) {
891
+ const os = require('os');
892
+ const { getAssetCategories } = require('../lib/asset-catalog');
893
+
894
+ const opts = options || {};
895
+ const effectiveHome = opts.home !== undefined ? opts.home : os.homedir();
896
+ const effectiveProjectDir = opts.projectDir !== undefined ? opts.projectDir : process.cwd();
897
+
898
+ // ── Phase 0: Input validation (no filesystem access) ──────────────────────
899
+ // Delegate all path-safety + canonicalization to the shared primitive. The
900
+ // resolver is lenient (requireCanonical omitted): it normalizes backslashes,
901
+ // '.'/empty segments, and trailing slashes into the canonical form and
902
+ // proceeds, but still rejects null bytes, absolute/drive paths, and traversal.
903
+ const inputCheck = validateRuntimeRelativePath(relativePath);
904
+ if (!inputCheck.ok) {
905
+ throw new Error(`relativePath ${inputCheck.reason}`);
906
+ }
907
+ relativePath = inputCheck.canonical;
908
+
909
+ // ── Runtime root definitions ───────────────────────────────────────────────
910
+ const localRuntimeRoot = path.join(effectiveProjectDir, '.claude');
911
+ const globalRuntimeRoot = path.join(effectiveHome, '.claude');
912
+
913
+ // ── Phase A: Presence detection ────────────────────────────────────────────
914
+ const localPresent = isToolkitInstalled(localRuntimeRoot);
915
+ const globalPresent = isToolkitInstalled(globalRuntimeRoot);
916
+
917
+ // ── Phase B: Mode decision ─────────────────────────────────────────────────
918
+ if (!localPresent && !globalPresent) {
919
+ throw new Error(
920
+ "No toolkit installation found. Run 'npm run toolkit:dev-install-global' to install globally, or the installer in your project."
921
+ );
922
+ }
923
+ if (localPresent && globalPresent) {
924
+ throw new Error(
925
+ `Ambiguous: toolkit installations detected at both ${localRuntimeRoot} and ` +
926
+ `${globalRuntimeRoot}. The error is raised even if one installation has corrupt ` +
927
+ 'metadata — presence is established regardless of metadata validity.'
928
+ );
929
+ }
930
+
931
+ const effectiveRoot = localPresent ? localRuntimeRoot : globalRuntimeRoot;
932
+ const effectiveMode = localPresent ? 'local' : 'global';
933
+
934
+ // Confinement lives in the shared primitive: re-validate the (already canonical)
935
+ // relativePath against the now-known installation root and reuse its resolved
936
+ // absolute path everywhere below, instead of re-implementing path.resolve +
937
+ // startsWith here.
938
+ const rootedCheck = validateRuntimeRelativePath(relativePath, { root: effectiveRoot });
939
+ if (!rootedCheck.ok) {
940
+ throw new Error('Resolved path escapes installation root (confinement violation)');
941
+ }
942
+ const absoluteRequested = rootedCheck.resolved;
943
+
944
+ // ── Phase C: Metadata warnings (emit to stderr; never abort) ──────────────
945
+ const manifestPath = path.join(effectiveRoot, '.ai-toolkit-manifest.json');
946
+ let manifest = null;
947
+ let manifestSchemaValid = false;
948
+
949
+ if (!fs.existsSync(manifestPath)) {
950
+ process.stderr.write('Warning: No manifest found; installation may be manual or manifest was lost\n');
951
+ } else {
952
+ let parsed = null;
953
+ try {
954
+ parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
955
+ } catch (_) {
956
+ process.stderr.write(`Warning: Manifest is corrupt (invalid JSON) at ${manifestPath}\n`);
957
+ }
958
+ if (parsed !== null) {
959
+ const required = ['version', 'installedAt', 'installationMode', 'files'];
960
+ const missingFields = required.filter(f => parsed[f] === undefined);
961
+ if (missingFields.length > 0) {
962
+ process.stderr.write(`Warning: Manifest schema invalid: missing fields ${missingFields.join(', ')}\n`);
963
+ } else {
964
+ manifestSchemaValid = true;
965
+ manifest = parsed;
966
+ if (parsed.installationMode !== effectiveMode) {
967
+ process.stderr.write(
968
+ `Warning: Manifest installationMode='${parsed.installationMode}' mismatches effective mode '${effectiveMode}'\n`
969
+ );
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ const versionStampPath = path.join(effectiveRoot, '.ai-toolkit-version');
976
+ if (!fs.existsSync(versionStampPath)) {
977
+ process.stderr.write(`Warning: No version stamp at ${versionStampPath}\n`);
978
+ } else {
979
+ const stampVersion = fs.readFileSync(versionStampPath, 'utf8').trim();
980
+ const resolverVersion = require('../package.json').version;
981
+ if (stampVersion !== resolverVersion) {
982
+ process.stderr.write(
983
+ `Warning: Version mismatch: installed=${stampVersion}, resolver=${resolverVersion}; ` +
984
+ 'run toolkit:dev-install-global to update\n'
985
+ );
986
+ }
987
+ }
988
+
989
+ // ── Phase D: Catalog membership + completeness check ──────────────────────
990
+ // Use the shared distributable payload function so the resolver checks exactly
991
+ // the same files that the installer writes (TOOLKIT_INTERNAL_ASSETS excluded).
992
+ const expectedPayload = buildExpectedPayload(effectiveRoot);
993
+
994
+ // Step 6e: warn about stale manifest entries (in manifest but no longer in catalog)
995
+ if (manifestSchemaValid && manifest && Array.isArray(manifest.files)) {
996
+ const parentDir = path.join(effectiveRoot, '..');
997
+ const stale = manifest.files.filter(f => !expectedPayload.has(path.resolve(path.join(parentDir, f))));
998
+ if (stale.length > 0) {
999
+ process.stderr.write(
1000
+ `Warning: Manifest has ${stale.length} stale entries not in current catalog: ${stale.join(', ')}\n`
1001
+ );
1002
+ }
1003
+ }
1004
+
1005
+ // Step 8a: catalog membership — requested asset must be in expectedPayload
1006
+ if (!expectedPayload.has(absoluteRequested)) {
1007
+ throw new Error(
1008
+ `Requested asset '${relativePath}' is not a registered catalog asset (mode: ${effectiveMode}). ` +
1009
+ 'Only toolkit-installed catalog assets may be resolved; arbitrary files under .claude/ are ' +
1010
+ 'not resolvable via this function.'
1011
+ );
1012
+ }
1013
+
1014
+ // Step 9: warn about expected assets absent from manifest (metadata-staleness; disk is authoritative)
1015
+ if (manifestSchemaValid && manifest && Array.isArray(manifest.files)) {
1016
+ const parentDir = path.join(effectiveRoot, '..');
1017
+ const manifestAbsSet = new Set(
1018
+ manifest.files.map(f => path.resolve(path.join(parentDir, f)))
1019
+ );
1020
+ for (const f of expectedPayload) {
1021
+ if (!manifestAbsSet.has(f)) {
1022
+ process.stderr.write(`Warning: Expected asset missing from manifest: ${f}\n`);
1023
+ }
1024
+ }
1025
+ }
1026
+
1027
+ // Step 10: disk completeness — every expected catalog asset must exist on disk;
1028
+ // a stale manifest cannot suppress this error — disk truth is the only authority.
1029
+ const missingFiles = [...expectedPayload].filter(f => !fs.existsSync(f));
1030
+ if (missingFiles.length > 0) {
1031
+ throw new Error(
1032
+ `Installation incomplete (${effectiveMode}). Missing files:\n ${missingFiles.join('\n ')}\n` +
1033
+ 'Run the installer.'
1034
+ );
1035
+ }
1036
+
1037
+ // ── Phase E: Return path ───────────────────────────────────────────────────
1038
+ // Confinement was already enforced by the shared primitive above; absoluteRequested
1039
+ // is its confined, resolved absolute path (effectiveRoot already ends at .claude/).
1040
+ const absolutePath = absoluteRequested;
1041
+
1042
+ // Step 12: final existence check
1043
+ if (!fs.existsSync(absolutePath)) {
1044
+ throw new Error(`Asset not found at ${absolutePath} (mode: ${effectiveMode})`);
1045
+ }
1046
+
1047
+ // Step 13: return
1048
+ return absolutePath;
1049
+ }
1050
+
1051
+ // ── runDoctorResolution ───────────────────────────────────────────────────────
1052
+
1053
+ // US-07-TASK-BE-01 (FTR-015):
1054
+ // Human-readable diagnostics of runtime installation mode, asset resolution,
1055
+ // and potential conflicts. Read-only — never modifies or deletes any file.
1056
+ //
1057
+ // Parameters (via options object):
1058
+ // projectDir (string): target project directory (defaults to process.cwd())
1059
+ // home (string): override for os.homedir() to enable test isolation
1060
+ //
1061
+ // Output: writes to stdout; always exits 0 (reports, does not fail).
1062
+ function runDoctorResolution(options) {
1063
+ const os = require('os');
1064
+ const { getAssetCategories } = require('../lib/asset-catalog');
1065
+
1066
+ const opts = options || {};
1067
+ const effectiveHome = opts.home !== undefined ? path.resolve(opts.home) : os.homedir();
1068
+ const effectiveProject = opts.projectDir !== undefined ? path.resolve(opts.projectDir) : process.cwd();
1069
+
1070
+ const categories = getAssetCategories();
1071
+ const localRuntimeRoot = path.join(effectiveProject, '.claude');
1072
+ const globalRuntimeRoot = path.join(effectiveHome, '.claude');
1073
+
1074
+ // ── Internal helpers ────────────────────────────────────────────────────────
1075
+
1076
+ function catRelDir(cat) {
1077
+ return cat.runtimeDir.replace(/^\.claude\//, '');
1078
+ }
1079
+
1080
+ function countCategoryFiles(runtimeRoot, cat) {
1081
+ const catDir = path.join(runtimeRoot, catRelDir(cat));
1082
+ if (!fs.existsSync(catDir)) return 0;
1083
+ try { return walkDir(catDir).length; } catch (_) { return 0; }
1084
+ }
1085
+
1086
+ function readVersionStamp(runtimeRoot) {
1087
+ const vFile = path.join(runtimeRoot, '.ai-toolkit-version');
1088
+ if (!fs.existsSync(vFile)) return null;
1089
+ try { return fs.readFileSync(vFile, 'utf8').trim(); } catch (_) { return null; }
1090
+ }
1091
+
1092
+ // Returns { status: 'present'|'missing'|'corrupt', data: parsed|null, path: string }
1093
+ function readManifestInfo(runtimeRoot) {
1094
+ const manifestPath = path.join(runtimeRoot, '.ai-toolkit-manifest.json');
1095
+ if (!fs.existsSync(manifestPath)) return { status: 'missing', data: null, path: manifestPath };
1096
+ let raw;
1097
+ try { raw = fs.readFileSync(manifestPath, 'utf8'); } catch (_) {
1098
+ return { status: 'missing', data: null, path: manifestPath };
1099
+ }
1100
+ try {
1101
+ const parsed = JSON.parse(raw);
1102
+ return { status: 'present', data: parsed, path: manifestPath };
1103
+ } catch (_) {
1104
+ return { status: 'corrupt', data: null, path: manifestPath };
1105
+ }
1106
+ }
1107
+
1108
+ // Return category files present under runtimeRoot that are NOT in manifest.files.
1109
+ // Only meaningful when manifestData exists with a files array; returns [] otherwise.
1110
+ function findResiduals(runtimeRoot, manifestData) {
1111
+ if (!manifestData || !Array.isArray(manifestData.files)) return [];
1112
+ // Defensive: a malformed manifest may contain non-string entries; ignore them
1113
+ // here so doctor still produces a read-only report (the schema check flags them).
1114
+ const tracked = new Set(
1115
+ manifestData.files.filter(f => typeof f === 'string').map(f => f.replace(/\\/g, '/'))
1116
+ );
1117
+ const residuals = [];
1118
+ const parentDir = path.dirname(runtimeRoot); // e.g. /proj (parent of .claude)
1119
+ for (const cat of categories) {
1120
+ const catDir = path.join(runtimeRoot, catRelDir(cat));
1121
+ if (!fs.existsSync(catDir)) continue;
1122
+ try {
1123
+ for (const f of walkDir(catDir)) {
1124
+ const rel = path.relative(parentDir, f).replace(/\\/g, '/');
1125
+ if (!tracked.has(rel)) residuals.push(rel);
1126
+ }
1127
+ } catch (_) { /* ignore */ }
1128
+ }
1129
+ return residuals;
1130
+ }
1131
+
1132
+ // ── Detect installations ────────────────────────────────────────────────────
1133
+ const localPresent = isToolkitInstalled(localRuntimeRoot);
1134
+ const globalPresent = isToolkitInstalled(globalRuntimeRoot);
1135
+
1136
+ let mode, modeStatus;
1137
+ if (localPresent && globalPresent) { mode = 'both'; modeStatus = 'AMBIGUOUS'; }
1138
+ else if (localPresent) { mode = 'local-only'; modeStatus = 'VALID'; }
1139
+ else if (globalPresent) { mode = 'global-only'; modeStatus = 'VALID'; }
1140
+ else { mode = 'none'; modeStatus = 'NOT INSTALLED'; }
1141
+
1142
+ const presentRoots = [];
1143
+ if (localPresent) presentRoots.push({ label: 'Local', root: localRuntimeRoot });
1144
+ if (globalPresent) presentRoots.push({ label: 'Global', root: globalRuntimeRoot });
1145
+
1146
+ // ── Output helpers ──────────────────────────────────────────────────────────
1147
+ const L = (line = '') => console.log(line);
1148
+ const H = (title) => { L(bold(title)); L(divider('─', 68)); };
1149
+ const tick = clr('green', '✔');
1150
+ const cross = clr('red', '✖');
1151
+ const warnS = clr('yellow', '⚠');
1152
+
1153
+ // ── Header ──────────────────────────────────────────────────────────────────
1154
+ L('');
1155
+ L(clr('cyan', '╔══════════════════════════════════════════════════════════════════════╗'));
1156
+ L(clr('cyan', '║') + bold(clr('white', ' Claude Runtime Resolution Diagnostics ')) + clr('cyan', '║'));
1157
+ L(clr('cyan', '╚══════════════════════════════════════════════════════════════════════╝'));
1158
+ L('');
1159
+
1160
+ // ── Toolkit Source ──────────────────────────────────────────────────────────
1161
+ H('Toolkit Source');
1162
+ L(` Repository root: ${dim(packageRoot)}`);
1163
+ L(` Source directory: ${dim(path.join(packageRoot, 'src', 'claude'))}`);
1164
+ L('');
1165
+
1166
+ // ── Installation Detection ──────────────────────────────────────────────────
1167
+ H('Installation Detection');
1168
+ L(` Local runtime present: ${localPresent ? `${tick} ${dim(localRuntimeRoot)}` : `${cross} ${dim('not found')}`}`);
1169
+ L(` Global runtime present: ${globalPresent ? `${tick} ${dim(globalRuntimeRoot)}` : `${cross} ${dim('not found')}`}`);
1170
+ L('');
1171
+
1172
+ // ── Effective Runtime Mode ──────────────────────────────────────────────────
1173
+ H('Effective Runtime Mode');
1174
+ const modeColors = { 'local-only': 'green', 'global-only': 'green', 'both': 'yellow', 'none': 'red' };
1175
+ const statusColors = { 'VALID': 'green', 'AMBIGUOUS': 'yellow', 'NOT INSTALLED': 'red' };
1176
+ L(` Mode: ${clr(modeColors[mode], mode)}`);
1177
+ L(` Status: ${clr(statusColors[modeStatus] || 'gray', modeStatus)}`);
1178
+ L('');
1179
+
1180
+ // ── Runtime Inventory ───────────────────────────────────────────────────────
1181
+ for (const { label, root } of presentRoots) {
1182
+ H(`Runtime Inventory (${label})`);
1183
+ for (const cat of categories) {
1184
+ const count = countCategoryFiles(root, cat);
1185
+ const name = (cat.name.charAt(0).toUpperCase() + cat.name.slice(1)).padEnd(12);
1186
+ L(` ${name} ${clr(count > 0 ? 'cyan' : 'gray', String(count).padStart(3))} file${count !== 1 ? 's' : ' '}`);
1187
+ }
1188
+ L('');
1189
+ }
1190
+
1191
+ // ── Version Stamps ──────────────────────────────────────────────────────────
1192
+ H('Version Stamps');
1193
+ L(` Toolkit source version: ${clr('cyan', TOOLKIT_VERSION)} ${dim('(from package.json)')}`);
1194
+ if (localPresent) {
1195
+ const lv = readVersionStamp(localRuntimeRoot);
1196
+ const lvDisplay = lv || dim('not found');
1197
+ const lvColor = lv === TOOLKIT_VERSION ? 'green' : lv ? 'yellow' : 'gray';
1198
+ L(` Local installed version: ${lv ? clr(lvColor, lv) : lvDisplay} ${dim('(from .claude/.ai-toolkit-version)')}`);
1199
+ }
1200
+ if (globalPresent) {
1201
+ const gv = readVersionStamp(globalRuntimeRoot);
1202
+ const gvDisplay = gv || dim('not found');
1203
+ const gvColor = gv === TOOLKIT_VERSION ? 'green' : gv ? 'yellow' : 'gray';
1204
+ L(` Global installed version: ${gv ? clr(gvColor, gv) : gvDisplay} ${dim('(from ~/.claude/.ai-toolkit-version)')}`);
1205
+ }
1206
+ if (localPresent && globalPresent) {
1207
+ const lv = readVersionStamp(localRuntimeRoot);
1208
+ const gv = readVersionStamp(globalRuntimeRoot);
1209
+ const match = lv !== null && gv !== null && lv === gv;
1210
+ L(` Match: ${match ? tick : `${cross} ${clr('yellow', `local=${lv || '?'}, global=${gv || '?'}`)}`}`);
1211
+ }
1212
+ L('');
1213
+
1214
+ // ── Duplicate Files (only when both installations present) ──────────────────
1215
+ if (mode === 'both') {
1216
+ H('Duplicate Files (local vs global)');
1217
+ const dupes = [];
1218
+ for (const cat of categories) {
1219
+ const relDir = catRelDir(cat);
1220
+ const localCatDir = path.join(localRuntimeRoot, relDir);
1221
+ const globalCatDir = path.join(globalRuntimeRoot, relDir);
1222
+ if (!fs.existsSync(localCatDir) || !fs.existsSync(globalCatDir)) continue;
1223
+ try {
1224
+ const localFiles = walkDir(localCatDir).map(f => path.relative(localCatDir, f).replace(/\\/g, '/'));
1225
+ const globalSet = new Set(walkDir(globalCatDir).map(f => path.relative(globalCatDir, f).replace(/\\/g, '/')));
1226
+ for (const f of localFiles) {
1227
+ if (!globalSet.has(f)) continue;
1228
+ const lp = path.join(localCatDir, f);
1229
+ const gp = path.join(globalCatDir, f);
1230
+ const same = fileHash(lp) === fileHash(gp);
1231
+ dupes.push({ relPath: `${relDir}/${f}`, same });
1232
+ }
1233
+ } catch (_) { /* ignore */ }
1234
+ }
1235
+ if (dupes.length === 0) {
1236
+ L(` ${dim('(no files appear in both installations)')}`);
1237
+ } else {
1238
+ for (const d of dupes) {
1239
+ L(` ${dim(d.relPath)}: ${d.same ? `${tick} content matches` : `${cross} content differs`}`);
1240
+ }
1241
+ }
1242
+ L('');
1243
+ }
1244
+
1245
+ // ── Manifest Consistency ────────────────────────────────────────────────────
1246
+ H('Manifest Consistency');
1247
+ if (presentRoots.length === 0) {
1248
+ L(` ${dim('(no installation to check)')}`);
1249
+ } else {
1250
+ for (const { label, root } of presentRoots) {
1251
+ const mInfo = readManifestInfo(root);
1252
+ const mSymbol = { present: tick, missing: warnS, corrupt: cross }[mInfo.status];
1253
+ const mStatusColor = { present: 'green', missing: 'yellow', corrupt: 'red' }[mInfo.status];
1254
+ L(` ${bold(label)} manifest:`);
1255
+ L(` File: ${dim(mInfo.path)}`);
1256
+ L(` Status: ${mSymbol} ${clr(mStatusColor, mInfo.status)}`);
1257
+ if (mInfo.status === 'present' && mInfo.data) {
1258
+ const fCount = Array.isArray(mInfo.data.files) ? mInfo.data.files.length : '?';
1259
+ L(` Files: ${fCount}`);
1260
+ if (mInfo.data.version) L(` Version: ${mInfo.data.version}`);
1261
+ if (mInfo.data.installationMode) L(` Installation mode: ${mInfo.data.installationMode}`);
1262
+ }
1263
+ }
1264
+ }
1265
+ L('');
1266
+
1267
+ // ── Residual .claude/ Assets ────────────────────────────────────────────────
1268
+ H('Residual .claude/ Assets');
1269
+ if (presentRoots.length === 0) {
1270
+ L(` ${dim('(no installation to check)')}`);
1271
+ } else {
1272
+ for (const { label, root } of presentRoots) {
1273
+ const mInfo = readManifestInfo(root);
1274
+ if (mInfo.status !== 'present') {
1275
+ L(` ${label}: ${warnS} ${dim('cannot check residuals — manifest missing or corrupt')}`);
1276
+ } else {
1277
+ const residuals = findResiduals(root, mInfo.data);
1278
+ if (residuals.length === 0) {
1279
+ L(` ${label}: ${tick} ${clr('green', 'CLEAN')} ${dim('(all category files tracked in manifest)')}`);
1280
+ } else {
1281
+ L(` ${label}: ${warnS} ${clr('yellow', 'WARNING')} — ${residuals.length} untracked file(s):`);
1282
+ for (const r of residuals.slice(0, 10)) L(` ${dim(r)}`);
1283
+ if (residuals.length > 10) L(` ${dim(`... and ${residuals.length - 10} more`)}`);
1284
+ }
1285
+ }
1286
+ }
1287
+ }
1288
+ L('');
1289
+
1290
+ // ── Disk Completeness ─────────────────────────────────────────────────────────
1291
+ H('Disk Completeness');
1292
+ let diskIncomplete = false;
1293
+ let missingRuntimeFiles = [];
1294
+
1295
+ if (mode === 'local-only' || mode === 'global-only') {
1296
+ const singleRoot = mode === 'local-only' ? localRuntimeRoot : globalRuntimeRoot;
1297
+ const expected = buildExpectedPayload(singleRoot);
1298
+ missingRuntimeFiles = [...expected].filter(f => !fs.existsSync(f));
1299
+ diskIncomplete = missingRuntimeFiles.length > 0;
1300
+ }
1301
+
1302
+ if (mode !== 'local-only' && mode !== 'global-only') {
1303
+ L(` ${dim('(check runs only for single-installation mode)')}`);
1304
+ } else if (!diskIncomplete) {
1305
+ L(` ${tick} ${clr('green', 'COMPLETE')} ${dim('all catalog files present on disk')}`);
1306
+ } else {
1307
+ L(` ${cross} ${clr('red', 'INCOMPLETE')} — ${missingRuntimeFiles.length} required file(s) missing:`);
1308
+ for (const f of missingRuntimeFiles.slice(0, 10)) {
1309
+ L(` ${clr('red', '✖')} ${dim(f)}`);
1310
+ }
1311
+ if (missingRuntimeFiles.length > 10) L(` ${dim(`... and ${missingRuntimeFiles.length - 10} more`)}`);
1312
+ }
1313
+ L('');
1314
+
1315
+ // ── Action Items ────────────────────────────────────────────────────────────
1316
+ H('Action Items');
1317
+ const actions = [];
1318
+ let manifestSchemaProblematic = false;
1319
+ let manifestModeInconsistent = false;
1320
+ let manifestFilesIncomplete = false;
1321
+
1322
+ if (mode === 'both') {
1323
+ actions.push(`${warnS} Choose one: delete local or global installation to resolve ambiguity`);
1324
+ }
1325
+ if (mode === 'none') {
1326
+ actions.push(`${cross} No installation found. Run 'npm run toolkit:dev-install-global' or the local installer`);
1327
+ }
1328
+ if (mode === 'local-only' || mode === 'global-only') {
1329
+ const effectiveRoot = mode === 'local-only' ? localRuntimeRoot : globalRuntimeRoot;
1330
+ const detectedMode = mode === 'local-only' ? 'local' : 'global';
1331
+ const iv = readVersionStamp(effectiveRoot);
1332
+ if (iv && iv !== TOOLKIT_VERSION) {
1333
+ actions.push(`${warnS} Version mismatch: run 'npm run toolkit:dev-install-global' to update`);
1334
+ }
1335
+ const mInfo = readManifestInfo(effectiveRoot);
1336
+ if (mInfo.status === 'corrupt') {
1337
+ manifestSchemaProblematic = true;
1338
+ actions.push(`${cross} Manifest schema invalid: file is corrupt (invalid JSON)`);
1339
+ } else if (mInfo.status === 'missing') {
1340
+ manifestSchemaProblematic = true;
1341
+ actions.push(`${warnS} Manifest is ${mInfo.status}. Run the installer to regenerate.`);
1342
+ } else if (mInfo.status === 'present' && mInfo.data) {
1343
+ // Check required manifest fields (including installationMode).
1344
+ const required = ['version', 'installedAt', 'installationMode', 'files'];
1345
+ const missingFields = required.filter(f => mInfo.data[f] === undefined);
1346
+ if (missingFields.length > 0) {
1347
+ manifestSchemaProblematic = true;
1348
+ for (const f of missingFields) {
1349
+ actions.push(`${cross} Manifest schema invalid: missing required field '${f}'`);
1350
+ }
1351
+ actions.push(`${warnS} Run the installer to regenerate the manifest with all required fields.`);
1352
+ } else {
1353
+ const parentDir = path.join(effectiveRoot, '..');
1354
+ // installationMode must match the detected installation mode.
1355
+ if (mInfo.data.installationMode !== detectedMode) {
1356
+ manifestModeInconsistent = true;
1357
+ actions.push(
1358
+ `${cross} Manifest installationMode='${mInfo.data.installationMode}' does not match ` +
1359
+ `detected mode '${detectedMode}' — run the installer to repair`
1360
+ );
1361
+ }
1362
+ // manifest.files must be a well-formed array of relative, in-bounds paths.
1363
+ const filesErrors = validateManifestFilesField(mInfo.data.files, parentDir);
1364
+ if (filesErrors.length > 0) {
1365
+ manifestSchemaProblematic = true;
1366
+ for (const e of filesErrors.slice(0, 5)) {
1367
+ actions.push(`${cross} Manifest schema invalid: ${e}`);
1368
+ }
1369
+ if (filesErrors.length > 5) {
1370
+ actions.push(`${cross} ... and ${filesErrors.length - 5} more manifest 'files' error(s)`);
1371
+ }
1372
+ actions.push(`${warnS} Run the installer to regenerate the manifest with a valid 'files' list.`);
1373
+ } else {
1374
+ // files is a valid array — it must cover all expected catalog assets.
1375
+ const expected = buildExpectedPayload(effectiveRoot);
1376
+ const manifestAbsSet = new Set(
1377
+ mInfo.data.files.map(f => path.resolve(path.join(parentDir, f)))
1378
+ );
1379
+ const missingFromManifest = [...expected].filter(f => !manifestAbsSet.has(f));
1380
+ if (missingFromManifest.length > 0) {
1381
+ manifestFilesIncomplete = true;
1382
+ actions.push(
1383
+ `${cross} manifest.files is missing ${missingFromManifest.length} expected catalog asset(s) — run the installer`
1384
+ );
1385
+ for (const f of missingFromManifest.slice(0, 3)) {
1386
+ actions.push(` ${dim(path.basename(f))}`);
1387
+ }
1388
+ if (missingFromManifest.length > 3) {
1389
+ actions.push(` ${dim(`... and ${missingFromManifest.length - 3} more`)}`);
1390
+ }
1391
+ }
1392
+ }
1393
+ }
1394
+ }
1395
+ }
1396
+ if (diskIncomplete) {
1397
+ const display = missingRuntimeFiles.slice(0, 5);
1398
+ for (const f of display) {
1399
+ actions.push(`${cross} Missing required file: ${dim(path.basename(f))} — run the installer`);
1400
+ }
1401
+ if (missingRuntimeFiles.length > 5) {
1402
+ actions.push(`${cross} ... and ${missingRuntimeFiles.length - 5} more missing file(s) — run the installer`);
1403
+ }
1404
+ }
1405
+
1406
+ if (actions.length === 0) {
1407
+ L(` ${tick} ${clr('green', 'runtime ready for pipelines')}`);
1408
+ } else {
1409
+ for (const a of actions) L(` • ${a}`);
1410
+ }
1411
+ L('');
1412
+
1413
+ // ── Summary ─────────────────────────────────────────────────────────────────
1414
+ H('Summary');
1415
+ // READY requires: single installation, valid manifest schema, consistent installationMode,
1416
+ // manifest.files covering all expected assets, and complete disk payload.
1417
+ const overallOk = (mode === 'local-only' || mode === 'global-only')
1418
+ && !manifestSchemaProblematic
1419
+ && !manifestModeInconsistent
1420
+ && !manifestFilesIncomplete
1421
+ && !diskIncomplete;
1422
+ const overallStatus = overallOk ? 'READY' : 'PROBLEMATIC';
1423
+ L(` Status: ${clr(overallOk ? 'green' : 'red', overallStatus)}`);
1424
+ let recommendation;
1425
+ if (mode === 'none') {
1426
+ recommendation = "Run 'npm run toolkit:dev-install-global' to install globally, or use the local installer.";
1427
+ } else if (mode === 'both') {
1428
+ recommendation = 'Remove one installation to resolve ambiguity before running pipelines.';
1429
+ } else if (manifestSchemaProblematic || manifestModeInconsistent || manifestFilesIncomplete) {
1430
+ recommendation = 'Run the installer to repair the manifest before running pipelines.';
1431
+ } else if (diskIncomplete) {
1432
+ recommendation = 'Run the installer to complete the installation before running pipelines.';
1433
+ } else {
1434
+ recommendation = `Installation is operational in ${mode} mode.`;
1435
+ }
1436
+ L(` Recommendation: ${dim(recommendation)}`);
1437
+ L('');
1438
+ L(clr('gray', '═'.repeat(72)));
1439
+ L('');
1440
+ }
1441
+
1442
+ // ── validatePurityGuard ───────────────────────────────────────────────────────
1443
+
1444
+ // US-05-TASK-BE-03 (FTR-015):
1445
+ // Rejects any *.test.js files or blocked directory segments under sourceDir.
1446
+ // Called by the installer before copying and by the prepack lifecycle hook.
1447
+ //
1448
+ // Blocked file patterns: *.test.js
1449
+ // Blocked directory names: tests, fixtures, mocks, helpers
1450
+ //
1451
+ // Returns an array of violation strings (empty = clean).
1452
+ // Never modifies the filesystem.
1453
+ const PURITY_BLOCKED_DIRS = new Set(['tests', 'fixtures', 'mocks', 'helpers']);
1454
+ const PURITY_BLOCKED_EXTS = ['.test.js'];
1455
+
1456
+ function validatePurityGuard(sourceDir) {
1457
+ const violations = [];
1458
+
1459
+ function walk(dir) {
1460
+ if (!fs.existsSync(dir)) return;
1461
+ let entries;
1462
+ try { entries = fs.readdirSync(dir); } catch (_) { return; }
1463
+ for (const entry of entries) {
1464
+ const full = path.join(dir, entry);
1465
+ let stat;
1466
+ try { stat = fs.statSync(full); } catch (_) { continue; }
1467
+ if (stat.isDirectory()) {
1468
+ if (PURITY_BLOCKED_DIRS.has(entry)) {
1469
+ violations.push(`Blocked directory: ${full}`);
1470
+ } else {
1471
+ walk(full);
1472
+ }
1473
+ } else {
1474
+ if (PURITY_BLOCKED_EXTS.some(ext => entry.endsWith(ext))) {
1475
+ violations.push(`Blocked file: ${full}`);
1476
+ }
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ walk(sourceDir);
1482
+ return violations;
1483
+ }
1484
+
1485
+ // validate-purity CLI command entry point.
1486
+ // Scans sourceDir (defaults to src/claude/) for test or fixture contamination.
1487
+ // Exit 0 = clean. Exit 1 = violations found. Violations written to stderr.
1488
+ function runValidatePurity(sourceDir) {
1489
+ const effectiveDir = sourceDir
1490
+ ? path.resolve(sourceDir)
1491
+ : path.join(packageRoot, 'src', 'claude');
1492
+
1493
+ const violations = validatePurityGuard(effectiveDir);
1494
+ if (violations.length === 0) {
1495
+ process.stdout.write(`Purity guard: PASS — no test files or blocked dirs under ${effectiveDir}\n`);
1496
+ process.exit(0);
1497
+ } else {
1498
+ process.stderr.write(`Purity guard: FAIL — ${violations.length} violation(s) under ${effectiveDir}:\n`);
1499
+ for (const v of violations) process.stderr.write(` ${v}\n`);
1500
+ process.exit(1);
1501
+ }
1502
+ }
1503
+
1504
+ // ── distributable asset mapping ───────────────────────────────────────────────
1505
+
1506
+ // Assets that are toolkit-internal and must NEVER be distributed to consumer
1507
+ // projects. Keyed by category name; value is a Set of item names to skip.
1508
+ // See AGENTS.md hard constraints.
1509
+ const TOOLKIT_INTERNAL_ASSETS = {
1510
+ agents: new Set(['install-toolkit.md']),
1511
+ skills: new Set(['install-toolkit']),
1512
+ };
1513
+
1514
+ // ── verifyInstall ─────────────────────────────────────────────────────────────
1515
+
1516
+ // Checks that the version stamp in <home>/.claude/.ai-toolkit-version matches
1517
+ // package.json. Called as the final step of toolkit:dev-install-global.
1518
+ // Exit 0 = versions match. Exit 1 = stamp missing, unreadable, or wrong version.
1519
+ function runVerifyInstall(options) {
1520
+ const os = require('os');
1521
+ const opts = options || {};
1522
+ const effectiveHome = opts.home !== undefined ? path.resolve(opts.home) : os.homedir();
1523
+ const versionFile = path.join(effectiveHome, '.claude', VERSION_FILE);
1524
+
1525
+ if (!fs.existsSync(versionFile)) {
1526
+ process.stderr.write(`Error: verify-install FAILED — no version stamp at ${versionFile}\n`);
1527
+ process.exit(1);
1528
+ }
1529
+
1530
+ let stamp;
1531
+ try {
1532
+ stamp = fs.readFileSync(versionFile, 'utf8').trim();
1533
+ } catch (err) {
1534
+ process.stderr.write(`Error: verify-install FAILED — cannot read ${versionFile}: ${err.message}\n`);
1535
+ process.exit(1);
1536
+ }
1537
+
1538
+ if (!stamp) {
1539
+ process.stderr.write(`Error: verify-install FAILED — version stamp is empty at ${versionFile}\n`);
1540
+ process.exit(1);
1541
+ }
1542
+
1543
+ if (stamp !== TOOLKIT_VERSION) {
1544
+ process.stderr.write(
1545
+ `Error: verify-install FAILED — version mismatch\n` +
1546
+ ` installed: ${stamp}\n` +
1547
+ ` expected: ${TOOLKIT_VERSION}\n` +
1548
+ `Run 'npm run toolkit:dev-install-global' to update.\n`
1549
+ );
1550
+ process.exit(1);
1551
+ }
1552
+
1553
+ process.stdout.write(`verify-install: PASS — version ${stamp} matches package.json\n`);
1554
+ process.exit(0);
1555
+ }
1556
+
647
1557
  // ── entry points ──────────────────────────────────────────────────────────────
648
1558
 
649
- async function installLocal(targetDir, force) {
1559
+ async function installLocal(targetDir, force, dryRun = false) {
650
1560
  banner();
651
1561
  targetDir = path.resolve(process.cwd(), targetDir || '.');
652
1562
  console.log(` ${clr('cyan', '▸')} Target: ${bold(targetDir)}\n`);
653
- await checkVersion(targetDir, force);
1563
+ if (!dryRun) await checkVersion(targetDir, force);
1564
+ // Purity guard (UC-05 / BR-16 / AC-29): abort if source contains test files or blocked dirs.
1565
+ const srcClaudeDir = path.join(packageRoot, 'src', 'claude');
1566
+ const purityViolations = validatePurityGuard(srcClaudeDir);
1567
+ if (purityViolations.length > 0) {
1568
+ process.stderr.write(`Purity guard: FAIL — ${purityViolations.length} violation(s):\n`);
1569
+ for (const v of purityViolations) process.stderr.write(` ${v}\n`);
1570
+ process.exit(1);
1571
+ }
1572
+ // Build copy plan from the single canonical source (buildPayloadFileMappings) so
1573
+ // installer, resolver, doctor, and list-assets all use the same distributable set.
654
1574
  const mappings = [
655
- { src: path.join(packageRoot, '.claude'), dest: path.join(targetDir, '.claude') },
1575
+ ...buildPayloadFileMappings(path.join(targetDir, '.claude')),
656
1576
  { src: path.join(packageRoot, 'docs'), dest: path.join(targetDir, 'docs') },
657
1577
  { src: path.join(packageRoot, 'CLAUDE.md'), dest: path.join(targetDir, 'CLAUDE.md') },
658
1578
  ];
659
- await runInstall(`local project`, mappings, force, targetDir);
1579
+ await runInstall(`local project`, mappings, force, targetDir, dryRun, 'local');
1580
+ if (dryRun) return;
660
1581
  writeInstalledVersion(targetDir);
661
1582
  console.log(` ${clr('green', '✔')} ${bold('Install complete.')}\n`);
662
1583
  checkSpawnDepth(targetDir);
@@ -667,24 +1588,32 @@ async function installLocal(targetDir, force) {
667
1588
  console.log();
668
1589
  }
669
1590
 
670
- async function installGlobal(force) {
1591
+ async function installGlobal(force, dryRun = false, homeOverride = undefined) {
671
1592
  banner();
672
1593
  try {
673
- const homedir = require('os').homedir();
1594
+ const homedir = homeOverride !== undefined ? path.resolve(homeOverride) : require('os').homedir();
674
1595
  const target = path.join(homedir, '.claude');
675
1596
  console.log(` ${clr('cyan', '▸')} Target: ${bold(target)} ${clr('gray', '(global Claude folder)')}\n`);
676
- await checkVersion(target, force);
1597
+ // destRoot is homedir so helpers (manifest, version stamp, trash) resolve to
1598
+ // ~/.claude/... rather than ~/.claude/.claude/... (FIX: global install root path)
1599
+ if (!dryRun) await checkVersion(homedir, force);
1600
+ // Purity guard (UC-05 / BR-16 / AC-29): abort if source contains test files or blocked dirs.
1601
+ const srcClaudeDir = path.join(packageRoot, 'src', 'claude');
1602
+ const purityViolations = validatePurityGuard(srcClaudeDir);
1603
+ if (purityViolations.length > 0) {
1604
+ process.stderr.write(`Purity guard: FAIL — ${purityViolations.length} violation(s):\n`);
1605
+ for (const v of purityViolations) process.stderr.write(` ${v}\n`);
1606
+ process.exit(1);
1607
+ }
1608
+ // Build copy plan from the single canonical source (buildPayloadFileMappings).
677
1609
  const mappings = [
678
- { src: path.join(packageRoot, '.claude', 'agents'), dest: path.join(target, 'agents') },
679
- { src: path.join(packageRoot, '.claude', 'skills'), dest: path.join(target, 'skills') },
680
- { src: path.join(packageRoot, '.claude', 'commands'), dest: path.join(target, 'commands') },
681
- { src: path.join(packageRoot, '.claude', 'workflows'), dest: path.join(target, 'workflows') },
682
- { src: path.join(packageRoot, '.claude', 'scripts'), dest: path.join(target, 'scripts') },
683
- { src: path.join(packageRoot, 'docs'), dest: path.join(target, 'docs') },
684
- { src: path.join(packageRoot, 'CLAUDE.global.md'), dest: path.join(target, 'CLAUDE.md') },
1610
+ ...buildPayloadFileMappings(target),
1611
+ { src: path.join(packageRoot, 'docs'), dest: path.join(target, 'docs') },
1612
+ { src: path.join(packageRoot, 'CLAUDE.global.md'), dest: path.join(target, 'CLAUDE.md') },
685
1613
  ];
686
- await runInstall('global Claude folder', mappings, force, target);
687
- writeInstalledVersion(target);
1614
+ await runInstall('global Claude folder', mappings, force, homedir, dryRun, 'global');
1615
+ if (dryRun) return;
1616
+ writeInstalledVersion(homedir);
688
1617
  console.log(` ${clr('green', '✔')} ${bold('Global install complete.')}\n`);
689
1618
  checkSpawnDepth(homedir);
690
1619
  console.log(divider());
@@ -705,22 +1634,38 @@ function help() {
705
1634
  console.log(` ${clr('cyan', 'ai-toolkit')} ${clr('yellow', '--local <dir>')} Install into target directory`);
706
1635
  console.log(` ${clr('cyan', 'ai-toolkit')} ${clr('yellow', '--global')} Install into ~/.claude (global)`);
707
1636
  console.log(` ${clr('gray', ' --force')} Overwrite all files without prompting`);
1637
+ console.log(` ${clr('gray', ' --dry-run')} Preview what would change — no files written`);
708
1638
  console.log();
709
1639
  }
710
1640
 
711
1641
  async function main() {
712
- const argv = process.argv.slice(2);
713
- const force = argv.includes('--force');
1642
+ const argv = process.argv.slice(2);
1643
+ const force = argv.includes('--force');
1644
+ const dryRun = argv.includes('--dry-run');
714
1645
 
715
- if (argv.length === 0 || (argv.length === 1 && argv[0] === '--force')) {
716
- await installLocal('.', force);
1646
+ if (argv.length === 0 || argv.every(a => a === '--force' || a === '--dry-run')) {
1647
+ await installLocal('.', force, dryRun);
717
1648
  return;
718
1649
  }
719
1650
 
720
1651
  if (argv[0] === '--local') {
721
- await installLocal(argv[1] || '.', force);
1652
+ await installLocal(argv[1] || '.', force, dryRun);
722
1653
  } else if (argv[0] === '--global') {
723
- await installGlobal(force);
1654
+ const homeIdx = argv.indexOf('--home');
1655
+ const homeOverride = homeIdx !== -1 ? argv[homeIdx + 1] : undefined;
1656
+ await installGlobal(force, dryRun, homeOverride);
1657
+ } else if (argv[0] === 'install') {
1658
+ // Subcommand aliases: `install --project <dir>` ≡ `--local <dir>`
1659
+ // `install --global` ≡ `--global`
1660
+ const sub = argv[1];
1661
+ if (sub === '--project') {
1662
+ await installLocal(argv[2] || '.', force, dryRun);
1663
+ } else if (sub === '--global') {
1664
+ await installGlobal(force, dryRun);
1665
+ } else {
1666
+ // No sub-flag: install into current directory (same as bare invocation)
1667
+ await installLocal(sub && !sub.startsWith('-') ? sub : '.', force, dryRun);
1668
+ }
724
1669
  } else if (argv[0] === 'help' || argv[0] === '--help') {
725
1670
  help();
726
1671
  } else if (argv[0] === 'merge-allowlist') {
@@ -759,8 +1704,166 @@ async function main() {
759
1704
  console.error(`Error: ${err.message}`);
760
1705
  process.exit(1);
761
1706
  }
1707
+ } else if (argv[0] === 'verify-install') {
1708
+ const remaining = argv.slice(1);
1709
+ const homeIdx = remaining.indexOf('--home');
1710
+ const home = homeIdx !== -1 ? remaining[homeIdx + 1] : undefined;
1711
+ runVerifyInstall({ home });
1712
+ } else if (argv[0] === 'doctor' && argv[1] === 'resolution') {
1713
+ const remaining = argv.slice(2);
1714
+ const projectIdx = remaining.indexOf('--project');
1715
+ const homeIdx = remaining.indexOf('--home');
1716
+ const projectDir = projectIdx !== -1 ? remaining[projectIdx + 1] : process.cwd();
1717
+ const home = homeIdx !== -1 ? remaining[homeIdx + 1] : undefined;
1718
+ runDoctorResolution({ projectDir, home });
1719
+ process.exit(0);
1720
+ } else if (argv[0] === 'validate-purity') {
1721
+ runValidatePurity(argv[1]);
1722
+ } else if (argv[0] === 'resolve-asset') {
1723
+ // resolve-asset <relativePath> [--project <dir>] [--home <dir>]
1724
+ // Three-tier model: Tier 1 = exit 0 + stdout=path + stderr="";
1725
+ // Tier 2 = exit 0 + stdout=path + stderr=warnings (emitted by resolver);
1726
+ // Tier 3 = exit 1 + stdout="" + stderr=error.
1727
+ const remaining = argv.slice(1);
1728
+ let relativePath = null;
1729
+ let projectDir = process.cwd();
1730
+ let home;
1731
+ for (let i = 0; i < remaining.length; i++) {
1732
+ if (remaining[i] === '--project' && remaining[i + 1]) { projectDir = remaining[++i]; }
1733
+ else if (remaining[i] === '--home' && remaining[i + 1]) { home = remaining[++i]; }
1734
+ else if (!remaining[i].startsWith('-')) { relativePath = remaining[i]; }
1735
+ }
1736
+ if (!relativePath) {
1737
+ process.stderr.write('Error: resolve-asset requires a relative path argument\n');
1738
+ process.exit(1);
1739
+ }
1740
+ try {
1741
+ const resolved = resolveClaudeRuntimeAsset(relativePath, { projectDir, home });
1742
+ process.stdout.write(resolved + '\n');
1743
+ process.exit(0);
1744
+ } catch (err) {
1745
+ process.stderr.write(err.message + '\n');
1746
+ process.exit(1);
1747
+ }
1748
+ } else if (argv[0] === 'run-asset') {
1749
+ // run-asset <relativePath> [--project <dir>] [--home <dir>] [-- ...scriptArgs]
1750
+ // Security constraints: scripts category only; .js extension only; spawnSync shell:false.
1751
+ const remaining = argv.slice(1);
1752
+ const dashDashIdx = remaining.indexOf('--');
1753
+ const scriptArgs = dashDashIdx !== -1 ? remaining.slice(dashDashIdx + 1) : [];
1754
+ const beforeDash = dashDashIdx !== -1 ? remaining.slice(0, dashDashIdx) : remaining;
1755
+ let relativePath = null;
1756
+ let projectDir = process.cwd();
1757
+ let home;
1758
+ for (let i = 0; i < beforeDash.length; i++) {
1759
+ if (beforeDash[i] === '--project' && beforeDash[i + 1]) { projectDir = beforeDash[++i]; }
1760
+ else if (beforeDash[i] === '--home' && beforeDash[i + 1]) { home = beforeDash[++i]; }
1761
+ else if (!beforeDash[i].startsWith('-')) { relativePath = beforeDash[i]; }
1762
+ }
1763
+ if (!relativePath) {
1764
+ process.stderr.write('Error: run-asset requires a relative path argument\n');
1765
+ process.exit(1);
1766
+ }
1767
+ // Security: only the scripts category is executable
1768
+ if (!relativePath.startsWith('scripts/')) {
1769
+ process.stderr.write(`Error: run-asset is restricted to the scripts category (got: ${relativePath})\n`);
1770
+ process.exit(1);
1771
+ }
1772
+ // Security: .js extension only
1773
+ if (!relativePath.endsWith('.js')) {
1774
+ process.stderr.write(`Error: run-asset only executes .js files (got: ${relativePath})\n`);
1775
+ process.exit(1);
1776
+ }
1777
+ try {
1778
+ const scriptPath = resolveClaudeRuntimeAsset(relativePath, { projectDir, home });
1779
+ const { spawnSync } = require('child_process');
1780
+ const result = spawnSync(process.execPath, [scriptPath, ...scriptArgs], {
1781
+ stdio: 'inherit',
1782
+ shell: false,
1783
+ });
1784
+ if (result.error) {
1785
+ process.stderr.write(`Error spawning script: ${result.error.message}\n`);
1786
+ process.exit(1);
1787
+ }
1788
+ process.exit(result.status || 0);
1789
+ } catch (err) {
1790
+ process.stderr.write(err.message + '\n');
1791
+ process.exit(1);
1792
+ }
1793
+ } else if (argv[0] === 'list-assets') {
1794
+ // list-assets --project <dir> --category <name> [--format json|plain] [--home <dir>]
1795
+ // Both --project and --category are MANDATORY (Tech Spec contract).
1796
+ // Returns exclusively assets from the distributable payload (buildExpectedPayload).
1797
+ // Foreign/user-created files under .claude/ are never returned.
1798
+ // Exit 0 + result for installed single-mode; exit 1 for no-install or mixed install.
1799
+ const { getAssetCategories, getCategoryByName } = require('../lib/asset-catalog');
1800
+ const os = require('os');
1801
+ const remaining = argv.slice(1);
1802
+ let category = null;
1803
+ let format = 'json';
1804
+ let projectDir = null;
1805
+ let home;
1806
+ for (let i = 0; i < remaining.length; i++) {
1807
+ if (remaining[i] === '--category' && remaining[i + 1]) { category = remaining[++i]; }
1808
+ else if (remaining[i] === '--format' && remaining[i + 1]) { format = remaining[++i]; }
1809
+ else if (remaining[i] === '--project' && remaining[i + 1]) { projectDir = remaining[++i]; }
1810
+ else if (remaining[i] === '--home' && remaining[i + 1]) { home = remaining[++i]; }
1811
+ }
1812
+ // Mandatory parameter contract (Tier 3: exit 1, stdout empty, diagnostic on stderr).
1813
+ if (!projectDir) {
1814
+ process.stderr.write('Error: list-assets requires --project <dir>\n');
1815
+ process.exit(1);
1816
+ }
1817
+ if (!category) {
1818
+ process.stderr.write('Error: list-assets requires --category <name>\n');
1819
+ process.exit(1);
1820
+ }
1821
+ const cat = getCategoryByName(category);
1822
+ if (!cat) {
1823
+ const valid = getAssetCategories().map(c => c.name).join(', ');
1824
+ process.stderr.write(`Error: unknown category '${category}'. Valid: ${valid}\n`);
1825
+ process.exit(1);
1826
+ }
1827
+ // Shared 3-condition presence detection (same as resolver and doctor).
1828
+ const effectiveHome = home ? path.resolve(home) : os.homedir();
1829
+ const effectiveProject = path.resolve(projectDir);
1830
+ const localClaude = path.join(effectiveProject, '.claude');
1831
+ const globalClaude = path.join(effectiveHome, '.claude');
1832
+
1833
+ const localPresent = isToolkitInstalled(localClaude);
1834
+ const globalPresent = isToolkitInstalled(globalClaude);
1835
+
1836
+ if (localPresent && globalPresent) {
1837
+ process.stderr.write(
1838
+ 'Error: mixed installation — toolkit is present in both local (.claude/) and global ' +
1839
+ `(${globalClaude}) locations. Remove one before listing assets.\n`
1840
+ );
1841
+ process.exit(1);
1842
+ }
1843
+
1844
+ // No installation → Tier 3 error (exit 1, stdout empty, diagnostic on stderr).
1845
+ if (!localPresent && !globalPresent) {
1846
+ process.stderr.write('Error: no toolkit installation found. Install with --local or --global.\n');
1847
+ process.exit(1);
1848
+ }
1849
+ const runtimeRoot = localPresent ? localClaude : globalClaude;
1850
+
1851
+ // Return only catalog-defined assets (no foreign files) for the requested
1852
+ // category, and only files that actually exist on disk.
1853
+ const allExpected = [...buildExpectedPayload(runtimeRoot)].sort();
1854
+ const catDir = path.resolve(path.join(runtimeRoot, cat.name));
1855
+ const results = allExpected
1856
+ .filter(f => f.startsWith(catDir + path.sep) || f.startsWith(catDir + '/'))
1857
+ .filter(f => fs.existsSync(f));
1858
+
1859
+ if (format === 'json') {
1860
+ process.stdout.write(JSON.stringify(results, null, 2) + '\n');
1861
+ } else {
1862
+ for (const r of results) process.stdout.write(r + '\n');
1863
+ }
1864
+ process.exit(0);
762
1865
  } else if (fs.existsSync(argv[0]) && fs.statSync(argv[0]).isDirectory()) {
763
- await installLocal(argv[0], force);
1866
+ await installLocal(argv[0], force, dryRun);
764
1867
  } else {
765
1868
  help();
766
1869
  process.exit(1);
@@ -780,10 +1883,10 @@ if (require.main === module) {
780
1883
  expandMappings,
781
1884
  categorize,
782
1885
  readInstalledVersion,
783
- NEVER_COPY,
784
1886
  readManifest,
785
1887
  computeOrphans,
786
1888
  moveToTrash,
1889
+ trashTimestamp,
787
1890
  writeManifest,
788
1891
  CANONICAL_ALLOW,
789
1892
  CANONICAL_ASK,
@@ -797,5 +1900,16 @@ if (require.main === module) {
797
1900
  updateGitignore,
798
1901
  appendLedgerEntry,
799
1902
  updateLedgerEntry,
1903
+ resolveClaudeRuntimeAsset,
1904
+ runDoctorResolution,
1905
+ validatePurityGuard,
1906
+ TOOLKIT_INTERNAL_ASSETS,
1907
+ buildPayloadFileMappings,
1908
+ buildExpectedPayload,
1909
+ validateRuntimeRelativePath,
1910
+ validateManifestFilesField,
1911
+ hasToolkitPayloadFiles,
1912
+ isToolkitInstalled,
1913
+ runVerifyInstall,
800
1914
  };
801
1915
  }