@dalzoubi/dev-agents-sync 2.0.17 → 2.0.19

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dalzoubi/dev-agents-sync",
3
- "version": "2.0.17",
3
+ "version": "2.0.19",
4
4
  "type": "module",
5
5
  "description": "CLI that syncs managed dev-agent prompts into consumer repos (.claude/ and/or .cursor/).",
6
6
  "license": "UNLICENSED",
@@ -5,7 +5,7 @@
5
5
  * and rewrites managed files for the recorded targets.
6
6
  */
7
7
 
8
- import { existsSync, readFileSync } from 'node:fs';
8
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { createRequire } from 'node:module';
11
11
 
@@ -33,6 +33,71 @@ function filterFileMapByTargets(fileMap, targets) {
33
33
  return out;
34
34
  }
35
35
 
36
+ // Maps a lockfile target key to the consumer subdirectory it owns. Only these
37
+ // directories are ever scanned for orphans — pruning never crosses into another
38
+ // target's tree or the repo root. Mirrors writer.mjs TARGET_PREFIX, restricted
39
+ // to the targets that have a dedicated managed directory ('root'/'codex' are
40
+ // out of scope by design — root-level files are never pruned).
41
+ const PRUNE_TARGET_DIR = {
42
+ claude: '.claude',
43
+ cursor: '.cursor',
44
+ copilot: '.github',
45
+ };
46
+
47
+ /**
48
+ * Computes the set of marker-bearing managed files that are present on disk
49
+ * under the active target directories but absent from the freshly-fetched
50
+ * `scoped` file-map. Those are orphans to delete.
51
+ *
52
+ * Returns consumer-relative POSIX paths (e.g. ".claude/agents/old.md"),
53
+ * deterministically sorted. Unmarked files are never included (consumer-owned).
54
+ */
55
+ function computeOrphans(consumerCwd, scopedKeys, targets) {
56
+ const orphans = [];
57
+ for (const target of targets) {
58
+ const subdir = PRUNE_TARGET_DIR[target];
59
+ if (!subdir) continue; // target has no managed dir (root/codex) — never scan.
60
+ const targetRoot = path.join(consumerCwd, subdir);
61
+ if (!existsSync(targetRoot)) continue;
62
+
63
+ walkFiles(targetRoot, (absPath) => {
64
+ let content;
65
+ try {
66
+ content = readFileSync(absPath, 'utf8');
67
+ } catch {
68
+ // Unreadable entry (e.g. removed mid-walk) — skip; never treat as orphan.
69
+ return;
70
+ }
71
+ // Never touch files without the managed-by marker — consumer owns them.
72
+ if (!hasMarker(content)) return;
73
+
74
+ // Reconstruct the target-prefixed relKey the fetch would have used,
75
+ // e.g. ".claude/agents/old.md" -> "claude/agents/old.md".
76
+ const rel = path.relative(targetRoot, absPath).split(path.sep).join('/');
77
+ const relKey = `${target}/${rel}`;
78
+ if (scopedKeys.has(relKey)) return; // still shipped — keep it.
79
+
80
+ // Consumer-relative path for reporting/deletion bookkeeping.
81
+ orphans.push(`${subdir}/${rel}`);
82
+ });
83
+ }
84
+ orphans.sort();
85
+ return orphans;
86
+ }
87
+
88
+ /** Depth-first walk invoking `onFile(absPath)` for every regular file under `dir`. */
89
+ function walkFiles(dir, onFile) {
90
+ const entries = readdirSync(dir, { withFileTypes: true });
91
+ for (const entry of entries) {
92
+ const full = path.join(dir, entry.name);
93
+ if (entry.isDirectory()) {
94
+ walkFiles(full, onFile);
95
+ } else if (entry.isFile()) {
96
+ onFile(full);
97
+ }
98
+ }
99
+ }
100
+
36
101
  export async function runUpdate(consumerCwd, opts = {}) {
37
102
  const {
38
103
  force = false,
@@ -113,6 +178,12 @@ export async function runUpdate(consumerCwd, opts = {}) {
113
178
  : null;
114
179
  const claudeMdPath = path.join(consumerCwd, 'CLAUDE.md');
115
180
 
181
+ // Orphan set: marker-bearing managed files under active target dirs that the
182
+ // fetch no longer ships. Computed from the scoped keys so it honors the same
183
+ // target scoping as the writes.
184
+ const scopedKeys = new Set(Object.keys(scoped));
185
+ const orphans = computeOrphans(consumerCwd, scopedKeys, lock.targets);
186
+
116
187
  if (dryRun) {
117
188
  // Compute the planned CLAUDE.md change so callers can inspect it without
118
189
  // the result shape silently diverging from the init --dry-run contract.
@@ -125,6 +196,7 @@ export async function runUpdate(consumerCwd, opts = {}) {
125
196
  previousVersion: lock.resolvedVersion,
126
197
  files: plannedWrites.map(({ relKey }) => relKey),
127
198
  claudeMd: claudeMdResult,
199
+ plannedPrune: orphans,
128
200
  steering,
129
201
  };
130
202
  }
@@ -174,6 +246,31 @@ export async function runUpdate(consumerCwd, opts = {}) {
174
246
  }
175
247
  }
176
248
 
249
+ // Prune orphans: marker-bearing managed files no longer shipped. Delete in
250
+ // sorted order (computeOrphans returns sorted) before the lockfile is rewritten
251
+ // so a delete failure aborts that file and leaves the lockfile untouched (never
252
+ // corrupted). A failure surfaces a generic, actionable error naming the path.
253
+ const pruned = [];
254
+ for (const relPath of orphans) {
255
+ const absPath = path.join(consumerCwd, relPath);
256
+ try {
257
+ rmSync(absPath, { force: false });
258
+ } catch (cause) {
259
+ const err = new Error(
260
+ `failed to prune orphaned managed file ${relPath} (${absPath}). ` +
261
+ `Check filesystem permissions and remove it manually, then re-run update.`,
262
+ );
263
+ err.exitCode = 1;
264
+ err.cause = cause;
265
+ throw err;
266
+ }
267
+ pruned.push(relPath);
268
+ }
269
+ const pruneSummary = `pruned ${pruned.length} orphaned managed file(s)`;
270
+ if (pruned.length > 0) {
271
+ process.stderr.write(`info: dev-agents-sync: ${pruneSummary}\n`);
272
+ }
273
+
177
274
  writeLockfile(consumerCwd, {
178
275
  source: lock.source,
179
276
  range: lock.range,
@@ -194,6 +291,9 @@ export async function runUpdate(consumerCwd, opts = {}) {
194
291
  previousVersion: lock.resolvedVersion,
195
292
  files: plannedWrites.map(({ relKey }) => relKey),
196
293
  hooks: hookPaths,
294
+ pruned,
295
+ prunedCount: pruned.length,
296
+ summary: pruneSummary,
197
297
  steering,
198
298
  };
199
299
  }
@@ -78,18 +78,22 @@ let mirrorDefineContent;
78
78
  let mirrorImplementContent;
79
79
 
80
80
  before(() => {
81
+ // Post-S6 hard cutover: the old roster was relocated to the define/deliver
82
+ // topology. The steering-behavior contract moved with it —
83
+ // implement→deliver-solution, analyze→define-analyze, test→deliver-test,
84
+ // validate→deliver-validate, supervise→deliver. define stays.
81
85
  defineContent = readFileSync(path.join(AGENTS_DIR, 'define.md'), 'utf8');
82
- implementContent = readFileSync(path.join(AGENTS_DIR, 'implement.md'), 'utf8');
83
- analyzeContent = readFileSync(path.join(AGENTS_DIR, 'analyze.md'), 'utf8');
84
- testContent = readFileSync(path.join(AGENTS_DIR, 'test.md'), 'utf8');
85
- validateContent = readFileSync(path.join(AGENTS_DIR, 'validate.md'), 'utf8');
86
- superviseContent = readFileSync(path.join(AGENTS_DIR, 'supervise.md'), 'utf8');
86
+ implementContent = readFileSync(path.join(AGENTS_DIR, 'deliver-solution.md'), 'utf8');
87
+ analyzeContent = readFileSync(path.join(AGENTS_DIR, 'define-analyze.md'), 'utf8');
88
+ testContent = readFileSync(path.join(AGENTS_DIR, 'deliver-test.md'), 'utf8');
89
+ validateContent = readFileSync(path.join(AGENTS_DIR, 'deliver-validate.md'), 'utf8');
90
+ superviseContent = readFileSync(path.join(AGENTS_DIR, 'deliver.md'), 'utf8');
87
91
 
88
92
  mirrorDefineContent = existsSync(path.join(MIRROR_DIR, 'define.md'))
89
93
  ? readFileSync(path.join(MIRROR_DIR, 'define.md'), 'utf8')
90
94
  : '';
91
- mirrorImplementContent = existsSync(path.join(MIRROR_DIR, 'implement.md'))
92
- ? readFileSync(path.join(MIRROR_DIR, 'implement.md'), 'utf8')
95
+ mirrorImplementContent = existsSync(path.join(MIRROR_DIR, 'deliver-solution.md'))
96
+ ? readFileSync(path.join(MIRROR_DIR, 'deliver-solution.md'), 'utf8')
93
97
  : '';
94
98
  });
95
99
 
@@ -26,7 +26,8 @@ import path from 'node:path';
26
26
 
27
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
28
  const REPO_ROOT = path.resolve(__dirname, '../../..');
29
- const SUPERVISE_MD = path.join(REPO_ROOT, '.agents', 'agents', 'supervise.md');
29
+ // Post-S6 hard cutover: supervise relocated to the deliver orchestrator.
30
+ const SUPERVISE_MD = path.join(REPO_ROOT, '.agents', 'agents', 'deliver.md');
30
31
 
31
32
  let content;
32
33
 
@@ -0,0 +1,686 @@
1
+ /**
2
+ * tests/update-prune.test.mjs
3
+ *
4
+ * Behavior-anchored tests for the orphan-pruning feature of `runUpdate`.
5
+ *
6
+ * SLICE: cli-orphan-pruning
7
+ *
8
+ * These tests are written RED-first. They will FAIL until `update.mjs` gains
9
+ * the pruning logic described below. They are intentionally NOT "does code run"
10
+ * tests — each assertion guards a specific observable behavior that would break
11
+ * if the feature regressed.
12
+ *
13
+ * ASSUMED API CONTRACT (for the implement agent to match):
14
+ *
15
+ * result = await runUpdate(consumerDir, { fetcher, availableTags, ... })
16
+ *
17
+ * result.pruned — string[] of consumer-relative paths that were deleted
18
+ * (e.g. [".claude/agents/old-agent.md"])
19
+ * result.prunedCount — number: result.pruned.length (convenience field)
20
+ * result.summary — string containing a one-line prune summary, e.g.
21
+ * "pruned 1 orphaned managed file(s)"
22
+ * (must include the count and the word "pruned")
23
+ *
24
+ * Under --dry-run:
25
+ * result.plannedPrune — string[] of paths that WOULD be deleted (not deleted)
26
+ * OR result.pruned contains those paths (same entries as
27
+ * a live run, but nothing was removed from disk).
28
+ * (implement agent may choose either field name — but at
29
+ * least one of result.pruned or result.plannedPrune must
30
+ * carry the orphan list and the file must NOT be deleted.)
31
+ *
32
+ * SCOPING RULE (what counts as a managed target dir):
33
+ * - "claude" → <consumerDir>/.claude/
34
+ * - "cursor" → <consumerDir>/.cursor/
35
+ * - "copilot" → <consumerDir>/.github/
36
+ *
37
+ * Only directories whose target key is in `lock.targets` are scanned for orphans.
38
+ */
39
+
40
+ import { describe, it, beforeEach, afterEach } from 'node:test';
41
+ import assert from 'node:assert/strict';
42
+ import {
43
+ mkdtempSync,
44
+ mkdirSync,
45
+ writeFileSync,
46
+ readFileSync,
47
+ existsSync,
48
+ rmSync,
49
+ readdirSync,
50
+ } from 'node:fs';
51
+ import { tmpdir } from 'node:os';
52
+ import path from 'node:path';
53
+ import { fileURLToPath } from 'node:url';
54
+
55
+ import { runUpdate } from '../src/commands/update.mjs';
56
+ import { writeLockfile } from '../src/lockfile.mjs';
57
+ import { hasMarker } from '../src/marker.mjs';
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Fixture helpers (mirrors update.test.mjs conventions exactly)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
64
+ const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'release-v1.0.0');
65
+
66
+ function readFixtureContent(relativePath) {
67
+ return readFileSync(path.join(FIXTURE_DIR, relativePath), 'utf8');
68
+ }
69
+
70
+ /**
71
+ * Builds a FileMap from the fixture directory for the given targets.
72
+ * Keys are prefixed with target name: "claude/agents/define.md", etc.
73
+ */
74
+ function buildFixtureFileMap(targets = ['claude', 'cursor', 'copilot']) {
75
+ const map = {};
76
+ for (const target of targets) {
77
+ const targetDir = path.join(FIXTURE_DIR, target);
78
+ if (!existsSync(targetDir)) continue;
79
+ collectFiles(targetDir, targetDir, map, target);
80
+ }
81
+ return map;
82
+ }
83
+
84
+ function collectFiles(baseDir, currentDir, map, prefix) {
85
+ const entries = readdirSync(currentDir, { withFileTypes: true });
86
+ for (const entry of entries) {
87
+ const full = path.join(currentDir, entry.name);
88
+ if (entry.isDirectory()) {
89
+ collectFiles(baseDir, full, map, prefix);
90
+ } else {
91
+ const rel = path.relative(baseDir, full).replace(/\\/g, '/');
92
+ map[`${prefix}/${rel}`] = readFileSync(full, 'utf8');
93
+ }
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Fetcher that returns the v1.0.0 fixture content but with only `define.md`
99
+ * in the claude/agents/ directory (omitting `test.md`).
100
+ *
101
+ * This simulates a release that DROPPED test.md — so any pre-existing
102
+ * test.md in the consumer is an orphan.
103
+ */
104
+ function makeReducedFetcher() {
105
+ return async (_repo, _tag, _token) => {
106
+ const fileMap = {};
107
+ // Include define.md but NOT test.md — test.md is the "orphaned" file.
108
+ fileMap['claude/agents/define.md'] = readFixtureContent('claude/agents/define.md').replace(
109
+ /<!-- managed-by: dev-agents-sync v[\d.]+ -->/,
110
+ '<!-- managed-by: dev-agents-sync v1.2.0 -->',
111
+ );
112
+ // Also include commands to give the update something to write
113
+ fileMap['claude/commands/preflight.md'] = readFixtureContent(
114
+ 'claude/commands/preflight.md',
115
+ ).replace(
116
+ /<!-- managed-by: dev-agents-sync v[\d.]+ -->/,
117
+ '<!-- managed-by: dev-agents-sync v1.2.0 -->',
118
+ );
119
+ return fileMap;
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Fetcher that returns the full v1.2.0 fixture set (no files dropped).
125
+ * Used for the "no orphans" baseline and for confirming unmanaged files survive.
126
+ */
127
+ function makeFullFetcher() {
128
+ return async (_repo, _tag, _token) => {
129
+ const base = buildFixtureFileMap(['claude']);
130
+ const updated = {};
131
+ for (const [key, content] of Object.entries(base)) {
132
+ updated[key] = content.replace(
133
+ /<!-- managed-by: dev-agents-sync v[\d.]+ -->/,
134
+ '<!-- managed-by: dev-agents-sync v1.2.0 -->',
135
+ );
136
+ }
137
+ return updated;
138
+ };
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Helpers
143
+ // ---------------------------------------------------------------------------
144
+
145
+ function makeTmpDir() {
146
+ return mkdtempSync(path.join(tmpdir(), 'das-prune-test-'));
147
+ }
148
+
149
+ /**
150
+ * Sets up a consumer repo with:
151
+ * - lockfile pinned to resolvedVersion (triggers a version bump to 1.2.0)
152
+ * - .claude/agents/define.md (managed — will be kept)
153
+ * - .claude/agents/test.md (managed — will be orphaned when fetcher omits it)
154
+ * - .claude/commands/preflight.md (managed — will be kept)
155
+ */
156
+ async function setupRepoWithOrphan(consumerDir, opts = {}) {
157
+ const { targets = ['claude'], resolvedVersion = '1.0.0' } = opts;
158
+
159
+ writeLockfile(consumerDir, {
160
+ source: 'github:dalzoubi/dev-agents',
161
+ range: '^1',
162
+ resolvedVersion,
163
+ targets,
164
+ lastUpdated: '2026-04-01T00:00:00Z',
165
+ });
166
+
167
+ if (targets.includes('claude')) {
168
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
169
+ const commandsDir = path.join(consumerDir, '.claude', 'commands');
170
+ mkdirSync(agentsDir, { recursive: true });
171
+ mkdirSync(commandsDir, { recursive: true });
172
+
173
+ // define.md: managed (present in the next fetch → KEPT)
174
+ writeFileSync(
175
+ path.join(agentsDir, 'define.md'),
176
+ readFixtureContent('claude/agents/define.md'),
177
+ 'utf8',
178
+ );
179
+ // test.md: managed (ABSENT from the next fetch → ORPHAN to be deleted)
180
+ writeFileSync(
181
+ path.join(agentsDir, 'test.md'),
182
+ readFixtureContent('claude/agents/test.md'),
183
+ 'utf8',
184
+ );
185
+ // preflight.md: managed (present in the next fetch → KEPT)
186
+ writeFileSync(
187
+ path.join(commandsDir, 'preflight.md'),
188
+ readFixtureContent('claude/commands/preflight.md'),
189
+ 'utf8',
190
+ );
191
+ }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Verify the fixture files have the managed-by marker — if this fails, the
196
+ // fixture is broken, not our test. Keeps error messages clear.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ {
200
+ const defineContent = readFixtureContent('claude/agents/define.md');
201
+ const testContent = readFixtureContent('claude/agents/test.md');
202
+ if (!hasMarker(defineContent)) throw new Error('fixture: define.md is missing managed-by marker');
203
+ if (!hasMarker(testContent)) throw new Error('fixture: test.md is missing managed-by marker');
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Test 1: Orphan in a managed dir is DELETED after update
208
+ // ---------------------------------------------------------------------------
209
+
210
+ describe('update — orphan pruning: deletes marker-bearing orphan files', () => {
211
+ let consumerDir;
212
+
213
+ beforeEach(() => {
214
+ consumerDir = makeTmpDir();
215
+ });
216
+
217
+ afterEach(() => {
218
+ rmSync(consumerDir, { recursive: true, force: true });
219
+ });
220
+
221
+ it('deletes a managed orphan file that is absent from the fetched set', async () => {
222
+ // Behavior: after update, .claude/agents/test.md is absent from the fetcher
223
+ // response. Because it bears the managed-by marker, update must DELETE it.
224
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
225
+
226
+ const orphanPath = path.join(consumerDir, '.claude', 'agents', 'test.md');
227
+
228
+ // Pre-condition: file exists before update
229
+ assert.ok(existsSync(orphanPath), 'test setup: test.md must exist before update');
230
+
231
+ await runUpdate(consumerDir, {
232
+ fetcher: makeReducedFetcher(),
233
+ availableTags: ['v1.0.0', 'v1.2.0'],
234
+ });
235
+
236
+ // POST-CONDITION (the behavior this test guards):
237
+ // The orphan must be deleted because it bore the marker and was absent from the fetch.
238
+ assert.ok(
239
+ !existsSync(orphanPath),
240
+ '.claude/agents/test.md must be deleted after update when absent from fetched set',
241
+ );
242
+ });
243
+
244
+ it('result.pruned includes the deleted orphan path', async () => {
245
+ // Behavior: the return value of runUpdate must carry an array of deleted paths
246
+ // so the caller / CLI can log what was removed.
247
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
248
+
249
+ const result = await runUpdate(consumerDir, {
250
+ fetcher: makeReducedFetcher(),
251
+ availableTags: ['v1.0.0', 'v1.2.0'],
252
+ });
253
+
254
+ assert.ok(
255
+ Array.isArray(result.pruned),
256
+ `result.pruned must be an array, got: ${JSON.stringify(result?.pruned)}`,
257
+ );
258
+ assert.ok(
259
+ result.pruned.length > 0,
260
+ `result.pruned must contain at least one entry, got empty array`,
261
+ );
262
+ // The path must be identifiable as pointing at test.md
263
+ const hasPrunedEntry = result.pruned.some(
264
+ (p) => p.includes('test.md') || p.includes('agents'),
265
+ );
266
+ assert.ok(
267
+ hasPrunedEntry,
268
+ `result.pruned must mention test.md or agents dir, got: ${JSON.stringify(result.pruned)}`,
269
+ );
270
+ });
271
+
272
+ it('result.prunedCount equals the number of deleted files', async () => {
273
+ // Behavior: result.prunedCount (convenience count) must match result.pruned.length.
274
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
275
+
276
+ const result = await runUpdate(consumerDir, {
277
+ fetcher: makeReducedFetcher(),
278
+ availableTags: ['v1.0.0', 'v1.2.0'],
279
+ });
280
+
281
+ assert.ok(
282
+ typeof result.prunedCount === 'number',
283
+ `result.prunedCount must be a number, got: ${typeof result.prunedCount}`,
284
+ );
285
+ assert.equal(
286
+ result.prunedCount,
287
+ result.pruned?.length ?? -1,
288
+ 'result.prunedCount must equal result.pruned.length',
289
+ );
290
+ assert.ok(result.prunedCount > 0, 'prunedCount must be > 0 when an orphan exists');
291
+ });
292
+
293
+ it('result includes a summary mentioning "pruned" and the count', async () => {
294
+ // Behavior: result.summary is a human-readable one-line string that tells the
295
+ // operator how many files were pruned so CLI/stdout can surface it.
296
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
297
+
298
+ const result = await runUpdate(consumerDir, {
299
+ fetcher: makeReducedFetcher(),
300
+ availableTags: ['v1.0.0', 'v1.2.0'],
301
+ });
302
+
303
+ assert.ok(
304
+ typeof result.summary === 'string' && result.summary.length > 0,
305
+ `result.summary must be a non-empty string, got: ${JSON.stringify(result.summary)}`,
306
+ );
307
+ assert.ok(
308
+ /pruned/i.test(result.summary),
309
+ `result.summary must contain "pruned", got: "${result.summary}"`,
310
+ );
311
+ assert.ok(
312
+ /1/.test(result.summary),
313
+ `result.summary must mention the count (1), got: "${result.summary}"`,
314
+ );
315
+ });
316
+
317
+ it('non-orphan managed files are not deleted', async () => {
318
+ // Behavior: define.md IS in the fetched set — it must remain on disk untouched.
319
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
320
+
321
+ const definePath = path.join(consumerDir, '.claude', 'agents', 'define.md');
322
+ const preflightPath = path.join(consumerDir, '.claude', 'commands', 'preflight.md');
323
+
324
+ await runUpdate(consumerDir, {
325
+ fetcher: makeReducedFetcher(),
326
+ availableTags: ['v1.0.0', 'v1.2.0'],
327
+ });
328
+
329
+ assert.ok(existsSync(definePath), 'define.md must NOT be deleted — it is in the fetched set');
330
+ assert.ok(
331
+ existsSync(preflightPath),
332
+ 'preflight.md must NOT be deleted — it is in the fetched set',
333
+ );
334
+ });
335
+ });
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // Test 2: UNMARKED files in the same dir are PRESERVED (never deleted)
339
+ // ---------------------------------------------------------------------------
340
+
341
+ describe('update — orphan pruning: never deletes unmanaged (unmarked) files', () => {
342
+ let consumerDir;
343
+
344
+ beforeEach(() => {
345
+ consumerDir = makeTmpDir();
346
+ });
347
+
348
+ afterEach(() => {
349
+ rmSync(consumerDir, { recursive: true, force: true });
350
+ });
351
+
352
+ it('a file without the managed-by marker is never deleted, even if absent from fetch', async () => {
353
+ // Behavior: consumer-owned files (no marker) must survive update no matter what.
354
+ // This is the "never touch unmarked files" contract from CLAUDE.md.
355
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
356
+
357
+ // Add an unmarked file at the same level as the orphan-eligible files.
358
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
359
+ const unmanagedFile = path.join(agentsDir, 'my-custom-agent.md');
360
+ writeFileSync(unmanagedFile, '# My custom agent\nNo marker here — I own this.', 'utf8');
361
+
362
+ // Confirm no marker
363
+ const content = readFileSync(unmanagedFile, 'utf8');
364
+ assert.ok(!hasMarker(content), 'test setup: custom file must NOT have the managed-by marker');
365
+
366
+ await runUpdate(consumerDir, {
367
+ fetcher: makeReducedFetcher(),
368
+ availableTags: ['v1.0.0', 'v1.2.0'],
369
+ });
370
+
371
+ // POST-CONDITION: unmanaged file must still exist with original content
372
+ assert.ok(
373
+ existsSync(unmanagedFile),
374
+ 'unmanaged file (no marker) must NOT be deleted during orphan pruning',
375
+ );
376
+ const afterContent = readFileSync(unmanagedFile, 'utf8');
377
+ assert.equal(
378
+ afterContent,
379
+ '# My custom agent\nNo marker here — I own this.',
380
+ 'unmanaged file content must be byte-identical after update',
381
+ );
382
+ });
383
+
384
+ it('unmanaged file is not included in result.pruned', async () => {
385
+ // Behavior: only marker-bearing orphans appear in result.pruned.
386
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
387
+
388
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
389
+ const unmanagedFile = path.join(agentsDir, 'my-custom-agent.md');
390
+ writeFileSync(unmanagedFile, '# Custom — no marker', 'utf8');
391
+
392
+ const result = await runUpdate(consumerDir, {
393
+ fetcher: makeReducedFetcher(),
394
+ availableTags: ['v1.0.0', 'v1.2.0'],
395
+ });
396
+
397
+ const pruned = result.pruned ?? [];
398
+ const unmanagedInPruned = pruned.some((p) => p.includes('my-custom-agent.md'));
399
+ assert.ok(
400
+ !unmanagedInPruned,
401
+ `unmanaged file must not appear in result.pruned, got: ${JSON.stringify(pruned)}`,
402
+ );
403
+ });
404
+ });
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Test 3: --dry-run deletes NOTHING but reports the orphan
408
+ // ---------------------------------------------------------------------------
409
+
410
+ describe('update — orphan pruning: --dry-run preview without deletion', () => {
411
+ let consumerDir;
412
+
413
+ beforeEach(() => {
414
+ consumerDir = makeTmpDir();
415
+ });
416
+
417
+ afterEach(() => {
418
+ rmSync(consumerDir, { recursive: true, force: true });
419
+ });
420
+
421
+ it('--dry-run does not delete the orphan file from disk', async () => {
422
+ // Behavior: dry-run must never modify the filesystem. The orphan must survive.
423
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
424
+
425
+ const orphanPath = path.join(consumerDir, '.claude', 'agents', 'test.md');
426
+
427
+ await runUpdate(consumerDir, {
428
+ dryRun: true,
429
+ fetcher: makeReducedFetcher(),
430
+ availableTags: ['v1.0.0', 'v1.2.0'],
431
+ });
432
+
433
+ assert.ok(
434
+ existsSync(orphanPath),
435
+ 'orphan file must NOT be deleted in --dry-run mode',
436
+ );
437
+ });
438
+
439
+ it('--dry-run result reports the would-be-pruned orphan (plannedPrune or pruned)', async () => {
440
+ // Behavior: the caller must be able to see WHAT would be pruned without it
441
+ // actually being deleted. result.plannedPrune OR result.pruned must include
442
+ // the orphan path, and the file must still be on disk.
443
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
444
+
445
+ const result = await runUpdate(consumerDir, {
446
+ dryRun: true,
447
+ fetcher: makeReducedFetcher(),
448
+ availableTags: ['v1.0.0', 'v1.2.0'],
449
+ });
450
+
451
+ // Either field name is accepted; the implement agent picks one.
452
+ const reportedOrphans = result.plannedPrune ?? result.pruned ?? [];
453
+
454
+ assert.ok(
455
+ Array.isArray(reportedOrphans),
456
+ `dry-run result must carry orphan list in plannedPrune or pruned, got: ${JSON.stringify(result)}`,
457
+ );
458
+ assert.ok(
459
+ reportedOrphans.length > 0,
460
+ 'dry-run result must report at least one would-be-pruned path',
461
+ );
462
+ const hasTestMd = reportedOrphans.some((p) => p.includes('test.md'));
463
+ assert.ok(
464
+ hasTestMd,
465
+ `dry-run orphan list must include test.md, got: ${JSON.stringify(reportedOrphans)}`,
466
+ );
467
+ });
468
+
469
+ it('--dry-run with no orphans reports empty plannedPrune or pruned', async () => {
470
+ // Behavior: when nothing would be pruned, the list is empty (not undefined).
471
+ await setupRepoWithOrphan(consumerDir, { targets: ['claude'] });
472
+ // Remove the orphan pre-emptively so there is nothing to prune
473
+ rmSync(path.join(consumerDir, '.claude', 'agents', 'test.md'));
474
+
475
+ const result = await runUpdate(consumerDir, {
476
+ dryRun: true,
477
+ fetcher: makeReducedFetcher(),
478
+ availableTags: ['v1.0.0', 'v1.2.0'],
479
+ });
480
+
481
+ const reportedOrphans = result.plannedPrune ?? result.pruned ?? [];
482
+ assert.ok(
483
+ Array.isArray(reportedOrphans),
484
+ 'dry-run result must still have an array field for planned prunes even when empty',
485
+ );
486
+ assert.equal(
487
+ reportedOrphans.length,
488
+ 0,
489
+ 'no orphans should be reported when none exist on disk',
490
+ );
491
+ });
492
+ });
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Test 4: Pruning is scoped to active `targets` only
496
+ // ---------------------------------------------------------------------------
497
+
498
+ describe('update — orphan pruning: scoped to active targets only', () => {
499
+ let consumerDir;
500
+
501
+ beforeEach(() => {
502
+ consumerDir = makeTmpDir();
503
+ });
504
+
505
+ afterEach(() => {
506
+ rmSync(consumerDir, { recursive: true, force: true });
507
+ });
508
+
509
+ it('a marker-bearing file under an inactive target directory is NOT deleted', async () => {
510
+ // Behavior: if lockfile.targets is ["claude"], orphan pruning must only scan
511
+ // .claude/. Files in .cursor/ or .github/ must be left alone even if they
512
+ // bear the managed-by marker and are absent from the fetch.
513
+ //
514
+ // Setup: targets=["claude"]; we manually plant a marker-bearing file in
515
+ // .cursor/rules/ (as if a previous cursor-target run wrote it). The current
516
+ // update has targets=["claude"] only, so .cursor/ is an inactive target and
517
+ // must never be touched.
518
+
519
+ // Write lockfile with claude-only targets
520
+ writeLockfile(consumerDir, {
521
+ source: 'github:dalzoubi/dev-agents',
522
+ range: '^1',
523
+ resolvedVersion: '1.0.0',
524
+ targets: ['claude'],
525
+ lastUpdated: '2026-04-01T00:00:00Z',
526
+ });
527
+
528
+ // Plant managed files in .claude/ (active target)
529
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
530
+ mkdirSync(agentsDir, { recursive: true });
531
+ writeFileSync(
532
+ path.join(agentsDir, 'define.md'),
533
+ readFixtureContent('claude/agents/define.md'),
534
+ 'utf8',
535
+ );
536
+
537
+ // Plant a marker-bearing file in .cursor/ (INACTIVE target)
538
+ const cursorRulesDir = path.join(consumerDir, '.cursor', 'rules');
539
+ mkdirSync(cursorRulesDir, { recursive: true });
540
+ // Build a synthetic cursor file with a managed-by marker on the first
541
+ // non-frontmatter body line (mimicking what a cursor-target init would write).
542
+ const cursorOrphanContent = `<!-- managed-by: dev-agents-sync v1.0.0 -->\n# define cursor rule\n`;
543
+ writeFileSync(path.join(cursorRulesDir, 'old-rule.mdc'), cursorOrphanContent, 'utf8');
544
+
545
+ // Confirm our cursor file is recognized as managed
546
+ assert.ok(
547
+ hasMarker(cursorOrphanContent),
548
+ 'test setup: cursor orphan file must have the managed-by marker',
549
+ );
550
+
551
+ // Run update with a fetcher that omits all cursor content
552
+ // (only claude files are returned, matching the active target)
553
+ const claudeOnlyFetcher = async () => ({
554
+ 'claude/agents/define.md': readFixtureContent('claude/agents/define.md').replace(
555
+ /<!-- managed-by: dev-agents-sync v[\d.]+ -->/,
556
+ '<!-- managed-by: dev-agents-sync v1.2.0 -->',
557
+ ),
558
+ });
559
+
560
+ await runUpdate(consumerDir, {
561
+ fetcher: claudeOnlyFetcher,
562
+ availableTags: ['v1.0.0', 'v1.2.0'],
563
+ });
564
+
565
+ // POST-CONDITION: .cursor/rules/old-rule.mdc must be untouched.
566
+ // Pruning must NOT cross into an inactive target.
567
+ const cursorOrphanPath = path.join(cursorRulesDir, 'old-rule.mdc');
568
+ assert.ok(
569
+ existsSync(cursorOrphanPath),
570
+ '.cursor/rules/old-rule.mdc must NOT be deleted — cursor is not in active targets',
571
+ );
572
+ const afterContent = readFileSync(cursorOrphanPath, 'utf8');
573
+ assert.equal(
574
+ afterContent,
575
+ cursorOrphanContent,
576
+ '.cursor orphan file content must be byte-identical — inactive target must not be touched',
577
+ );
578
+ });
579
+
580
+ it('result.pruned does not include paths from inactive targets', async () => {
581
+ // Behavior: even if the implement agent walks the filesystem broadly,
582
+ // result.pruned must not list paths outside the active target dirs.
583
+ writeLockfile(consumerDir, {
584
+ source: 'github:dalzoubi/dev-agents',
585
+ range: '^1',
586
+ resolvedVersion: '1.0.0',
587
+ targets: ['claude'],
588
+ lastUpdated: '2026-04-01T00:00:00Z',
589
+ });
590
+
591
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
592
+ mkdirSync(agentsDir, { recursive: true });
593
+ writeFileSync(
594
+ path.join(agentsDir, 'define.md'),
595
+ readFixtureContent('claude/agents/define.md'),
596
+ 'utf8',
597
+ );
598
+
599
+ const cursorRulesDir = path.join(consumerDir, '.cursor', 'rules');
600
+ mkdirSync(cursorRulesDir, { recursive: true });
601
+ writeFileSync(
602
+ path.join(cursorRulesDir, 'old-rule.mdc'),
603
+ `<!-- managed-by: dev-agents-sync v1.0.0 -->\n# old rule\n`,
604
+ 'utf8',
605
+ );
606
+
607
+ const claudeOnlyFetcher = async () => ({
608
+ 'claude/agents/define.md': readFixtureContent('claude/agents/define.md').replace(
609
+ /<!-- managed-by: dev-agents-sync v[\d.]+ -->/,
610
+ '<!-- managed-by: dev-agents-sync v1.2.0 -->',
611
+ ),
612
+ });
613
+
614
+ const result = await runUpdate(consumerDir, {
615
+ fetcher: claudeOnlyFetcher,
616
+ availableTags: ['v1.0.0', 'v1.2.0'],
617
+ });
618
+
619
+ const pruned = result.pruned ?? [];
620
+ const hasCursorEntry = pruned.some((p) => p.includes('.cursor') || p.includes('cursor'));
621
+ assert.ok(
622
+ !hasCursorEntry,
623
+ `result.pruned must not include .cursor/ paths (inactive target), got: ${JSON.stringify(pruned)}`,
624
+ );
625
+ });
626
+
627
+ it('prunes from multiple active targets simultaneously', async () => {
628
+ // Behavior: when targets=["claude","cursor"] both dirs are scanned.
629
+ // Orphans in BOTH active target dirs must be pruned in one update call.
630
+ writeLockfile(consumerDir, {
631
+ source: 'github:dalzoubi/dev-agents',
632
+ range: '^1',
633
+ resolvedVersion: '1.0.0',
634
+ targets: ['claude', 'cursor'],
635
+ lastUpdated: '2026-04-01T00:00:00Z',
636
+ });
637
+
638
+ // Active: .claude/
639
+ const agentsDir = path.join(consumerDir, '.claude', 'agents');
640
+ mkdirSync(agentsDir, { recursive: true });
641
+ writeFileSync(
642
+ path.join(agentsDir, 'define.md'),
643
+ readFixtureContent('claude/agents/define.md'),
644
+ 'utf8',
645
+ );
646
+ // Orphan in .claude/ — managed but absent from fetch
647
+ writeFileSync(
648
+ path.join(agentsDir, 'old-claude-agent.md'),
649
+ `<!-- managed-by: dev-agents-sync v1.0.0 -->\n# old claude agent\n`,
650
+ 'utf8',
651
+ );
652
+
653
+ // Active: .cursor/
654
+ const rulesDir = path.join(consumerDir, '.cursor', 'rules');
655
+ mkdirSync(rulesDir, { recursive: true });
656
+ // Orphan in .cursor/ — managed but absent from fetch
657
+ writeFileSync(
658
+ path.join(rulesDir, 'old-cursor-rule.mdc'),
659
+ `<!-- managed-by: dev-agents-sync v1.0.0 -->\n# old cursor rule\n`,
660
+ 'utf8',
661
+ );
662
+
663
+ // Fetcher returns define.md for claude and nothing for cursor
664
+ const multiTargetFetcher = async () => ({
665
+ 'claude/agents/define.md': readFixtureContent('claude/agents/define.md').replace(
666
+ /<!-- managed-by: dev-agents-sync v[\d.]+ -->/,
667
+ '<!-- managed-by: dev-agents-sync v1.2.0 -->',
668
+ ),
669
+ });
670
+
671
+ await runUpdate(consumerDir, {
672
+ fetcher: multiTargetFetcher,
673
+ availableTags: ['v1.0.0', 'v1.2.0'],
674
+ });
675
+
676
+ // Both orphans must be deleted
677
+ assert.ok(
678
+ !existsSync(path.join(agentsDir, 'old-claude-agent.md')),
679
+ '.claude orphan must be deleted when claude is an active target',
680
+ );
681
+ assert.ok(
682
+ !existsSync(path.join(rulesDir, 'old-cursor-rule.mdc')),
683
+ '.cursor orphan must be deleted when cursor is an active target',
684
+ );
685
+ });
686
+ });