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
@@ -0,0 +1,545 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const assert = require("node:assert/strict");
6
+ const fs = require("node:fs");
7
+ const os = require("node:os");
8
+ const path = require("node:path");
9
+ const vm = require("node:vm");
10
+ const { spawnSync } = require("node:child_process");
11
+
12
+ const repo = path.resolve(__dirname, "..");
13
+ const cliPath = path.join(repo, "bin", "macca-method.js");
14
+
15
+ function loadInstaller(overrides = {}) {
16
+ const source = fs.readFileSync(cliPath, "utf8");
17
+ assert.match(source, /main\(\);\s*$/);
18
+ const sandbox = {
19
+ require: (name) => overrides[name] || require(name),
20
+ process,
21
+ console,
22
+ Buffer,
23
+ __dirname: path.dirname(cliPath),
24
+ };
25
+ vm.runInNewContext(source.replace(/main\(\);\s*$/, ""), sandbox, {
26
+ filename: cliPath,
27
+ });
28
+ return sandbox;
29
+ }
30
+
31
+ // Run the actual transaction code in a child and exit at an FS boundary. This
32
+ // leaves real journals/backups without adding a production crash-test hook.
33
+ if (process.argv[2] === "--crash") {
34
+ const [, , , project, kind, phase] = process.argv;
35
+ const watched = kind === "file"
36
+ ? path.join(project, ".agents", "developer-config.json")
37
+ : path.join(project, ".agents", "skills", "developer");
38
+ const crashFs = Object.create(fs);
39
+ crashFs.renameSync = (source, destination) => {
40
+ fs.renameSync(source, destination);
41
+ if (phase === "committing" && destination === watched && source.includes(".macca-stage-")) {
42
+ process.exit(86);
43
+ }
44
+ if (phase === "committed" && destination.endsWith("macca-transaction.json") &&
45
+ JSON.parse(fs.readFileSync(destination, "utf8")).phase === "committed") {
46
+ process.exit(86);
47
+ }
48
+ };
49
+ const instance = loadInstaller({ "node:fs": crashFs });
50
+ if (kind === "file") instance.applyInstall(project, ["codex"], {}, { force: true });
51
+ else instance.applyUpgrade(project, { force: true });
52
+ throw new Error("Crash boundary was not reached");
53
+ }
54
+
55
+ const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "macca-safety-"));
56
+ let passed = 0;
57
+
58
+ function write(file, content) {
59
+ fs.mkdirSync(path.dirname(file), { recursive: true });
60
+ fs.writeFileSync(file, content);
61
+ }
62
+
63
+ function json(file, value) {
64
+ write(file, `${JSON.stringify(value, null, 2)}\n`);
65
+ }
66
+
67
+ function runCli(project, args, expectedError, executable = cliPath) {
68
+ const result = spawnSync(process.execPath, [executable, ...args, "--directory", project], {
69
+ encoding: "utf8",
70
+ timeout: 60000,
71
+ });
72
+ assert.ifError(result.error);
73
+ const output = `${result.stdout}${result.stderr}`;
74
+ if (expectedError) {
75
+ assert.equal(result.status, 1, output);
76
+ assert.match(output, expectedError);
77
+ } else {
78
+ assert.equal(result.status, 0, output);
79
+ }
80
+ return output;
81
+ }
82
+
83
+ function install(name) {
84
+ const project = path.join(root, name);
85
+ runCli(project, ["install", "--yes", "--tool", "codex"]);
86
+ return project;
87
+ }
88
+
89
+ function journalPath(project) {
90
+ return path.join(project, ".agents", "macca-transaction.json");
91
+ }
92
+
93
+ function crash(project, kind = "directory", phase = "committing") {
94
+ const result = spawnSync(process.execPath, [__filename, "--crash", project, kind, phase], {
95
+ encoding: "utf8",
96
+ timeout: 60000,
97
+ });
98
+ assert.ifError(result.error);
99
+ assert.equal(result.status, 86, `${result.stdout}${result.stderr}`);
100
+ const journal = JSON.parse(fs.readFileSync(journalPath(project), "utf8"));
101
+ assert.equal(journal.version, 2);
102
+ assert.equal(journal.phase, phase);
103
+ for (const entry of journal.entries) {
104
+ if (entry.hadTarget) assert.match(entry.originalHash, /^[a-f0-9]{64}$/);
105
+ if (entry.stagingPath) assert.match(entry.stagedSnapshotHash, /^[a-f0-9]{64}$/);
106
+ }
107
+ return journal;
108
+ }
109
+
110
+ function test(name, action) {
111
+ action();
112
+ passed += 1;
113
+ process.stdout.write(`OK: ${name}\n`);
114
+ }
115
+
116
+ function transactionEvidence(project, journal) {
117
+ const installer = loadInstaller();
118
+ return {
119
+ journal: fs.readFileSync(journalPath(project), "utf8"),
120
+ paths: journal.entries.map((entry) => [
121
+ entry.targetPath, entry.backupPath, entry.stagingPath,
122
+ ].map((candidate) => candidate && fs.existsSync(candidate)
123
+ ? installer.hashSnapshot(candidate, entry.kind) : null)),
124
+ };
125
+ }
126
+
127
+ try {
128
+ for (const edit of ["target", "backup", "stage"]) {
129
+ test(`ordinary rename failure preserves all evidence after a ${edit} edit`, () => {
130
+ const project = install(`ordinary-${edit}`);
131
+ const sentinel = path.join(project, "src", "user.js");
132
+ write(sentinel, "unrelated user file");
133
+ const injectedFs = Object.create(fs);
134
+ let evidence;
135
+ let journal;
136
+ let failed = false;
137
+ injectedFs.renameSync = (source, destination) => {
138
+ if (!failed && destination.endsWith("macca-lock.json") && source.includes(".macca-stage-")) {
139
+ failed = true;
140
+ journal = JSON.parse(fs.readFileSync(journalPath(project), "utf8"));
141
+ const entry = journal.entries.find((item) => item.targetPath.endsWith(`${path.sep}developer`));
142
+ const candidate = edit === "stage"
143
+ ? journal.entries.find((item) => item.stagingPath && fs.existsSync(item.stagingPath)).stagingPath
144
+ : edit === "backup" ? entry.backupPath : entry.targetPath;
145
+ if (fs.statSync(candidate).isDirectory()) write(path.join(candidate, "user.tmp", "notes.js"), "concurrent edit");
146
+ else fs.appendFileSync(candidate, "\nconcurrent edit\n");
147
+ evidence = transactionEvidence(project, journal);
148
+ throw new Error("injected ordinary rename failure");
149
+ }
150
+ fs.renameSync(source, destination);
151
+ };
152
+ assert.throws(() => loadInstaller({ "node:fs": injectedFs }).applyUpgrade(project), /injected ordinary rename failure; Refusing recovery/);
153
+ assert.ok(failed);
154
+ assert.deepEqual(transactionEvidence(project, journal), evidence);
155
+ runCli(project, ["upgrade", "--force"], /Refusing recovery/);
156
+ assert.deepEqual(transactionEvidence(project, journal), evidence);
157
+ assert.equal(fs.readFileSync(sentinel, "utf8"), "unrelated user file");
158
+ });
159
+ }
160
+
161
+ for (const edit of ["target", "backup"]) {
162
+ test(`successful promotion preserves all evidence when ${edit} changed before cleanup`, () => {
163
+ const project = install(`cleanup-${edit}`);
164
+ const injectedFs = Object.create(fs);
165
+ let evidence;
166
+ let journal;
167
+ injectedFs.renameSync = (source, destination) => {
168
+ fs.renameSync(source, destination);
169
+ if (destination === journalPath(project)) {
170
+ const current = JSON.parse(fs.readFileSync(destination, "utf8"));
171
+ if (current.phase === "committed") {
172
+ journal = current;
173
+ const entry = journal.entries.find((item) => item.targetPath.endsWith(`${path.sep}developer`));
174
+ write(path.join(edit === "backup" ? entry.backupPath : entry.targetPath, "notes.tmp"), "late edit");
175
+ evidence = transactionEvidence(project, journal);
176
+ }
177
+ }
178
+ };
179
+ assert.throws(() => loadInstaller({ "node:fs": injectedFs }).applyUpgrade(project), /Refusing recovery/);
180
+ assert.ok(evidence);
181
+ assert.deepEqual(transactionEvidence(project, journal), evidence);
182
+ runCli(project, ["upgrade", "--force"], /Refusing recovery/);
183
+ assert.deepEqual(transactionEvidence(project, journal), evidence);
184
+ });
185
+ }
186
+
187
+ test("clean ordinary rename failure restores the entire original project", () => {
188
+ const project = install("ordinary-clean");
189
+ write(path.join(project, "src", "user.js"), "unrelated");
190
+ const installer = loadInstaller();
191
+ const before = installer.hashSnapshot(project, "directory");
192
+ const injectedFs = Object.create(fs);
193
+ let failed = false;
194
+ injectedFs.renameSync = (source, destination) => {
195
+ if (!failed && destination.endsWith("macca-lock.json") && source.includes(".macca-stage-")) {
196
+ failed = true;
197
+ throw new Error("injected clean failure");
198
+ }
199
+ fs.renameSync(source, destination);
200
+ };
201
+ assert.throws(() => loadInstaller({ "node:fs": injectedFs }).applyUpgrade(project), /injected clean failure/);
202
+ assert.ok(failed);
203
+ assert.equal(installer.hashSnapshot(project, "directory"), before);
204
+ runCli(project, ["upgrade"]);
205
+ });
206
+
207
+ for (const changed of [false, true]) {
208
+ test(`pre-journal failure ${changed ? "preserves modified" : "cleans verified"} stages`, () => {
209
+ const project = install(`pre-journal-${changed}`);
210
+ const injectedFs = Object.create(fs);
211
+ const installer = loadInstaller();
212
+ const before = installer.hashSnapshot(project, "directory");
213
+ let staged;
214
+ let hashes;
215
+ injectedFs.renameSync = (source, destination) => {
216
+ if (destination === journalPath(project)) {
217
+ const journal = JSON.parse(fs.readFileSync(source, "utf8"));
218
+ staged = journal.entries.filter((entry) => entry.stagingPath);
219
+ if (changed) write(path.join(staged.find((entry) => entry.kind === "directory").stagingPath, "notes.tmp"), "stage edit");
220
+ hashes = staged.map((entry) => installer.hashSnapshot(entry.stagingPath, entry.kind));
221
+ throw new Error("injected journal write failure");
222
+ }
223
+ fs.renameSync(source, destination);
224
+ };
225
+ assert.throws(() => loadInstaller({ "node:fs": injectedFs }).applyUpgrade(project), /injected journal write failure/);
226
+ assert.ok(staged);
227
+ assert.ok(!fs.existsSync(journalPath(project)));
228
+ if (changed) {
229
+ assert.deepEqual(staged.map((entry) => installer.hashSnapshot(entry.stagingPath, entry.kind)), hashes);
230
+ } else {
231
+ assert.equal(installer.hashSnapshot(project, "directory"), before);
232
+ }
233
+ });
234
+ }
235
+
236
+ test("partial pre-journal copies are preserved without guessing their snapshot", () => {
237
+ const project = install("partial-stage");
238
+ const injectedFs = Object.create(fs);
239
+ let stage;
240
+ injectedFs.cpSync = (source, destination, options) => {
241
+ fs.cpSync(source, destination, options);
242
+ stage = destination;
243
+ write(path.join(stage, "user.tmp"), "partial copy edit");
244
+ throw new Error("injected partial copy failure");
245
+ };
246
+ assert.throws(() => loadInstaller({ "node:fs": injectedFs }).applyUpgrade(project), /injected partial copy failure; Preserving incomplete transaction staging/);
247
+ assert.equal(fs.readFileSync(path.join(stage, "user.tmp"), "utf8"), "partial copy edit");
248
+ assert.ok(!fs.existsSync(journalPath(project)));
249
+ });
250
+
251
+ for (const phase of ["committing", "committed"]) {
252
+ test(`interrupted ${phase} recovery can safely resume`, () => {
253
+ const project = install(`retry-${phase}`);
254
+ const installer = loadInstaller();
255
+ const before = installer.hashSnapshot(project, "directory");
256
+ const journal = crash(project, "file", phase);
257
+ const injectedFs = Object.create(fs);
258
+ let interrupted = false;
259
+ if (phase === "committing") {
260
+ injectedFs.renameSync = (source, destination) => {
261
+ fs.renameSync(source, destination);
262
+ if (!interrupted && source.includes(".macca-backup-")) {
263
+ interrupted = true;
264
+ throw new Error("injected recovery interruption");
265
+ }
266
+ };
267
+ } else {
268
+ injectedFs.rmSync = (candidate, options) => {
269
+ fs.rmSync(candidate, options);
270
+ if (!interrupted && candidate.includes(".macca-backup-")) {
271
+ interrupted = true;
272
+ throw new Error("injected recovery interruption");
273
+ }
274
+ };
275
+ }
276
+ assert.throws(() => loadInstaller({ "node:fs": injectedFs }).recoverInterruptedTransaction(project), /injected recovery interruption/);
277
+ assert.ok(interrupted);
278
+ assert.ok(fs.existsSync(journalPath(project)));
279
+ installer.recoverInterruptedTransaction(project);
280
+ installer.recoverInterruptedTransaction(project);
281
+ assert.ok(!fs.existsSync(journalPath(project)));
282
+ for (const entry of journal.entries) {
283
+ assert.ok(!fs.existsSync(entry.backupPath));
284
+ if (entry.stagingPath) assert.ok(!fs.existsSync(entry.stagingPath));
285
+ }
286
+ if (phase === "committing") assert.equal(installer.hashSnapshot(project, "directory"), before);
287
+ runCli(project, ["upgrade"]);
288
+ });
289
+ }
290
+
291
+ test("normal upgrades preserve ignored files, directories, caches, and empty directories", () => {
292
+ const project = install("ignored-content");
293
+ const skill = path.join(project, ".agents", "skills", "developer");
294
+ const files = [
295
+ "work.tmp/important-source.js",
296
+ "draft.tmp",
297
+ ".tmp-work/notes.md",
298
+ "__pycache__/cache.pyc",
299
+ "references/.pytest_cache/notes.txt",
300
+ "references/.macca-owned.json",
301
+ ];
302
+ for (const file of files) write(path.join(skill, file), `user content: ${file}`);
303
+ fs.mkdirSync(path.join(skill, "empty-work"));
304
+ runCli(project, ["upgrade"]);
305
+ runCli(project, ["upgrade"]);
306
+ for (const file of files) {
307
+ assert.equal(fs.readFileSync(path.join(skill, file), "utf8"), `user content: ${file}`);
308
+ }
309
+ assert.ok(fs.statSync(path.join(skill, "empty-work")).isDirectory());
310
+ fs.appendFileSync(path.join(skill, "SKILL.md"), "\nlocal modification\n");
311
+ runCli(project, ["upgrade"], /locally modified managed skill/);
312
+ });
313
+
314
+ for (const phase of ["committing", "committed"]) {
315
+ for (const edit of ["payload", "ignored"]) {
316
+ test(`${phase} recovery rejects post-crash ${edit} directory edits before cleanup`, () => {
317
+ const project = install(`edited-${phase}-${edit}`);
318
+ const journal = crash(project, "directory", phase);
319
+ const entry = journal.entries.find((item) => item.targetPath.endsWith(`${path.sep}developer`));
320
+ const edited = path.join(entry.targetPath, edit === "payload" ? "SKILL.md" : "work.tmp/notes.js");
321
+ write(edited, "post-crash user edit");
322
+ const journalBefore = fs.readFileSync(journalPath(project), "utf8");
323
+ runCli(project, ["upgrade", "--force"], /Refusing recovery/);
324
+ assert.equal(fs.readFileSync(edited, "utf8"), "post-crash user edit");
325
+ assert.ok(fs.existsSync(entry.backupPath));
326
+ assert.equal(fs.readFileSync(journalPath(project), "utf8"), journalBefore);
327
+ });
328
+ }
329
+ }
330
+
331
+ for (const kind of ["directory", "file"]) {
332
+ for (const phase of ["committing", "committed"]) {
333
+ test(`forced ${kind} ${phase} recovery accepts the actual pre-transaction snapshot`, () => {
334
+ const project = install(`snapshot-${kind}-${phase}`);
335
+ const original = kind === "directory"
336
+ ? path.join(project, ".agents", "skills", "developer", "SKILL.md")
337
+ : path.join(project, ".agents", "developer-config.json");
338
+ const content = kind === "directory"
339
+ ? `${fs.readFileSync(original, "utf8")}\nlegitimate pre-force edit\n`
340
+ : '{"custom":"legitimate pre-force edit"}\n';
341
+ write(original, content);
342
+ const journal = crash(project, kind, phase);
343
+ const entry = journal.entries.find((item) => item.targetPath === (
344
+ kind === "directory" ? path.dirname(original) : original
345
+ ));
346
+ // A normal upgrade may reject restored local skill edits, but recovery
347
+ // must already have restored the backup and removed its journal.
348
+ runCli(project, ["upgrade"], kind === "directory" && phase === "committing"
349
+ ? /locally modified managed skill/ : undefined);
350
+ assert.ok(!fs.existsSync(journalPath(project)));
351
+ assert.ok(!fs.existsSync(entry.backupPath));
352
+ if (phase === "committing") assert.equal(fs.readFileSync(original, "utf8"), content);
353
+ if (kind === "file") assert.equal(JSON.parse(fs.readFileSync(original, "utf8")).custom, "legitimate pre-force edit");
354
+ });
355
+
356
+ test(`forced ${kind} ${phase} recovery refuses later backup modifications`, () => {
357
+ const project = install(`backup-edit-${kind}-${phase}`);
358
+ const original = kind === "directory"
359
+ ? path.join(project, ".agents", "skills", "developer", "SKILL.md")
360
+ : path.join(project, ".agents", "developer-config.json");
361
+ write(original, kind === "directory" ? "legitimate pre-force edit" : '{"local":true}\n');
362
+ const journal = crash(project, kind, phase);
363
+ const entry = journal.entries.find((item) => item.kind === kind &&
364
+ item.targetPath.endsWith(kind === "directory" ? `${path.sep}developer` : "developer-config.json"));
365
+ const edited = kind === "directory"
366
+ ? path.join(entry.backupPath, "extra.tmp", "notes.txt") : entry.backupPath;
367
+ write(edited, "backup edited after crash");
368
+ runCli(project, ["upgrade", "--force"], /modified transaction backup/);
369
+ assert.equal(fs.readFileSync(edited, "utf8"), "backup edited after crash");
370
+ assert.ok(fs.existsSync(journalPath(project)));
371
+ assert.ok(fs.existsSync(entry.targetPath));
372
+ });
373
+ }
374
+ }
375
+
376
+ test("legacy directory journals check staged payload hashes and retain ignored additions", () => {
377
+ const project = install("legacy-journal");
378
+ const journal = crash(project);
379
+ journal.version = 1;
380
+ for (const entry of journal.entries) {
381
+ delete entry.originalHash;
382
+ delete entry.stagedSnapshotHash;
383
+ }
384
+ json(journalPath(project), journal);
385
+ const entry = journal.entries.find((item) => item.targetPath.endsWith(`${path.sep}developer`));
386
+ const skillFile = path.join(entry.targetPath, "SKILL.md");
387
+ const before = fs.readFileSync(skillFile, "utf8");
388
+ write(skillFile, "legacy post-crash edit");
389
+ runCli(project, ["upgrade"], /matching transaction evidence/);
390
+ assert.equal(fs.readFileSync(skillFile, "utf8"), "legacy post-crash edit");
391
+ write(skillFile, before);
392
+ const ignored = path.join(entry.targetPath, "work.tmp", "notes.js");
393
+ write(ignored, "keep legacy ignored content");
394
+ runCli(project, ["upgrade"], /legacy recovery with unhashed directory content/);
395
+ assert.ok(fs.existsSync(ignored));
396
+ assert.ok(fs.existsSync(entry.backupPath));
397
+ });
398
+
399
+ test("legacy clean directory rollback still recovers", () => {
400
+ const project = install("legacy-clean");
401
+ const journal = crash(project);
402
+ journal.version = 1;
403
+ for (const entry of journal.entries) {
404
+ delete entry.originalHash;
405
+ delete entry.stagedSnapshotHash;
406
+ }
407
+ json(journalPath(project), journal);
408
+ runCli(project, ["upgrade"]);
409
+ assert.ok(!fs.existsSync(journalPath(project)));
410
+ });
411
+
412
+ test("legacy committed file backups without snapshots are preserved", () => {
413
+ const project = install("legacy-committed");
414
+ const journal = crash(project, "file", "committed");
415
+ journal.version = 1;
416
+ for (const entry of journal.entries) {
417
+ delete entry.originalHash;
418
+ delete entry.stagedSnapshotHash;
419
+ }
420
+ json(journalPath(project), journal);
421
+ runCli(project, ["upgrade"], /legacy backup without a snapshot/);
422
+ for (const entry of journal.entries) assert.ok(fs.existsSync(entry.backupPath));
423
+ assert.ok(fs.existsSync(journalPath(project)));
424
+ });
425
+
426
+ test("v2 recovery requires both snapshots and rejects modified staged files", () => {
427
+ const project = install("missing-snapshot");
428
+ const journal = crash(project, "file");
429
+ const entry = journal.entries.find((item) => item.targetPath.endsWith("developer-config.json"));
430
+ for (const field of ["originalHash", "stagedSnapshotHash"]) {
431
+ const hash = entry[field];
432
+ delete entry[field];
433
+ json(journalPath(project), journal);
434
+ runCli(project, ["upgrade"], new RegExp(`${field} is missing or invalid`));
435
+ assert.ok(fs.existsSync(entry.backupPath));
436
+ entry[field] = hash;
437
+ }
438
+ json(journalPath(project), journal);
439
+ write(entry.targetPath, '{"postCrashEdit":true}\n');
440
+ runCli(project, ["upgrade", "--force"], /matching transaction hash/);
441
+ assert.deepEqual(JSON.parse(fs.readFileSync(entry.targetPath, "utf8")), { postCrashEdit: true });
442
+ assert.ok(fs.existsSync(entry.backupPath));
443
+ });
444
+
445
+ test("atomic JSON writes use private exclusive files and preserve colliding symlinks", () => {
446
+ const directory = path.join(root, "atomic");
447
+ fs.mkdirSync(directory);
448
+ const destination = path.join(directory, "journal.json");
449
+ const sentinel = path.join(directory, "sentinel.txt");
450
+ write(sentinel, "do not overwrite");
451
+ const oldTemporary = `${destination}.tmp-${process.pid}`;
452
+ try {
453
+ fs.symlinkSync(sentinel, oldTemporary);
454
+ } catch (error) {
455
+ if (process.platform !== "win32" || !["EPERM", "EACCES"].includes(error.code)) throw error;
456
+ process.stdout.write(" Symlink fixture unavailable; exercising exclusive regular-file collision instead\n");
457
+ write(oldTemporary, "old collision");
458
+ }
459
+ const atomicFs = Object.create(fs);
460
+ let opened;
461
+ atomicFs.openSync = (file, flags, mode) => {
462
+ opened = file;
463
+ assert.equal(flags, "wx");
464
+ assert.equal(mode, 0o600);
465
+ assert.match(file, /\.tmp-\d+-[a-f0-9]{32}$/);
466
+ assert.notEqual(file, oldTemporary);
467
+ return fs.openSync(file, flags, mode);
468
+ };
469
+ loadInstaller({ "node:fs": atomicFs }).writeJsonAtomic(destination, { safe: true });
470
+ assert.deepEqual(JSON.parse(fs.readFileSync(destination, "utf8")), { safe: true });
471
+ assert.equal(fs.readFileSync(sentinel, "utf8"), "do not overwrite");
472
+ assert.ok(!fs.existsSync(opened));
473
+ if (process.platform !== "win32") assert.equal(fs.statSync(destination).mode & 0o777, 0o600);
474
+
475
+ atomicFs.openSync = (file, flags, mode) => {
476
+ opened = file;
477
+ if (fs.lstatSync(oldTemporary).isSymbolicLink()) fs.symlinkSync(sentinel, file);
478
+ else write(file, "collision");
479
+ return fs.openSync(file, flags, mode);
480
+ };
481
+ assert.throws(() => loadInstaller({ "node:fs": atomicFs }).writeJsonAtomic(destination, {}), { code: "EEXIST" });
482
+ assert.ok(fs.existsSync(opened), "must not unlink a temp path we did not create");
483
+ assert.equal(fs.readFileSync(sentinel, "utf8"), "do not overwrite");
484
+ assert.deepEqual(JSON.parse(fs.readFileSync(destination, "utf8")), { safe: true });
485
+
486
+ const failingFs = Object.create(fs);
487
+ failingFs.renameSync = () => { throw new Error("rename failed"); };
488
+ const before = fs.readdirSync(directory).sort();
489
+ assert.throws(() => loadInstaller({ "node:fs": failingFs }).writeJsonAtomic(destination, {}), /rename failed/);
490
+ assert.deepEqual(fs.readdirSync(directory).sort(), before, "owned temporary must be cleaned on failure");
491
+ });
492
+
493
+ for (const command of ["install", "upgrade"]) {
494
+ test(`${command} retires only verified obsolete skills across previous tools and legacy roots`, () => {
495
+ const fixture = path.join(root, `old-package-${command}`);
496
+ const oldCli = path.join(fixture, "bin", "macca-method.js");
497
+ write(oldCli, fs.readFileSync(cliPath));
498
+ fs.cpSync(path.join(repo, ".agents", "skills"), path.join(fixture, ".agents", "skills"), { recursive: true });
499
+ for (const name of ["macca-lock.json", "macca-managed-skills.txt", "legacy-payloads.json"]) {
500
+ write(path.join(fixture, ".agents", name), fs.readFileSync(path.join(repo, ".agents", name)));
501
+ }
502
+ const obsolete = ["obsolete-owned", "obsolete-modified", "obsolete-unowned", "obsolete-ignored"];
503
+ for (const name of obsolete) {
504
+ write(path.join(fixture, ".agents", "skills", name, "SKILL.md"), `old payload for ${name}\n`);
505
+ }
506
+ fs.appendFileSync(path.join(fixture, ".agents", "macca-managed-skills.txt"), `\n${obsolete.join("\n")}\n`);
507
+ const project = path.join(root, `retirement-${command}`);
508
+ runCli(project, ["install", "--yes", "--tool", "copilot,opencode"], undefined, oldCli);
509
+ const roots = [path.join(project, ".github", "skills"), path.join(project, ".opencode", "skills")];
510
+ const legacy = path.join(project, ".opencode", "skill");
511
+ for (const name of obsolete) {
512
+ fs.cpSync(path.join(roots[1], name), path.join(legacy, name), { recursive: true });
513
+ }
514
+ roots.push(legacy);
515
+ for (const skills of roots) {
516
+ write(path.join(skills, "obsolete-modified", "SKILL.md"), "locally modified obsolete skill");
517
+ fs.unlinkSync(path.join(skills, "obsolete-unowned", ".macca-owned.json"));
518
+ write(path.join(skills, "obsolete-ignored", "notes.tmp"), "retained notes");
519
+ }
520
+ const unrelated = path.join(project, "src", "user.js");
521
+ write(unrelated, "unrelated work");
522
+ const preserved = roots.flatMap((skills) => obsolete.slice(1).map((name) => path.join(skills, name)));
523
+ const installer = loadInstaller();
524
+ const before = preserved.map((directory) => installer.hashSnapshot(directory, "directory"));
525
+ runCli(project, command === "install"
526
+ ? ["install", "--yes", "--tool", "codex", "--force"]
527
+ : ["upgrade", "--force"]);
528
+ assert.deepEqual(preserved.map((directory) => installer.hashSnapshot(directory, "directory")), before);
529
+ assert.equal(fs.readFileSync(unrelated, "utf8"), "unrelated work");
530
+ for (const skills of roots) {
531
+ assert.ok(!fs.existsSync(path.join(skills, "obsolete-owned")));
532
+ assert.equal(fs.readFileSync(path.join(skills, "obsolete-modified", "SKILL.md"), "utf8"), "locally modified obsolete skill");
533
+ assert.ok(fs.existsSync(path.join(skills, "obsolete-unowned", "SKILL.md")));
534
+ assert.equal(fs.readFileSync(path.join(skills, "obsolete-ignored", "notes.tmp"), "utf8"), "retained notes");
535
+ }
536
+ assert.deepEqual(fs.readFileSync(path.join(project, ".agents", "macca-tools.txt"), "utf8").trim().split("\n"), command === "install" ? ["copilot", "opencode", "codex"] : ["copilot", "opencode"]);
537
+ for (const skills of roots.slice(0, 2)) assert.ok(fs.existsSync(path.join(skills, "developer", "SKILL.md")));
538
+ if (command === "install") assert.ok(fs.existsSync(path.join(project, ".agents", "skills", "developer", "SKILL.md")));
539
+ });
540
+ }
541
+
542
+ process.stdout.write(`Installer safety: ${passed} tests passed\n`);
543
+ } finally {
544
+ fs.rmSync(root, { recursive: true, force: true });
545
+ }