macca-method 2.1.2 → 3.0.0-rc.1

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 (89) hide show
  1. package/.agents/macca-lock.json +2 -1
  2. package/.agents/macca-managed-skills.txt +1 -0
  3. package/.agents/skills/_shared/references/additional-skills.md +17 -1
  4. package/.agents/skills/_shared/references/brainstorm-session.md +61 -21
  5. package/.agents/skills/_shared/references/config-mutation.md +58 -18
  6. package/.agents/skills/_shared/references/fix-mode.md +21 -7
  7. package/.agents/skills/_shared/references/human-loop.md +2 -0
  8. package/.agents/skills/_shared/references/implementation-principles.md +4 -0
  9. package/.agents/skills/_shared/references/interaction-contract.md +33 -0
  10. package/.agents/skills/_shared/references/invocation-policy.md +14 -3
  11. package/.agents/skills/_shared/references/language-config.md +24 -4
  12. package/.agents/skills/_shared/references/output-ownership.md +12 -5
  13. package/.agents/skills/_shared/references/planning-principles.md +35 -0
  14. package/.agents/skills/_shared/references/scope-delta.md +21 -0
  15. package/.agents/skills/_shared/references/scope-rules.md +1 -1
  16. package/.agents/skills/_shared/references/skill-catalog.md +30 -13
  17. package/.agents/skills/_shared/references/workspace-safety.md +11 -0
  18. package/.agents/skills/_shared/scripts/config-file.js +132 -0
  19. package/.agents/skills/_shared/scripts/config-validator.js +164 -0
  20. package/.agents/skills/_shared/scripts/read-preferences.js +128 -0
  21. package/.agents/skills/add-feature/SKILL.md +24 -18
  22. package/.agents/skills/brainstorm-api/SKILL.md +24 -23
  23. package/.agents/skills/brainstorm-api/assets/api.template.md +11 -23
  24. package/.agents/skills/brainstorm-architecture/SKILL.md +41 -48
  25. package/.agents/skills/brainstorm-architecture/assets/architecture.template.md +24 -16
  26. package/.agents/skills/brainstorm-prd/SKILL.md +26 -28
  27. package/.agents/skills/brainstorm-prd/assets/PRD.template.md +10 -9
  28. package/.agents/skills/brainstorm-rules/SKILL.md +42 -46
  29. package/.agents/skills/brainstorm-rules/assets/rules.template.md +8 -8
  30. package/.agents/skills/brainstorm-schema/SKILL.md +17 -17
  31. package/.agents/skills/brainstorm-schema/assets/schema.template.md +9 -8
  32. package/.agents/skills/brainstorm-styleguide/SKILL.md +27 -26
  33. package/.agents/skills/brainstorm-styleguide/assets/StyleGuide.template.md +27 -51
  34. package/.agents/skills/brainstorm-task/SKILL.md +53 -37
  35. package/.agents/skills/brainstorm-task/assets/Task.template.md +11 -12
  36. package/.agents/skills/bug-fix/SKILL.md +78 -175
  37. package/.agents/skills/bug-fix/assets/bug-log.template.md +61 -0
  38. package/.agents/skills/code-review/SKILL.md +14 -6
  39. package/.agents/skills/code-review/references/review-checklist.md +24 -44
  40. package/.agents/skills/developer/SKILL.md +23 -25
  41. package/.agents/skills/developer/references/close-phase.md +11 -6
  42. package/.agents/skills/developer/references/execute-task.md +15 -8
  43. package/.agents/skills/developer/references/onboarding.md +16 -19
  44. package/.agents/skills/help/SKILL.md +49 -49
  45. package/.agents/skills/meet/SKILL.md +37 -85
  46. package/.agents/skills/quick-dev/SKILL.md +40 -75
  47. package/.agents/skills/release-readiness/SKILL.md +1 -1
  48. package/.agents/skills/setup-macca-method/SKILL.md +79 -0
  49. package/.agents/skills/spec-audit/SKILL.md +21 -17
  50. package/.agents/skills/spec-compliance/SKILL.md +36 -18
  51. package/.agents/skills/spec-init/SKILL.md +53 -22
  52. package/CHANGELOG.md +31 -0
  53. package/README.md +92 -1195
  54. package/bin/macca-method.js +833 -286
  55. package/docs/configuration.md +174 -0
  56. package/docs/troubleshooting.md +180 -0
  57. package/docs/workflows.md +175 -0
  58. package/package.json +48 -4
  59. package/scripts/lib/npm-command.js +76 -0
  60. package/scripts/test-cli-setup.js +869 -0
  61. package/scripts/test-config.js +320 -0
  62. package/scripts/test-install.js +63 -28
  63. package/scripts/test-installer-safety.js +545 -0
  64. package/scripts/test-npm-command.js +143 -0
  65. package/scripts/test-package-safety.js +252 -0
  66. package/scripts/test-preferences.js +475 -0
  67. package/scripts/test-upgrade-legacy.js +11 -21
  68. package/scripts/validate-docs.js +166 -0
  69. package/scripts/validate-package.js +285 -0
  70. package/scripts/validate-skill-behavior.js +914 -12
  71. package/.agents/skills/antislop-copywriting/SKILL.md +0 -372
  72. package/.agents/skills/skill-creator/LICENSE.txt +0 -202
  73. package/.agents/skills/skill-creator/SKILL.md +0 -485
  74. package/.agents/skills/skill-creator/agents/analyzer.md +0 -274
  75. package/.agents/skills/skill-creator/agents/comparator.md +0 -202
  76. package/.agents/skills/skill-creator/agents/grader.md +0 -223
  77. package/.agents/skills/skill-creator/assets/eval_review.html +0 -146
  78. package/.agents/skills/skill-creator/eval-viewer/generate_review.py +0 -471
  79. package/.agents/skills/skill-creator/eval-viewer/viewer.html +0 -1325
  80. package/.agents/skills/skill-creator/references/schemas.md +0 -441
  81. package/.agents/skills/skill-creator/scripts/__init__.py +0 -0
  82. package/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -401
  83. package/.agents/skills/skill-creator/scripts/generate_report.py +0 -326
  84. package/.agents/skills/skill-creator/scripts/improve_description.py +0 -247
  85. package/.agents/skills/skill-creator/scripts/package_skill.py +0 -136
  86. package/.agents/skills/skill-creator/scripts/quick_validate.py +0 -103
  87. package/.agents/skills/skill-creator/scripts/run_eval.py +0 -310
  88. package/.agents/skills/skill-creator/scripts/run_loop.py +0 -328
  89. package/.agents/skills/skill-creator/scripts/utils.py +0 -47
@@ -11,6 +11,10 @@ const readline = require("node:readline");
11
11
  const PACKAGE_ROOT = path.resolve(__dirname, "..");
12
12
  const SOURCE_AGENTS_DIR = path.join(PACKAGE_ROOT, ".agents");
13
13
  const SOURCE_SKILLS_DIR = path.join(SOURCE_AGENTS_DIR, "skills");
14
+ const { assertValidConfig } = require(path.join(
15
+ SOURCE_SKILLS_DIR, "_shared", "scripts", "config-validator.js",
16
+ ));
17
+ const operation = { stage: "not started", targetDir: null, recoveryChanged: false };
14
18
  const SOURCE_MANAGED_SKILLS_FILE = path.join(
15
19
  SOURCE_AGENTS_DIR,
16
20
  "macca-managed-skills.txt",
@@ -122,10 +126,15 @@ function main() {
122
126
  return;
123
127
  }
124
128
 
129
+ if (command === "doctor") {
130
+ runDoctor(args);
131
+ return;
132
+ }
133
+
125
134
  ensurePackagedFiles();
126
135
 
127
136
  if (command === "install") {
128
- runInstall(args).catch((error) => exitWithError(error.message));
137
+ runInstall(args).catch(exitWithError);
129
138
  return;
130
139
  }
131
140
 
@@ -136,7 +145,7 @@ function main() {
136
145
 
137
146
  exitWithError(`Unknown command: ${command}`);
138
147
  } catch (error) {
139
- exitWithError(error.message);
148
+ exitWithError(error);
140
149
  }
141
150
  }
142
151
 
@@ -164,6 +173,11 @@ function parseArgs(argv) {
164
173
  continue;
165
174
  }
166
175
 
176
+ if (token === "-f" || token === "--force") {
177
+ args.force = true;
178
+ continue;
179
+ }
180
+
167
181
  if (token === "--list-tools") {
168
182
  args.listTools = true;
169
183
  continue;
@@ -251,6 +265,9 @@ function parseArgs(argv) {
251
265
  args._.push(token);
252
266
  }
253
267
 
268
+ if (args._.length > 1) {
269
+ throw new Error("Unexpected positional arguments. Use --directory <path> for the target project.");
270
+ }
254
271
  return args;
255
272
  }
256
273
 
@@ -286,20 +303,23 @@ function printHelp() {
286
303
  "Usage:",
287
304
  " npx macca-method@latest install [options]",
288
305
  " npx macca-method@latest upgrade [options]",
306
+ " macca-method doctor [--directory <path>] Read-only installation check (no repair).",
289
307
  " npx macca-method@latest --list-tools",
290
308
  "",
291
309
  "Install options:",
292
310
  " -t, --tool <name> Repeatable. Also accepts comma-separated values.",
293
311
  " -d, --directory <path> Target project directory. Defaults to the current directory.",
294
312
  " -y, --yes Skip prompts and use defaults where needed.",
313
+ " -f, --force Overwrite locally modified managed skills.",
295
314
  " --name <value> Developer name.",
296
315
  " --project <value> Project name.",
297
316
  " --communication-language <value>",
298
- " --document-language <value>",
317
+ " --document-language <value> Defaults to the communication language on first install.",
318
+ " Requires Node.js >=22; Node.js 22 and 24 are supported runtimes in the configured CI matrix.",
299
319
  "",
300
320
  "Examples:",
301
321
  " npx macca-method@latest install",
302
- " npx macca-method@latest install --tool github-copilot --tool codex --yes",
322
+ " npx macca-method@latest install --tool github-copilot --tool codex --name \"Your Name\" --project \"Your Project\" --yes",
303
323
  " npx macca-method@latest upgrade",
304
324
  "",
305
325
  ].join("\n"),
@@ -326,27 +346,29 @@ function resolveTargetDirectory(rawDirectory) {
326
346
  }
327
347
 
328
348
  function assertSafeTargetDirectory(rawDirectory) {
349
+ if (path.sep === "\\" && /^[\\/]{2}/.test(rawDirectory || process.cwd())) {
350
+ throw Object.assign(new Error("Windows UNC/device paths are unsupported. Use a local drive path such as C:\\projects\\app."), { code: "MACCA_UNSUPPORTED_PATH", path: rawDirectory });
351
+ }
329
352
  const requested = rawDirectory
330
353
  ? path.resolve(process.cwd(), rawDirectory)
331
354
  : process.cwd();
332
355
  const absolute = path.isAbsolute(requested)
333
356
  ? requested
334
357
  : path.resolve(requested);
335
- const segments = absolute.split(path.sep).filter(Boolean);
336
- let current = absolute.startsWith(path.sep)
337
- ? path.sep
338
- : segments.shift() || absolute;
358
+ const root = path.parse(absolute).root;
359
+ if (path.sep === "\\" && /^[\\/]{2}/.test(root)) {
360
+ throw Object.assign(new Error("Windows UNC/device paths are unsupported. Use a local drive path."), { code: "MACCA_UNSUPPORTED_PATH", path: absolute });
361
+ }
362
+ const segments = absolute.slice(root.length).split(path.sep).filter(Boolean);
363
+ let current = root;
339
364
 
340
- if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
365
+ if (isSymlink(current)) {
341
366
  throw new Error(`Refusing symlinked target project ancestor: ${current}`);
342
367
  }
343
368
 
344
369
  for (const segment of segments) {
345
- current =
346
- current === path.sep
347
- ? path.join(current, segment)
348
- : path.join(current, segment);
349
- if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
370
+ current = path.join(current, segment);
371
+ if (isSymlink(current)) {
350
372
  throw new Error(`Refusing symlinked target project ancestor: ${current}`);
351
373
  }
352
374
  }
@@ -436,27 +458,37 @@ function normalizeLanguage(value) {
436
458
  case "bahasa inggris":
437
459
  return "english";
438
460
  default:
439
- return lowered;
461
+ return "indonesian";
440
462
  }
441
463
  }
442
464
 
443
465
  function ensureDirectory(directoryPath) {
466
+ operation.stage = "writing";
444
467
  fs.mkdirSync(directoryPath, { recursive: true });
445
468
  }
446
469
 
470
+ function isSymlink(candidate) {
471
+ try {
472
+ return fs.lstatSync(candidate).isSymbolicLink();
473
+ } catch (error) {
474
+ if (error.code === "ENOENT") return false;
475
+ throw error;
476
+ }
477
+ }
478
+
447
479
  function assertSafeProjectPath(targetDir, destination) {
448
480
  const targetRoot = path.resolve(targetDir);
449
481
  const resolvedDestination = path.resolve(destination);
450
482
  if (
451
483
  resolvedDestination !== targetRoot &&
452
- !resolvedDestination.startsWith(`${targetRoot}${path.sep}`)
484
+ !resolvedDestination.startsWith(targetRoot.endsWith(path.sep) ? targetRoot : `${targetRoot}${path.sep}`)
453
485
  ) {
454
486
  throw new Error(
455
487
  `Refusing destination outside target project: ${destination}`,
456
488
  );
457
489
  }
458
490
 
459
- if (fs.existsSync(targetRoot) && fs.lstatSync(targetRoot).isSymbolicLink()) {
491
+ if (isSymlink(targetRoot)) {
460
492
  throw new Error(`Refusing symlinked target project: ${targetRoot}`);
461
493
  }
462
494
 
@@ -464,7 +496,7 @@ function assertSafeProjectPath(targetDir, destination) {
464
496
  let current = targetRoot;
465
497
  for (const segment of relative.split(path.sep).filter(Boolean)) {
466
498
  current = path.join(current, segment);
467
- if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
499
+ if (isSymlink(current)) {
468
500
  throw new Error(
469
501
  `Refusing symlinked skill destination component: ${current}`,
470
502
  );
@@ -493,13 +525,37 @@ function hashText(content) {
493
525
  return crypto.createHash("sha256").update(content).digest("hex");
494
526
  }
495
527
 
528
+ const IGNORED_DIRECTORY_NAMES = new Set([
529
+ OWNERSHIP_MARKER,
530
+ ".DS_Store",
531
+ "Thumbs.db",
532
+ "__pycache__",
533
+ ".pytest_cache",
534
+ ".ruff_cache",
535
+ ]);
536
+
537
+ function shouldIgnoreDirectoryEntry(name) {
538
+ if (IGNORED_DIRECTORY_NAMES.has(name)) {
539
+ return true;
540
+ }
541
+ if (
542
+ name.endsWith(".pyc") ||
543
+ name.endsWith(".pyo") ||
544
+ name.endsWith(".tmp") ||
545
+ name.startsWith(".tmp-")
546
+ ) {
547
+ return true;
548
+ }
549
+ return false;
550
+ }
551
+
496
552
  function getDirectoryFileHashes(directoryPath) {
497
553
  const hashes = {};
498
554
 
499
555
  function walk(currentPath, relativePath = "") {
500
556
  const entries = fs
501
557
  .readdirSync(currentPath, { withFileTypes: true })
502
- .filter((entry) => entry.name !== OWNERSHIP_MARKER)
558
+ .filter((entry) => !shouldIgnoreDirectoryEntry(entry.name))
503
559
  .sort((left, right) => left.name.localeCompare(right.name));
504
560
 
505
561
  for (const entry of entries) {
@@ -531,6 +587,86 @@ function hashDirectory(directoryPath) {
531
587
  return hashText(JSON.stringify(getDirectoryFileHashes(directoryPath)));
532
588
  }
533
589
 
590
+ // Payload fingerprints intentionally omit caches. Destructive operations need
591
+ // a separate snapshot that includes every entry, including empty directories.
592
+ function hashSnapshot(targetPath, kind) {
593
+ const stat = fs.lstatSync(targetPath);
594
+ if (
595
+ stat.isSymbolicLink() ||
596
+ (kind === "directory" ? !stat.isDirectory() : !stat.isFile())
597
+ ) {
598
+ throw new Error(`Refusing unexpected managed path type: ${targetPath}`);
599
+ }
600
+ if (kind === "file") return hashFile(targetPath);
601
+
602
+ const entries = [];
603
+ function walk(directory, relative = "") {
604
+ for (const name of fs.readdirSync(directory).sort()) {
605
+ const candidate = path.join(directory, name);
606
+ const entryPath = relative ? `${relative}/${name}` : name;
607
+ const entryStat = fs.lstatSync(candidate);
608
+ if (entryStat.isSymbolicLink()) {
609
+ throw new Error(`Refusing symlink inside managed skill: ${candidate}`);
610
+ }
611
+ if (entryStat.isDirectory()) {
612
+ entries.push([entryPath, "directory"]);
613
+ walk(candidate, entryPath);
614
+ } else if (entryStat.isFile()) {
615
+ entries.push([entryPath, "file", hashFile(candidate)]);
616
+ } else {
617
+ throw new Error(
618
+ `Unsupported file type inside managed skill: ${candidate}`,
619
+ );
620
+ }
621
+ }
622
+ }
623
+ walk(targetPath);
624
+ return hashText(JSON.stringify(entries));
625
+ }
626
+
627
+ function getUnhashedEntries(directoryPath) {
628
+ const omitted = [];
629
+ function walk(directory, relative = "") {
630
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
631
+ if (!relative && entry.name === OWNERSHIP_MARKER) continue;
632
+ const entryPath = path.join(relative, entry.name);
633
+ const candidate = path.join(directory, entry.name);
634
+ if (
635
+ shouldIgnoreDirectoryEntry(entry.name) ||
636
+ (entry.isDirectory() && fs.readdirSync(candidate).length === 0)
637
+ ) {
638
+ omitted.push(entryPath);
639
+ } else if (entry.isDirectory()) {
640
+ walk(candidate, entryPath);
641
+ }
642
+ }
643
+ }
644
+ walk(directoryPath);
645
+ return omitted;
646
+ }
647
+
648
+ function preserveUnhashedEntries(targetPath, stagingPath) {
649
+ for (const relative of getUnhashedEntries(targetPath)) {
650
+ const source = path.join(targetPath, relative);
651
+ const destination = path.join(stagingPath, relative);
652
+ if (fs.existsSync(destination)) {
653
+ const kind = fs.lstatSync(source).isDirectory() ? "directory" : "file";
654
+ if (hashSnapshot(source, kind) !== hashSnapshot(destination, kind)) {
655
+ throw new Error(
656
+ `Refusing to overwrite preserved skill content: ${source}`,
657
+ );
658
+ }
659
+ } else {
660
+ ensureDirectory(path.dirname(destination));
661
+ fs.cpSync(source, destination, {
662
+ recursive: true,
663
+ errorOnExist: true,
664
+ force: false,
665
+ });
666
+ }
667
+ }
668
+ }
669
+
534
670
  function getLegacyPayloadHashes() {
535
671
  if (!fs.existsSync(SOURCE_LEGACY_PAYLOADS_FILE)) return {};
536
672
  const releases = readJsonObject(
@@ -581,46 +717,107 @@ function buildInstalledLock(managedSkills) {
581
717
  }
582
718
 
583
719
  function compareSemver(left, right) {
584
- const parse = (value) =>
585
- String(value || "0.0.0")
586
- .split("-")[0]
587
- .split(".")
588
- .map((part) => Number.parseInt(part, 10) || 0);
589
- const a = parse(left);
590
- const b = parse(right);
591
- const length = Math.max(a.length, b.length);
592
- for (let index = 0; index < length; index += 1) {
593
- const diff = (a[index] || 0) - (b[index] || 0);
720
+ const parse = (value, label) => {
721
+ const match = typeof value === "string" && value.match(
722
+ /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/,
723
+ );
724
+ const prerelease = match && match[4] ? match[4].split(".") : [];
725
+ if (!match || match[0] !== value || prerelease.some((part) => /^0[0-9]+$/.test(part))) {
726
+ throw Object.assign(new Error(
727
+ `Invalid semantic version for ${label}: expected MAJOR.MINOR.PATCH with optional prerelease/build metadata; numeric version and prerelease identifiers must not have leading zeros.`,
728
+ ), { code: "MACCA_VERSION_INVALID" });
729
+ }
730
+ return { core: match.slice(1, 4), prerelease };
731
+ };
732
+ // Keep numeric identifiers as decimal strings: SemVer does not bound their size.
733
+ const compareNumeric = (a, b) => a.length !== b.length
734
+ ? (a.length < b.length ? -1 : 1)
735
+ : (a === b ? 0 : a < b ? -1 : 1);
736
+ const a = parse(left, "left version");
737
+ const b = parse(right, "right version");
738
+ for (let index = 0; index < 3; index += 1) {
739
+ const diff = compareNumeric(a.core[index], b.core[index]);
594
740
  if (diff !== 0) return diff;
595
741
  }
742
+ if (!a.prerelease.length || !b.prerelease.length) {
743
+ return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length ? -1 : 1;
744
+ }
745
+ for (let index = 0; index < Math.min(a.prerelease.length, b.prerelease.length); index += 1) {
746
+ const x = a.prerelease[index];
747
+ const y = b.prerelease[index];
748
+ if (x === y) continue;
749
+ const xNumeric = /^[0-9]+$/.test(x);
750
+ const yNumeric = /^[0-9]+$/.test(y);
751
+ if (xNumeric && yNumeric) return compareNumeric(x, y);
752
+ if (xNumeric !== yNumeric) return xNumeric ? -1 : 1;
753
+ return x < y ? -1 : 1;
754
+ }
755
+ if (a.prerelease.length !== b.prerelease.length) {
756
+ return a.prerelease.length < b.prerelease.length ? -1 : 1;
757
+ }
596
758
  return 0;
597
759
  }
598
760
 
599
- function assertPackageNotOlderThanInstalled(agentsDirectory) {
600
- const installedLockPath = path.join(agentsDirectory, "macca-lock.json");
601
- if (!fs.existsSync(installedLockPath)) {
602
- return;
603
- }
604
-
761
+ function assertPackageNotOlderThanInstalled(agentsDirectory, includeRecovery = false) {
605
762
  const packagedLock = readJsonObject(SOURCE_LOCK_FILE, "packaged MACCA lock");
606
- const installedLock = readJsonObject(
607
- installedLockPath,
608
- "installed MACCA lock",
609
- );
610
763
  const packagedVersion = packagedLock.version;
611
- const installedVersion = installedLock.version;
612
- if (
613
- typeof packagedVersion !== "string" ||
614
- typeof installedVersion !== "string"
615
- ) {
616
- return;
617
- }
764
+ const targetDir = path.dirname(agentsDirectory);
765
+ const installedLockPath = path.join(agentsDirectory, "macca-lock.json");
766
+ const assertRegularLock = (lockPath) => {
767
+ assertSafeProjectPath(targetDir, lockPath);
768
+ if (!fs.lstatSync(lockPath).isFile()) {
769
+ throw new Error(`Invalid MACCA lock at ${lockPath}: expected a regular file.`);
770
+ }
771
+ };
772
+ const checkVersion = (lockPath) => {
773
+ let installedVersion = packagedVersion;
774
+ if (lockPath) {
775
+ assertRegularLock(lockPath);
776
+ installedVersion = readJsonObject(lockPath, "installed MACCA lock").version;
777
+ }
778
+ let comparison;
779
+ try {
780
+ comparison = compareSemver(packagedVersion, installedVersion);
781
+ } catch (error) {
782
+ if (error.code !== "MACCA_VERSION_INVALID") throw error;
783
+ throw Object.assign(new Error(
784
+ `Cannot verify MACCA downgrade safety: ${error.message} ` +
785
+ `Check the version fields in the packaged MACCA lock (${SOURCE_LOCK_FILE}, left) and installed MACCA lock (${lockPath || installedLockPath}, right) before retrying.`,
786
+ ), { code: error.code });
787
+ }
788
+
789
+ if (comparison < 0) {
790
+ throw new Error(
791
+ `Refusing to downgrade MACCA from ${installedVersion} to ${packagedVersion}. ` +
792
+ "Publish or use a newer package before running install/upgrade.",
793
+ );
794
+ }
795
+ };
796
+ // Validate the package even on first install, before a target can be created.
797
+ checkVersion(null);
798
+ assertSafeProjectPath(targetDir, installedLockPath);
799
+ if (fs.existsSync(installedLockPath)) checkVersion(installedLockPath);
800
+ if (!includeRecovery) return;
618
801
 
619
- if (compareSemver(packagedVersion, installedVersion) < 0) {
620
- throw new Error(
621
- `Refusing to downgrade MACCA from ${installedVersion} to ${packagedVersion}. ` +
622
- "Publish or use a newer package before running install/upgrade.",
623
- );
802
+ const journalPath = path.join(agentsDirectory, TRANSACTION_FILE);
803
+ assertSafeProjectPath(targetDir, journalPath);
804
+ if (!fs.existsSync(journalPath)) return;
805
+ const journal = readJsonObject(journalPath, "MACCA transaction journal");
806
+ const entries = validateTransactionEntries(targetDir, journal);
807
+ // Recovery may restore a newer lock or delete version evidence. Inspect only
808
+ // the whitelisted lock entry, after validating its paths and recovery hashes.
809
+ for (const entry of entries) {
810
+ if (path.resolve(entry.targetPath) !== path.resolve(installedLockPath)) continue;
811
+ if (fs.existsSync(entry.backupPath)) {
812
+ assertRegularLock(entry.backupPath);
813
+ assertTransactionBackup(entry, journal.phase);
814
+ checkVersion(entry.backupPath);
815
+ }
816
+ if (entry.stagingPath && fs.existsSync(entry.stagingPath)) {
817
+ assertRegularLock(entry.stagingPath);
818
+ assertTransactionPayload(entry, journal.transactionId, entry.stagingPath);
819
+ checkVersion(entry.stagingPath);
820
+ }
624
821
  }
625
822
  }
626
823
 
@@ -629,12 +826,24 @@ function readOwnershipMarker(
629
826
  expectedSkill = path.basename(targetPath),
630
827
  ) {
631
828
  const markerPath = path.join(targetPath, OWNERSHIP_MARKER);
632
- if (!fs.existsSync(markerPath)) {
633
- return null;
829
+ let stat;
830
+ try {
831
+ stat = fs.lstatSync(markerPath);
832
+ } catch (error) {
833
+ if (error.code === "ENOENT") return null;
834
+ throw error;
835
+ }
836
+ // Payload hashing omits this metadata file. Check its type independently so
837
+ // ownership checks never follow a symlink or try to read a pipe/device.
838
+ if (stat.isSymbolicLink() || !stat.isFile()) {
839
+ throw Object.assign(new Error(
840
+ `Unsafe ownership marker at ${JSON.stringify(markerPath)}: expected a regular non-symlink file. Back up and review this path before retrying.`,
841
+ ), { code: "MACCA_UNSAFE_MARKER", path: markerPath });
634
842
  }
635
843
 
844
+ const content = fs.readFileSync(markerPath, "utf8");
636
845
  try {
637
- const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
846
+ const marker = JSON.parse(content);
638
847
  if (marker.owner !== "macca-method" || marker.skill !== expectedSkill) {
639
848
  return null;
640
849
  }
@@ -648,7 +857,10 @@ function isMaccaOwned(targetPath) {
648
857
  return readOwnershipMarker(targetPath) !== null;
649
858
  }
650
859
 
651
- function assertManagedDirectoryUnchanged(targetPath) {
860
+ function assertManagedDirectoryUnchanged(targetPath, force = false) {
861
+ if (force) {
862
+ return;
863
+ }
652
864
  const marker = readOwnershipMarker(targetPath);
653
865
  if (!marker) {
654
866
  throw new Error(
@@ -657,7 +869,8 @@ function assertManagedDirectoryUnchanged(targetPath) {
657
869
  }
658
870
  if (!marker.payloadHash || marker.payloadHash !== hashDirectory(targetPath)) {
659
871
  throw new Error(
660
- `Refusing to overwrite locally modified managed skill: ${targetPath}`,
872
+ `Refusing to overwrite locally modified managed skill: ${targetPath}. ` +
873
+ "Back up and review local modifications first. --force destructively overwrites managed local modifications.",
661
874
  );
662
875
  }
663
876
  }
@@ -670,11 +883,15 @@ function readJsonObject(filePath, label) {
670
883
  }
671
884
  return value;
672
885
  } catch (error) {
673
- throw new Error(`Invalid ${label} at ${filePath}: ${error.message}`);
886
+ if (error.code) throw error;
887
+ throw Object.assign(new Error(`Invalid ${label} at ${filePath}: expected a JSON object.`), { path: filePath, code: "MACCA_METADATA_INVALID" });
674
888
  }
675
889
  }
676
890
 
677
- function assertManagedFilesUnchanged(agentsDirectory) {
891
+ function assertManagedFilesUnchanged(agentsDirectory, force = false) {
892
+ if (force) {
893
+ return;
894
+ }
678
895
  const statePath = path.join(agentsDirectory, STATE_FILE);
679
896
  if (!fs.existsSync(statePath)) {
680
897
  return;
@@ -684,7 +901,8 @@ function assertManagedFilesUnchanged(agentsDirectory) {
684
901
  const filePath = path.join(agentsDirectory, fileName);
685
902
  if (!fs.existsSync(filePath) || hashFile(filePath) !== expectedHash) {
686
903
  throw new Error(
687
- `Refusing to overwrite locally modified MACCA metadata: ${filePath}`,
904
+ `Refusing to overwrite locally modified MACCA metadata: ${filePath}. ` +
905
+ "Back up and review local modifications first. --force destructively overwrites managed local modifications.",
688
906
  );
689
907
  }
690
908
  }
@@ -699,13 +917,20 @@ function buildStateContent(files) {
699
917
  }
700
918
 
701
919
  function writeJsonAtomic(filePath, value) {
702
- const temporaryPath = `${filePath}.tmp-${process.pid}`;
703
- fs.writeFileSync(
704
- temporaryPath,
705
- `${JSON.stringify(value, null, 2)}\n`,
706
- "utf8",
707
- );
708
- fs.renameSync(temporaryPath, filePath);
920
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(16).toString("hex")}`;
921
+ let descriptor;
922
+ let created = false;
923
+ try {
924
+ descriptor = fs.openSync(temporaryPath, "wx", 0o600);
925
+ created = true;
926
+ fs.writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8");
927
+ fs.closeSync(descriptor);
928
+ descriptor = undefined;
929
+ fs.renameSync(temporaryPath, filePath);
930
+ } finally {
931
+ if (descriptor !== undefined) fs.closeSync(descriptor);
932
+ if (created) fs.rmSync(temporaryPath, { force: true });
933
+ }
709
934
  }
710
935
 
711
936
  function removeManagedPath(targetPath, kind) {
@@ -721,24 +946,55 @@ function assertTransactionPayload(entry, transactionId, candidatePath) {
721
946
  candidatePath,
722
947
  path.basename(entry.targetPath),
723
948
  );
724
- if (!marker || marker.transactionId !== transactionId) {
949
+ if (
950
+ !marker ||
951
+ marker.transactionId !== transactionId ||
952
+ !entry.stagedHash ||
953
+ hashDirectory(candidatePath) !== entry.stagedHash
954
+ ) {
725
955
  throw new Error(
726
956
  `Refusing recovery of directory without matching transaction evidence: ${candidatePath}`,
727
957
  );
728
958
  }
729
- return;
730
- }
731
- if (!entry.stagedHash || hashFile(candidatePath) !== entry.stagedHash) {
959
+ if (!entry.stagedSnapshotHash && getUnhashedEntries(candidatePath).length) {
960
+ throw new Error(
961
+ `Refusing legacy recovery with unhashed directory content: ${candidatePath}`,
962
+ );
963
+ }
964
+ } else if (!entry.stagedHash || hashFile(candidatePath) !== entry.stagedHash) {
732
965
  throw new Error(
733
966
  `Refusing recovery of file without matching transaction hash: ${candidatePath}`,
734
967
  );
735
968
  }
969
+ const snapshot = hashSnapshot(candidatePath, entry.kind);
970
+ if (entry.stagedSnapshotHash && snapshot !== entry.stagedSnapshotHash) {
971
+ throw new Error(
972
+ `Refusing recovery of modified transaction payload: ${candidatePath}`,
973
+ );
974
+ }
736
975
  }
737
976
 
738
- function assertTransactionBackup(entry) {
977
+ function assertTransactionBackup(entry, phase) {
739
978
  if (!fs.existsSync(entry.backupPath)) {
740
979
  return;
741
980
  }
981
+ const snapshot = hashSnapshot(entry.backupPath, entry.kind);
982
+ if (
983
+ !entry.hadTarget ||
984
+ (entry.originalHash && snapshot !== entry.originalHash)
985
+ ) {
986
+ throw new Error(
987
+ `Refusing recovery from modified transaction backup: ${entry.backupPath}`,
988
+ );
989
+ }
990
+ if (entry.originalHash) return;
991
+ // Old journals lack a pre-transaction snapshot. A file backup can be restored
992
+ // without discarding its contents, but cannot safely be deleted on commit.
993
+ if (entry.kind === "file" && phase === "committed") {
994
+ throw new Error(
995
+ `Refusing cleanup of legacy backup without a snapshot: ${entry.backupPath}`,
996
+ );
997
+ }
742
998
  if (entry.kind === "directory") {
743
999
  const marker = readOwnershipMarker(
744
1000
  entry.backupPath,
@@ -751,7 +1007,10 @@ function assertTransactionBackup(entry) {
751
1007
  const validLegacy =
752
1008
  entry.legacySkillName &&
753
1009
  isLegacyDirectoryUnchanged(entry.backupPath, entry.legacySkillName);
754
- if (!validManaged && !validLegacy) {
1010
+ if (
1011
+ (!validManaged && !validLegacy) ||
1012
+ getUnhashedEntries(entry.backupPath).length
1013
+ ) {
755
1014
  throw new Error(
756
1015
  `Refusing recovery from unowned or modified backup directory: ${entry.backupPath}`,
757
1016
  );
@@ -761,7 +1020,7 @@ function assertTransactionBackup(entry) {
761
1020
 
762
1021
  function validateTransactionEntries(targetDir, journal) {
763
1022
  if (
764
- journal.version !== 1 ||
1023
+ ![1, 2].includes(journal.version) ||
765
1024
  !["committing", "committed"].includes(journal.phase)
766
1025
  ) {
767
1026
  throw new Error("Unsupported MACCA transaction journal version or phase");
@@ -828,6 +1087,19 @@ function validateTransactionEntries(targetDir, journal) {
828
1087
  ) {
829
1088
  throw new Error("Transaction staged payload hash is missing or invalid");
830
1089
  }
1090
+ for (const [field, required] of [
1091
+ ["originalHash", entry.hadTarget],
1092
+ ["stagedSnapshotHash", entry.stagingPath !== null],
1093
+ ]) {
1094
+ if ((journal.version === 2 && required) || entry[field] != null) {
1095
+ if (
1096
+ typeof entry[field] !== "string" ||
1097
+ !/^[a-f0-9]{64}$/.test(entry[field])
1098
+ ) {
1099
+ throw new Error(`Transaction ${field} is missing or invalid`);
1100
+ }
1101
+ }
1102
+ }
831
1103
 
832
1104
  const targetPath = path.resolve(entry.targetPath);
833
1105
  const parent = path.dirname(targetPath);
@@ -893,7 +1165,7 @@ function recoverInterruptedTransaction(targetDir) {
893
1165
  const journal = readJsonObject(journalPath, "MACCA transaction journal");
894
1166
  const entries = validateTransactionEntries(targetDir, journal);
895
1167
  for (const entry of entries) {
896
- assertTransactionBackup(entry);
1168
+ assertTransactionBackup(entry, journal.phase);
897
1169
  if (
898
1170
  journal.phase === "committed" ||
899
1171
  fs.existsSync(entry.backupPath) ||
@@ -904,13 +1176,38 @@ function recoverInterruptedTransaction(targetDir) {
904
1176
  if (entry.stagingPath && fs.existsSync(entry.stagingPath)) {
905
1177
  assertTransactionPayload(entry, journal.transactionId, entry.stagingPath);
906
1178
  }
1179
+ if (
1180
+ journal.phase === "committing" &&
1181
+ entry.hadTarget &&
1182
+ !fs.existsSync(entry.backupPath) &&
1183
+ entry.originalHash &&
1184
+ (!fs.existsSync(entry.targetPath) ||
1185
+ hashSnapshot(entry.targetPath, entry.kind) !== entry.originalHash)
1186
+ ) {
1187
+ throw new Error(
1188
+ `Refusing recovery of modified original target: ${entry.targetPath}`,
1189
+ );
1190
+ }
1191
+ }
1192
+ // Recovery can restore a config different from the visible one. Validate the
1193
+ // candidate after evidence checks, but before the first rollback/cleanup.
1194
+ for (const entry of entries) {
1195
+ if (entry.targetPath === path.join(targetDir, ".agents", "developer-config.json")) {
1196
+ const candidate = journal.phase === "committing" && fs.existsSync(entry.backupPath)
1197
+ ? entry.backupPath : entry.targetPath;
1198
+ readDeveloperConfig(candidate);
1199
+ }
907
1200
  }
908
1201
  if (journal.phase === "committed") {
1202
+ operation.stage = "recovery";
1203
+ operation.recoveryChanged = true;
909
1204
  for (const entry of entries) {
910
1205
  removeManagedPath(entry.backupPath, entry.kind);
911
1206
  if (entry.stagingPath) removeManagedPath(entry.stagingPath, entry.kind);
912
1207
  }
913
1208
  } else {
1209
+ operation.stage = "recovery";
1210
+ operation.recoveryChanged = true;
914
1211
  for (const entry of [...entries].reverse()) {
915
1212
  if (fs.existsSync(entry.backupPath)) {
916
1213
  removeManagedPath(entry.targetPath, entry.kind);
@@ -938,10 +1235,9 @@ function getUniqueDestinations(targetDir, tools) {
938
1235
  return [...destinations.values()];
939
1236
  }
940
1237
 
941
- function applySkillTransaction(entries, targetDir) {
1238
+ function applySkillTransaction(entries, targetDir, force = false) {
942
1239
  const suffix = `${process.pid}-${Date.now()}-${crypto.randomBytes(8).toString("hex")}`;
943
1240
  const prepared = [];
944
- const committed = [];
945
1241
  const journalPath = path.join(targetDir, ".agents", TRANSACTION_FILE);
946
1242
 
947
1243
  for (const entry of entries) {
@@ -971,7 +1267,10 @@ function applySkillTransaction(entries, targetDir) {
971
1267
  }
972
1268
  if (kind === "directory") {
973
1269
  if (isMaccaOwned(targetPath)) {
974
- assertManagedDirectoryUnchanged(targetPath);
1270
+ assertManagedDirectoryUnchanged(
1271
+ targetPath,
1272
+ force && Boolean(sourcePath),
1273
+ );
975
1274
  } else if (
976
1275
  !legacySkillName ||
977
1276
  !isLegacyDirectoryUnchanged(targetPath, legacySkillName)
@@ -980,12 +1279,20 @@ function applySkillTransaction(entries, targetDir) {
980
1279
  `Refusing to overwrite or remove unowned skill directory: ${targetPath}`,
981
1280
  );
982
1281
  }
1282
+ if (!sourcePath && getUnhashedEntries(targetPath).length) {
1283
+ throw new Error(
1284
+ `Refusing to remove skill with unhashed content: ${targetPath}`,
1285
+ );
1286
+ }
983
1287
  }
984
1288
  }
985
1289
  prepared.push({
986
1290
  ...entry,
987
1291
  kind,
988
1292
  hadTarget: fs.existsSync(targetPath),
1293
+ originalHash: fs.existsSync(targetPath)
1294
+ ? hashSnapshot(targetPath, kind)
1295
+ : null,
989
1296
  stagingPath:
990
1297
  sourcePath || content !== undefined
991
1298
  ? path.join(parent, `.${targetName}.macca-stage-${suffix}`)
@@ -1008,6 +1315,9 @@ function applySkillTransaction(entries, targetDir) {
1008
1315
  }
1009
1316
  } else {
1010
1317
  fs.cpSync(entry.sourcePath, entry.stagingPath, { recursive: true });
1318
+ if (entry.hadTarget) {
1319
+ preserveUnhashedEntries(entry.targetPath, entry.stagingPath);
1320
+ }
1011
1321
  const payloadHash = hashDirectory(entry.stagingPath);
1012
1322
  writeTextFile(
1013
1323
  path.join(entry.stagingPath, OWNERSHIP_MARKER),
@@ -1028,10 +1338,11 @@ function applySkillTransaction(entries, targetDir) {
1028
1338
  entry.kind === "file"
1029
1339
  ? hashFile(entry.stagingPath)
1030
1340
  : hashDirectory(entry.stagingPath);
1341
+ entry.stagedSnapshotHash = hashSnapshot(entry.stagingPath, entry.kind);
1031
1342
  }
1032
1343
 
1033
1344
  writeJsonAtomic(journalPath, {
1034
- version: 1,
1345
+ version: 2,
1035
1346
  phase: "committing",
1036
1347
  transactionId: suffix,
1037
1348
  entries: prepared.map(
@@ -1042,6 +1353,8 @@ function applySkillTransaction(entries, targetDir) {
1042
1353
  kind,
1043
1354
  hadTarget,
1044
1355
  stagedHash,
1356
+ stagedSnapshotHash,
1357
+ originalHash,
1045
1358
  legacySkillName,
1046
1359
  }) => ({
1047
1360
  targetPath,
@@ -1050,6 +1363,8 @@ function applySkillTransaction(entries, targetDir) {
1050
1363
  kind,
1051
1364
  hadTarget,
1052
1365
  stagedHash: stagingPath ? stagedHash : null,
1366
+ stagedSnapshotHash: stagingPath ? stagedSnapshotHash : null,
1367
+ originalHash,
1053
1368
  legacySkillName,
1054
1369
  }),
1055
1370
  ),
@@ -1057,15 +1372,19 @@ function applySkillTransaction(entries, targetDir) {
1057
1372
 
1058
1373
  for (const entry of prepared) {
1059
1374
  if (entry.hadTarget) {
1375
+ if (hashSnapshot(entry.targetPath, entry.kind) !== entry.originalHash) {
1376
+ throw new Error(
1377
+ `Managed path changed during transaction: ${entry.targetPath}`,
1378
+ );
1379
+ }
1060
1380
  fs.renameSync(entry.targetPath, entry.backupPath);
1061
1381
  }
1062
- committed.push(entry);
1063
1382
  if (entry.sourcePath || entry.content !== undefined) {
1064
1383
  fs.renameSync(entry.stagingPath, entry.targetPath);
1065
1384
  }
1066
1385
  }
1067
1386
  writeJsonAtomic(journalPath, {
1068
- version: 1,
1387
+ version: 2,
1069
1388
  phase: "committed",
1070
1389
  transactionId: suffix,
1071
1390
  entries: prepared.map(
@@ -1076,6 +1395,8 @@ function applySkillTransaction(entries, targetDir) {
1076
1395
  kind,
1077
1396
  hadTarget,
1078
1397
  stagedHash,
1398
+ stagedSnapshotHash,
1399
+ originalHash,
1079
1400
  legacySkillName,
1080
1401
  }) => ({
1081
1402
  targetPath,
@@ -1084,40 +1405,44 @@ function applySkillTransaction(entries, targetDir) {
1084
1405
  kind,
1085
1406
  hadTarget,
1086
1407
  stagedHash: stagingPath ? stagedHash : null,
1408
+ stagedSnapshotHash: stagingPath ? stagedSnapshotHash : null,
1409
+ originalHash,
1087
1410
  legacySkillName,
1088
1411
  }),
1089
1412
  ),
1090
1413
  });
1091
1414
  } catch (error) {
1092
- for (const entry of [...committed].reverse()) {
1093
- fs.rmSync(entry.targetPath, {
1094
- recursive: entry.kind === "directory",
1095
- force: true,
1096
- });
1097
- if (entry.hadTarget && fs.existsSync(entry.backupPath)) {
1098
- fs.renameSync(entry.backupPath, entry.targetPath);
1415
+ try {
1416
+ if (fs.existsSync(journalPath)) {
1417
+ // Live failures and crash recovery must use the same preflight checks.
1418
+ // A conflict leaves the journal and every remaining path for recovery.
1419
+ recoverInterruptedTransaction(targetDir);
1420
+ } else {
1421
+ const staged = prepared.filter(
1422
+ (entry) => entry.stagingPath && fs.existsSync(entry.stagingPath),
1423
+ );
1424
+ // A partially written stage has no trustworthy snapshot. Validate all
1425
+ // completed stages before deleting any; retain partial/conflicting work.
1426
+ for (const entry of staged) {
1427
+ if (!entry.stagedSnapshotHash) {
1428
+ throw new Error(
1429
+ `Preserving incomplete transaction staging: ${entry.stagingPath}`,
1430
+ );
1431
+ }
1432
+ assertTransactionPayload(entry, suffix, entry.stagingPath);
1433
+ }
1434
+ for (const entry of staged) {
1435
+ removeManagedPath(entry.stagingPath, entry.kind);
1436
+ }
1099
1437
  }
1438
+ } catch (recoveryError) {
1439
+ error.message = `${error.message}; ${recoveryError.message}`;
1440
+ throw error;
1100
1441
  }
1101
- fs.rmSync(journalPath, { force: true });
1102
1442
  throw error;
1103
- } finally {
1104
- for (const entry of prepared) {
1105
- if (entry.stagingPath) {
1106
- fs.rmSync(entry.stagingPath, {
1107
- recursive: entry.kind === "directory",
1108
- force: true,
1109
- });
1110
- }
1111
- }
1112
1443
  }
1113
1444
 
1114
- for (const entry of committed) {
1115
- fs.rmSync(entry.backupPath, {
1116
- recursive: entry.kind === "directory",
1117
- force: true,
1118
- });
1119
- }
1120
- fs.rmSync(journalPath, { force: true });
1445
+ recoverInterruptedTransaction(targetDir);
1121
1446
  }
1122
1447
 
1123
1448
  function normalizeToolList(values) {
@@ -1176,11 +1501,12 @@ function mergeObjects(existing, update) {
1176
1501
 
1177
1502
  const merged = { ...existing };
1178
1503
  for (const [key, value] of Object.entries(update)) {
1179
- if (value && typeof value === "object" && !Array.isArray(value)) {
1180
- merged[key] = mergeObjects(existing[key], value);
1181
- } else {
1182
- merged[key] = value;
1183
- }
1504
+ Object.defineProperty(merged, key, {
1505
+ value: value && typeof value === "object" && !Array.isArray(value)
1506
+ ? mergeObjects(Object.hasOwn(existing, key) ? existing[key] : undefined, value)
1507
+ : value,
1508
+ enumerable: true, writable: true, configurable: true,
1509
+ });
1184
1510
  }
1185
1511
  return merged;
1186
1512
  }
@@ -1192,15 +1518,61 @@ function readDeveloperConfig(filePath) {
1192
1518
 
1193
1519
  try {
1194
1520
  const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
1195
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1196
- throw new Error("root value must be an object");
1197
- }
1521
+ assertValidConfig(value, filePath);
1198
1522
  return value;
1199
1523
  } catch (error) {
1200
- throw new Error(`Cannot preserve malformed ${filePath}: ${error.message}`);
1524
+ if (error.code) {
1525
+ error.path = filePath;
1526
+ throw error;
1527
+ }
1528
+ throw Object.assign(new Error(`Cannot preserve malformed ${filePath}: $ must contain valid JSON.`), {
1529
+ code: "MACCA_CONFIG_INVALID", field: "$", fields: ["$"], path: filePath,
1530
+ });
1201
1531
  }
1202
1532
  }
1203
1533
 
1534
+ const promptStates = new WeakMap();
1535
+
1536
+ function cancellation() {
1537
+ return Object.assign(new Error("Setup cancelled."), { code: "MACCA_CANCELLED" });
1538
+ }
1539
+
1540
+ function preparePrompt(prompt) {
1541
+ if (promptStates.has(prompt)) return promptStates.get(prompt);
1542
+ const state = { lines: [], pending: null, error: null };
1543
+ const fail = (error) => {
1544
+ state.error = error;
1545
+ if (state.pending) {
1546
+ state.pending.reject(error);
1547
+ state.pending = null;
1548
+ }
1549
+ };
1550
+ const onLine = (line) => {
1551
+ if (state.pending) {
1552
+ state.pending.resolve(line);
1553
+ state.pending = null;
1554
+ } else state.lines.push(line);
1555
+ };
1556
+ const onCancel = () => fail(cancellation());
1557
+ const onError = (error) => fail(Object.assign(new Error("Interactive input failed. Check your terminal and retry."), { code: error.code || "MACCA_INPUT_ERROR" }));
1558
+ prompt.on("line", onLine);
1559
+ prompt.on("SIGINT", onCancel);
1560
+ prompt.on("close", onCancel);
1561
+ prompt.on("error", onError);
1562
+ prompt.input?.on("error", onError);
1563
+ state.dispose = () => {
1564
+ prompt.close();
1565
+ prompt.removeListener("line", onLine);
1566
+ prompt.removeListener("SIGINT", onCancel);
1567
+ prompt.removeListener("close", onCancel);
1568
+ prompt.removeListener("error", onError);
1569
+ prompt.input?.removeListener("error", onError);
1570
+ promptStates.delete(prompt);
1571
+ };
1572
+ promptStates.set(prompt, state);
1573
+ return state;
1574
+ }
1575
+
1204
1576
  function createPrompt() {
1205
1577
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1206
1578
  throw new Error(
@@ -1208,45 +1580,54 @@ function createPrompt() {
1208
1580
  );
1209
1581
  }
1210
1582
 
1211
- return readline.createInterface({
1583
+ const prompt = readline.createInterface({
1212
1584
  input: process.stdin,
1213
1585
  output: process.stdout,
1214
1586
  });
1587
+ preparePrompt(prompt);
1588
+ return prompt;
1215
1589
  }
1216
1590
 
1217
1591
  function askQuestion(prompt, question) {
1218
- return new Promise((resolve) => {
1219
- prompt.question(question, resolve);
1592
+ const state = preparePrompt(prompt);
1593
+ return new Promise((resolve, reject) => {
1594
+ if (state.error) return reject(state.error);
1595
+ state.pending = { resolve, reject };
1596
+ try {
1597
+ prompt.setPrompt(question);
1598
+ prompt.prompt();
1599
+ if (state.lines.length && state.pending) {
1600
+ state.pending = null;
1601
+ resolve(state.lines.shift());
1602
+ }
1603
+ } catch (error) {
1604
+ state.pending = null;
1605
+ reject(error);
1606
+ }
1220
1607
  });
1221
1608
  }
1222
1609
 
1223
- async function promptForTools() {
1224
- const prompt = createPrompt();
1225
-
1226
- try {
1227
- process.stdout.write("\nPilih AI tool yang mau dipasang:\n");
1228
- TOOL_DEFINITIONS.forEach((tool, index) => {
1229
- process.stdout.write(
1230
- ` ${index + 1}. ${tool.label} -> ${tool.displayDestination}\n`,
1231
- );
1232
- });
1233
- process.stdout.write("\n");
1234
-
1235
- while (true) {
1236
- const answer = await askQuestion(
1237
- prompt,
1238
- "Masukkan nomor/nama tool (pisahkan dengan koma, 'all', kosong = codex): ",
1239
- );
1240
- const parsed = parseInteractiveToolSelection(answer);
1241
- if (parsed.error) {
1242
- process.stdout.write(` ${parsed.error}\n`);
1243
- continue;
1244
- }
1610
+ async function promptForTools(prompt) {
1611
+ process.stdout.write("\nPilih AI tool yang mau dipasang:\n");
1612
+ TOOL_DEFINITIONS.forEach((tool, index) => {
1613
+ process.stdout.write(
1614
+ ` ${index + 1}. ${tool.label} -> ${tool.displayDestination}\n`,
1615
+ );
1616
+ });
1617
+ process.stdout.write("\n");
1245
1618
 
1246
- return parsed.tools;
1619
+ while (true) {
1620
+ const answer = await askQuestion(
1621
+ prompt,
1622
+ "Masukkan nomor/nama tool (pisahkan dengan koma, 'all', kosong = codex): ",
1623
+ );
1624
+ const parsed = parseInteractiveToolSelection(answer);
1625
+ if (parsed.error) {
1626
+ process.stdout.write(` ${parsed.error}\n`);
1627
+ continue;
1247
1628
  }
1248
- } finally {
1249
- prompt.close();
1629
+
1630
+ return parsed.tools;
1250
1631
  }
1251
1632
  }
1252
1633
 
@@ -1288,51 +1669,65 @@ function parseInteractiveToolSelection(answer) {
1288
1669
  return { tools: unique(tools) };
1289
1670
  }
1290
1671
 
1291
- async function promptForMetadata(seed) {
1292
- const prompt = createPrompt();
1672
+ async function promptForMetadata(seed, prompt) {
1673
+ const communication =
1674
+ seed.communicationLanguage !== undefined
1675
+ ? seed.communicationLanguage
1676
+ : await askQuestion(
1677
+ prompt,
1678
+ "Bahasa komunikasi yang anda inginkan? (Kosong = Bahasa Indonesia): ",
1679
+ );
1680
+ const documents =
1681
+ seed.documentLanguage !== undefined
1682
+ ? seed.documentLanguage
1683
+ : await askQuestion(
1684
+ prompt,
1685
+ "Bahasa dokumen yang dihasilkan? (Kosong = sama dengan komunikasi): ",
1686
+ );
1293
1687
 
1294
- try {
1295
- const name =
1296
- seed.name !== undefined
1297
- ? seed.name
1298
- : await askQuestion(
1299
- prompt,
1300
- "Kamu mau dipanggil apa? (Kosong = Skip): ",
1301
- );
1302
- const project =
1303
- seed.project !== undefined
1304
- ? seed.project
1305
- : await askQuestion(prompt, "Nama project ini apa? (Kosong = Skip): ");
1306
- const communication =
1307
- seed.communicationLanguage !== undefined
1308
- ? seed.communicationLanguage
1309
- : await askQuestion(
1310
- prompt,
1311
- "Bahasa komunikasi yang anda inginkan? (Kosong = Bahasa Indonesia): ",
1312
- );
1313
- const documents =
1314
- seed.documentLanguage !== undefined
1315
- ? seed.documentLanguage
1316
- : await askQuestion(
1317
- prompt,
1318
- "Bahasa dokumen yang dihasilkan? (Kosong = Bahasa Indonesia): ",
1319
- );
1688
+ return {
1689
+ name: seed.name,
1690
+ project: seed.project,
1691
+ communicationLanguage: communication || "Bahasa Indonesia",
1692
+ documentLanguage: documents || communication || "Bahasa Indonesia",
1693
+ };
1694
+ }
1320
1695
 
1321
- return {
1322
- name: name || "",
1323
- project: project || "",
1324
- communicationLanguage: communication || "Bahasa Indonesia",
1325
- documentLanguage: documents || "Bahasa Indonesia",
1326
- };
1327
- } finally {
1328
- prompt.close();
1696
+ function addRetiredSkills(entries, targetDir, destination, skillNames) {
1697
+ assertSafeProjectPath(targetDir, destination);
1698
+ for (const skillName of skillNames) {
1699
+ const targetPath = resolveOwnedSkillPath(destination, skillName);
1700
+ if (!fs.existsSync(targetPath)) continue;
1701
+ try {
1702
+ assertSafeProjectPath(targetDir, targetPath);
1703
+ hashSnapshot(targetPath, "directory");
1704
+ if (isMaccaOwned(targetPath)) {
1705
+ // Retirement never needs to discard local edits, even with --force.
1706
+ assertManagedDirectoryUnchanged(targetPath);
1707
+ } else if (!isLegacyDirectoryUnchanged(targetPath, skillName)) {
1708
+ throw new Error("ownership could not be verified");
1709
+ }
1710
+ if (getUnhashedEntries(targetPath).length) {
1711
+ throw new Error("directory contains unhashed content");
1712
+ }
1713
+ } catch (error) {
1714
+ process.stderr.write(
1715
+ `Preserving obsolete skill directory: ${targetPath} (${error.message})\n`,
1716
+ );
1717
+ continue;
1718
+ }
1719
+ entries.push({ sourcePath: null, targetPath, legacySkillName: skillName });
1329
1720
  }
1330
1721
  }
1331
1722
 
1332
- function applyInstall(targetDir, tools, metadata) {
1723
+ function applyInstall(targetDir, tools, metadata, options = {}) {
1724
+ const force = Boolean(options.force);
1333
1725
  const managedSkills = getSourceManagedSkills();
1334
1726
  const agentsDirectory = path.join(targetDir, ".agents");
1335
- ensureDirectory(targetDir);
1727
+ const configPath = path.join(agentsDirectory, "developer-config.json");
1728
+ assertSafeProjectPath(targetDir, configPath);
1729
+ const config = mergeObjects(readDeveloperConfig(configPath), buildDeveloperConfig(metadata));
1730
+ assertValidConfig(config, configPath);
1336
1731
  assertSafeProjectPath(targetDir, agentsDirectory);
1337
1732
  assertPackageNotOlderThanInstalled(agentsDirectory);
1338
1733
  for (const fileName of [
@@ -1344,11 +1739,15 @@ function applyInstall(targetDir, tools, metadata) {
1344
1739
  ]) {
1345
1740
  assertSafeProjectPath(targetDir, path.join(agentsDirectory, fileName));
1346
1741
  }
1347
- assertManagedFilesUnchanged(agentsDirectory);
1742
+ assertManagedFilesUnchanged(agentsDirectory, force);
1348
1743
  const previousManagedSkills = validateManagedSkillNames(
1349
1744
  readNonEmptyLines(path.join(agentsDirectory, "macca-managed-skills.txt")),
1350
1745
  ".agents/macca-managed-skills.txt",
1351
1746
  );
1747
+ tools = unique([
1748
+ ...readNonEmptyLines(path.join(agentsDirectory, "macca-tools.txt")),
1749
+ ...tools,
1750
+ ]);
1352
1751
  const entries = [];
1353
1752
  for (const destination of getUniqueDestinations(targetDir, tools)) {
1354
1753
  for (const skillName of managedSkills) {
@@ -1358,12 +1757,21 @@ function applyInstall(targetDir, tools, metadata) {
1358
1757
  legacySkillName: skillName,
1359
1758
  });
1360
1759
  }
1760
+ addRetiredSkills(
1761
+ entries,
1762
+ targetDir,
1763
+ destination,
1764
+ previousManagedSkills.filter((name) => !managedSkills.includes(name)),
1765
+ );
1766
+ }
1767
+ if (tools.includes("opencode")) {
1768
+ addRetiredSkills(
1769
+ entries,
1770
+ targetDir,
1771
+ path.join(targetDir, ".opencode", "skill"),
1772
+ previousManagedSkills,
1773
+ );
1361
1774
  }
1362
- const configPath = path.join(agentsDirectory, "developer-config.json");
1363
- const config = mergeObjects(
1364
- readDeveloperConfig(configPath),
1365
- buildDeveloperConfig(metadata),
1366
- );
1367
1775
  const managedContent = fs.existsSync(SOURCE_MANAGED_SKILLS_FILE)
1368
1776
  ? fs.readFileSync(SOURCE_MANAGED_SKILLS_FILE, "utf8")
1369
1777
  : `${managedSkills.join("\n")}\n`;
@@ -1401,12 +1809,16 @@ function applyInstall(targetDir, tools, metadata) {
1401
1809
  targetPath: path.join(agentsDirectory, STATE_FILE),
1402
1810
  },
1403
1811
  );
1404
- applySkillTransaction(entries, targetDir);
1812
+ applySkillTransaction(entries, targetDir, force);
1405
1813
  }
1406
1814
 
1407
- function applyUpgrade(targetDir) {
1815
+ function applyUpgrade(targetDir, options = {}) {
1816
+ const force = Boolean(options.force);
1408
1817
  const agentsDirectory = path.join(targetDir, ".agents");
1409
1818
  assertSafeProjectPath(targetDir, agentsDirectory);
1819
+ const configPath = path.join(agentsDirectory, "developer-config.json");
1820
+ assertSafeProjectPath(targetDir, configPath);
1821
+ readDeveloperConfig(configPath);
1410
1822
  assertPackageNotOlderThanInstalled(agentsDirectory);
1411
1823
  for (const fileName of [
1412
1824
  "macca-managed-skills.txt",
@@ -1417,7 +1829,7 @@ function applyUpgrade(targetDir) {
1417
1829
  ]) {
1418
1830
  assertSafeProjectPath(targetDir, path.join(agentsDirectory, fileName));
1419
1831
  }
1420
- assertManagedFilesUnchanged(agentsDirectory);
1832
+ assertManagedFilesUnchanged(agentsDirectory, force);
1421
1833
  const tools = readNonEmptyLines(
1422
1834
  path.join(agentsDirectory, "macca-tools.txt"),
1423
1835
  );
@@ -1442,47 +1854,21 @@ function applyUpgrade(targetDir) {
1442
1854
  });
1443
1855
  }
1444
1856
 
1445
- for (const skillName of previousManagedSkills.filter(
1446
- (name) => !nextManagedSkills.includes(name),
1447
- )) {
1448
- const oldPath = resolveOwnedSkillPath(destination, skillName);
1449
- if (fs.existsSync(oldPath) && isMaccaOwned(oldPath)) {
1450
- assertManagedDirectoryUnchanged(oldPath);
1451
- entries.push({ sourcePath: null, targetPath: oldPath });
1452
- } else if (isLegacyDirectoryUnchanged(oldPath, skillName)) {
1453
- entries.push({
1454
- sourcePath: null,
1455
- targetPath: oldPath,
1456
- legacySkillName: skillName,
1457
- });
1458
- } else if (fs.existsSync(oldPath)) {
1459
- process.stderr.write(
1460
- `Preserving unmarked legacy skill directory: ${oldPath}\n`,
1461
- );
1462
- }
1463
- }
1857
+ addRetiredSkills(
1858
+ entries,
1859
+ targetDir,
1860
+ destination,
1861
+ previousManagedSkills.filter((name) => !nextManagedSkills.includes(name)),
1862
+ );
1464
1863
  }
1465
1864
 
1466
1865
  if (tools.includes("opencode")) {
1467
- const legacyOpenCodeRoot = path.join(targetDir, ".opencode", "skill");
1468
- assertSafeProjectPath(targetDir, legacyOpenCodeRoot);
1469
- for (const skillName of previousManagedSkills) {
1470
- const oldPath = resolveOwnedSkillPath(legacyOpenCodeRoot, skillName);
1471
- if (fs.existsSync(oldPath) && isMaccaOwned(oldPath)) {
1472
- assertManagedDirectoryUnchanged(oldPath);
1473
- entries.push({ sourcePath: null, targetPath: oldPath });
1474
- } else if (isLegacyDirectoryUnchanged(oldPath, skillName)) {
1475
- entries.push({
1476
- sourcePath: null,
1477
- targetPath: oldPath,
1478
- legacySkillName: skillName,
1479
- });
1480
- } else if (fs.existsSync(oldPath)) {
1481
- throw new Error(
1482
- `Legacy OpenCode skill was modified; move or back it up before upgrade: ${oldPath}`,
1483
- );
1484
- }
1485
- }
1866
+ addRetiredSkills(
1867
+ entries,
1868
+ targetDir,
1869
+ path.join(targetDir, ".opencode", "skill"),
1870
+ previousManagedSkills,
1871
+ );
1486
1872
  }
1487
1873
  const managedContent = fs.existsSync(SOURCE_MANAGED_SKILLS_FILE)
1488
1874
  ? fs.readFileSync(SOURCE_MANAGED_SKILLS_FILE, "utf8")
@@ -1511,7 +1897,7 @@ function applyUpgrade(targetDir) {
1511
1897
  targetPath: path.join(agentsDirectory, STATE_FILE),
1512
1898
  },
1513
1899
  );
1514
- applySkillTransaction(entries, targetDir);
1900
+ applySkillTransaction(entries, targetDir, force);
1515
1901
 
1516
1902
  if (tools.includes("kimi")) reportLegacyKimiInstall();
1517
1903
  }
@@ -1562,85 +1948,246 @@ function printInstallSummary(action, tools) {
1562
1948
  }
1563
1949
 
1564
1950
  async function runInstall(args) {
1565
- let tools = normalizeToolList(args.tools);
1566
- if (tools.length === 0) {
1567
- tools = args.yes ? ["codex"] : await promptForTools();
1568
- }
1569
-
1951
+ assertSupportedRuntime();
1952
+ operation.stage = "not started";
1953
+ operation.recoveryChanged = false;
1954
+ operation.targetDir = resolveTargetDirectory(args.directory);
1570
1955
  const targetDir = assertSafeTargetDirectory(args.directory);
1571
- ensureDirectory(targetDir);
1572
- assertSafeProjectPath(targetDir, path.join(targetDir, ".agents"));
1573
- recoverInterruptedTransaction(targetDir);
1956
+ const configPath = path.join(targetDir, ".agents", "developer-config.json");
1957
+ assertSafeProjectPath(targetDir, configPath);
1958
+ assertSafeProjectPath(targetDir, path.join(targetDir, ".agents", "macca-tools.txt"));
1959
+ const existingConfig = readDeveloperConfig(configPath);
1574
1960
  const existingTools = readNonEmptyLines(
1575
1961
  path.join(targetDir, ".agents", "macca-tools.txt"),
1576
1962
  );
1577
- tools = unique([...existingTools, ...tools]);
1578
- const configPath = path.join(targetDir, ".agents", "developer-config.json");
1579
- const existingConfig = readDeveloperConfig(configPath);
1580
1963
  const hasExistingConfig = fs.existsSync(configPath);
1581
- const metadata = args.yes
1582
- ? {
1583
- name:
1584
- args.name !== undefined
1585
- ? args.name
1586
- : hasExistingConfig
1587
- ? undefined
1588
- : "",
1589
- project:
1590
- args.project !== undefined
1591
- ? args.project
1592
- : hasExistingConfig
1593
- ? undefined
1594
- : "",
1595
- communicationLanguage:
1596
- args.communicationLanguage !== undefined
1597
- ? args.communicationLanguage
1598
- : hasExistingConfig
1599
- ? undefined
1600
- : "Bahasa Indonesia",
1601
- documentLanguage:
1602
- args.documentLanguage !== undefined
1603
- ? args.documentLanguage
1604
- : hasExistingConfig
1605
- ? undefined
1606
- : "Bahasa Indonesia",
1964
+ let tools = normalizeToolList(args.tools);
1965
+ let metadata;
1966
+ let prompt;
1967
+ try {
1968
+ const getPrompt = () => (prompt ||= createPrompt());
1969
+ if (!tools.length) {
1970
+ tools = args.yes ? (existingTools.length ? existingTools : ["codex"]) : await promptForTools(getPrompt());
1971
+ }
1972
+ tools = unique([...existingTools, ...tools]);
1973
+ const seed = {
1974
+ name: args.name,
1975
+ project: args.project,
1976
+ communicationLanguage: args.communicationLanguage,
1977
+ documentLanguage: args.documentLanguage,
1978
+ };
1979
+ if (args.yes) {
1980
+ metadata = { ...seed };
1981
+ if (!hasExistingConfig) {
1982
+ metadata.communicationLanguage ??= "Bahasa Indonesia";
1983
+ metadata.documentLanguage ??= metadata.communicationLanguage;
1607
1984
  }
1608
- : await promptForMetadata({
1609
- ...args,
1610
- name: args.name !== undefined ? args.name : existingConfig.name,
1611
- project:
1612
- args.project !== undefined ? args.project : existingConfig.project,
1613
- communicationLanguage:
1614
- args.communicationLanguage !== undefined
1615
- ? args.communicationLanguage
1616
- : existingConfig.languagePreferences?.communication?.raw,
1617
- documentLanguage:
1618
- args.documentLanguage !== undefined
1619
- ? args.documentLanguage
1620
- : existingConfig.languagePreferences?.documents?.raw,
1621
- });
1985
+ } else {
1986
+ // Reusing a saved section must not rewrite its raw/normalized pair.
1987
+ const saved = existingConfig.languagePreferences || {};
1988
+ const communication = seed.communicationLanguage ?? saved.communication?.raw ?? saved.communication?.normalized;
1989
+ const documents = seed.documentLanguage ?? saved.documents?.raw ?? saved.documents?.normalized;
1990
+ metadata = await promptForMetadata({ ...seed, communicationLanguage: communication, documentLanguage: documents },
1991
+ communication === undefined || documents === undefined ? getPrompt() : prompt);
1992
+ if (seed.communicationLanguage === undefined && saved.communication) delete metadata.communicationLanguage;
1993
+ if (seed.documentLanguage === undefined && saved.documents) delete metadata.documentLanguage;
1994
+ }
1995
+ assertValidConfig(mergeObjects(existingConfig, buildDeveloperConfig(metadata)), configPath);
1996
+ getUniqueDestinations(targetDir, tools);
1997
+ if (prompt && promptStates.get(prompt).error) throw promptStates.get(prompt).error;
1998
+ } finally {
1999
+ if (prompt) promptStates.get(prompt).dispose();
2000
+ }
1622
2001
 
1623
- applyInstall(targetDir, tools, metadata);
2002
+ assertPackageNotOlderThanInstalled(path.join(targetDir, ".agents"), true);
2003
+ recoverInterruptedTransaction(targetDir);
2004
+ // A crash can leave the config only in its backup. First-install defaults
2005
+ // must not overwrite settings restored by recovery on an unattended retry.
2006
+ if (args.yes && !hasExistingConfig && fs.existsSync(configPath)) {
2007
+ if (args.communicationLanguage === undefined) delete metadata.communicationLanguage;
2008
+ if (args.documentLanguage === undefined) delete metadata.documentLanguage;
2009
+ }
2010
+ applyInstall(targetDir, tools, metadata, { force: Boolean(args.force) });
1624
2011
 
2012
+ operation.stage = "complete";
1625
2013
  printInstallSummary("installed", tools);
1626
- process.stdout.write(` Target project: ${targetDir}\n\n`);
2014
+ printSetupHint(targetDir);
1627
2015
  }
1628
2016
 
1629
2017
  function runUpgrade(args) {
2018
+ assertSupportedRuntime();
2019
+ operation.stage = "not started";
2020
+ operation.recoveryChanged = false;
2021
+ operation.targetDir = resolveTargetDirectory(args.directory);
1630
2022
  const targetDir = assertSafeTargetDirectory(args.directory);
1631
- assertSafeProjectPath(targetDir, path.join(targetDir, ".agents"));
2023
+ const configPath = path.join(targetDir, ".agents", "developer-config.json");
2024
+ assertSafeProjectPath(targetDir, configPath);
2025
+ readDeveloperConfig(configPath);
2026
+ assertPackageNotOlderThanInstalled(path.join(targetDir, ".agents"), true);
1632
2027
  recoverInterruptedTransaction(targetDir);
2028
+ assertSafeProjectPath(targetDir, path.join(targetDir, ".agents", "macca-tools.txt"));
1633
2029
  const tools = readNonEmptyLines(
1634
2030
  path.join(targetDir, ".agents", "macca-tools.txt"),
1635
2031
  );
1636
- applyUpgrade(targetDir);
2032
+ applyUpgrade(targetDir, { force: Boolean(args.force) });
2033
+ operation.stage = "complete";
1637
2034
  printInstallSummary("updated", tools);
1638
- process.stdout.write(` Target project: ${targetDir}\n\n`);
2035
+ printSetupHint(targetDir);
1639
2036
  }
1640
2037
 
1641
- function exitWithError(message) {
1642
- process.stderr.write(`\nError: ${message}\n`);
1643
- process.exit(1);
2038
+ function printSetupHint(targetDir) {
2039
+ process.stdout.write(` Target project: ${targetDir}\n Restart your AI host to load the skills. Run the setup-macca-method skill for setup; name/project are optional.\n\n`);
2040
+ }
2041
+
2042
+ function assertSupportedRuntime() {
2043
+ const major = Number(process.versions.node.split(".")[0]);
2044
+ if (major < 22) {
2045
+ throw Object.assign(new Error("Unsupported Node.js runtime. Install Node.js 22 or 24, then retry."), { code: "MACCA_NODE_UNSUPPORTED" });
2046
+ }
2047
+ if (![22, 24].includes(major)) process.stderr.write("Notice: this Node.js runtime is unverified; CI verifies Node.js 22 and 24.\n");
2048
+ }
2049
+
2050
+ function runDoctor(args) {
2051
+ operation.stage = "read-only diagnosis";
2052
+ operation.targetDir = resolveTargetDirectory(args.directory);
2053
+ let failures = 0;
2054
+ let warnings = 0;
2055
+ const report = (level, message, filePath) => {
2056
+ if (level === "FAIL") failures += 1;
2057
+ if (level === "WARN") warnings += 1;
2058
+ process.stdout.write(`${level}: ${message}${filePath ? ` Path: ${JSON.stringify(filePath)}` : ""}\n`);
2059
+ };
2060
+ const inspect = (filePath, check) => {
2061
+ try { return check(); } catch (error) {
2062
+ report("FAIL", error.message, filePath);
2063
+ return undefined;
2064
+ }
2065
+ };
2066
+ const major = Number(process.versions.node.split(".")[0]);
2067
+ report(major < 22 ? "FAIL" : [22, 24].includes(major) ? "OK" : "WARN",
2068
+ major < 22 ? "Unsupported Node.js runtime; use Node.js 22 or 24." :
2069
+ [22, 24].includes(major) ? "Node.js runtime is supported." : "Node.js runtime is unverified; CI verifies 22 and 24.");
2070
+ const targetDir = inspect(operation.targetDir, () => assertSafeTargetDirectory(args.directory));
2071
+ if (targetDir) {
2072
+ const metadata = (name) => path.join(targetDir, ".agents", name);
2073
+ const required = (filePath) => {
2074
+ assertSafeProjectPath(targetDir, filePath);
2075
+ const stat = fs.lstatSync(filePath);
2076
+ if (!stat.isFile()) throw new Error("Required installed file is not a regular file.");
2077
+ };
2078
+ inspect(targetDir, () => {
2079
+ if (!fs.statSync(targetDir).isDirectory()) throw new Error("Target project is not a directory.");
2080
+ });
2081
+ const journalPath = metadata(TRANSACTION_FILE);
2082
+ inspect(journalPath, () => {
2083
+ assertSafeProjectPath(targetDir, journalPath);
2084
+ if (fs.existsSync(journalPath)) report("FAIL", "Interrupted transaction retained. Back up the project and journal, then rerun install/upgrade for validated recovery; do not delete the journal.", journalPath);
2085
+ });
2086
+ inspect(metadata("developer-config.json"), () => {
2087
+ required(metadata("developer-config.json"));
2088
+ readDeveloperConfig(metadata("developer-config.json"));
2089
+ report("OK", "Developer config is valid (values hidden).");
2090
+ });
2091
+ const tools = inspect(metadata("macca-tools.txt"), () => {
2092
+ required(metadata("macca-tools.txt"));
2093
+ const selected = readNonEmptyLines(metadata("macca-tools.txt"));
2094
+ if (!selected.length || selected.some((key) => !TOOL_BY_KEY.has(key))) throw new Error("Selected tools are missing or unsupported; review macca-tools.txt.");
2095
+ report("OK", `Selected tools: ${unique(selected).join(", ")}.`);
2096
+ return unique(selected);
2097
+ });
2098
+ const skills = inspect(metadata("macca-managed-skills.txt"), () => {
2099
+ required(metadata("macca-managed-skills.txt"));
2100
+ const names = validateManagedSkillNames(readNonEmptyLines(metadata("macca-managed-skills.txt")), "installed manifest");
2101
+ if (!names.length || !names.includes("_shared")) throw new Error("Installed managed-skill manifest is empty or missing _shared.");
2102
+ return names;
2103
+ });
2104
+ const lock = inspect(metadata("macca-lock.json"), () => {
2105
+ required(metadata("macca-lock.json"));
2106
+ const value = readJsonObject(metadata("macca-lock.json"), "installed lock");
2107
+ if (typeof value.version !== "string" || !value.version) throw new Error("Installed lock version is missing or invalid.");
2108
+ if (!Array.isArray(value.skills) || !value.skills.length || value.skills.some((name) => typeof name !== "string")) throw new Error("Installed lock skill list is missing or invalid.");
2109
+ validateManagedSkillNames(value.skills, "installed lock");
2110
+ if (skills && (value.skills.some((name) => !skills.includes(name)) || skills.some((name) => !value.skills.includes(name)))) throw new Error("Installed lock and managed-skill manifest disagree.");
2111
+ if (value.payloadFiles !== undefined && (!value.payloadFiles || typeof value.payloadFiles !== "object" || Array.isArray(value.payloadFiles))) throw new Error("Installed lock payloadFiles must be an object.");
2112
+ return value;
2113
+ });
2114
+ inspect(metadata(STATE_FILE), () => {
2115
+ assertSafeProjectPath(targetDir, metadata(STATE_FILE));
2116
+ if (!fs.existsSync(metadata(STATE_FILE))) {
2117
+ report("WARN", "Installation state is absent; metadata integrity cannot be verified.", metadata(STATE_FILE));
2118
+ return;
2119
+ }
2120
+ required(metadata(STATE_FILE));
2121
+ const state = readJsonObject(metadata(STATE_FILE), "MACCA state");
2122
+ for (const name of ["macca-tools.txt", "macca-managed-skills.txt", "macca-lock.json"]) {
2123
+ required(metadata(name));
2124
+ if (!/^[a-f0-9]{64}$/.test(state.files?.[name] || "")) throw new Error("Installation state is missing a valid metadata fingerprint.");
2125
+ if (hashFile(metadata(name)) !== state.files[name]) report("WARN", "Local metadata drift; back up and review before upgrade.", metadata(name));
2126
+ }
2127
+ });
2128
+ if (tools && skills && lock) {
2129
+ if (!lock.payloadFiles) report("WARN", "Installed lock has no payloadFiles; checking current packaged references/assets for existence only. Upgrade after backup to establish fingerprints.", metadata("macca-lock.json"));
2130
+ const destinations = inspect(targetDir, () => getUniqueDestinations(targetDir, tools));
2131
+ for (const destination of destinations || []) {
2132
+ for (const skill of skills) {
2133
+ const skillPath = resolveOwnedSkillPath(destination, skill);
2134
+ inspect(skillPath, () => {
2135
+ assertSafeProjectPath(targetDir, skillPath);
2136
+ const actual = getDirectoryFileHashes(skillPath);
2137
+ if (skill !== "_shared") required(path.join(skillPath, "SKILL.md"));
2138
+ const expected = lock.payloadFiles ? lock.payloadFiles[skill] : getDirectoryFileHashes(path.join(SOURCE_SKILLS_DIR, skill));
2139
+ if (!expected || typeof expected !== "object" || Array.isArray(expected) || !Object.keys(expected).length) throw new Error("Installed lock is missing valid payload fingerprints for this skill.");
2140
+ if (skill === "_shared" && !Object.keys(expected).some((name) => name.startsWith("references/"))) throw new Error("Shared references are missing from installed lock.");
2141
+ for (const [relative, hash] of Object.entries(expected)) {
2142
+ if (!relative || relative.includes("\\") || relative.split("/").some((part) => !part || part === "." || part === "..") || path.isAbsolute(relative) || /^[A-Za-z]:/.test(relative) || typeof hash !== "string" || !/^[a-f0-9]{64}$/.test(hash)) throw new Error("Invalid payload path or hash in installed lock.");
2143
+ const filePath = path.join(skillPath, relative);
2144
+ if (!Object.hasOwn(actual, relative)) report("FAIL", "Required installed payload file is missing.", filePath);
2145
+ else if (lock.payloadFiles && actual[relative] !== hash) report("WARN", "Local skill drift; back up and review before upgrade.", filePath);
2146
+ }
2147
+ if (lock.payloadFiles && Object.keys(actual).some((relative) => !Object.hasOwn(expected, relative))) report("WARN", "Additional local skill files; back up and review before upgrade.", skillPath);
2148
+ const marker = readOwnershipMarker(skillPath, skill);
2149
+ const markerPath = path.join(skillPath, OWNERSHIP_MARKER);
2150
+ if (!marker) {
2151
+ report("WARN", "Ownership marker is absent or invalid; upgrade may require review.", markerPath);
2152
+ } else if (typeof marker.payloadHash !== "string" ||
2153
+ !/^[a-f0-9]{64}$/.test(marker.payloadHash) ||
2154
+ marker.payloadHash !== hashText(JSON.stringify(actual))) {
2155
+ report("WARN", "Ownership marker payload fingerprint is missing, malformed, or mismatched; ordinary upgrade will refuse this skill. Back up and review the skill and marker; doctor does not repair them.", markerPath);
2156
+ }
2157
+ });
2158
+ }
2159
+ }
2160
+ }
2161
+ }
2162
+ process.stdout.write(`Doctor: ${failures} failure(s), ${warnings} warning(s). Read-only; no files changed.\n`);
2163
+ if (failures) process.stdout.write("Next: review the reported paths and back up existing files before install/upgrade.\n");
2164
+ process.exitCode = failures ? 1 : 0;
2165
+ }
2166
+
2167
+ function exitWithError(error) {
2168
+ if (typeof error === "string") error = new Error(error);
2169
+ const nextSteps = {
2170
+ EACCES: "Check permissions and ownership of the reported path; choose a writable project directory.",
2171
+ EPERM: "Check permissions or file locks at the reported path; close programs holding it and retry.",
2172
+ EBUSY: "Close programs holding the reported path, then retry.",
2173
+ ENOSPC: "Free space on the target volume, then retry; preserve transaction files for recovery.",
2174
+ ENOENT: "Check the reported path and parent directory. Restore missing package files or run install for a missing installation.",
2175
+ MACCA_CONFIG_INVALID: "Back up developer-config.json, correct the listed fields without sharing secret values, then retry. --force cannot bypass config validation.",
2176
+ MACCA_CANCELLED: "Rerun install when ready, or use --yes with explicit flags.",
2177
+ MACCA_INPUT_ERROR: "Open a working terminal, or use --yes with explicit flags.",
2178
+ MACCA_NODE_UNSUPPORTED: "Use Node.js 22 or 24, then retry.",
2179
+ };
2180
+ let next = nextSteps[error.code] || "Review the reported path and back up local files before retrying. Use doctor for a read-only check.";
2181
+ if (/symlink/i.test(error.message)) next = "Symlinked paths are rejected conservatively. Resolve the real path (including macOS aliases such as /var or /tmp), review it, and pass that real directory with --directory.";
2182
+ let pending = false;
2183
+ if (operation.targetDir) {
2184
+ try { pending = fs.existsSync(path.join(operation.targetDir, ".agents", TRANSACTION_FILE)); } catch { /* Error reporting must not mask the original error. */ }
2185
+ }
2186
+ const stage = operation.stage === "not started" ? "not started; no files changed by this attempt" :
2187
+ operation.stage === "read-only diagnosis" ? "read-only diagnosis; no files changed" :
2188
+ `${operation.stage}; files may have changed${operation.recoveryChanged ? "; recovery may have changed files" : ""}`;
2189
+ process.stderr.write(`\nError [${error.code || "MACCA_ERROR"}]: ${error.message}\nPath: ${JSON.stringify(error.path || operation.targetDir || process.cwd())}\nChange stage: ${stage}.\n${pending ? "Pending transaction journal retained; back up and review it before retrying recovery.\n" : ""}Next: ${next}\n`);
2190
+ process.exitCode = error.code === "MACCA_CANCELLED" ? 130 : 1;
1644
2191
  }
1645
2192
 
1646
2193
  main();