@abarcenas/ai-workflow-template 1.48.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.agents/instructions/knowledgebase.instructions.md +2 -2
  2. package/.agents/instructions/learned-knowledge.instructions.md +47 -2
  3. package/.agents/instructions/memory-schema.instructions.md +1 -1
  4. package/.github/workflows/npm-publish.yml +69 -1
  5. package/.opencode/agents/implementer.md +3 -1
  6. package/.opencode/agents/unit-tester.md +21 -2
  7. package/.opencode/plugins/graphify.js +22 -0
  8. package/LICENSE +15 -0
  9. package/README.md +56 -22
  10. package/memory-bank/.vocabulary.json +3 -0
  11. package/opencode.mcp.example.json +13 -0
  12. package/package.json +20 -10
  13. package/scripts/bump-version.js +18 -2
  14. package/scripts/mcp/playwright-mcp-launcher.js +45 -2
  15. package/scripts/bump-version.test.js +0 -238
  16. package/scripts/knowledgebase-cli.test.js +0 -576
  17. package/scripts/knowledgebase-index.test.js +0 -759
  18. package/scripts/mcp/playwright-mcp-launcher.test.js +0 -217
  19. package/scripts/mcp-knowledgebase-server.test.js +0 -745
  20. package/scripts/mcp-memory-server.test.js +0 -398
  21. package/scripts/memory-cli.test.js +0 -297
  22. package/scripts/memory-index.test.js +0 -1173
  23. package/scripts/normalize-memory.test.js +0 -753
  24. package/scripts/setup/discover.test.js +0 -530
  25. package/scripts/setup/hooks.test.js +0 -722
  26. package/scripts/setup/husky-init.test.js +0 -311
  27. package/scripts/setup/index.test.js +0 -1021
  28. package/scripts/setup/knowledgebase.test.js +0 -352
  29. package/scripts/setup/prepare.test.js +0 -718
  30. package/scripts/setup/skills-phase.test.js +0 -1274
  31. package/scripts/setup/sync-phase.test.js +0 -373
  32. package/scripts/setup/ui.test.js +0 -579
  33. package/scripts/setup/utils.test.js +0 -586
  34. package/scripts/sync-unit.test.js +0 -1881
  35. package/scripts/sync.test.js +0 -472
  36. package/scripts/validate-memory-schema.test.js +0 -328
  37. package/scripts/vocab-sync.test.js +0 -902
@@ -1,1881 +0,0 @@
1
- // ═════════════════════════════════════════════════════════════════════════════
2
- // sync-unit.test.js — In-process unit tests for scripts/sync.js
3
- // ═════════════════════════════════════════════════════════════════════════════
4
- //
5
- // The spawn-based suite (sync.test.js) exercises the CLI end-to-end but v8
6
- // cannot instrument child processes. These tests import the module directly and
7
- // call runSync() with explicit package/consumer roots backed by temp dirs, so
8
- // every branch below is measured in the test process.
9
- // ═════════════════════════════════════════════════════════════════════════════
10
-
11
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
- import { spawnSync } from 'child_process'
13
- import { createHash } from 'crypto'
14
- import {
15
- chmodSync,
16
- existsSync,
17
- mkdirSync,
18
- mkdtempSync,
19
- readFileSync,
20
- readdirSync,
21
- rmSync,
22
- symlinkSync,
23
- writeFileSync,
24
- } from 'fs'
25
- import { dirname, join, resolve } from 'path'
26
- import { tmpdir } from 'os'
27
- import { fileURLToPath } from 'url'
28
-
29
- import {
30
- agentInstructionsStubs,
31
- collectFiles,
32
- createSyncContext,
33
- hashFile,
34
- isEntryPoint,
35
- isPathInside,
36
- isRegularFile,
37
- isUntouched,
38
- memoryBankStubs,
39
- missingPrettierIgnorePatterns,
40
- parseArgs,
41
- parsePrettierIgnoreRules,
42
- projectOwnedPaths,
43
- projectOwnedStubs,
44
- readManifest,
45
- requiredPrettierIgnorePatterns,
46
- resolvePruneEntry,
47
- rootFiles,
48
- runSync,
49
- scriptsToSync,
50
- writeManifest,
51
- } from './sync.js'
52
-
53
- const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
54
-
55
- const tempDirs = []
56
-
57
- function createTempDir() {
58
- const dir = mkdtempSync(join(tmpdir(), 'sync-unit-'))
59
- tempDirs.push(dir)
60
- return dir
61
- }
62
-
63
- function writeFixtureFile(baseDir, relativePath, contents) {
64
- const filePath = join(baseDir, relativePath)
65
- mkdirSync(dirname(filePath), { recursive: true })
66
- writeFileSync(filePath, contents)
67
- return filePath
68
- }
69
-
70
- function sha256(contents) {
71
- return createHash('sha256').update(contents).digest('hex')
72
- }
73
-
74
- function readConsumerManifest(consumerDir) {
75
- return JSON.parse(
76
- readFileSync(join(consumerDir, '.agents-sync-manifest.json'), 'utf8'),
77
- )
78
- }
79
-
80
- function outputOf(spy) {
81
- return spy.mock.calls.map((call) => call.join(' ')).join('\n')
82
- }
83
-
84
- // Builds a throwaway "published package" with its own .agents/.opencode/scripts
85
- // and root files. Omitted sections are not created at all, which lets tests hit
86
- // the "source directory/file missing" branches.
87
- function createPackage({
88
- agents,
89
- opencode,
90
- scripts,
91
- root,
92
- mcpExample,
93
- } = {}) {
94
- const packageRoot = createTempDir()
95
- writeFileSync(join(packageRoot, 'package.json'), '{ "type": "module" }\n')
96
-
97
- if (agents) {
98
- mkdirSync(join(packageRoot, '.agents'), { recursive: true })
99
- for (const [relativePath, contents] of Object.entries(agents)) {
100
- writeFixtureFile(packageRoot, join('.agents', relativePath), contents)
101
- }
102
- }
103
-
104
- if (opencode) {
105
- mkdirSync(join(packageRoot, '.opencode'), { recursive: true })
106
- for (const [relativePath, contents] of Object.entries(opencode)) {
107
- writeFixtureFile(packageRoot, join('.opencode', relativePath), contents)
108
- }
109
- }
110
-
111
- if (scripts) {
112
- mkdirSync(join(packageRoot, 'scripts'), { recursive: true })
113
- for (const [relativePath, contents] of Object.entries(scripts)) {
114
- writeFixtureFile(packageRoot, join('scripts', relativePath), contents)
115
- }
116
- }
117
-
118
- if (root) {
119
- for (const [relativePath, contents] of Object.entries(root)) {
120
- writeFixtureFile(packageRoot, relativePath, contents)
121
- }
122
- }
123
-
124
- if (mcpExample !== undefined) {
125
- writeFixtureFile(packageRoot, 'opencode.mcp.example.json', mcpExample)
126
- }
127
-
128
- return packageRoot
129
- }
130
-
131
- let warnSpy
132
- let errorSpy
133
- let logSpy
134
-
135
- beforeEach(() => {
136
- warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
137
- errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
138
- logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
139
- })
140
-
141
- afterEach(() => {
142
- vi.restoreAllMocks()
143
- while (tempDirs.length > 0) {
144
- rmSync(tempDirs.pop(), { recursive: true, force: true })
145
- }
146
- })
147
-
148
- // ── Argument parsing ─────────────────────────────────────────────────────────
149
-
150
- describe('parseArgs', () => {
151
- it('defaults every flag to false for an empty argv', () => {
152
- expect(parseArgs([], {})).toEqual({
153
- forceOverwrite: false,
154
- dryRun: false,
155
- prune: false,
156
- isVerbose: false,
157
- })
158
- })
159
-
160
- it('uses an empty argv when none is supplied', () => {
161
- expect(parseArgs(undefined, {})).toMatchObject({
162
- forceOverwrite: false,
163
- dryRun: false,
164
- prune: false,
165
- })
166
- })
167
-
168
- it('recognises --force, --dry-run and --prune individually', () => {
169
- expect(parseArgs(['--force'], {}).forceOverwrite).toBe(true)
170
- expect(parseArgs(['--dry-run'], {}).dryRun).toBe(true)
171
- expect(parseArgs(['--prune'], {}).prune).toBe(true)
172
- })
173
-
174
- it('recognises any combination of flags', () => {
175
- expect(parseArgs(['--force', '--prune', '--dry-run'], {})).toMatchObject({
176
- forceOverwrite: true,
177
- dryRun: true,
178
- prune: true,
179
- })
180
- })
181
-
182
- it('ignores unknown flags and positional arguments', () => {
183
- expect(parseArgs(['--unknown', 'positional', '--forc'], {})).toMatchObject({
184
- forceOverwrite: false,
185
- dryRun: false,
186
- prune: false,
187
- })
188
- })
189
-
190
- it('enables verbose output only when AI_WORKFLOW_VERBOSE is "1"', () => {
191
- expect(parseArgs([], { AI_WORKFLOW_VERBOSE: '1' }).isVerbose).toBe(true)
192
- expect(parseArgs([], { AI_WORKFLOW_VERBOSE: '0' }).isVerbose).toBe(false)
193
- expect(parseArgs([], {}).isVerbose).toBe(false)
194
- })
195
-
196
- it('reads process.env by default', () => {
197
- const previous = process.env.AI_WORKFLOW_VERBOSE
198
- process.env.AI_WORKFLOW_VERBOSE = '1'
199
- try {
200
- expect(parseArgs([]).isVerbose).toBe(true)
201
- } finally {
202
- if (previous === undefined) {
203
- delete process.env.AI_WORKFLOW_VERBOSE
204
- } else {
205
- process.env.AI_WORKFLOW_VERBOSE = previous
206
- }
207
- }
208
- })
209
- })
210
-
211
- // ── Context resolution ───────────────────────────────────────────────────────
212
-
213
- describe('createSyncContext', () => {
214
- it('resolves explicit package and consumer roots', () => {
215
- const packageRoot = createTempDir()
216
- const consumerRoot = createTempDir()
217
-
218
- const context = createSyncContext({
219
- packageRoot,
220
- consumerRoot,
221
- argv: ['--force', '--prune'],
222
- env: {},
223
- })
224
-
225
- expect(context.packageRoot).toBe(packageRoot)
226
- expect(context.consumerRoot).toBe(consumerRoot)
227
- expect(context.sourceAgentsDir).toBe(join(packageRoot, '.agents'))
228
- expect(context.targetAgentsDir).toBe(join(consumerRoot, '.agents'))
229
- expect(context.sourceOpenCodeDir).toBe(join(packageRoot, '.opencode'))
230
- expect(context.sourceScriptsDir).toBe(join(packageRoot, 'scripts'))
231
- expect(context.targetScriptsDir).toBe(join(consumerRoot, 'scripts'))
232
- expect(context.manifestPath).toBe(
233
- join(consumerRoot, '.agents-sync-manifest.json'),
234
- )
235
- expect(context.forceOverwrite).toBe(true)
236
- expect(context.prune).toBe(true)
237
- expect(context.dryRun).toBe(false)
238
- })
239
-
240
- it('derives the consumer root from INIT_CWD', () => {
241
- const packageRoot = createTempDir()
242
- const initCwd = createTempDir()
243
-
244
- const context = createSyncContext({
245
- packageRoot,
246
- env: { INIT_CWD: initCwd },
247
- argv: [],
248
- })
249
-
250
- expect(context.consumerRoot).toBe(initCwd)
251
- })
252
-
253
- it('falls back to process.cwd() when INIT_CWD is absent', () => {
254
- const packageRoot = createTempDir()
255
-
256
- const context = createSyncContext({ packageRoot, env: {}, argv: [] })
257
-
258
- expect(context.consumerRoot).toBe(process.cwd())
259
- })
260
-
261
- it('derives the package root from the module location by default', () => {
262
- const consumerRoot = createTempDir()
263
-
264
- const context = createSyncContext({ consumerRoot, env: {}, argv: [] })
265
-
266
- expect(context.packageRoot).toBe(resolveRepoRoot())
267
- expect(context.sourceAgentsDir).toBe(join(resolveRepoRoot(), '.agents'))
268
- })
269
-
270
- it('accepts an explicit manifest path', () => {
271
- const consumerRoot = createTempDir()
272
- const manifestPath = join(consumerRoot, 'nested', 'custom-manifest.json')
273
-
274
- const context = createSyncContext({
275
- packageRoot: createTempDir(),
276
- consumerRoot,
277
- manifestPath,
278
- env: {},
279
- argv: [],
280
- })
281
-
282
- expect(context.manifestPath).toBe(manifestPath)
283
- })
284
-
285
- it('builds prune channels that match the copy-pass roots', () => {
286
- const packageRoot = createTempDir()
287
- const consumerRoot = createTempDir()
288
-
289
- const context = createSyncContext({ packageRoot, consumerRoot })
290
-
291
- expect(context.pruneChannels.agents).toEqual({
292
- prefix: '',
293
- packageDir: join(packageRoot, '.agents'),
294
- consumerDir: join(consumerRoot, '.agents'),
295
- })
296
- expect(context.pruneChannels.opencode).toEqual({
297
- prefix: '__opencode__/',
298
- packageDir: join(packageRoot, '.opencode'),
299
- consumerDir: join(consumerRoot, '.opencode'),
300
- })
301
- expect(context.pruneChannels.scripts).toEqual({
302
- prefix: '__scripts__/',
303
- packageDir: join(packageRoot, 'scripts'),
304
- consumerDir: join(consumerRoot, 'scripts'),
305
- })
306
- expect(context.pruneChannels.root).toEqual({
307
- prefix: '__root__/',
308
- packageDir: packageRoot,
309
- consumerDir: consumerRoot,
310
- })
311
- })
312
-
313
- it('falls back to process.argv when argv is omitted', () => {
314
- const context = createSyncContext({ env: {} })
315
-
316
- expect(typeof context.forceOverwrite).toBe('boolean')
317
- })
318
- })
319
-
320
- function resolveRepoRoot() {
321
- return process.cwd()
322
- }
323
-
324
- // ── Manifest I/O ─────────────────────────────────────────────────────────────
325
-
326
- describe('readManifest / writeManifest', () => {
327
- it('returns an empty manifest when the file is missing', () => {
328
- const manifestPath = join(createTempDir(), 'missing.json')
329
-
330
- expect(readManifest(manifestPath)).toEqual({ version: 1, files: {} })
331
- })
332
-
333
- it('returns an empty manifest for malformed JSON', () => {
334
- const dir = createTempDir()
335
- const manifestPath = writeFixtureFile(dir, 'manifest.json', '{ not json')
336
-
337
- expect(readManifest(manifestPath)).toEqual({ version: 1, files: {} })
338
- })
339
-
340
- it('returns an empty manifest for non-object JSON values', () => {
341
- const dir = createTempDir()
342
-
343
- expect(readManifest(writeFixtureFile(dir, 'null.json', 'null'))).toEqual({
344
- version: 1,
345
- files: {},
346
- })
347
- expect(readManifest(writeFixtureFile(dir, 'string.json', '"text"'))).toEqual({
348
- version: 1,
349
- files: {},
350
- })
351
- })
352
-
353
- it('repairs a missing or non-object files map', () => {
354
- const dir = createTempDir()
355
-
356
- const noFiles = readManifest(
357
- writeFixtureFile(dir, 'no-files.json', '{"version":1}'),
358
- )
359
- expect(noFiles.files).toEqual({})
360
-
361
- const badFiles = readManifest(
362
- writeFixtureFile(dir, 'bad-files.json', '{"version":1,"files":"nope"}'),
363
- )
364
- expect(badFiles.files).toEqual({})
365
-
366
- const arrayManifest = readManifest(
367
- writeFixtureFile(dir, 'array.json', '[]'),
368
- )
369
- expect(arrayManifest.files).toEqual({})
370
- })
371
-
372
- it('preserves valid manifest contents, including unknown fields', () => {
373
- const dir = createTempDir()
374
- const manifest = {
375
- version: 2,
376
- extra: true,
377
- files: { 'skills/a/SKILL.md': 'abc' },
378
- }
379
- const manifestPath = writeFixtureFile(
380
- dir,
381
- 'manifest.json',
382
- JSON.stringify(manifest),
383
- )
384
-
385
- expect(readManifest(manifestPath)).toEqual(manifest)
386
- })
387
-
388
- it('writes pretty-printed JSON with a trailing newline and round-trips', () => {
389
- const dir = createTempDir()
390
- const manifestPath = join(dir, '.agents-sync-manifest.json')
391
- const manifest = { version: 1, files: { 'a.md': sha256('a') } }
392
-
393
- writeManifest(manifestPath, manifest)
394
-
395
- const raw = readFileSync(manifestPath, 'utf8')
396
- expect(raw.endsWith('\n')).toBe(true)
397
- expect(raw).toBe(`${JSON.stringify(manifest, null, 2)}\n`)
398
- expect(readManifest(manifestPath)).toEqual(manifest)
399
- })
400
- })
401
-
402
- // ── Hashing and overwrite decisions ──────────────────────────────────────────
403
-
404
- describe('hashFile / isUntouched', () => {
405
- it('hashes file contents with sha256', () => {
406
- const dir = createTempDir()
407
- const filePath = writeFixtureFile(dir, 'file.txt', 'hello\n')
408
-
409
- expect(hashFile(filePath)).toBe(sha256('hello\n'))
410
- expect(hashFile(filePath)).toMatch(/^[a-f0-9]{64}$/)
411
- })
412
-
413
- it('decides a copy is safe only when a recorded hash matches the local file', () => {
414
- expect(isUntouched('abc', 'abc')).toBe(true)
415
- expect(isUntouched('abc', 'def')).toBe(false)
416
- expect(isUntouched(null, 'abc')).toBe(false)
417
- expect(isUntouched(null, null)).toBe(false)
418
- })
419
- })
420
-
421
- // ── File collection ──────────────────────────────────────────────────────────
422
-
423
- describe('collectFiles', () => {
424
- it('collects files recursively and skips directories as entries', () => {
425
- const dir = createTempDir()
426
- writeFixtureFile(dir, 'a.md', 'a')
427
- writeFixtureFile(dir, join('nested', 'b.md'), 'b')
428
- writeFixtureFile(dir, join('nested', 'deep', 'c.md'), 'c')
429
-
430
- const files = collectFiles(dir).map((filePath) => filePath.slice(dir.length + 1))
431
-
432
- expect(files.sort()).toEqual(['a.md', 'nested/b.md', 'nested/deep/c.md'])
433
- })
434
-
435
- it('returns an empty list for an empty directory', () => {
436
- expect(collectFiles(createTempDir())).toEqual([])
437
- })
438
-
439
- it('skips non-regular entries such as symlinks', () => {
440
- const dir = createTempDir()
441
- const target = writeFixtureFile(dir, 'target.md', 'target')
442
- symlinkSync(target, join(dir, 'link.md'))
443
-
444
- const files = collectFiles(dir)
445
-
446
- expect(files).toContain(target)
447
- expect(files).not.toContain(join(dir, 'link.md'))
448
- })
449
- })
450
-
451
- // ── Path containment and regular-file checks ─────────────────────────────────
452
-
453
- describe('isPathInside', () => {
454
- it('accepts descendants', () => {
455
- expect(isPathInside('/base', '/base/child')).toBe(true)
456
- expect(isPathInside('/base', join('/base', 'a', 'b'))).toBe(true)
457
- })
458
-
459
- it('rejects the base directory itself', () => {
460
- expect(isPathInside('/base', '/base')).toBe(false)
461
- })
462
-
463
- it('rejects the parent directory and any escape', () => {
464
- expect(isPathInside('/base', '/base/..')).toBe(false)
465
- expect(isPathInside('/base', '/base/../sibling')).toBe(false)
466
- expect(isPathInside('/base', '/other/child')).toBe(false)
467
- })
468
- })
469
-
470
- describe('isRegularFile', () => {
471
- it('returns true for regular files and false for directories', () => {
472
- const dir = createTempDir()
473
- const filePath = writeFixtureFile(dir, 'file.md', 'x')
474
-
475
- expect(isRegularFile(filePath)).toBe(true)
476
- expect(isRegularFile(dir)).toBe(false)
477
- })
478
-
479
- it('returns false when the path cannot be statted', () => {
480
- expect(isRegularFile(join(createTempDir(), 'missing.md'))).toBe(false)
481
- })
482
-
483
- it.skipIf(process.getuid?.() === 0)(
484
- 'returns false for an unreadable parent directory',
485
- () => {
486
- const dir = createTempDir()
487
- writeFixtureFile(dir, 'locked/file.md', 'x')
488
- chmodSync(join(dir, 'locked'), 0o000)
489
-
490
- try {
491
- expect(isRegularFile(join(dir, 'locked', 'file.md'))).toBe(false)
492
- } finally {
493
- chmodSync(join(dir, 'locked'), 0o755)
494
- }
495
- },
496
- )
497
- })
498
-
499
- // ── Entry-point detection ────────────────────────────────────────────────────
500
-
501
- describe('isEntryPoint', () => {
502
- const modulePath = new URL('./sync.js', import.meta.url)
503
-
504
- it('returns false when process.argv[1] is missing', () => {
505
- // Passing '' (rather than undefined, which triggers the default parameter)
506
- // exercises the falsy-argv guard while also exercising the default moduleUrl.
507
- expect(isEntryPoint(undefined, '')).toBe(false)
508
- })
509
-
510
- it('returns true when argv points at this module', () => {
511
- expect(isEntryPoint(modulePath.href, fileURLToPath(modulePath))).toBe(true)
512
- })
513
-
514
- it('returns true across symlinked paths', () => {
515
- const dir = createTempDir()
516
- const linkPath = join(dir, 'sync-link.js')
517
- symlinkSync(fileURLToPath(modulePath), linkPath)
518
-
519
- expect(isEntryPoint(modulePath.href, linkPath)).toBe(true)
520
- })
521
-
522
- it('returns false for a different existing file', () => {
523
- const other = writeFixtureFile(createTempDir(), 'other.js', '')
524
-
525
- expect(isEntryPoint(modulePath.href, other)).toBe(false)
526
- })
527
-
528
- it('returns false when argv points at a path that does not exist', () => {
529
- expect(
530
- isEntryPoint(modulePath.href, join(createTempDir(), 'nope.js')),
531
- ).toBe(false)
532
- })
533
-
534
- it('runs one sync pass when Node executes the module as the entry point', async () => {
535
- const consumerRoot = createTempDir()
536
- const syncPath = fileURLToPath(new URL('./sync.js', import.meta.url))
537
- const originalArgv1 = process.argv[1]
538
- const originalInitCwd = process.env.INIT_CWD
539
-
540
- process.argv[1] = syncPath
541
- process.env.INIT_CWD = consumerRoot
542
- try {
543
- // A query string busts the module cache so the entry-point guard evaluates
544
- // with argv[1] pointing at sync.js — exactly what `node scripts/sync.js` does.
545
- await import('./sync.js?entry-point-test')
546
- } finally {
547
- process.argv[1] = originalArgv1
548
- if (originalInitCwd === undefined) {
549
- delete process.env.INIT_CWD
550
- } else {
551
- process.env.INIT_CWD = originalInitCwd
552
- }
553
- }
554
-
555
- const manifest = readConsumerManifest(consumerRoot)
556
- expect(Object.keys(manifest.files).length).toBeGreaterThan(0)
557
- expect(existsSync(join(consumerRoot, 'AGENTS.md'))).toBe(true)
558
- })
559
- })
560
-
561
- // ── Prune entry resolution (channel + path table) ────────────────────────────
562
-
563
- describe('resolvePruneEntry', () => {
564
- function context() {
565
- return createSyncContext({
566
- packageRoot: createTempDir(),
567
- consumerRoot: createTempDir(),
568
- })
569
- }
570
-
571
- it('resolves an untagged manifest key through the .agents channel', () => {
572
- const ctx = context()
573
-
574
- const entry = resolvePruneEntry('skills/alpha/SKILL.md', ctx)
575
-
576
- expect(entry.packagePaths).toEqual([
577
- join(ctx.sourceAgentsDir, 'skills/alpha/SKILL.md'),
578
- ])
579
- expect(entry.consumerPath).toBe(
580
- join(ctx.targetAgentsDir, 'skills/alpha/SKILL.md'),
581
- )
582
- })
583
-
584
- it.each([
585
- ['__opencode__/agents/coder.md', 'opencode', 'agents/coder.md'],
586
- ['__scripts__/memory-cli.js', 'scripts', 'memory-cli.js'],
587
- ['__root__/AGENTS.md', 'root', 'AGENTS.md'],
588
- ])('resolves %s through the %s channel', (key, channel, relativePath) => {
589
- const ctx = context()
590
-
591
- const entry = resolvePruneEntry(key, ctx)
592
-
593
- expect(entry.packagePaths[0]).toBe(
594
- join(ctx.pruneChannels[channel].packageDir, relativePath),
595
- )
596
- expect(entry.consumerPath).toBe(
597
- join(ctx.pruneChannels[channel].consumerDir, relativePath),
598
- )
599
- })
600
-
601
- it('never resolves project-owned files as prune entries', () => {
602
- const ctx = context()
603
-
604
- for (const key of [
605
- '__root__/AGENTS.project.md',
606
- '__root__/opencode.json',
607
- '__root__/.prettierignore',
608
- '__root__/.agents-sync-ignore',
609
- 'instructions/learned-knowledge.instructions.md',
610
- '__project__/AGENTS.project.md',
611
- '__project__/.prettierignore',
612
- ]) {
613
- expect(resolvePruneEntry(key, ctx), key).toBeNull()
614
- }
615
- })
616
-
617
- it.each([
618
- ['unknown internal namespace', '__future__/thing.md'],
619
- ['scaffold-only project namespace', '__project__/AGENTS.project.md'],
620
- ['scaffold-only memory-bank namespace', '__memory-bank__/projectbrief.md'],
621
- ['scaffold-only instructions namespace', '__agents-instructions__/x.md'],
622
- ['excluded learned-knowledge file', 'instructions/learned-knowledge.instructions.md'],
623
- ['project-owned root AGENTS.project.md', '__root__/AGENTS.project.md'],
624
- ['project-owned root opencode.json', '__root__/opencode.json'],
625
- ['project-owned root .prettierignore', '__root__/.prettierignore'],
626
- ['project-owned root .agents-sync-ignore', '__root__/.agents-sync-ignore'],
627
- ['excluded opencode node_modules path', '__opencode__/node_modules/dep.js'],
628
- ['excluded opencode package.json', '__opencode__/package.json'],
629
- ['empty opencode key', '__opencode__/'],
630
- ['empty scripts key', '__scripts__/'],
631
- ['empty root key', '__root__/'],
632
- ['empty agents key', ''],
633
- ['parent-directory escape', '..'],
634
- ['sibling-directory escape', '../escape.txt'],
635
- ['self-referential key', './.'],
636
- ])('returns null for %s', (_label, key) => {
637
- expect(resolvePruneEntry(key, context())).toBeNull()
638
- })
639
- })
640
-
641
- // ── Copy passes ──────────────────────────────────────────────────────────────
642
-
643
- const AGENTS_FILES = {
644
- 'instructions/typescript.md': 'typescript instructions\n',
645
- 'instructions/learned-knowledge.instructions.md': 'package copy must not win\n',
646
- 'skills/alpha/SKILL.md': 'alpha skill\n',
647
- }
648
- const OPENCODE_FILES = {
649
- 'agents/coder.md': 'coder agent\n',
650
- 'node_modules/dep/index.js': 'dependency\n',
651
- 'nested/node_modules/keep.js': 'nested kept\n',
652
- 'package.json': '{"name":"pkg"}\n',
653
- '.gitignore': 'ignored\n',
654
- 'skills/beta/SKILL.md': 'beta skill\n',
655
- }
656
- const SCRIPT_FILES = {
657
- 'memory-cli.js': '// memory cli\n',
658
- 'mcp/playwright-mcp-launcher.js': '// launcher\n',
659
- }
660
- const ROOT_FILE_CONTENTS = {
661
- 'AGENTS.md': '# package agents\n',
662
- 'skills-lock.json': '{ "skills": [] }\n',
663
- }
664
- const MCP_EXAMPLE = '{\n "mcp": {}\n}\n'
665
-
666
- function createCopyFixture() {
667
- const packageRoot = createPackage({
668
- agents: AGENTS_FILES,
669
- opencode: OPENCODE_FILES,
670
- scripts: SCRIPT_FILES,
671
- root: ROOT_FILE_CONTENTS,
672
- mcpExample: MCP_EXAMPLE,
673
- })
674
-
675
- return { packageRoot, consumerRoot: createTempDir() }
676
- }
677
-
678
- describe('runSync copy passes', () => {
679
- it('copies .agents, .opencode, scripts and root files and scaffolds stubs', () => {
680
- const { packageRoot, consumerRoot } = createCopyFixture()
681
-
682
- const result = runSync({ packageRoot, consumerRoot })
683
-
684
- expect(result.exitCode).toBe(0)
685
- expect(result.copied).toBe(9)
686
- expect(result.added).toBe(9)
687
- expect(result.scaffolded).toBe(10)
688
- expect(result.skipped).toBe(0)
689
- expect(errorSpy).not.toHaveBeenCalled()
690
-
691
- // .agents copies the regular file and skips the excluded learned-knowledge path.
692
- expect(
693
- readFileSync(
694
- join(consumerRoot, '.agents', 'skills', 'alpha', 'SKILL.md'),
695
- 'utf8',
696
- ),
697
- ).toBe(AGENTS_FILES['skills/alpha/SKILL.md'])
698
- expect(
699
- readFileSync(
700
- join(consumerRoot, '.agents', 'instructions', 'typescript.md'),
701
- 'utf8',
702
- ),
703
- ).toBe(AGENTS_FILES['instructions/typescript.md'])
704
- expect(
705
- readFileSync(
706
- join(
707
- consumerRoot,
708
- '.agents',
709
- 'instructions',
710
- 'learned-knowledge.instructions.md',
711
- ),
712
- 'utf8',
713
- ),
714
- ).toBe(agentInstructionsStubs['learned-knowledge.instructions.md'])
715
-
716
- // .opencode copies regular files but not excluded paths or top-level node_modules.
717
- expect(
718
- readFileSync(join(consumerRoot, '.opencode', 'agents', 'coder.md'), 'utf8'),
719
- ).toBe(OPENCODE_FILES['agents/coder.md'])
720
- expect(
721
- readFileSync(
722
- join(consumerRoot, '.opencode', 'skills', 'beta', 'SKILL.md'),
723
- 'utf8',
724
- ),
725
- ).toBe(OPENCODE_FILES['skills/beta/SKILL.md'])
726
- expect(
727
- readFileSync(
728
- join(consumerRoot, '.opencode', 'nested', 'node_modules', 'keep.js'),
729
- 'utf8',
730
- ),
731
- ).toBe(OPENCODE_FILES['nested/node_modules/keep.js'])
732
- expect(existsSync(join(consumerRoot, '.opencode', 'node_modules'))).toBe(false)
733
- expect(existsSync(join(consumerRoot, '.opencode', 'package.json'))).toBe(false)
734
- expect(existsSync(join(consumerRoot, '.opencode', '.gitignore'))).toBe(false)
735
-
736
- // scripts are copied from the allow-list only.
737
- expect(
738
- readFileSync(join(consumerRoot, 'scripts', 'memory-cli.js'), 'utf8'),
739
- ).toBe(SCRIPT_FILES['memory-cli.js'])
740
- expect(
741
- readFileSync(
742
- join(consumerRoot, 'scripts', 'mcp', 'playwright-mcp-launcher.js'),
743
- 'utf8',
744
- ),
745
- ).toBe(SCRIPT_FILES['mcp/playwright-mcp-launcher.js'])
746
- expect(existsSync(join(consumerRoot, 'scripts', 'memory-index.js'))).toBe(false)
747
-
748
- // root files, with opencode.json scaffolded from the example.
749
- for (const [fileName, contents] of Object.entries(ROOT_FILE_CONTENTS)) {
750
- expect(readFileSync(join(consumerRoot, fileName), 'utf8')).toBe(contents)
751
- }
752
- expect(readFileSync(join(consumerRoot, 'opencode.json'), 'utf8')).toBe(
753
- MCP_EXAMPLE,
754
- )
755
-
756
- // memory-bank stubs.
757
- for (const fileName of Object.keys(memoryBankStubs)) {
758
- expect(existsSync(join(consumerRoot, 'memory-bank', fileName))).toBe(true)
759
- }
760
-
761
- // Manifest tracks every channel, including scaffolds.
762
- const manifest = readConsumerManifest(consumerRoot)
763
- expect(manifest.version).toBe(1)
764
- expect(manifest.files['skills/alpha/SKILL.md']).toBe(
765
- sha256(AGENTS_FILES['skills/alpha/SKILL.md']),
766
- )
767
- expect(manifest.files['instructions/typescript.md']).toBe(
768
- sha256(AGENTS_FILES['instructions/typescript.md']),
769
- )
770
- expect(manifest.files['instructions/learned-knowledge.instructions.md']).toBeUndefined()
771
- expect(manifest.files['__opencode__/agents/coder.md']).toBe(
772
- sha256(OPENCODE_FILES['agents/coder.md']),
773
- )
774
- expect(manifest.files['__scripts__/memory-cli.js']).toBe(
775
- sha256(SCRIPT_FILES['memory-cli.js']),
776
- )
777
- expect(manifest.files['__root__/AGENTS.md']).toBe(
778
- sha256(ROOT_FILE_CONTENTS['AGENTS.md']),
779
- )
780
- expect(manifest.files['__project__/opencode.json']).toBe(sha256(MCP_EXAMPLE))
781
- expect(manifest.files['__root__/opencode.json']).toBeUndefined()
782
- expect(
783
- manifest.files['__agents-instructions__/learned-knowledge.instructions.md'],
784
- ).toBe(
785
- sha256(agentInstructionsStubs['learned-knowledge.instructions.md']),
786
- )
787
- expect(manifest.files['__project__/AGENTS.project.md']).toBe(
788
- sha256(projectOwnedStubs['AGENTS.project.md']),
789
- )
790
- expect(manifest.files['__project__/.prettierignore']).toBe(
791
- sha256(projectOwnedStubs['.prettierignore']),
792
- )
793
- expect(manifest.files['__root__/AGENTS.project.md']).toBeUndefined()
794
- expect(manifest.files['__root__/.prettierignore']).toBeUndefined()
795
- for (const fileName of Object.keys(memoryBankStubs)) {
796
- expect(manifest.files[`__memory-bank__/${fileName}`]).toBeDefined()
797
- }
798
-
799
- expect(readFileSync(join(consumerRoot, '.agents-sync-manifest.json'), 'utf8'))
800
- .toMatch(/\n$/)
801
- expect(logSpy.mock.calls.join('\n')).toContain(
802
- `Synced ${result.copied} files (${result.added} new)`,
803
- )
804
- expect(outputOf(logSpy)).toContain(
805
- 'skipped 0 modified files. Mode: safe.',
806
- )
807
- })
808
-
809
- it('re-copies untouched files on a second run without warnings', () => {
810
- const { packageRoot, consumerRoot } = createCopyFixture()
811
-
812
- runSync({ packageRoot, consumerRoot })
813
- const second = runSync({ packageRoot, consumerRoot })
814
-
815
- expect(second.copied).toBe(9)
816
- expect(second.added).toBe(0)
817
- expect(second.scaffolded).toBe(0)
818
- expect(second.skipped).toBe(0)
819
- expect(warnSpy).not.toHaveBeenCalled()
820
- expect(outputOf(logSpy)).toContain(
821
- 'skipped 0 modified files. Mode: safe.',
822
- )
823
- })
824
-
825
- it('skips locally modified files in every channel', () => {
826
- const { packageRoot, consumerRoot } = createCopyFixture()
827
-
828
- runSync({ packageRoot, consumerRoot })
829
-
830
- const modified = [
831
- ['.agents/skills/alpha/SKILL.md', 'agents local'],
832
- ['.opencode/agents/coder.md', 'opencode local'],
833
- ['scripts/memory-cli.js', 'scripts local'],
834
- ['AGENTS.md', 'root local'],
835
- ]
836
- for (const [relativePath, contents] of modified) {
837
- writeFixtureFile(consumerRoot, relativePath, contents)
838
- }
839
-
840
- const result = runSync({ packageRoot, consumerRoot })
841
-
842
- expect(result.skipped).toBe(4)
843
- for (const [relativePath, contents] of modified) {
844
- expect(readFileSync(join(consumerRoot, relativePath), 'utf8')).toBe(
845
- contents,
846
- )
847
- }
848
-
849
- const warnings = outputOf(warnSpy)
850
- expect(warnings).toContain(
851
- 'Skipped locally modified file: skills/alpha/SKILL.md',
852
- )
853
- expect(warnings).toContain(
854
- 'Skipped locally modified opencode file: agents/coder.md',
855
- )
856
- expect(warnings).toContain(
857
- 'Skipped locally modified script file: scripts/memory-cli.js',
858
- )
859
- expect(warnings).toContain('Skipped locally modified root file: AGENTS.md')
860
- })
861
-
862
- it('overwrites locally modified files with --force', () => {
863
- const { packageRoot, consumerRoot } = createCopyFixture()
864
-
865
- runSync({ packageRoot, consumerRoot })
866
- writeFixtureFile(consumerRoot, '.agents/skills/alpha/SKILL.md', 'agents local')
867
- writeFixtureFile(consumerRoot, '.opencode/agents/coder.md', 'opencode local')
868
- writeFixtureFile(consumerRoot, 'scripts/memory-cli.js', 'scripts local')
869
- writeFixtureFile(consumerRoot, 'AGENTS.md', 'root local')
870
-
871
- const result = runSync({
872
- packageRoot,
873
- consumerRoot,
874
- argv: ['--force'],
875
- env: {},
876
- })
877
-
878
- expect(result.skipped).toBe(0)
879
- expect(warnSpy).not.toHaveBeenCalled()
880
- expect(
881
- readFileSync(
882
- join(consumerRoot, '.agents', 'skills', 'alpha', 'SKILL.md'),
883
- 'utf8',
884
- ),
885
- ).toBe(AGENTS_FILES['skills/alpha/SKILL.md'])
886
- expect(
887
- readFileSync(join(consumerRoot, '.opencode', 'agents', 'coder.md'), 'utf8'),
888
- ).toBe(OPENCODE_FILES['agents/coder.md'])
889
- expect(
890
- readFileSync(join(consumerRoot, 'scripts', 'memory-cli.js'), 'utf8'),
891
- ).toBe(SCRIPT_FILES['memory-cli.js'])
892
- expect(readFileSync(join(consumerRoot, 'AGENTS.md'), 'utf8')).toBe(
893
- ROOT_FILE_CONTENTS['AGENTS.md'],
894
- )
895
- expect(outputOf(logSpy)).toContain('Mode: force.')
896
- })
897
-
898
- it('never adopts a package-shipped opencode.json: the example scaffold wins', () => {
899
- const packageRoot = createPackage({
900
- agents: {},
901
- root: { 'opencode.json': '{ "mcp": { "from": "package" } }\n' },
902
- mcpExample: MCP_EXAMPLE,
903
- })
904
- const consumerRoot = createTempDir()
905
-
906
- runSync({ packageRoot, consumerRoot })
907
-
908
- // opencode.json is project-owned, so the root copy pass skips it and the
909
- // create-if-absent scaffold from the shipped example is what lands.
910
- expect(readFileSync(join(consumerRoot, 'opencode.json'), 'utf8')).toBe(
911
- MCP_EXAMPLE,
912
- )
913
- })
914
-
915
- it('logs progress details when AI_WORKFLOW_VERBOSE=1', () => {
916
- const { packageRoot, consumerRoot } = createCopyFixture()
917
-
918
- runSync({
919
- packageRoot,
920
- consumerRoot,
921
- argv: [],
922
- env: { AI_WORKFLOW_VERBOSE: '1' },
923
- })
924
-
925
- const errors = outputOf(errorSpy)
926
- expect(errors).toContain('… syncing: .agents/skills/alpha/SKILL.md')
927
- expect(errors).toContain('… syncing: .opencode/agents/coder.md')
928
- expect(errors).toContain('… syncing: scripts/memory-cli.js')
929
- expect(errors).toContain('… syncing: AGENTS.md')
930
- expect(errors).toContain('… scaffolding: memory-bank/projectbrief.md')
931
- expect(errors).toContain(
932
- '… scaffolding: .agents/instructions/learned-knowledge.instructions.md',
933
- )
934
- expect(errors).toContain('… scaffolding: opencode.json from example')
935
-
936
- // A second verbose run covers the overwrite branch logging.
937
- runSync({
938
- packageRoot,
939
- consumerRoot,
940
- argv: [],
941
- env: { AI_WORKFLOW_VERBOSE: '1' },
942
- })
943
- expect(outputOf(errorSpy)).toContain('… syncing: AGENTS.md')
944
- })
945
-
946
- it('writes nothing in --dry-run except pre-existing scaffolding behaviour', () => {
947
- const { packageRoot, consumerRoot } = createCopyFixture()
948
-
949
- const result = runSync({
950
- packageRoot,
951
- consumerRoot,
952
- argv: ['--dry-run'],
953
- env: {},
954
- })
955
-
956
- expect(result.copied).toBe(9)
957
- expect(result.added).toBe(9)
958
- // Copy passes are suppressed.
959
- expect(existsSync(join(consumerRoot, '.agents', 'skills', 'alpha', 'SKILL.md'))).toBe(false)
960
- expect(existsSync(join(consumerRoot, '.opencode', 'agents', 'coder.md'))).toBe(false)
961
- expect(existsSync(join(consumerRoot, 'scripts', 'memory-cli.js'))).toBe(false)
962
- expect(existsSync(join(consumerRoot, 'AGENTS.md'))).toBe(false)
963
- // No manifest is persisted.
964
- expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(false)
965
- // Scaffolding is not gated by --dry-run (pre-existing behaviour).
966
- expect(existsSync(join(consumerRoot, 'memory-bank', 'projectbrief.md'))).toBe(true)
967
- expect(existsSync(join(consumerRoot, 'opencode.json'))).toBe(true)
968
- expect(outputOf(logSpy)).toContain('Mode: dry-run.')
969
- })
970
-
971
- it('does not copy .opencode when the package has no such directory', () => {
972
- const packageRoot = createPackage({
973
- agents: { 'skills/alpha/SKILL.md': 'alpha\n' },
974
- mcpExample: MCP_EXAMPLE,
975
- })
976
- const consumerRoot = createTempDir()
977
-
978
- const result = runSync({ packageRoot, consumerRoot })
979
-
980
- expect(result.copied).toBe(1)
981
- expect(existsSync(join(consumerRoot, '.opencode'))).toBe(false)
982
- })
983
-
984
- it('warns and returns early when the package has no .agents directory', () => {
985
- const packageRoot = createPackage({})
986
- const consumerRoot = createTempDir()
987
-
988
- const result = runSync({ packageRoot, consumerRoot })
989
-
990
- expect(result.exitCode).toBe(0)
991
- expect(result.copied).toBe(0)
992
- expect(outputOf(warnSpy)).toContain(
993
- 'No .agents directory found to sync.',
994
- )
995
- expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(false)
996
- })
997
-
998
- it('creates missing parent directories for nested targets', () => {
999
- const packageRoot = createPackage({
1000
- agents: { 'deep/nested/tree/SKILL.md': 'deep\n' },
1001
- mcpExample: MCP_EXAMPLE,
1002
- })
1003
- const consumerRoot = createTempDir()
1004
-
1005
- runSync({ packageRoot, consumerRoot })
1006
-
1007
- expect(
1008
- readFileSync(
1009
- join(consumerRoot, '.agents', 'deep', 'nested', 'tree', 'SKILL.md'),
1010
- 'utf8',
1011
- ),
1012
- ).toBe('deep\n')
1013
- })
1014
- })
1015
-
1016
- // ── Project-owned files ──────────────────────────────────────────────────────
1017
-
1018
- const PROJECT_OWNED_LOCAL_CONTENT = {
1019
- 'AGENTS.project.md': '# Local project rules\n\n- never overwrite this',
1020
- 'opencode.json': '{ "local": true }\n',
1021
- '.prettierignore': 'my-build/\n',
1022
- '.agents-sync-ignore': '# local ignore rules\n',
1023
- '.agents/instructions/learned-knowledge.instructions.md': '# Local knowledge\n',
1024
- }
1025
-
1026
- const LEGACY_PRETTIER_IGNORE =
1027
- '/dist\n/coverage\n/.nx/cache\n/.nx/workspace-data\n'
1028
-
1029
- const PRETTIER_IGNORE_WARNING =
1030
- '[ai-workflow-template] Existing .prettierignore does not protect managed files; merge the patterns from node_modules/@abarcenas/ai-workflow-template/.prettierignore'
1031
-
1032
- const COMPLETE_PRETTIER_IGNORE = [
1033
- '.agents/',
1034
- '.opencode/',
1035
- 'scripts/',
1036
- 'AGENTS.md',
1037
- 'skills-lock.json',
1038
- ].join('\n')
1039
-
1040
- // B1 coverage matrix: parent-directory coverage, negation holes, strict
1041
- // descendants, comments, inline literals, normalisation and ignore-all forms.
1042
- const PRETTIER_IGNORE_MATRIX = [
1043
- ['exact shipped content', projectOwnedStubs['.prettierignore'], false],
1044
- ['parent directories cover descendants', COMPLETE_PRETTIER_IGNORE, false],
1045
- [
1046
- 'shipped without AGENTS.md',
1047
- projectOwnedStubs['.prettierignore'].replace('\nAGENTS.md\n', '\n'),
1048
- true,
1049
- ],
1050
- [
1051
- 'negation hole below .agents/',
1052
- [
1053
- '.agents/',
1054
- '!.agents/skills/**',
1055
- '.opencode/',
1056
- 'scripts/',
1057
- 'AGENTS.md',
1058
- 'skills-lock.json',
1059
- ].join('\n'),
1060
- true,
1061
- ],
1062
- [
1063
- 'later positive restores a negated subtree',
1064
- [
1065
- '.agents/',
1066
- '!.agents/skills/**',
1067
- '.agents/skills/',
1068
- '.opencode/',
1069
- 'scripts/',
1070
- 'AGENTS.md',
1071
- 'skills-lock.json',
1072
- ].join('\n'),
1073
- false,
1074
- ],
1075
- [
1076
- 'AGENTS.md/** does not cover the file',
1077
- COMPLETE_PRETTIER_IGNORE.replace('AGENTS.md', 'AGENTS.md/**'),
1078
- true,
1079
- ],
1080
- [
1081
- '.agents without a trailing slash',
1082
- COMPLETE_PRETTIER_IGNORE.replace('.agents/', '.agents'),
1083
- false,
1084
- ],
1085
- [
1086
- 'commented-out .agents/',
1087
- COMPLETE_PRETTIER_IGNORE.replace('.agents/', '# .agents/'),
1088
- true,
1089
- ],
1090
- [
1091
- 'leading **/ wildcard',
1092
- COMPLETE_PRETTIER_IGNORE.replace('AGENTS.md', '**/AGENTS.md'),
1093
- false,
1094
- ],
1095
- [
1096
- 'inline hash is literal',
1097
- COMPLETE_PRETTIER_IGNORE.replace('AGENTS.md', 'AGENTS.md # managed'),
1098
- true,
1099
- ],
1100
- [
1101
- 'CRLF and trailing spaces',
1102
- COMPLETE_PRETTIER_IGNORE.split('\n')
1103
- .map((line) => `${line} `)
1104
- .join('\r\n'),
1105
- false,
1106
- ],
1107
- [
1108
- 'leading-slash patterns (including /dist)',
1109
- ['/dist', '/.agents/', '/.opencode/', '/scripts/', '/AGENTS.md', '/skills-lock.json'].join(
1110
- '\n',
1111
- ),
1112
- false,
1113
- ],
1114
- ['* ignores everything', '*', false],
1115
- ['** ignores everything', '**', false],
1116
- ]
1117
-
1118
- function createProjectOwnedFixture() {
1119
- // The package ships competing copies at the project-owned paths. None may win.
1120
- const packageRoot = createPackage({
1121
- agents: {
1122
- 'instructions/learned-knowledge.instructions.md': '# Package knowledge\n',
1123
- },
1124
- root: {
1125
- 'AGENTS.project.md': '# Package rules\n',
1126
- 'opencode.json': '{ "package": true }\n',
1127
- '.prettierignore': 'package-build/\n',
1128
- '.agents-sync-ignore': '# package ignore\n',
1129
- },
1130
- mcpExample: MCP_EXAMPLE,
1131
- })
1132
- const consumerRoot = createTempDir()
1133
-
1134
- for (const [relativePath, contents] of Object.entries(
1135
- PROJECT_OWNED_LOCAL_CONTENT,
1136
- )) {
1137
- writeFixtureFile(consumerRoot, relativePath, contents)
1138
- }
1139
-
1140
- return { packageRoot, consumerRoot }
1141
- }
1142
-
1143
- describe('project-owned files', () => {
1144
- it('scaffolds AGENTS.project.md and .prettierignore create-if-absent', () => {
1145
- const { packageRoot, consumerRoot } = createCopyFixture()
1146
-
1147
- const result = runSync({ packageRoot, consumerRoot })
1148
-
1149
- expect(result.scaffolded).toBe(10)
1150
- expect(readFileSync(join(consumerRoot, 'AGENTS.project.md'), 'utf8')).toBe(
1151
- projectOwnedStubs['AGENTS.project.md'],
1152
- )
1153
- expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
1154
- projectOwnedStubs['.prettierignore'],
1155
- )
1156
-
1157
- const manifest = readConsumerManifest(consumerRoot)
1158
- expect(manifest.files['__project__/AGENTS.project.md']).toBe(
1159
- sha256(projectOwnedStubs['AGENTS.project.md']),
1160
- )
1161
- expect(manifest.files['__project__/.prettierignore']).toBe(
1162
- sha256(projectOwnedStubs['.prettierignore']),
1163
- )
1164
- // Ownership is reported through __project__, never through a sync channel.
1165
- expect(manifest.files['__root__/AGENTS.project.md']).toBeUndefined()
1166
- expect(manifest.files['__root__/.prettierignore']).toBeUndefined()
1167
- })
1168
-
1169
- it('does not overwrite existing project-owned files, even with --force', () => {
1170
- const { packageRoot, consumerRoot } = createProjectOwnedFixture()
1171
-
1172
- const result = runSync({
1173
- packageRoot,
1174
- consumerRoot,
1175
- argv: ['--force'],
1176
- env: {},
1177
- })
1178
-
1179
- expect(result.skipped).toBe(0)
1180
- for (const [relativePath, contents] of Object.entries(
1181
- PROJECT_OWNED_LOCAL_CONTENT,
1182
- )) {
1183
- expect(
1184
- readFileSync(join(consumerRoot, relativePath), 'utf8'),
1185
- relativePath,
1186
- ).toBe(contents)
1187
- }
1188
-
1189
- const manifest = readConsumerManifest(consumerRoot)
1190
- for (const key of [
1191
- '__root__/AGENTS.project.md',
1192
- '__root__/opencode.json',
1193
- '__root__/.prettierignore',
1194
- '__root__/.agents-sync-ignore',
1195
- '__project__/AGENTS.project.md',
1196
- '__project__/.prettierignore',
1197
- '__project__/opencode.json',
1198
- ]) {
1199
- expect(manifest.files[key], key).toBeUndefined()
1200
- }
1201
- })
1202
-
1203
- it('preserves a local AGENTS.project.md edit byte-for-byte across a sync', () => {
1204
- const { packageRoot, consumerRoot } = createCopyFixture()
1205
-
1206
- runSync({ packageRoot, consumerRoot })
1207
- const localEdit = '# Project rules\n\n- local edit, no trailing newline'
1208
- writeFixtureFile(consumerRoot, 'AGENTS.project.md', localEdit)
1209
-
1210
- const result = runSync({
1211
- packageRoot,
1212
- consumerRoot,
1213
- argv: ['--force'],
1214
- env: {},
1215
- })
1216
-
1217
- expect(result.skipped).toBe(0)
1218
- expect(readFileSync(join(consumerRoot, 'AGENTS.project.md'), 'utf8')).toBe(
1219
- localEdit,
1220
- )
1221
- })
1222
-
1223
- it('never clobbers an existing consumer .prettierignore', () => {
1224
- const { packageRoot, consumerRoot } = createCopyFixture()
1225
- const localIgnore = 'coverage/\nplaywright-report/\n'
1226
- writeFixtureFile(consumerRoot, '.prettierignore', localIgnore)
1227
-
1228
- runSync({ packageRoot, consumerRoot })
1229
- runSync({ packageRoot, consumerRoot, argv: ['--force'], env: {} })
1230
-
1231
- expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
1232
- localIgnore,
1233
- )
1234
- expect(
1235
- readConsumerManifest(consumerRoot).files['__project__/.prettierignore'],
1236
- ).toBeUndefined()
1237
- })
1238
-
1239
- it('warns once when an existing .prettierignore does not protect managed files', () => {
1240
- const { packageRoot, consumerRoot } = createCopyFixture()
1241
- writeFixtureFile(consumerRoot, '.prettierignore', LEGACY_PRETTIER_IGNORE)
1242
-
1243
- const result = runSync({ packageRoot, consumerRoot })
1244
-
1245
- expect(result.scaffolded).toBe(9)
1246
- expect(warnSpy).toHaveBeenCalledTimes(1)
1247
- expect(outputOf(warnSpy)).toBe(PRETTIER_IGNORE_WARNING)
1248
- // Warning only: the consumer file is byte-for-byte unchanged.
1249
- expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
1250
- LEGACY_PRETTIER_IGNORE,
1251
- )
1252
- })
1253
-
1254
- it.skipIf(process.getuid?.() === 0)(
1255
- 'skips the advisory when .prettierignore cannot be read',
1256
- () => {
1257
- const { packageRoot, consumerRoot } = createCopyFixture()
1258
- const ignorePath = writeFixtureFile(
1259
- consumerRoot,
1260
- '.prettierignore',
1261
- LEGACY_PRETTIER_IGNORE,
1262
- )
1263
- chmodSync(ignorePath, 0o000)
1264
-
1265
- try {
1266
- const result = runSync({ packageRoot, consumerRoot })
1267
-
1268
- expect(result.exitCode).toBe(0)
1269
- expect(warnSpy).not.toHaveBeenCalled()
1270
- expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(
1271
- true,
1272
- )
1273
- } finally {
1274
- chmodSync(ignorePath, 0o644)
1275
- }
1276
- },
1277
- )
1278
-
1279
- it('does not warn when an existing .prettierignore covers managed files', () => {
1280
- const { packageRoot, consumerRoot } = createCopyFixture()
1281
- writeFixtureFile(
1282
- consumerRoot,
1283
- '.prettierignore',
1284
- projectOwnedStubs['.prettierignore'],
1285
- )
1286
-
1287
- runSync({ packageRoot, consumerRoot })
1288
-
1289
- expect(warnSpy).not.toHaveBeenCalled()
1290
- })
1291
-
1292
- it('does not warn when .prettierignore was just scaffolded', () => {
1293
- const { packageRoot, consumerRoot } = createCopyFixture()
1294
-
1295
- runSync({ packageRoot, consumerRoot })
1296
-
1297
- expect(warnSpy).not.toHaveBeenCalled()
1298
- expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
1299
- projectOwnedStubs['.prettierignore'],
1300
- )
1301
- })
1302
-
1303
- // Pins current behaviour: scaffolding is not gated by --dry-run. Update this
1304
- // test when scaffolding becomes dry-run aware.
1305
- it('documents that --dry-run still scaffolds missing project-owned files', () => {
1306
- const { packageRoot, consumerRoot } = createCopyFixture()
1307
-
1308
- const result = runSync({
1309
- packageRoot,
1310
- consumerRoot,
1311
- argv: ['--dry-run'],
1312
- env: {},
1313
- })
1314
-
1315
- expect(result.scaffolded).toBe(10)
1316
- expect(readFileSync(join(consumerRoot, 'AGENTS.project.md'), 'utf8')).toBe(
1317
- projectOwnedStubs['AGENTS.project.md'],
1318
- )
1319
- expect(readFileSync(join(consumerRoot, '.prettierignore'), 'utf8')).toBe(
1320
- projectOwnedStubs['.prettierignore'],
1321
- )
1322
- expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(
1323
- false,
1324
- )
1325
- })
1326
-
1327
- it('does not refresh __project__ hashes after a consumer edit', () => {
1328
- const { packageRoot, consumerRoot } = createCopyFixture()
1329
-
1330
- runSync({ packageRoot, consumerRoot })
1331
- const originalHash = readConsumerManifest(consumerRoot).files[
1332
- '__project__/AGENTS.project.md'
1333
- ]
1334
- writeFixtureFile(consumerRoot, 'AGENTS.project.md', '# edited locally\n')
1335
-
1336
- runSync({ packageRoot, consumerRoot, argv: ['--force'], env: {} })
1337
-
1338
- expect(
1339
- readConsumerManifest(consumerRoot).files['__project__/AGENTS.project.md'],
1340
- ).toBe(originalHash)
1341
- })
1342
-
1343
- it('does not throw when a project-owned scaffold path is a directory', () => {
1344
- const { packageRoot, consumerRoot } = createCopyFixture()
1345
- mkdirSync(join(consumerRoot, 'AGENTS.project.md'), { recursive: true })
1346
- mkdirSync(join(consumerRoot, '.prettierignore'), { recursive: true })
1347
-
1348
- const result = runSync({ packageRoot, consumerRoot })
1349
-
1350
- expect(result.exitCode).toBe(0)
1351
- expect(existsSync(join(consumerRoot, '.agents-sync-manifest.json'))).toBe(
1352
- true,
1353
- )
1354
- expect(warnSpy).not.toHaveBeenCalled()
1355
- })
1356
-
1357
- it('never prunes any project-owned path, including with --prune', () => {
1358
- const packageRoot = createPackage({ agents: {} })
1359
- const consumerRoot = createTempDir()
1360
-
1361
- for (const [relativePath, contents] of Object.entries(
1362
- PROJECT_OWNED_LOCAL_CONTENT,
1363
- )) {
1364
- writeFixtureFile(consumerRoot, relativePath, contents)
1365
- }
1366
-
1367
- const trackedKeys = {
1368
- '__root__/AGENTS.project.md': 'AGENTS.project.md',
1369
- '__root__/opencode.json': 'opencode.json',
1370
- '__root__/.prettierignore': '.prettierignore',
1371
- '__root__/.agents-sync-ignore': '.agents-sync-ignore',
1372
- 'instructions/learned-knowledge.instructions.md':
1373
- '.agents/instructions/learned-knowledge.instructions.md',
1374
- '__project__/AGENTS.project.md': 'AGENTS.project.md',
1375
- '__project__/.prettierignore': '.prettierignore',
1376
- }
1377
- writeManifest(
1378
- join(consumerRoot, '.agents-sync-manifest.json'),
1379
- {
1380
- version: 1,
1381
- files: Object.fromEntries(
1382
- Object.entries(trackedKeys).map(([key, relativePath]) => [
1383
- key,
1384
- sha256(PROJECT_OWNED_LOCAL_CONTENT[relativePath]),
1385
- ]),
1386
- ),
1387
- },
1388
- )
1389
-
1390
- const result = runSync({
1391
- packageRoot,
1392
- consumerRoot,
1393
- argv: ['--prune', '--force'],
1394
- env: {},
1395
- })
1396
-
1397
- expect(result.pruned).toBe(0)
1398
- for (const [relativePath, contents] of Object.entries(
1399
- PROJECT_OWNED_LOCAL_CONTENT,
1400
- )) {
1401
- expect(
1402
- readFileSync(join(consumerRoot, relativePath), 'utf8'),
1403
- relativePath,
1404
- ).toBe(contents)
1405
- }
1406
-
1407
- const manifest = readConsumerManifest(consumerRoot)
1408
- for (const key of Object.keys(trackedKeys)) {
1409
- expect(manifest.files, key).toHaveProperty([key])
1410
- }
1411
- })
1412
-
1413
- it('keeps the shipped .prettierignore covering every managed root file and script', () => {
1414
- const shipped = readFileSync(join(repoRoot, '.prettierignore'), 'utf8')
1415
-
1416
- expect(shipped).toBe(projectOwnedStubs['.prettierignore'])
1417
- expect(missingPrettierIgnorePatterns(shipped)).toEqual([])
1418
-
1419
- for (const rootFile of rootFiles) {
1420
- expect(
1421
- missingPrettierIgnorePatterns(shipped, [rootFile]),
1422
- rootFile,
1423
- ).toEqual([])
1424
- }
1425
- for (const script of scriptsToSync) {
1426
- const pattern = `scripts/${script}`
1427
- expect(
1428
- missingPrettierIgnorePatterns(shipped, [pattern]),
1429
- pattern,
1430
- ).toEqual([])
1431
- }
1432
- })
1433
-
1434
- it.each(PRETTIER_IGNORE_MATRIX)(
1435
- 'detects coverage correctly: %s',
1436
- (_label, contents, expectWarning) => {
1437
- const { packageRoot, consumerRoot } = createCopyFixture()
1438
- writeFixtureFile(consumerRoot, '.prettierignore', contents)
1439
-
1440
- runSync({ packageRoot, consumerRoot })
1441
-
1442
- expect(
1443
- missingPrettierIgnorePatterns(contents).length > 0,
1444
- 'detector result',
1445
- ).toBe(expectWarning)
1446
- if (expectWarning) {
1447
- expect(warnSpy).toHaveBeenCalledTimes(1)
1448
- expect(outputOf(warnSpy)).toBe(PRETTIER_IGNORE_WARNING)
1449
- } else {
1450
- expect(warnSpy).not.toHaveBeenCalled()
1451
- }
1452
- },
1453
- )
1454
-
1455
- it('parses gitignore comments, negations and escaped markers', () => {
1456
- expect(
1457
- parsePrettierIgnoreRules(
1458
- '# comment\n!AGENTS.md\n\\#literal\n\\!literal\nAGENTS.md # inline\n',
1459
- ),
1460
- ).toEqual([
1461
- { negated: true, pattern: 'AGENTS.md' },
1462
- { negated: false, pattern: '#literal' },
1463
- { negated: false, pattern: '!literal' },
1464
- { negated: false, pattern: 'AGENTS.md # inline' },
1465
- ])
1466
- })
1467
-
1468
- it('ships an opencode example that loads AGENTS.project.md', () => {
1469
- const example = JSON.parse(
1470
- readFileSync(join(repoRoot, 'opencode.mcp.example.json'), 'utf8'),
1471
- )
1472
-
1473
- expect(example.instructions).toEqual(['AGENTS.project.md'])
1474
- })
1475
-
1476
- it('scaffolds opencode.json with the project instructions entry', () => {
1477
- const example = readFileSync(
1478
- join(repoRoot, 'opencode.mcp.example.json'),
1479
- 'utf8',
1480
- )
1481
- const packageRoot = createPackage({ agents: {}, mcpExample: example })
1482
- const consumerRoot = createTempDir()
1483
-
1484
- runSync({ packageRoot, consumerRoot })
1485
-
1486
- const config = JSON.parse(
1487
- readFileSync(join(consumerRoot, 'opencode.json'), 'utf8'),
1488
- )
1489
- expect(config.instructions).toEqual(['AGENTS.project.md'])
1490
- })
1491
-
1492
- it('does not ship project-owned files in the npm package', () => {
1493
- const packageManifest = JSON.parse(
1494
- readFileSync(join(repoRoot, 'package.json'), 'utf8'),
1495
- )
1496
- expect(packageManifest.files).not.toContain('AGENTS.project.md')
1497
- expect(packageManifest.files).not.toContain('opencode.json')
1498
- expect(packageManifest.files).toContain('.prettierignore')
1499
-
1500
- const packed = spawnSync('npm', ['pack', '--dry-run', '--json'], {
1501
- cwd: repoRoot,
1502
- encoding: 'utf8',
1503
- })
1504
- expect(packed.status).toBe(0)
1505
- const files = JSON.parse(packed.stdout)[0].files.map((file) => file.path)
1506
- expect(files).toContain('.prettierignore')
1507
- expect(files).not.toContain('AGENTS.project.md')
1508
- expect(files).not.toContain('opencode.json')
1509
- })
1510
-
1511
- it('reserves every documented project-owned path', () => {
1512
- for (const relativePath of [
1513
- 'AGENTS.project.md',
1514
- 'opencode.json',
1515
- '.prettierignore',
1516
- '.agents-sync-ignore',
1517
- 'instructions/learned-knowledge.instructions.md',
1518
- ]) {
1519
- expect(projectOwnedPaths.has(relativePath), relativePath).toBe(true)
1520
- }
1521
- })
1522
- })
1523
-
1524
- // ── Prune decisions ──────────────────────────────────────────────────────────
1525
-
1526
- const PRUNE_CONTENT = {
1527
- agentsShipped: 'shipped skill\n',
1528
- agentsStale: 'stale skill\n',
1529
- agentsModifiedOriginal: 'modified skill v1\n',
1530
- agentsModifiedLocal: 'local edits must survive\n',
1531
- opencodeShipped: 'shipped opencode\n',
1532
- opencodeStale: 'stale opencode\n',
1533
- scriptsShipped: '// memory cli shipped\n',
1534
- scriptsStale: '// old tool\n',
1535
- rootAgents: '# package agents\n',
1536
- mcpExample: '{ "mcp": {} }\n',
1537
- rootStale: 'stale root\n',
1538
- rootModifiedOriginal: 'root original\n',
1539
- rootModifiedLocal: 'root local edits\n',
1540
- }
1541
-
1542
- function createPruneFixture() {
1543
- const { agentsShipped, opencodeShipped, scriptsShipped, rootAgents, mcpExample } =
1544
- PRUNE_CONTENT
1545
-
1546
- const packageRoot = createPackage({
1547
- agents: { 'skills/shipped/SKILL.md': agentsShipped },
1548
- opencode: { 'agents/shipped.md': opencodeShipped },
1549
- scripts: { 'memory-cli.js': scriptsShipped },
1550
- root: { 'AGENTS.md': rootAgents },
1551
- mcpExample,
1552
- })
1553
- const consumerRoot = createTempDir()
1554
-
1555
- writeFixtureFile(
1556
- consumerRoot,
1557
- '.agents/skills/shipped/SKILL.md',
1558
- agentsShipped,
1559
- )
1560
- writeFixtureFile(consumerRoot, '.agents/skills/stale/SKILL.md', PRUNE_CONTENT.agentsStale)
1561
- writeFixtureFile(
1562
- consumerRoot,
1563
- '.agents/skills/modified/SKILL.md',
1564
- PRUNE_CONTENT.agentsModifiedLocal,
1565
- )
1566
- mkdirSync(join(consumerRoot, '.agents', 'skills', 'dir', 'SKILL.md'), {
1567
- recursive: true,
1568
- })
1569
-
1570
- writeFixtureFile(
1571
- consumerRoot,
1572
- '.opencode/agents/shipped.md',
1573
- opencodeShipped,
1574
- )
1575
- writeFixtureFile(
1576
- consumerRoot,
1577
- '.opencode/agents/stale.md',
1578
- PRUNE_CONTENT.opencodeStale,
1579
- )
1580
- writeFixtureFile(consumerRoot, '.opencode/node_modules/dep.js', 'dep\n')
1581
- writeFixtureFile(consumerRoot, '.opencode/package.json', '{}\n')
1582
-
1583
- writeFixtureFile(consumerRoot, 'scripts/memory-cli.js', scriptsShipped)
1584
- writeFixtureFile(consumerRoot, 'scripts/old-tool.js', PRUNE_CONTENT.scriptsStale)
1585
-
1586
- writeFixtureFile(consumerRoot, 'AGENTS.md', rootAgents)
1587
- writeFixtureFile(consumerRoot, 'opencode.json', mcpExample)
1588
- writeFixtureFile(consumerRoot, 'stale-root.md', PRUNE_CONTENT.rootStale)
1589
- writeFixtureFile(consumerRoot, 'untracked.txt', 'untracked\n')
1590
- writeFixtureFile(consumerRoot, 'empty-hash.txt', 'empty hash\n')
1591
- writeFixtureFile(
1592
- consumerRoot,
1593
- 'modified-root.md',
1594
- PRUNE_CONTENT.rootModifiedLocal,
1595
- )
1596
-
1597
- const manifest = {
1598
- version: 1,
1599
- files: {
1600
- 'skills/shipped/SKILL.md': sha256(agentsShipped),
1601
- 'skills/stale/SKILL.md': sha256(PRUNE_CONTENT.agentsStale),
1602
- 'skills/modified/SKILL.md': sha256(PRUNE_CONTENT.agentsModifiedOriginal),
1603
- 'skills/gone/SKILL.md': sha256('gone'),
1604
- 'skills/dir/SKILL.md': sha256('directory placeholder'),
1605
- 'instructions/learned-knowledge.instructions.md': sha256('excluded'),
1606
- '../escape.txt': sha256('escape'),
1607
- '..': sha256('parent'),
1608
- './.': sha256('self'),
1609
- '': sha256('empty'),
1610
- '__memory-bank__/projectbrief.md': sha256('scaffold'),
1611
- '__agents-instructions__/learned-knowledge.instructions.md': sha256(
1612
- 'scaffold',
1613
- ),
1614
- '__future__/thing.md': sha256('future'),
1615
- '__opencode__/agents/shipped.md': sha256(opencodeShipped),
1616
- '__opencode__/agents/stale.md': sha256(PRUNE_CONTENT.opencodeStale),
1617
- '__opencode__/node_modules/dep.js': sha256('dep'),
1618
- '__opencode__/package.json': sha256('pkg'),
1619
- '__opencode__/': sha256('empty'),
1620
- '__opencode__/agents/missing.md': sha256('missing'),
1621
- '__scripts__/memory-cli.js': sha256(scriptsShipped),
1622
- '__scripts__/old-tool.js': sha256(PRUNE_CONTENT.scriptsStale),
1623
- '__scripts__/': sha256('empty'),
1624
- '__root__/AGENTS.md': sha256(rootAgents),
1625
- '__root__/opencode.json': sha256(mcpExample),
1626
- '__root__/stale-root.md': sha256(PRUNE_CONTENT.rootStale),
1627
- '__root__/gone-root.md': sha256('gone'),
1628
- '__root__/untracked.txt': null,
1629
- '__root__/empty-hash.txt': '',
1630
- '__root__/modified-root.md': sha256(PRUNE_CONTENT.rootModifiedOriginal),
1631
- },
1632
- }
1633
- writeFixtureFile(
1634
- consumerRoot,
1635
- '.agents-sync-manifest.json',
1636
- `${JSON.stringify(manifest, null, 2)}\n`,
1637
- )
1638
-
1639
- return { packageRoot, consumerRoot, manifest }
1640
- }
1641
-
1642
- describe('--prune decision matrix', () => {
1643
- it('prunes only unmodified files whose package source is gone', () => {
1644
- const { packageRoot, consumerRoot } = createPruneFixture()
1645
-
1646
- const result = runSync({
1647
- packageRoot,
1648
- consumerRoot,
1649
- argv: ['--prune'],
1650
- env: {},
1651
- })
1652
-
1653
- expect(result.pruned).toBe(4)
1654
- expect(result.pruneSkipped).toBe(4)
1655
-
1656
- // Pruned: agents stale, opencode stale, scripts old-tool, root stale-root.
1657
- for (const relativePath of [
1658
- '.agents/skills/stale/SKILL.md',
1659
- '.opencode/agents/stale.md',
1660
- 'scripts/old-tool.js',
1661
- 'stale-root.md',
1662
- ]) {
1663
- expect(existsSync(join(consumerRoot, relativePath)), relativePath).toBe(
1664
- false,
1665
- )
1666
- }
1667
-
1668
- // Kept: package-shipped files, locally modified files, directories.
1669
- for (const relativePath of [
1670
- '.agents/skills/shipped/SKILL.md',
1671
- '.agents/skills/modified/SKILL.md',
1672
- '.opencode/agents/shipped.md',
1673
- 'scripts/memory-cli.js',
1674
- 'AGENTS.md',
1675
- 'opencode.json',
1676
- 'modified-root.md',
1677
- 'untracked.txt',
1678
- 'empty-hash.txt',
1679
- ]) {
1680
- expect(existsSync(join(consumerRoot, relativePath)), relativePath).toBe(
1681
- true,
1682
- )
1683
- }
1684
- expect(
1685
- readFileSync(
1686
- join(consumerRoot, '.agents/skills/modified/SKILL.md'),
1687
- 'utf8',
1688
- ),
1689
- ).toBe(PRUNE_CONTENT.agentsModifiedLocal)
1690
- expect(
1691
- readFileSync(join(consumerRoot, 'modified-root.md'), 'utf8'),
1692
- ).toBe(PRUNE_CONTENT.rootModifiedLocal)
1693
-
1694
- // Directories are never deleted, and an emptied parent is reported.
1695
- expect(existsSync(join(consumerRoot, '.agents/skills/dir/SKILL.md'))).toBe(
1696
- true,
1697
- )
1698
- expect(existsSync(join(consumerRoot, '.agents/skills/stale'))).toBe(true)
1699
- expect(outputOf(logSpy)).toContain(
1700
- 'Left empty directory in place: .agents/skills/stale',
1701
- )
1702
-
1703
- const manifest = readConsumerManifest(consumerRoot)
1704
- // Pruned entries and entries whose consumer copy was already gone are dropped.
1705
- for (const key of [
1706
- 'skills/stale/SKILL.md',
1707
- 'skills/gone/SKILL.md',
1708
- '__opencode__/agents/stale.md',
1709
- '__opencode__/agents/missing.md',
1710
- '__scripts__/old-tool.js',
1711
- '__root__/stale-root.md',
1712
- '__root__/gone-root.md',
1713
- ]) {
1714
- expect(manifest.files[key], key).toBeUndefined()
1715
- }
1716
-
1717
- // Everything kept retains its bookkeeping entry, including defensive nulls.
1718
- for (const key of [
1719
- 'skills/shipped/SKILL.md',
1720
- 'skills/modified/SKILL.md',
1721
- 'skills/dir/SKILL.md',
1722
- 'instructions/learned-knowledge.instructions.md',
1723
- '../escape.txt',
1724
- '..',
1725
- './.',
1726
- '',
1727
- '__memory-bank__/projectbrief.md',
1728
- '__agents-instructions__/learned-knowledge.instructions.md',
1729
- '__future__/thing.md',
1730
- '__opencode__/agents/shipped.md',
1731
- '__opencode__/node_modules/dep.js',
1732
- '__opencode__/package.json',
1733
- '__opencode__/',
1734
- '__scripts__/memory-cli.js',
1735
- '__scripts__/',
1736
- '__root__/AGENTS.md',
1737
- '__root__/opencode.json',
1738
- '__root__/untracked.txt',
1739
- '__root__/empty-hash.txt',
1740
- '__root__/modified-root.md',
1741
- ]) {
1742
- expect(manifest.files, key).toHaveProperty([key])
1743
- }
1744
- expect(manifest.files['skills/modified/SKILL.md']).toBe(
1745
- sha256(PRUNE_CONTENT.agentsModifiedOriginal),
1746
- )
1747
- expect(manifest.files['__root__/untracked.txt']).toBeNull()
1748
- expect(manifest.files['__root__/empty-hash.txt']).toBe('')
1749
-
1750
- const warnings = outputOf(warnSpy)
1751
- expect(warnings).toContain(
1752
- 'Skipped pruning locally modified file: .agents/skills/modified/SKILL.md',
1753
- )
1754
- expect(warnings).toContain(
1755
- 'Skipped pruning locally modified file: modified-root.md',
1756
- )
1757
- expect(warnings).toContain(
1758
- 'Skipped pruning untracked file: untracked.txt',
1759
- )
1760
- expect(warnings).toContain(
1761
- 'Skipped pruning untracked file: empty-hash.txt',
1762
- )
1763
- expect(outputOf(logSpy)).toContain(
1764
- 'Pruned 4 stale file(s); skipped 4 locally modified.',
1765
- )
1766
- })
1767
-
1768
- it('reports prune candidates in --dry-run without deleting or persisting', () => {
1769
- const { packageRoot, consumerRoot } = createPruneFixture()
1770
- const manifestPath = join(consumerRoot, '.agents-sync-manifest.json')
1771
- const manifestBefore = readFileSync(manifestPath, 'utf8')
1772
-
1773
- const result = runSync({
1774
- packageRoot,
1775
- consumerRoot,
1776
- argv: ['--prune', '--dry-run'],
1777
- env: {},
1778
- })
1779
-
1780
- expect(result.pruned).toBe(4)
1781
- expect(result.pruneSkipped).toBe(4)
1782
-
1783
- for (const relativePath of [
1784
- '.agents/skills/stale/SKILL.md',
1785
- '.opencode/agents/stale.md',
1786
- 'scripts/old-tool.js',
1787
- 'stale-root.md',
1788
- ]) {
1789
- expect(existsSync(join(consumerRoot, relativePath)), relativePath).toBe(
1790
- true,
1791
- )
1792
- }
1793
-
1794
- // The manifest on disk is byte-for-byte unchanged.
1795
- expect(readFileSync(manifestPath, 'utf8')).toBe(manifestBefore)
1796
-
1797
- const logs = outputOf(logSpy)
1798
- expect(logs).toContain('Would prune: .agents/skills/stale/SKILL.md')
1799
- expect(logs).toContain('Would prune: .opencode/agents/stale.md')
1800
- expect(logs).toContain('Would prune: scripts/old-tool.js')
1801
- expect(logs).toContain('Would prune: stale-root.md')
1802
- expect(logs).toContain(
1803
- 'Would prune 4 stale file(s); skipped 4 locally modified.',
1804
- )
1805
- })
1806
-
1807
- it('does not drop stale entries for missing consumer files in --dry-run', () => {
1808
- const { packageRoot, consumerRoot } = createPruneFixture()
1809
-
1810
- const result = runSync({
1811
- packageRoot,
1812
- consumerRoot,
1813
- argv: ['--prune', '--dry-run'],
1814
- env: {},
1815
- })
1816
-
1817
- expect(result.exitCode).toBe(0)
1818
- // Dry-run must not drop the entries either; the file is simply reported as-is.
1819
- expect(readConsumerManifest(consumerRoot).files['skills/gone/SKILL.md']).toBeDefined()
1820
- })
1821
-
1822
- it('keeps scaffold-only namespaces out of the prune path', () => {
1823
- const { packageRoot, consumerRoot } = createPruneFixture()
1824
-
1825
- runSync({ packageRoot, consumerRoot, argv: ['--prune'], env: {} })
1826
-
1827
- expect(existsSync(join(consumerRoot, 'memory-bank/projectbrief.md'))).toBe(
1828
- true,
1829
- )
1830
- expect(
1831
- existsSync(
1832
- join(
1833
- consumerRoot,
1834
- '.agents/instructions/learned-knowledge.instructions.md',
1835
- ),
1836
- ),
1837
- ).toBe(true)
1838
- const manifest = readConsumerManifest(consumerRoot)
1839
- expect(manifest.files['__memory-bank__/projectbrief.md']).toBeDefined()
1840
- expect(
1841
- manifest.files['__agents-instructions__/learned-knowledge.instructions.md'],
1842
- ).toBeDefined()
1843
- })
1844
- })
1845
-
1846
- // ── Manifest snapshot semantics ──────────────────────────────────────────────
1847
-
1848
- describe('prune snapshot semantics', () => {
1849
- it('never prunes a file scaffolded during the same run', () => {
1850
- const packageRoot = createPackage({
1851
- agents: {},
1852
- mcpExample: MCP_EXAMPLE,
1853
- })
1854
- const consumerRoot = createTempDir()
1855
-
1856
- // First run scaffolds memory-bank + opencode.json and records them.
1857
- runSync({ packageRoot, consumerRoot })
1858
- // Second run with --prune: source package has no memory-bank, but the keys
1859
- // were present before this run and must not be treated as stale.
1860
- runSync({ packageRoot, consumerRoot, argv: ['--prune'], env: {} })
1861
-
1862
- expect(existsSync(join(consumerRoot, 'memory-bank', 'projectbrief.md'))).toBe(
1863
- true,
1864
- )
1865
- expect(existsSync(join(consumerRoot, 'opencode.json'))).toBe(true)
1866
- })
1867
-
1868
- it('keeps manifest bookkeeping for an emptied directory path', () => {
1869
- const { packageRoot, consumerRoot } = createPruneFixture()
1870
-
1871
- runSync({ packageRoot, consumerRoot, argv: ['--prune'], env: {} })
1872
-
1873
- const manifest = readConsumerManifest(consumerRoot)
1874
- expect(manifest.files['skills/dir/SKILL.md']).toBe(
1875
- sha256('directory placeholder'),
1876
- )
1877
- expect(readdirSync(join(consumerRoot, '.agents/skills/dir'))).toContain(
1878
- 'SKILL.md',
1879
- )
1880
- })
1881
- })