macca-method 2.1.3 → 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 +781 -277
  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 +62 -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
 
@@ -256,6 +265,9 @@ function parseArgs(argv) {
256
265
  args._.push(token);
257
266
  }
258
267
 
268
+ if (args._.length > 1) {
269
+ throw new Error("Unexpected positional arguments. Use --directory <path> for the target project.");
270
+ }
259
271
  return args;
260
272
  }
261
273
 
@@ -291,6 +303,7 @@ function printHelp() {
291
303
  "Usage:",
292
304
  " npx macca-method@latest install [options]",
293
305
  " npx macca-method@latest upgrade [options]",
306
+ " macca-method doctor [--directory <path>] Read-only installation check (no repair).",
294
307
  " npx macca-method@latest --list-tools",
295
308
  "",
296
309
  "Install options:",
@@ -301,11 +314,12 @@ function printHelp() {
301
314
  " --name <value> Developer name.",
302
315
  " --project <value> Project name.",
303
316
  " --communication-language <value>",
304
- " --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.",
305
319
  "",
306
320
  "Examples:",
307
321
  " npx macca-method@latest install",
308
- " 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",
309
323
  " npx macca-method@latest upgrade",
310
324
  "",
311
325
  ].join("\n"),
@@ -332,27 +346,29 @@ function resolveTargetDirectory(rawDirectory) {
332
346
  }
333
347
 
334
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
+ }
335
352
  const requested = rawDirectory
336
353
  ? path.resolve(process.cwd(), rawDirectory)
337
354
  : process.cwd();
338
355
  const absolute = path.isAbsolute(requested)
339
356
  ? requested
340
357
  : path.resolve(requested);
341
- const segments = absolute.split(path.sep).filter(Boolean);
342
- let current = absolute.startsWith(path.sep)
343
- ? path.sep
344
- : 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;
345
364
 
346
- if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
365
+ if (isSymlink(current)) {
347
366
  throw new Error(`Refusing symlinked target project ancestor: ${current}`);
348
367
  }
349
368
 
350
369
  for (const segment of segments) {
351
- current =
352
- current === path.sep
353
- ? path.join(current, segment)
354
- : path.join(current, segment);
355
- if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
370
+ current = path.join(current, segment);
371
+ if (isSymlink(current)) {
356
372
  throw new Error(`Refusing symlinked target project ancestor: ${current}`);
357
373
  }
358
374
  }
@@ -442,27 +458,37 @@ function normalizeLanguage(value) {
442
458
  case "bahasa inggris":
443
459
  return "english";
444
460
  default:
445
- return lowered;
461
+ return "indonesian";
446
462
  }
447
463
  }
448
464
 
449
465
  function ensureDirectory(directoryPath) {
466
+ operation.stage = "writing";
450
467
  fs.mkdirSync(directoryPath, { recursive: true });
451
468
  }
452
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
+
453
479
  function assertSafeProjectPath(targetDir, destination) {
454
480
  const targetRoot = path.resolve(targetDir);
455
481
  const resolvedDestination = path.resolve(destination);
456
482
  if (
457
483
  resolvedDestination !== targetRoot &&
458
- !resolvedDestination.startsWith(`${targetRoot}${path.sep}`)
484
+ !resolvedDestination.startsWith(targetRoot.endsWith(path.sep) ? targetRoot : `${targetRoot}${path.sep}`)
459
485
  ) {
460
486
  throw new Error(
461
487
  `Refusing destination outside target project: ${destination}`,
462
488
  );
463
489
  }
464
490
 
465
- if (fs.existsSync(targetRoot) && fs.lstatSync(targetRoot).isSymbolicLink()) {
491
+ if (isSymlink(targetRoot)) {
466
492
  throw new Error(`Refusing symlinked target project: ${targetRoot}`);
467
493
  }
468
494
 
@@ -470,7 +496,7 @@ function assertSafeProjectPath(targetDir, destination) {
470
496
  let current = targetRoot;
471
497
  for (const segment of relative.split(path.sep).filter(Boolean)) {
472
498
  current = path.join(current, segment);
473
- if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) {
499
+ if (isSymlink(current)) {
474
500
  throw new Error(
475
501
  `Refusing symlinked skill destination component: ${current}`,
476
502
  );
@@ -561,6 +587,86 @@ function hashDirectory(directoryPath) {
561
587
  return hashText(JSON.stringify(getDirectoryFileHashes(directoryPath)));
562
588
  }
563
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
+
564
670
  function getLegacyPayloadHashes() {
565
671
  if (!fs.existsSync(SOURCE_LEGACY_PAYLOADS_FILE)) return {};
566
672
  const releases = readJsonObject(
@@ -611,46 +717,107 @@ function buildInstalledLock(managedSkills) {
611
717
  }
612
718
 
613
719
  function compareSemver(left, right) {
614
- const parse = (value) =>
615
- String(value || "0.0.0")
616
- .split("-")[0]
617
- .split(".")
618
- .map((part) => Number.parseInt(part, 10) || 0);
619
- const a = parse(left);
620
- const b = parse(right);
621
- const length = Math.max(a.length, b.length);
622
- for (let index = 0; index < length; index += 1) {
623
- 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]);
624
740
  if (diff !== 0) return diff;
625
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
+ }
626
758
  return 0;
627
759
  }
628
760
 
629
- function assertPackageNotOlderThanInstalled(agentsDirectory) {
630
- const installedLockPath = path.join(agentsDirectory, "macca-lock.json");
631
- if (!fs.existsSync(installedLockPath)) {
632
- return;
633
- }
634
-
761
+ function assertPackageNotOlderThanInstalled(agentsDirectory, includeRecovery = false) {
635
762
  const packagedLock = readJsonObject(SOURCE_LOCK_FILE, "packaged MACCA lock");
636
- const installedLock = readJsonObject(
637
- installedLockPath,
638
- "installed MACCA lock",
639
- );
640
763
  const packagedVersion = packagedLock.version;
641
- const installedVersion = installedLock.version;
642
- if (
643
- typeof packagedVersion !== "string" ||
644
- typeof installedVersion !== "string"
645
- ) {
646
- return;
647
- }
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;
648
801
 
649
- if (compareSemver(packagedVersion, installedVersion) < 0) {
650
- throw new Error(
651
- `Refusing to downgrade MACCA from ${installedVersion} to ${packagedVersion}. ` +
652
- "Publish or use a newer package before running install/upgrade.",
653
- );
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
+ }
654
821
  }
655
822
  }
656
823
 
@@ -659,12 +826,24 @@ function readOwnershipMarker(
659
826
  expectedSkill = path.basename(targetPath),
660
827
  ) {
661
828
  const markerPath = path.join(targetPath, OWNERSHIP_MARKER);
662
- if (!fs.existsSync(markerPath)) {
663
- 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 });
664
842
  }
665
843
 
844
+ const content = fs.readFileSync(markerPath, "utf8");
666
845
  try {
667
- const marker = JSON.parse(fs.readFileSync(markerPath, "utf8"));
846
+ const marker = JSON.parse(content);
668
847
  if (marker.owner !== "macca-method" || marker.skill !== expectedSkill) {
669
848
  return null;
670
849
  }
@@ -691,7 +870,7 @@ function assertManagedDirectoryUnchanged(targetPath, force = false) {
691
870
  if (!marker.payloadHash || marker.payloadHash !== hashDirectory(targetPath)) {
692
871
  throw new Error(
693
872
  `Refusing to overwrite locally modified managed skill: ${targetPath}. ` +
694
- "Run upgrade with --force to overwrite local modifications.",
873
+ "Back up and review local modifications first. --force destructively overwrites managed local modifications.",
695
874
  );
696
875
  }
697
876
  }
@@ -704,7 +883,8 @@ function readJsonObject(filePath, label) {
704
883
  }
705
884
  return value;
706
885
  } catch (error) {
707
- 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" });
708
888
  }
709
889
  }
710
890
 
@@ -722,7 +902,7 @@ function assertManagedFilesUnchanged(agentsDirectory, force = false) {
722
902
  if (!fs.existsSync(filePath) || hashFile(filePath) !== expectedHash) {
723
903
  throw new Error(
724
904
  `Refusing to overwrite locally modified MACCA metadata: ${filePath}. ` +
725
- "Run upgrade with --force to overwrite local modifications.",
905
+ "Back up and review local modifications first. --force destructively overwrites managed local modifications.",
726
906
  );
727
907
  }
728
908
  }
@@ -737,13 +917,20 @@ function buildStateContent(files) {
737
917
  }
738
918
 
739
919
  function writeJsonAtomic(filePath, value) {
740
- const temporaryPath = `${filePath}.tmp-${process.pid}`;
741
- fs.writeFileSync(
742
- temporaryPath,
743
- `${JSON.stringify(value, null, 2)}\n`,
744
- "utf8",
745
- );
746
- 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
+ }
747
934
  }
748
935
 
749
936
  function removeManagedPath(targetPath, kind) {
@@ -759,24 +946,55 @@ function assertTransactionPayload(entry, transactionId, candidatePath) {
759
946
  candidatePath,
760
947
  path.basename(entry.targetPath),
761
948
  );
762
- if (!marker || marker.transactionId !== transactionId) {
949
+ if (
950
+ !marker ||
951
+ marker.transactionId !== transactionId ||
952
+ !entry.stagedHash ||
953
+ hashDirectory(candidatePath) !== entry.stagedHash
954
+ ) {
763
955
  throw new Error(
764
956
  `Refusing recovery of directory without matching transaction evidence: ${candidatePath}`,
765
957
  );
766
958
  }
767
- return;
768
- }
769
- 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) {
770
965
  throw new Error(
771
966
  `Refusing recovery of file without matching transaction hash: ${candidatePath}`,
772
967
  );
773
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
+ }
774
975
  }
775
976
 
776
- function assertTransactionBackup(entry) {
977
+ function assertTransactionBackup(entry, phase) {
777
978
  if (!fs.existsSync(entry.backupPath)) {
778
979
  return;
779
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
+ }
780
998
  if (entry.kind === "directory") {
781
999
  const marker = readOwnershipMarker(
782
1000
  entry.backupPath,
@@ -789,7 +1007,10 @@ function assertTransactionBackup(entry) {
789
1007
  const validLegacy =
790
1008
  entry.legacySkillName &&
791
1009
  isLegacyDirectoryUnchanged(entry.backupPath, entry.legacySkillName);
792
- if (!validManaged && !validLegacy) {
1010
+ if (
1011
+ (!validManaged && !validLegacy) ||
1012
+ getUnhashedEntries(entry.backupPath).length
1013
+ ) {
793
1014
  throw new Error(
794
1015
  `Refusing recovery from unowned or modified backup directory: ${entry.backupPath}`,
795
1016
  );
@@ -799,7 +1020,7 @@ function assertTransactionBackup(entry) {
799
1020
 
800
1021
  function validateTransactionEntries(targetDir, journal) {
801
1022
  if (
802
- journal.version !== 1 ||
1023
+ ![1, 2].includes(journal.version) ||
803
1024
  !["committing", "committed"].includes(journal.phase)
804
1025
  ) {
805
1026
  throw new Error("Unsupported MACCA transaction journal version or phase");
@@ -866,6 +1087,19 @@ function validateTransactionEntries(targetDir, journal) {
866
1087
  ) {
867
1088
  throw new Error("Transaction staged payload hash is missing or invalid");
868
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
+ }
869
1103
 
870
1104
  const targetPath = path.resolve(entry.targetPath);
871
1105
  const parent = path.dirname(targetPath);
@@ -931,7 +1165,7 @@ function recoverInterruptedTransaction(targetDir) {
931
1165
  const journal = readJsonObject(journalPath, "MACCA transaction journal");
932
1166
  const entries = validateTransactionEntries(targetDir, journal);
933
1167
  for (const entry of entries) {
934
- assertTransactionBackup(entry);
1168
+ assertTransactionBackup(entry, journal.phase);
935
1169
  if (
936
1170
  journal.phase === "committed" ||
937
1171
  fs.existsSync(entry.backupPath) ||
@@ -942,13 +1176,38 @@ function recoverInterruptedTransaction(targetDir) {
942
1176
  if (entry.stagingPath && fs.existsSync(entry.stagingPath)) {
943
1177
  assertTransactionPayload(entry, journal.transactionId, entry.stagingPath);
944
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
+ }
945
1200
  }
946
1201
  if (journal.phase === "committed") {
1202
+ operation.stage = "recovery";
1203
+ operation.recoveryChanged = true;
947
1204
  for (const entry of entries) {
948
1205
  removeManagedPath(entry.backupPath, entry.kind);
949
1206
  if (entry.stagingPath) removeManagedPath(entry.stagingPath, entry.kind);
950
1207
  }
951
1208
  } else {
1209
+ operation.stage = "recovery";
1210
+ operation.recoveryChanged = true;
952
1211
  for (const entry of [...entries].reverse()) {
953
1212
  if (fs.existsSync(entry.backupPath)) {
954
1213
  removeManagedPath(entry.targetPath, entry.kind);
@@ -979,7 +1238,6 @@ function getUniqueDestinations(targetDir, tools) {
979
1238
  function applySkillTransaction(entries, targetDir, force = false) {
980
1239
  const suffix = `${process.pid}-${Date.now()}-${crypto.randomBytes(8).toString("hex")}`;
981
1240
  const prepared = [];
982
- const committed = [];
983
1241
  const journalPath = path.join(targetDir, ".agents", TRANSACTION_FILE);
984
1242
 
985
1243
  for (const entry of entries) {
@@ -1009,7 +1267,10 @@ function applySkillTransaction(entries, targetDir, force = false) {
1009
1267
  }
1010
1268
  if (kind === "directory") {
1011
1269
  if (isMaccaOwned(targetPath)) {
1012
- assertManagedDirectoryUnchanged(targetPath, force);
1270
+ assertManagedDirectoryUnchanged(
1271
+ targetPath,
1272
+ force && Boolean(sourcePath),
1273
+ );
1013
1274
  } else if (
1014
1275
  !legacySkillName ||
1015
1276
  !isLegacyDirectoryUnchanged(targetPath, legacySkillName)
@@ -1018,12 +1279,20 @@ function applySkillTransaction(entries, targetDir, force = false) {
1018
1279
  `Refusing to overwrite or remove unowned skill directory: ${targetPath}`,
1019
1280
  );
1020
1281
  }
1282
+ if (!sourcePath && getUnhashedEntries(targetPath).length) {
1283
+ throw new Error(
1284
+ `Refusing to remove skill with unhashed content: ${targetPath}`,
1285
+ );
1286
+ }
1021
1287
  }
1022
1288
  }
1023
1289
  prepared.push({
1024
1290
  ...entry,
1025
1291
  kind,
1026
1292
  hadTarget: fs.existsSync(targetPath),
1293
+ originalHash: fs.existsSync(targetPath)
1294
+ ? hashSnapshot(targetPath, kind)
1295
+ : null,
1027
1296
  stagingPath:
1028
1297
  sourcePath || content !== undefined
1029
1298
  ? path.join(parent, `.${targetName}.macca-stage-${suffix}`)
@@ -1046,6 +1315,9 @@ function applySkillTransaction(entries, targetDir, force = false) {
1046
1315
  }
1047
1316
  } else {
1048
1317
  fs.cpSync(entry.sourcePath, entry.stagingPath, { recursive: true });
1318
+ if (entry.hadTarget) {
1319
+ preserveUnhashedEntries(entry.targetPath, entry.stagingPath);
1320
+ }
1049
1321
  const payloadHash = hashDirectory(entry.stagingPath);
1050
1322
  writeTextFile(
1051
1323
  path.join(entry.stagingPath, OWNERSHIP_MARKER),
@@ -1066,10 +1338,11 @@ function applySkillTransaction(entries, targetDir, force = false) {
1066
1338
  entry.kind === "file"
1067
1339
  ? hashFile(entry.stagingPath)
1068
1340
  : hashDirectory(entry.stagingPath);
1341
+ entry.stagedSnapshotHash = hashSnapshot(entry.stagingPath, entry.kind);
1069
1342
  }
1070
1343
 
1071
1344
  writeJsonAtomic(journalPath, {
1072
- version: 1,
1345
+ version: 2,
1073
1346
  phase: "committing",
1074
1347
  transactionId: suffix,
1075
1348
  entries: prepared.map(
@@ -1080,6 +1353,8 @@ function applySkillTransaction(entries, targetDir, force = false) {
1080
1353
  kind,
1081
1354
  hadTarget,
1082
1355
  stagedHash,
1356
+ stagedSnapshotHash,
1357
+ originalHash,
1083
1358
  legacySkillName,
1084
1359
  }) => ({
1085
1360
  targetPath,
@@ -1088,6 +1363,8 @@ function applySkillTransaction(entries, targetDir, force = false) {
1088
1363
  kind,
1089
1364
  hadTarget,
1090
1365
  stagedHash: stagingPath ? stagedHash : null,
1366
+ stagedSnapshotHash: stagingPath ? stagedSnapshotHash : null,
1367
+ originalHash,
1091
1368
  legacySkillName,
1092
1369
  }),
1093
1370
  ),
@@ -1095,15 +1372,19 @@ function applySkillTransaction(entries, targetDir, force = false) {
1095
1372
 
1096
1373
  for (const entry of prepared) {
1097
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
+ }
1098
1380
  fs.renameSync(entry.targetPath, entry.backupPath);
1099
1381
  }
1100
- committed.push(entry);
1101
1382
  if (entry.sourcePath || entry.content !== undefined) {
1102
1383
  fs.renameSync(entry.stagingPath, entry.targetPath);
1103
1384
  }
1104
1385
  }
1105
1386
  writeJsonAtomic(journalPath, {
1106
- version: 1,
1387
+ version: 2,
1107
1388
  phase: "committed",
1108
1389
  transactionId: suffix,
1109
1390
  entries: prepared.map(
@@ -1114,6 +1395,8 @@ function applySkillTransaction(entries, targetDir, force = false) {
1114
1395
  kind,
1115
1396
  hadTarget,
1116
1397
  stagedHash,
1398
+ stagedSnapshotHash,
1399
+ originalHash,
1117
1400
  legacySkillName,
1118
1401
  }) => ({
1119
1402
  targetPath,
@@ -1122,40 +1405,44 @@ function applySkillTransaction(entries, targetDir, force = false) {
1122
1405
  kind,
1123
1406
  hadTarget,
1124
1407
  stagedHash: stagingPath ? stagedHash : null,
1408
+ stagedSnapshotHash: stagingPath ? stagedSnapshotHash : null,
1409
+ originalHash,
1125
1410
  legacySkillName,
1126
1411
  }),
1127
1412
  ),
1128
1413
  });
1129
1414
  } catch (error) {
1130
- for (const entry of [...committed].reverse()) {
1131
- fs.rmSync(entry.targetPath, {
1132
- recursive: entry.kind === "directory",
1133
- force: true,
1134
- });
1135
- if (entry.hadTarget && fs.existsSync(entry.backupPath)) {
1136
- 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
+ }
1137
1437
  }
1438
+ } catch (recoveryError) {
1439
+ error.message = `${error.message}; ${recoveryError.message}`;
1440
+ throw error;
1138
1441
  }
1139
- fs.rmSync(journalPath, { force: true });
1140
1442
  throw error;
1141
- } finally {
1142
- for (const entry of prepared) {
1143
- if (entry.stagingPath) {
1144
- fs.rmSync(entry.stagingPath, {
1145
- recursive: entry.kind === "directory",
1146
- force: true,
1147
- });
1148
- }
1149
- }
1150
1443
  }
1151
1444
 
1152
- for (const entry of committed) {
1153
- fs.rmSync(entry.backupPath, {
1154
- recursive: entry.kind === "directory",
1155
- force: true,
1156
- });
1157
- }
1158
- fs.rmSync(journalPath, { force: true });
1445
+ recoverInterruptedTransaction(targetDir);
1159
1446
  }
1160
1447
 
1161
1448
  function normalizeToolList(values) {
@@ -1214,11 +1501,12 @@ function mergeObjects(existing, update) {
1214
1501
 
1215
1502
  const merged = { ...existing };
1216
1503
  for (const [key, value] of Object.entries(update)) {
1217
- if (value && typeof value === "object" && !Array.isArray(value)) {
1218
- merged[key] = mergeObjects(existing[key], value);
1219
- } else {
1220
- merged[key] = value;
1221
- }
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
+ });
1222
1510
  }
1223
1511
  return merged;
1224
1512
  }
@@ -1230,15 +1518,61 @@ function readDeveloperConfig(filePath) {
1230
1518
 
1231
1519
  try {
1232
1520
  const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
1233
- if (!value || typeof value !== "object" || Array.isArray(value)) {
1234
- throw new Error("root value must be an object");
1235
- }
1521
+ assertValidConfig(value, filePath);
1236
1522
  return value;
1237
1523
  } catch (error) {
1238
- 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
+ });
1239
1531
  }
1240
1532
  }
1241
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
+
1242
1576
  function createPrompt() {
1243
1577
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1244
1578
  throw new Error(
@@ -1246,45 +1580,54 @@ function createPrompt() {
1246
1580
  );
1247
1581
  }
1248
1582
 
1249
- return readline.createInterface({
1583
+ const prompt = readline.createInterface({
1250
1584
  input: process.stdin,
1251
1585
  output: process.stdout,
1252
1586
  });
1587
+ preparePrompt(prompt);
1588
+ return prompt;
1253
1589
  }
1254
1590
 
1255
1591
  function askQuestion(prompt, question) {
1256
- return new Promise((resolve) => {
1257
- 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
+ }
1258
1607
  });
1259
1608
  }
1260
1609
 
1261
- async function promptForTools() {
1262
- const prompt = createPrompt();
1263
-
1264
- try {
1265
- process.stdout.write("\nPilih AI tool yang mau dipasang:\n");
1266
- TOOL_DEFINITIONS.forEach((tool, index) => {
1267
- process.stdout.write(
1268
- ` ${index + 1}. ${tool.label} -> ${tool.displayDestination}\n`,
1269
- );
1270
- });
1271
- process.stdout.write("\n");
1272
-
1273
- while (true) {
1274
- const answer = await askQuestion(
1275
- prompt,
1276
- "Masukkan nomor/nama tool (pisahkan dengan koma, 'all', kosong = codex): ",
1277
- );
1278
- const parsed = parseInteractiveToolSelection(answer);
1279
- if (parsed.error) {
1280
- process.stdout.write(` ${parsed.error}\n`);
1281
- continue;
1282
- }
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");
1283
1618
 
1284
- 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;
1285
1628
  }
1286
- } finally {
1287
- prompt.close();
1629
+
1630
+ return parsed.tools;
1288
1631
  }
1289
1632
  }
1290
1633
 
@@ -1326,44 +1669,54 @@ function parseInteractiveToolSelection(answer) {
1326
1669
  return { tools: unique(tools) };
1327
1670
  }
1328
1671
 
1329
- async function promptForMetadata(seed) {
1330
- 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
+ );
1331
1687
 
1332
- try {
1333
- const name =
1334
- seed.name !== undefined
1335
- ? seed.name
1336
- : await askQuestion(
1337
- prompt,
1338
- "Kamu mau dipanggil apa? (Kosong = Skip): ",
1339
- );
1340
- const project =
1341
- seed.project !== undefined
1342
- ? seed.project
1343
- : await askQuestion(prompt, "Nama project ini apa? (Kosong = Skip): ");
1344
- const communication =
1345
- seed.communicationLanguage !== undefined
1346
- ? seed.communicationLanguage
1347
- : await askQuestion(
1348
- prompt,
1349
- "Bahasa komunikasi yang anda inginkan? (Kosong = Bahasa Indonesia): ",
1350
- );
1351
- const documents =
1352
- seed.documentLanguage !== undefined
1353
- ? seed.documentLanguage
1354
- : await askQuestion(
1355
- prompt,
1356
- "Bahasa dokumen yang dihasilkan? (Kosong = Bahasa Indonesia): ",
1357
- );
1688
+ return {
1689
+ name: seed.name,
1690
+ project: seed.project,
1691
+ communicationLanguage: communication || "Bahasa Indonesia",
1692
+ documentLanguage: documents || communication || "Bahasa Indonesia",
1693
+ };
1694
+ }
1358
1695
 
1359
- return {
1360
- name: name || "",
1361
- project: project || "",
1362
- communicationLanguage: communication || "Bahasa Indonesia",
1363
- documentLanguage: documents || "Bahasa Indonesia",
1364
- };
1365
- } finally {
1366
- 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 });
1367
1720
  }
1368
1721
  }
1369
1722
 
@@ -1371,7 +1724,10 @@ function applyInstall(targetDir, tools, metadata, options = {}) {
1371
1724
  const force = Boolean(options.force);
1372
1725
  const managedSkills = getSourceManagedSkills();
1373
1726
  const agentsDirectory = path.join(targetDir, ".agents");
1374
- 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);
1375
1731
  assertSafeProjectPath(targetDir, agentsDirectory);
1376
1732
  assertPackageNotOlderThanInstalled(agentsDirectory);
1377
1733
  for (const fileName of [
@@ -1388,6 +1744,10 @@ function applyInstall(targetDir, tools, metadata, options = {}) {
1388
1744
  readNonEmptyLines(path.join(agentsDirectory, "macca-managed-skills.txt")),
1389
1745
  ".agents/macca-managed-skills.txt",
1390
1746
  );
1747
+ tools = unique([
1748
+ ...readNonEmptyLines(path.join(agentsDirectory, "macca-tools.txt")),
1749
+ ...tools,
1750
+ ]);
1391
1751
  const entries = [];
1392
1752
  for (const destination of getUniqueDestinations(targetDir, tools)) {
1393
1753
  for (const skillName of managedSkills) {
@@ -1397,12 +1757,21 @@ function applyInstall(targetDir, tools, metadata, options = {}) {
1397
1757
  legacySkillName: skillName,
1398
1758
  });
1399
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
+ );
1400
1774
  }
1401
- const configPath = path.join(agentsDirectory, "developer-config.json");
1402
- const config = mergeObjects(
1403
- readDeveloperConfig(configPath),
1404
- buildDeveloperConfig(metadata),
1405
- );
1406
1775
  const managedContent = fs.existsSync(SOURCE_MANAGED_SKILLS_FILE)
1407
1776
  ? fs.readFileSync(SOURCE_MANAGED_SKILLS_FILE, "utf8")
1408
1777
  : `${managedSkills.join("\n")}\n`;
@@ -1447,6 +1816,9 @@ function applyUpgrade(targetDir, options = {}) {
1447
1816
  const force = Boolean(options.force);
1448
1817
  const agentsDirectory = path.join(targetDir, ".agents");
1449
1818
  assertSafeProjectPath(targetDir, agentsDirectory);
1819
+ const configPath = path.join(agentsDirectory, "developer-config.json");
1820
+ assertSafeProjectPath(targetDir, configPath);
1821
+ readDeveloperConfig(configPath);
1450
1822
  assertPackageNotOlderThanInstalled(agentsDirectory);
1451
1823
  for (const fileName of [
1452
1824
  "macca-managed-skills.txt",
@@ -1482,50 +1854,21 @@ function applyUpgrade(targetDir, options = {}) {
1482
1854
  });
1483
1855
  }
1484
1856
 
1485
- for (const skillName of previousManagedSkills.filter(
1486
- (name) => !nextManagedSkills.includes(name),
1487
- )) {
1488
- const oldPath = resolveOwnedSkillPath(destination, skillName);
1489
- if (fs.existsSync(oldPath) && isMaccaOwned(oldPath)) {
1490
- assertManagedDirectoryUnchanged(oldPath, force);
1491
- entries.push({ sourcePath: null, targetPath: oldPath });
1492
- } else if (isLegacyDirectoryUnchanged(oldPath, skillName)) {
1493
- entries.push({
1494
- sourcePath: null,
1495
- targetPath: oldPath,
1496
- legacySkillName: skillName,
1497
- });
1498
- } else if (fs.existsSync(oldPath)) {
1499
- process.stderr.write(
1500
- `Preserving unmarked legacy skill directory: ${oldPath}\n`,
1501
- );
1502
- }
1503
- }
1857
+ addRetiredSkills(
1858
+ entries,
1859
+ targetDir,
1860
+ destination,
1861
+ previousManagedSkills.filter((name) => !nextManagedSkills.includes(name)),
1862
+ );
1504
1863
  }
1505
1864
 
1506
1865
  if (tools.includes("opencode")) {
1507
- const legacyOpenCodeRoot = path.join(targetDir, ".opencode", "skill");
1508
- assertSafeProjectPath(targetDir, legacyOpenCodeRoot);
1509
- for (const skillName of previousManagedSkills) {
1510
- const oldPath = resolveOwnedSkillPath(legacyOpenCodeRoot, skillName);
1511
- if (fs.existsSync(oldPath) && isMaccaOwned(oldPath)) {
1512
- assertManagedDirectoryUnchanged(oldPath, force);
1513
- entries.push({ sourcePath: null, targetPath: oldPath });
1514
- } else if (isLegacyDirectoryUnchanged(oldPath, skillName)) {
1515
- entries.push({
1516
- sourcePath: null,
1517
- targetPath: oldPath,
1518
- legacySkillName: skillName,
1519
- });
1520
- } else if (fs.existsSync(oldPath)) {
1521
- if (!force) {
1522
- throw new Error(
1523
- `Legacy OpenCode skill was modified; move or back it up before upgrade: ${oldPath}. ` +
1524
- "Run upgrade with --force to overwrite local modifications.",
1525
- );
1526
- }
1527
- }
1528
- }
1866
+ addRetiredSkills(
1867
+ entries,
1868
+ targetDir,
1869
+ path.join(targetDir, ".opencode", "skill"),
1870
+ previousManagedSkills,
1871
+ );
1529
1872
  }
1530
1873
  const managedContent = fs.existsSync(SOURCE_MANAGED_SKILLS_FILE)
1531
1874
  ? fs.readFileSync(SOURCE_MANAGED_SKILLS_FILE, "utf8")
@@ -1605,85 +1948,246 @@ function printInstallSummary(action, tools) {
1605
1948
  }
1606
1949
 
1607
1950
  async function runInstall(args) {
1608
- let tools = normalizeToolList(args.tools);
1609
- if (tools.length === 0) {
1610
- tools = args.yes ? ["codex"] : await promptForTools();
1611
- }
1612
-
1951
+ assertSupportedRuntime();
1952
+ operation.stage = "not started";
1953
+ operation.recoveryChanged = false;
1954
+ operation.targetDir = resolveTargetDirectory(args.directory);
1613
1955
  const targetDir = assertSafeTargetDirectory(args.directory);
1614
- ensureDirectory(targetDir);
1615
- assertSafeProjectPath(targetDir, path.join(targetDir, ".agents"));
1616
- 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);
1617
1960
  const existingTools = readNonEmptyLines(
1618
1961
  path.join(targetDir, ".agents", "macca-tools.txt"),
1619
1962
  );
1620
- tools = unique([...existingTools, ...tools]);
1621
- const configPath = path.join(targetDir, ".agents", "developer-config.json");
1622
- const existingConfig = readDeveloperConfig(configPath);
1623
1963
  const hasExistingConfig = fs.existsSync(configPath);
1624
- const metadata = args.yes
1625
- ? {
1626
- name:
1627
- args.name !== undefined
1628
- ? args.name
1629
- : hasExistingConfig
1630
- ? undefined
1631
- : "",
1632
- project:
1633
- args.project !== undefined
1634
- ? args.project
1635
- : hasExistingConfig
1636
- ? undefined
1637
- : "",
1638
- communicationLanguage:
1639
- args.communicationLanguage !== undefined
1640
- ? args.communicationLanguage
1641
- : hasExistingConfig
1642
- ? undefined
1643
- : "Bahasa Indonesia",
1644
- documentLanguage:
1645
- args.documentLanguage !== undefined
1646
- ? args.documentLanguage
1647
- : hasExistingConfig
1648
- ? undefined
1649
- : "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;
1650
1984
  }
1651
- : await promptForMetadata({
1652
- ...args,
1653
- name: args.name !== undefined ? args.name : existingConfig.name,
1654
- project:
1655
- args.project !== undefined ? args.project : existingConfig.project,
1656
- communicationLanguage:
1657
- args.communicationLanguage !== undefined
1658
- ? args.communicationLanguage
1659
- : existingConfig.languagePreferences?.communication?.raw,
1660
- documentLanguage:
1661
- args.documentLanguage !== undefined
1662
- ? args.documentLanguage
1663
- : existingConfig.languagePreferences?.documents?.raw,
1664
- });
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
+ }
1665
2001
 
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
+ }
1666
2010
  applyInstall(targetDir, tools, metadata, { force: Boolean(args.force) });
1667
2011
 
2012
+ operation.stage = "complete";
1668
2013
  printInstallSummary("installed", tools);
1669
- process.stdout.write(` Target project: ${targetDir}\n\n`);
2014
+ printSetupHint(targetDir);
1670
2015
  }
1671
2016
 
1672
2017
  function runUpgrade(args) {
2018
+ assertSupportedRuntime();
2019
+ operation.stage = "not started";
2020
+ operation.recoveryChanged = false;
2021
+ operation.targetDir = resolveTargetDirectory(args.directory);
1673
2022
  const targetDir = assertSafeTargetDirectory(args.directory);
1674
- 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);
1675
2027
  recoverInterruptedTransaction(targetDir);
2028
+ assertSafeProjectPath(targetDir, path.join(targetDir, ".agents", "macca-tools.txt"));
1676
2029
  const tools = readNonEmptyLines(
1677
2030
  path.join(targetDir, ".agents", "macca-tools.txt"),
1678
2031
  );
1679
2032
  applyUpgrade(targetDir, { force: Boolean(args.force) });
2033
+ operation.stage = "complete";
1680
2034
  printInstallSummary("updated", tools);
1681
- process.stdout.write(` Target project: ${targetDir}\n\n`);
2035
+ printSetupHint(targetDir);
2036
+ }
2037
+
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;
1682
2165
  }
1683
2166
 
1684
- function exitWithError(message) {
1685
- process.stderr.write(`\nError: ${message}\n`);
1686
- process.exit(1);
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;
1687
2191
  }
1688
2192
 
1689
2193
  main();