@abarcenas/ai-workflow-template 1.45.0 → 1.47.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 (49) hide show
  1. package/.agents/config/README.md +43 -0
  2. package/.agents/config/domain.md +37 -0
  3. package/.agents/instructions/learned-knowledge.instructions.md +128 -0
  4. package/.agents/instructions/memory-bank.instructions.md +11 -0
  5. package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
  6. package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
  7. package/.agents/skills/domain-modeling/SKILL.md +74 -0
  8. package/.agents/skills/grill-me/SKILL.md +7 -0
  9. package/.agents/skills/grill-with-docs/SKILL.md +7 -0
  10. package/.agents/skills/grilling/SKILL.md +28 -0
  11. package/.agents/skills/prototype/LOGIC.md +67 -0
  12. package/.agents/skills/prototype/SKILL.md +26 -0
  13. package/.agents/skills/prototype/UI.md +112 -0
  14. package/.github/workflows/npm-publish.yml +10 -0
  15. package/.github/workflows/pr-test.yml +31 -0
  16. package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -3
  17. package/.opencode/agents/scope.agent.md +299 -0
  18. package/.prettierignore +26 -0
  19. package/AGENTS.md +46 -3
  20. package/README.md +29 -2
  21. package/opencode.mcp.example.json +1 -0
  22. package/package.json +3 -1
  23. package/scripts/bump-version.test.js +238 -0
  24. package/scripts/mcp/playwright-mcp-launcher.js +55 -29
  25. package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
  26. package/scripts/mcp-memory-server.js +93 -76
  27. package/scripts/mcp-memory-server.test.js +398 -0
  28. package/scripts/memory-cli.test.js +297 -0
  29. package/scripts/memory-index.js +119 -31
  30. package/scripts/memory-index.test.js +1173 -0
  31. package/scripts/normalize-memory.js +26 -1
  32. package/scripts/normalize-memory.test.js +753 -0
  33. package/scripts/setup/discover.test.js +124 -2
  34. package/scripts/setup/hooks.test.js +188 -1
  35. package/scripts/setup/husky-init.test.js +311 -0
  36. package/scripts/setup/index.js +35 -5
  37. package/scripts/setup/index.test.js +279 -0
  38. package/scripts/setup/knowledgebase.test.js +100 -0
  39. package/scripts/setup/skills-phase.js +568 -0
  40. package/scripts/setup/skills-phase.test.js +1274 -0
  41. package/scripts/setup/sync-phase.js +6 -1
  42. package/scripts/setup/sync-phase.test.js +180 -2
  43. package/scripts/setup/ui.test.js +579 -0
  44. package/scripts/setup/utils.test.js +586 -0
  45. package/scripts/sync-unit.test.js +1881 -0
  46. package/scripts/sync.js +816 -239
  47. package/scripts/sync.test.js +283 -0
  48. package/scripts/vocab-sync.test.js +88 -0
  49. package/skills-lock.json +90 -0
package/scripts/sync.js CHANGED
@@ -1,28 +1,411 @@
1
1
  import { createHash } from 'crypto'
2
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'
3
- import { dirname, join, relative, resolve } from 'path'
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ realpathSync,
9
+ rmSync,
10
+ statSync,
11
+ writeFileSync,
12
+ } from 'fs'
13
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'path'
4
14
  import { fileURLToPath } from 'url'
5
15
 
6
- const __dirname = dirname(fileURLToPath(import.meta.url))
7
- const packageRoot = resolve(__dirname, '..')
8
- const sourceAgentsDir = resolve(packageRoot, '.agents')
9
- const consumerRoot = process.env.INIT_CWD ? resolve(process.env.INIT_CWD) : process.cwd()
10
- const targetAgentsDir = resolve(consumerRoot, '.agents')
11
- const manifestPath = resolve(consumerRoot, '.agents-sync-manifest.json')
12
- const excludedRelativePaths = new Set([
16
+ const defaultPackageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
17
+
18
+ export const excludedRelativePaths = new Set([
13
19
  'instructions/learned-knowledge.instructions.md',
14
20
  ])
15
- const args = new Set(process.argv.slice(2))
16
- const forceOverwrite = args.has('--force')
17
- const dryRun = args.has('--dry-run')
18
- const isVerbose = process.env.AI_WORKFLOW_VERBOSE === '1'
19
21
 
20
- function hashFile(filePath) {
22
+ // Files the consumer owns outright. Sync never copies, overwrites, or prunes
23
+ // them — not even with --force — because local edits are the point, not drift.
24
+ // Matching is channel-agnostic: every copy pass compares its own relative path
25
+ // against this set, so a root entry such as "opencode.json" also protects
26
+ // ".opencode/opencode.json" and ".agents/opencode.json". That over-match is
27
+ // intentional and conservative. The original learned-knowledge exclusion is
28
+ // spread in so there is one rule, not two parallel checks.
29
+ export const projectOwnedPaths = new Set([
30
+ 'AGENTS.project.md',
31
+ 'opencode.json',
32
+ '.prettierignore',
33
+ // Reserved: sync protects this file from overwrite and prune, but it does
34
+ // NOT read or honour its globs yet. Do not present it as functional.
35
+ '.agents-sync-ignore',
36
+ ...excludedRelativePaths,
37
+ ])
38
+
39
+ export function isProjectOwnedPath(relativePath) {
40
+ return projectOwnedPaths.has(relativePath)
41
+ }
42
+
43
+ export const openCodeExcluded = new Set([
44
+ 'node_modules',
45
+ 'package.json',
46
+ 'package-lock.json',
47
+ 'bun.lock',
48
+ '.gitignore',
49
+ ])
50
+
51
+ // Never add a project-owned file here (AGENTS.project.md, opencode.json,
52
+ // .prettierignore): those are scaffolded create-if-absent and must not ship
53
+ // from the package. opencode.json is governed solely by projectOwnedPaths.
54
+ export const rootFiles = ['AGENTS.md', 'skills-lock.json']
55
+
56
+ export const scriptsToSync = [
57
+ 'memory-cli.js',
58
+ 'memory-index.js',
59
+ 'bump-version.js',
60
+ 'validate-memory-schema.js',
61
+ 'vocab-sync.js',
62
+ 'mcp-memory-server.js',
63
+ 'mcp/playwright-mcp-launcher.js',
64
+ // Knowledgebase scripts
65
+ 'knowledgebase-cli.js',
66
+ 'knowledgebase-index.js',
67
+ 'mcp-knowledgebase-server.js',
68
+ 'knowledgebase-init.sql',
69
+ ]
70
+
71
+ // The hash-managed paths a consumer's .prettierignore must cover so a format
72
+ // pass cannot freeze a sync. Directories carry a trailing slash so a pattern
73
+ // can cover their subtree. AGENTS.project.md is deliberately absent: it is
74
+ // project-owned, so formatting it cannot freeze a sync.
75
+ export const requiredPrettierIgnorePatterns = [
76
+ '.agents/',
77
+ '.opencode/agents/',
78
+ '.opencode/commands/',
79
+ ...rootFiles,
80
+ ...scriptsToSync.map((script) => `scripts/${script}`),
81
+ ]
82
+
83
+ const prettierIgnoreWarning =
84
+ '[ai-workflow-template] Existing .prettierignore does not protect managed files; merge the patterns from node_modules/@abarcenas/ai-workflow-template/.prettierignore'
85
+
86
+ const ignoreAllPatterns = new Set(['', '.', '*', '**', '/'])
87
+
88
+ function normalizeIgnorePattern(pattern) {
89
+ const trimmed = pattern.trim()
90
+ return trimmed === '/'
91
+ ? '/'
92
+ : trimmed.replace(/^\/+/, '').replace(/\/+$/, '')
93
+ }
94
+
95
+ // Gitignore lines, preserving negation and treating an inline "# ..." literally.
96
+ // Escaped leading "#" or "!" are literals, not comments/negations.
97
+ export function parsePrettierIgnoreRules(contents) {
98
+ const rules = []
99
+
100
+ for (const rawLine of contents.split(/\r?\n/)) {
101
+ const line = rawLine.trim()
102
+ if (!line || line.startsWith('#')) {
103
+ continue
104
+ }
105
+
106
+ if (line.startsWith('\\#') || line.startsWith('\\!')) {
107
+ rules.push({
108
+ negated: false,
109
+ pattern: normalizeIgnorePattern(line.slice(1)),
110
+ })
111
+ continue
112
+ }
113
+
114
+ const negated = line.startsWith('!')
115
+ const pattern = normalizeIgnorePattern(negated ? line.slice(1) : line)
116
+ if (pattern) {
117
+ rules.push({ negated, pattern })
118
+ }
119
+ }
120
+
121
+ return rules
122
+ }
123
+
124
+ // True when a pattern ignores targetPath or one of its ancestors. A "base/**"
125
+ // pattern ignores strict descendants only, so it never covers the file "base".
126
+ function patternAppliesTo(pattern, targetPath) {
127
+ if (ignoreAllPatterns.has(pattern)) {
128
+ return true
129
+ }
130
+ if (pattern.endsWith('/**')) {
131
+ return targetPath.startsWith(`${pattern.slice(0, -3)}/`)
132
+ }
133
+ if (pattern.startsWith('**/')) {
134
+ const suffix = pattern.slice(3)
135
+ return targetPath === suffix || targetPath.endsWith(`/${suffix}`)
136
+ }
137
+ if (/[*?[]/.test(pattern)) {
138
+ return false
139
+ }
140
+ return targetPath === pattern || targetPath.startsWith(`${pattern}/`)
141
+ }
142
+
143
+ // A negation below a managed directory punches a hole in that directory's
144
+ // subtree, so it removes coverage until a later positive restores the subtree.
145
+ function ignorePatternBase(pattern) {
146
+ return normalizeIgnorePattern(
147
+ pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern,
148
+ )
149
+ }
150
+
151
+ function patternAppliesBelow(pattern, directoryPath) {
152
+ if (ignoreAllPatterns.has(pattern)) {
153
+ return true
154
+ }
155
+
156
+ const base = ignorePatternBase(pattern)
157
+ return (
158
+ !/[*?[]/.test(base) &&
159
+ base.startsWith(directoryPath) &&
160
+ base !== normalizeIgnorePattern(directoryPath)
161
+ )
162
+ }
163
+
164
+ // Last-match-wins over the rule list, matching gitignore semantics. Required
165
+ // directories also track negated subtrees so a later positive can restore them.
166
+ function isCovered(requiredPath, rules) {
167
+ const isDirectory = requiredPath.endsWith('/')
168
+ let covered = false
169
+ const holes = new Set()
170
+
171
+ for (const { negated, pattern } of rules) {
172
+ if (!isDirectory) {
173
+ if (patternAppliesTo(pattern, requiredPath)) {
174
+ covered = !negated
175
+ }
176
+ continue
177
+ }
178
+
179
+ if (negated) {
180
+ if (patternAppliesTo(pattern, requiredPath)) {
181
+ covered = false
182
+ holes.clear()
183
+ } else if (patternAppliesBelow(pattern, requiredPath)) {
184
+ holes.add(ignorePatternBase(pattern))
185
+ }
186
+ continue
187
+ }
188
+
189
+ if (patternAppliesTo(pattern, requiredPath)) {
190
+ covered = true
191
+ holes.clear()
192
+ continue
193
+ }
194
+
195
+ if (patternAppliesBelow(pattern, requiredPath)) {
196
+ const base = ignorePatternBase(pattern)
197
+ for (const hole of holes) {
198
+ if (hole === base || hole.startsWith(`${base}/`)) {
199
+ holes.delete(hole)
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ return covered && holes.size === 0
206
+ }
207
+
208
+ export function missingPrettierIgnorePatterns(
209
+ contents,
210
+ requiredPatterns = requiredPrettierIgnorePatterns,
211
+ ) {
212
+ const rules = parsePrettierIgnoreRules(contents)
213
+ return requiredPatterns.filter(
214
+ (requiredPath) => !isCovered(requiredPath, rules),
215
+ )
216
+ }
217
+
218
+ export const memoryBankStubs = {
219
+ 'projectbrief.md': `# projectbrief
220
+
221
+ > Foundation document — defines core requirements and goals.
222
+
223
+ - **Purpose**: {{describe project purpose}}
224
+ - **Goals**: {{list project goals}}
225
+ - **Scope**: {{define scope}}
226
+ `,
227
+ 'productContext.md': `# productContext
228
+
229
+ > Why this project exists and what problems it solves.
230
+
231
+ - **Problem**: {{describe the problem}}
232
+ - **Solution**: {{describe the solution}}
233
+ - **User Experience**: {{describe UX goals}}
234
+ `,
235
+ 'activeContext.md': `# activeContext
236
+
237
+ > Current work focus, recent changes, next steps.
238
+
239
+ - **Current Focus**: {{current task or goal}}
240
+ - **Recent Changes**: {{list of recent changes}}
241
+ - **Next Steps**: {{next actions}}
242
+ `,
243
+ 'systemPatterns.md': `# systemPatterns
244
+
245
+ > Architecture, key decisions, design patterns.
246
+
247
+ - **Architecture**: {{describe architecture}}
248
+ - **Key Decisions**: {{list key technical decisions}}
249
+ - **Patterns**: {{list design patterns in use}}
250
+ `,
251
+ 'techContext.md': `# techContext
252
+
253
+ > Technologies, setup, constraints, dependencies.
254
+
255
+ - **Stack**: {{list technologies}}
256
+ - **Setup**: {{describe dev setup}}
257
+ - **Constraints**: {{list technical constraints}}
258
+ `,
259
+ 'progress.md': `# progress
260
+
261
+ > What works, what's left, current status.
262
+
263
+ - **Working**: {{what's implemented and working}}
264
+ - **To Build**: {{what remains}}
265
+ - **Status**: {{overall project status}}
266
+ - **Known Issues**: {{list known issues}}
267
+ `,
268
+ }
269
+
270
+ export const agentInstructionsStubs = {
271
+ 'learned-knowledge.instructions.md': `# Learned Knowledge
272
+
273
+ Cross-session knowledge accumulated by agent pipelines. Each session records discoveries, patterns, gotchas, and agent tuning notes for future reference.
274
+
275
+ ---
276
+
277
+ `,
278
+ }
279
+
280
+ // Project-owned files are scaffolded create-if-absent and then left alone for
281
+ // the lifetime of the consumer project (deleting one recreates it on the next
282
+ // sync). The .prettierignore text here must stay byte-identical to the
283
+ // repository's shipped .prettierignore (asserted in sync-unit.test.js) so the
284
+ // tarball copy and the scaffold cannot drift.
285
+ export const projectOwnedStubs = {
286
+ 'AGENTS.project.md': `# Project Rules
287
+
288
+ > Project-owned. This file is never overwritten or pruned by sync — edit it freely.
289
+
290
+ ## Project-specific agent rules
291
+
292
+ - {{add project-specific rules here}}
293
+ `,
294
+ '.prettierignore': `# Project-owned. Sync never overwrites this file.
295
+ # Keep formatters away from files managed by @abarcenas/ai-workflow-template:
296
+ # a formatting pass over managed files makes every future sync skip them.
297
+
298
+ # Managed agent configuration
299
+ .agents/
300
+ .opencode/agents/
301
+ .opencode/commands/
302
+
303
+ # Managed root files
304
+ AGENTS.md
305
+ AGENTS.project.md
306
+ skills-lock.json
307
+
308
+ # Managed scripts (scripts/sync.js scriptsToSync)
309
+ scripts/memory-cli.js
310
+ scripts/memory-index.js
311
+ scripts/bump-version.js
312
+ scripts/validate-memory-schema.js
313
+ scripts/vocab-sync.js
314
+ scripts/mcp-memory-server.js
315
+ scripts/mcp/playwright-mcp-launcher.js
316
+ scripts/knowledgebase-cli.js
317
+ scripts/knowledgebase-index.js
318
+ scripts/mcp-knowledgebase-server.js
319
+ scripts/knowledgebase-init.sql
320
+ `,
321
+ }
322
+
323
+ // Parses the flags supported by the sync entry point. Unknown flags are ignored
324
+ // so that consumer tooling can pass extra arguments without breaking a sync.
325
+ export function parseArgs(argv = [], env = process.env) {
326
+ const args = new Set(argv)
327
+
328
+ return {
329
+ forceOverwrite: args.has('--force'),
330
+ dryRun: args.has('--dry-run'),
331
+ // Pruning is opt-in and off by default: syncing never deletes anything unless
332
+ // the caller explicitly passes --prune. Even then, only manifest-tracked files
333
+ // whose package source has disappeared AND whose content is unmodified are removed.
334
+ prune: args.has('--prune'),
335
+ isVerbose: env.AI_WORKFLOW_VERBOSE === '1',
336
+ }
337
+ }
338
+
339
+ // Resolves every directory and flag the sync pass works with. Tests pass an
340
+ // explicit packageRoot/consumerRoot; production derives the package root from
341
+ // this module's location and the consumer root from INIT_CWD (npm) or cwd.
342
+ export function createSyncContext(options = {}) {
343
+ const {
344
+ packageRoot,
345
+ consumerRoot,
346
+ manifestPath,
347
+ argv = process.argv.slice(2),
348
+ env = process.env,
349
+ } = options
350
+
351
+ const resolvedPackageRoot = packageRoot
352
+ ? resolve(packageRoot)
353
+ : defaultPackageRoot
354
+ const resolvedConsumerRoot = consumerRoot
355
+ ? resolve(consumerRoot)
356
+ : env.INIT_CWD
357
+ ? resolve(env.INIT_CWD)
358
+ : process.cwd()
359
+
360
+ const sourceAgentsDir = resolve(resolvedPackageRoot, '.agents')
361
+ const targetAgentsDir = resolve(resolvedConsumerRoot, '.agents')
362
+ const sourceOpenCodeDir = resolve(resolvedPackageRoot, '.opencode')
363
+ const sourceScriptsDir = resolve(resolvedPackageRoot, 'scripts')
364
+ const targetScriptsDir = resolve(resolvedConsumerRoot, 'scripts')
365
+
366
+ return {
367
+ packageRoot: resolvedPackageRoot,
368
+ consumerRoot: resolvedConsumerRoot,
369
+ sourceAgentsDir,
370
+ targetAgentsDir,
371
+ sourceOpenCodeDir,
372
+ sourceScriptsDir,
373
+ targetScriptsDir,
374
+ manifestPath: manifestPath
375
+ ? resolve(manifestPath)
376
+ : resolve(resolvedConsumerRoot, '.agents-sync-manifest.json'),
377
+ ...parseArgs(argv, env),
378
+ pruneChannels: {
379
+ agents: {
380
+ prefix: '',
381
+ packageDir: sourceAgentsDir,
382
+ consumerDir: targetAgentsDir,
383
+ },
384
+ opencode: {
385
+ prefix: '__opencode__/',
386
+ packageDir: sourceOpenCodeDir,
387
+ consumerDir: resolve(resolvedConsumerRoot, '.opencode'),
388
+ },
389
+ scripts: {
390
+ prefix: '__scripts__/',
391
+ packageDir: sourceScriptsDir,
392
+ consumerDir: targetScriptsDir,
393
+ },
394
+ root: {
395
+ prefix: '__root__/',
396
+ packageDir: resolvedPackageRoot,
397
+ consumerDir: resolvedConsumerRoot,
398
+ },
399
+ },
400
+ }
401
+ }
402
+
403
+ export function hashFile(filePath) {
21
404
  const contents = readFileSync(filePath)
22
405
  return createHash('sha256').update(contents).digest('hex')
23
406
  }
24
407
 
25
- function collectFiles(directory) {
408
+ export function collectFiles(directory) {
26
409
  const entries = readdirSync(directory, { withFileTypes: true })
27
410
  const files = []
28
411
 
@@ -41,7 +424,7 @@ function collectFiles(directory) {
41
424
  return files
42
425
  }
43
426
 
44
- function readManifest() {
427
+ export function readManifest(manifestPath) {
45
428
  if (!existsSync(manifestPath)) {
46
429
  return { version: 1, files: {} }
47
430
  }
@@ -62,7 +445,7 @@ function readManifest() {
62
445
  }
63
446
  }
64
447
 
65
- function writeManifest(manifest) {
448
+ export function writeManifest(manifestPath, manifest) {
66
449
  writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
67
450
  }
68
451
 
@@ -70,7 +453,7 @@ function ensureParentDirectory(filePath) {
70
453
  mkdirSync(dirname(filePath), { recursive: true })
71
454
  }
72
455
 
73
- function syncFile(sourceFile, targetFile) {
456
+ function syncFile(sourceFile, targetFile, dryRun) {
74
457
  if (dryRun) {
75
458
  return
76
459
  }
@@ -79,311 +462,505 @@ function syncFile(sourceFile, targetFile) {
79
462
  copyFileSync(sourceFile, targetFile)
80
463
  }
81
464
 
82
- const rootFiles = ['AGENTS.md', 'opencode.json', 'skills-lock.json']
83
-
84
- if (!existsSync(sourceAgentsDir)) {
85
- console.warn('[ai-workflow-template] No .agents directory found to sync.')
86
- process.exit(0)
465
+ // A file is safe to overwrite when the manifest recorded a hash for it and the
466
+ // consumer copy still matches that hash. Missing recorded hashes mean the file
467
+ // is not tracked, so local edits are assumed and the copy is skipped.
468
+ export function isUntouched(recordedHash, existingHash) {
469
+ return recordedHash !== null && existingHash === recordedHash
87
470
  }
88
471
 
89
- const manifest = readManifest()
90
- const sourceFiles = collectFiles(sourceAgentsDir)
91
- let copied = 0
92
- let skipped = 0
93
- let added = 0
472
+ export function isPathInside(baseDir, candidatePath) {
473
+ const rel = relative(baseDir, candidatePath)
474
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
475
+ }
94
476
 
95
- /* ── Sync .agents/ directory ────────────────────────────────── */
477
+ // Resolves a manifest key to its package path (for the "still shipped?" check)
478
+ // and its consumer path (the prune candidate). Returns null for keys that the
479
+ // sync layer does not own, and for paths that escape their channel root.
480
+ export function resolvePruneEntry(manifestKey, { pruneChannels }) {
481
+ let channel
482
+ let relativePath
483
+
484
+ // Scaffold-only namespace for project-owned files. There is no package source
485
+ // to compare against and nothing here may ever be deleted.
486
+ if (manifestKey.startsWith('__project__/')) {
487
+ return null
488
+ }
96
489
 
97
- for (const sourceFile of sourceFiles) {
98
- const relativePath = relative(sourceAgentsDir, sourceFile)
99
- if (excludedRelativePaths.has(relativePath)) {
100
- continue
490
+ if (manifestKey.startsWith('__opencode__/')) {
491
+ channel = pruneChannels.opencode
492
+ relativePath = manifestKey.slice(channel.prefix.length)
493
+ // Never touch paths the .opencode copy pass deliberately ignores (e.g. node_modules).
494
+ if (
495
+ openCodeExcluded.has(relativePath) ||
496
+ openCodeExcluded.has(relativePath.split('/')[0])
497
+ ) {
498
+ return null
499
+ }
500
+ } else if (manifestKey.startsWith('__scripts__/')) {
501
+ channel = pruneChannels.scripts
502
+ relativePath = manifestKey.slice(channel.prefix.length)
503
+ } else if (manifestKey.startsWith('__root__/')) {
504
+ channel = pruneChannels.root
505
+ relativePath = manifestKey.slice(channel.prefix.length)
506
+ } else if (manifestKey.startsWith('__')) {
507
+ // Unknown internal namespace — never prune (scaffold-only or future keys).
508
+ return null
509
+ } else {
510
+ channel = pruneChannels.agents
511
+ relativePath = manifestKey
101
512
  }
102
513
 
103
- const targetFile = resolve(targetAgentsDir, relativePath)
104
- const sourceHash = hashFile(sourceFile)
105
- const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
106
- const trackedKey = relativePath
107
- const recordedHash = manifest.files[trackedKey] ?? null
514
+ if (!relativePath) {
515
+ return null
516
+ }
108
517
 
109
- if (!existsSync(targetFile)) {
110
- if (isVerbose) console.error(` … syncing: .agents/${relativePath}`)
111
- syncFile(sourceFile, targetFile)
112
- manifest.files[trackedKey] = sourceHash
113
- copied += 1
114
- added += 1
115
- continue
518
+ // Project-owned files survive every prune, even when the package no longer
519
+ // ships a same-named path. This is the single check for all four channels.
520
+ if (isProjectOwnedPath(relativePath)) {
521
+ return null
116
522
  }
117
523
 
118
- const isUntouched = recordedHash !== null && existingHash === recordedHash
524
+ const packagePaths = [resolve(channel.packageDir, relativePath)]
119
525
 
120
- if (forceOverwrite || isUntouched) {
121
- if (isVerbose) console.error(` … syncing: .agents/${relativePath}`)
122
- syncFile(sourceFile, targetFile)
123
- manifest.files[trackedKey] = sourceHash
124
- copied += 1
125
- continue
526
+ const consumerPath = resolve(channel.consumerDir, relativePath)
527
+ if (!isPathInside(channel.packageDir, packagePaths[0])) {
528
+ return null
529
+ }
530
+ if (!isPathInside(channel.consumerDir, consumerPath)) {
531
+ return null
126
532
  }
127
533
 
128
- skipped += 1
129
- console.warn(`[ai-workflow-template] Skipped locally modified file: ${relativePath}`)
534
+ return { packagePaths, consumerPath }
130
535
  }
131
536
 
132
- /* ── Sync .opencode/ directory ────────────────────────────── */
133
-
134
- const sourceOpenCodeDir = resolve(packageRoot, '.opencode')
135
- const openCodeExcluded = new Set(['node_modules', 'package.json', 'package-lock.json', 'bun.lock', '.gitignore'])
537
+ export function isRegularFile(filePath) {
538
+ try {
539
+ return statSync(filePath).isFile()
540
+ } catch {
541
+ return false
542
+ }
543
+ }
136
544
 
137
- if (existsSync(sourceOpenCodeDir)) {
138
- const openCodeFiles = collectFiles(sourceOpenCodeDir)
545
+ export function syncProject(context) {
546
+ const {
547
+ packageRoot,
548
+ consumerRoot,
549
+ sourceAgentsDir,
550
+ targetAgentsDir,
551
+ sourceOpenCodeDir,
552
+ sourceScriptsDir,
553
+ targetScriptsDir,
554
+ manifestPath,
555
+ forceOverwrite,
556
+ dryRun,
557
+ prune,
558
+ isVerbose,
559
+ } = context
560
+
561
+ if (!existsSync(sourceAgentsDir)) {
562
+ console.warn('[ai-workflow-template] No .agents directory found to sync.')
563
+ return { exitCode: 0, copied: 0, added: 0, scaffolded: 0, skipped: 0 }
564
+ }
139
565
 
140
- for (const sourceFile of openCodeFiles) {
141
- const relativePath = relative(sourceOpenCodeDir, sourceFile)
142
- if (openCodeExcluded.has(relativePath) || openCodeExcluded.has(relativePath.split('/')[0])) {
566
+ const manifest = readManifest(manifestPath)
567
+ // Snapshot before syncing: prune only considers entries that existed at the start
568
+ // of this run, so files scaffolded/copied now are never candidates in the same run.
569
+ const preexistingManifestKeys = Object.keys(manifest.files)
570
+ const sourceFiles = collectFiles(sourceAgentsDir)
571
+ let copied = 0
572
+ let skipped = 0
573
+ let added = 0
574
+
575
+ /* ── Sync .agents/ directory ────────────────────────────────── */
576
+
577
+ // Project ownership outranks every flag: these paths are skipped before the
578
+ // --force branch below, so --force can never replace consumer-owned content.
579
+ for (const sourceFile of sourceFiles) {
580
+ const relativePath = relative(sourceAgentsDir, sourceFile)
581
+ if (isProjectOwnedPath(relativePath)) {
143
582
  continue
144
583
  }
145
584
 
146
- const targetFile = resolve(consumerRoot, '.opencode', relativePath)
585
+ const targetFile = resolve(targetAgentsDir, relativePath)
147
586
  const sourceHash = hashFile(sourceFile)
148
587
  const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
149
- const trackedKey = `__opencode__/${relativePath}`
588
+ const trackedKey = relativePath
150
589
  const recordedHash = manifest.files[trackedKey] ?? null
151
590
 
152
591
  if (!existsSync(targetFile)) {
153
- if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
154
- syncFile(sourceFile, targetFile)
592
+ if (isVerbose) console.error(` … syncing: .agents/${relativePath}`)
593
+ syncFile(sourceFile, targetFile, dryRun)
155
594
  manifest.files[trackedKey] = sourceHash
156
595
  copied += 1
157
596
  added += 1
158
597
  continue
159
598
  }
160
599
 
161
- const isUntouched = recordedHash !== null && existingHash === recordedHash
162
-
163
- if (forceOverwrite || isUntouched) {
164
- if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
165
- syncFile(sourceFile, targetFile)
600
+ if (forceOverwrite || isUntouched(recordedHash, existingHash)) {
601
+ if (isVerbose) console.error(` … syncing: .agents/${relativePath}`)
602
+ syncFile(sourceFile, targetFile, dryRun)
166
603
  manifest.files[trackedKey] = sourceHash
167
604
  copied += 1
168
605
  continue
169
606
  }
170
607
 
171
608
  skipped += 1
172
- console.warn(`[ai-workflow-template] Skipped locally modified opencode file: ${relativePath}`)
609
+ console.warn(`[ai-workflow-template] Skipped locally modified file: ${relativePath}`)
173
610
  }
174
- }
175
-
176
- /* ── Sync scripts/ directory ──────────────────────────────── */
177
611
 
178
- const scriptsToSync = [
179
- 'memory-cli.js',
180
- 'memory-index.js',
181
- 'bump-version.js',
182
- 'validate-memory-schema.js',
183
- 'vocab-sync.js',
184
- 'mcp-memory-server.js',
185
- 'mcp/playwright-mcp-launcher.js',
186
- // Knowledgebase scripts
187
- 'knowledgebase-cli.js',
188
- 'knowledgebase-index.js',
189
- 'mcp-knowledgebase-server.js',
190
- 'knowledgebase-init.sql',
191
- ]
612
+ /* ── Sync .opencode/ directory ────────────────────────────── */
613
+
614
+ if (existsSync(sourceOpenCodeDir)) {
615
+ const openCodeFiles = collectFiles(sourceOpenCodeDir)
616
+
617
+ for (const sourceFile of openCodeFiles) {
618
+ const relativePath = relative(sourceOpenCodeDir, sourceFile)
619
+ if (
620
+ isProjectOwnedPath(relativePath) ||
621
+ openCodeExcluded.has(relativePath) ||
622
+ openCodeExcluded.has(relativePath.split('/')[0])
623
+ ) {
624
+ continue
625
+ }
626
+
627
+ const targetFile = resolve(consumerRoot, '.opencode', relativePath)
628
+ const sourceHash = hashFile(sourceFile)
629
+ const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
630
+ const trackedKey = `__opencode__/${relativePath}`
631
+ const recordedHash = manifest.files[trackedKey] ?? null
632
+
633
+ if (!existsSync(targetFile)) {
634
+ if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
635
+ syncFile(sourceFile, targetFile, dryRun)
636
+ manifest.files[trackedKey] = sourceHash
637
+ copied += 1
638
+ added += 1
639
+ continue
640
+ }
641
+
642
+ if (forceOverwrite || isUntouched(recordedHash, existingHash)) {
643
+ if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
644
+ syncFile(sourceFile, targetFile, dryRun)
645
+ manifest.files[trackedKey] = sourceHash
646
+ copied += 1
647
+ continue
648
+ }
649
+
650
+ skipped += 1
651
+ console.warn(`[ai-workflow-template] Skipped locally modified opencode file: ${relativePath}`)
652
+ }
653
+ }
192
654
 
193
- const sourceScriptsDir = resolve(packageRoot, 'scripts')
194
- const targetScriptsDir = resolve(consumerRoot, 'scripts')
195
- let scriptsCopied = 0
196
- let scriptsSkipped = 0
655
+ /* ── Sync scripts/ directory ──────────────────────────────── */
197
656
 
198
- for (const scriptRelPath of scriptsToSync) {
199
- const sourceFile = resolve(sourceScriptsDir, scriptRelPath)
200
- const targetFile = resolve(targetScriptsDir, scriptRelPath)
657
+ let scriptsCopied = 0
658
+ let scriptsSkipped = 0
201
659
 
202
- if (!existsSync(sourceFile)) {
203
- continue
204
- }
660
+ for (const scriptRelPath of scriptsToSync) {
661
+ if (isProjectOwnedPath(scriptRelPath)) {
662
+ continue
663
+ }
205
664
 
206
- const sourceHash = hashFile(sourceFile)
207
- const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
208
- const trackedKey = `__scripts__/${scriptRelPath}`
209
- const recordedHash = manifest.files[trackedKey] ?? null
665
+ const sourceFile = resolve(sourceScriptsDir, scriptRelPath)
666
+ const targetFile = resolve(targetScriptsDir, scriptRelPath)
210
667
 
211
- if (!existsSync(targetFile)) {
212
- if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
213
- syncFile(sourceFile, targetFile)
214
- manifest.files[trackedKey] = sourceHash
215
- scriptsCopied += 1
216
- copied += 1
217
- added += 1
218
- continue
219
- }
668
+ if (!existsSync(sourceFile)) {
669
+ continue
670
+ }
220
671
 
221
- const isUntouched = recordedHash !== null && existingHash === recordedHash
672
+ const sourceHash = hashFile(sourceFile)
673
+ const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
674
+ const trackedKey = `__scripts__/${scriptRelPath}`
675
+ const recordedHash = manifest.files[trackedKey] ?? null
222
676
 
223
- if (forceOverwrite || isUntouched) {
224
- if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
225
- syncFile(sourceFile, targetFile)
226
- manifest.files[trackedKey] = sourceHash
227
- scriptsCopied += 1
228
- copied += 1
229
- continue
230
- }
677
+ if (!existsSync(targetFile)) {
678
+ if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
679
+ syncFile(sourceFile, targetFile, dryRun)
680
+ manifest.files[trackedKey] = sourceHash
681
+ scriptsCopied += 1
682
+ copied += 1
683
+ added += 1
684
+ continue
685
+ }
231
686
 
232
- scriptsSkipped += 1
233
- skipped += 1
234
- console.warn(`[ai-workflow-template] Skipped locally modified script file: scripts/${scriptRelPath}`)
235
- }
687
+ if (forceOverwrite || isUntouched(recordedHash, existingHash)) {
688
+ if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
689
+ syncFile(sourceFile, targetFile, dryRun)
690
+ manifest.files[trackedKey] = sourceHash
691
+ scriptsCopied += 1
692
+ copied += 1
693
+ continue
694
+ }
236
695
 
237
- for (const rootFile of rootFiles) {
238
- const sourceFile = resolve(packageRoot, rootFile)
239
- if (!existsSync(sourceFile)) {
240
- continue
696
+ scriptsSkipped += 1
697
+ skipped += 1
698
+ console.warn(`[ai-workflow-template] Skipped locally modified script file: scripts/${scriptRelPath}`)
241
699
  }
242
700
 
243
- const targetFile = resolve(consumerRoot, rootFile)
244
- const sourceHash = hashFile(sourceFile)
245
- const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
246
- const trackedKey = `__root__/${rootFile}`
247
- const recordedHash = manifest.files[trackedKey] ?? null
248
-
249
- if (!existsSync(targetFile)) {
250
- if (isVerbose) console.error(` … syncing: ${rootFile}`)
251
- syncFile(sourceFile, targetFile)
252
- manifest.files[trackedKey] = sourceHash
253
- copied += 1
254
- added += 1
255
- continue
256
- }
701
+ /* ── Sync root files ────────────────────────────────────────── */
257
702
 
258
- const isUntouched = recordedHash !== null && existingHash === recordedHash
703
+ for (const rootFile of rootFiles) {
704
+ // Defense in depth: projectOwnedPaths wins if a project-owned name is ever
705
+ // added to rootFiles. opencode.json is no longer in this allowlist at all.
706
+ if (isProjectOwnedPath(rootFile)) {
707
+ continue
708
+ }
259
709
 
260
- if (forceOverwrite || isUntouched) {
261
- if (isVerbose) console.error(` … syncing: ${rootFile}`)
262
- syncFile(sourceFile, targetFile)
263
- manifest.files[trackedKey] = sourceHash
264
- copied += 1
265
- continue
266
- }
710
+ const sourceFile = resolve(packageRoot, rootFile)
711
+ if (!existsSync(sourceFile)) {
712
+ continue
713
+ }
267
714
 
268
- skipped += 1
269
- console.warn(`[ai-workflow-template] Skipped locally modified root file: ${rootFile}`)
270
- }
715
+ const targetFile = resolve(consumerRoot, rootFile)
716
+ const sourceHash = hashFile(sourceFile)
717
+ const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
718
+ const trackedKey = `__root__/${rootFile}`
719
+ const recordedHash = manifest.files[trackedKey] ?? null
271
720
 
272
- /* ── Scaffold memory-bank/ directory ──────────────────────────── */
721
+ if (!existsSync(targetFile)) {
722
+ if (isVerbose) console.error(` … syncing: ${rootFile}`)
723
+ syncFile(sourceFile, targetFile, dryRun)
724
+ manifest.files[trackedKey] = sourceHash
725
+ copied += 1
726
+ added += 1
727
+ continue
728
+ }
273
729
 
274
- const memoryBankStubs = {
275
- 'projectbrief.md': `# projectbrief
730
+ if (forceOverwrite || isUntouched(recordedHash, existingHash)) {
731
+ if (isVerbose) console.error(` … syncing: ${rootFile}`)
732
+ syncFile(sourceFile, targetFile, dryRun)
733
+ manifest.files[trackedKey] = sourceHash
734
+ copied += 1
735
+ continue
736
+ }
276
737
 
277
- > Foundation document — defines core requirements and goals.
738
+ skipped += 1
739
+ console.warn(`[ai-workflow-template] Skipped locally modified root file: ${rootFile}`)
740
+ }
278
741
 
279
- - **Purpose**: {{describe project purpose}}
280
- - **Goals**: {{list project goals}}
281
- - **Scope**: {{define scope}}
282
- `,
283
- 'productContext.md': `# productContext
742
+ /* ── Scaffold memory-bank/ directory ──────────────────────────── */
284
743
 
285
- > Why this project exists and what problems it solves.
744
+ const memoryBankDir = resolve(consumerRoot, 'memory-bank')
745
+ let scaffolded = 0
286
746
 
287
- - **Problem**: {{describe the problem}}
288
- - **Solution**: {{describe the solution}}
289
- - **User Experience**: {{describe UX goals}}
290
- `,
291
- 'activeContext.md': `# activeContext
747
+ for (const [fileName, fileContent] of Object.entries(memoryBankStubs)) {
748
+ const targetFile = resolve(memoryBankDir, fileName)
749
+ const trackedKey = `__memory-bank__/${fileName}`
292
750
 
293
- > Current work focus, recent changes, next steps.
751
+ if (existsSync(targetFile)) {
752
+ continue
753
+ }
294
754
 
295
- - **Current Focus**: {{current task or goal}}
296
- - **Recent Changes**: {{list of recent changes}}
297
- - **Next Steps**: {{next actions}}
298
- `,
299
- 'systemPatterns.md': `# systemPatterns
755
+ ensureParentDirectory(targetFile)
756
+ if (isVerbose) console.error(` … scaffolding: memory-bank/${fileName}`)
757
+ writeFileSync(targetFile, fileContent, 'utf8')
758
+ manifest.files[trackedKey] = hashFile(targetFile)
759
+ scaffolded += 1
760
+ }
300
761
 
301
- > Architecture, key decisions, design patterns.
762
+ /* ── Scaffold .agents/instructions/ directory ───────────────── */
302
763
 
303
- - **Architecture**: {{describe architecture}}
304
- - **Key Decisions**: {{list key technical decisions}}
305
- - **Patterns**: {{list design patterns in use}}
306
- `,
307
- 'techContext.md': `# techContext
764
+ for (const [fileName, fileContent] of Object.entries(agentInstructionsStubs)) {
765
+ const targetFile = resolve(targetAgentsDir, 'instructions', fileName)
766
+ const trackedKey = `__agents-instructions__/${fileName}`
308
767
 
309
- > Technologies, setup, constraints, dependencies.
768
+ if (existsSync(targetFile)) {
769
+ continue
770
+ }
310
771
 
311
- - **Stack**: {{list technologies}}
312
- - **Setup**: {{describe dev setup}}
313
- - **Constraints**: {{list technical constraints}}
314
- `,
315
- 'progress.md': `# progress
772
+ ensureParentDirectory(targetFile)
773
+ if (isVerbose) console.error(` … scaffolding: .agents/instructions/${fileName}`)
774
+ writeFileSync(targetFile, fileContent, 'utf8')
775
+ manifest.files[trackedKey] = hashFile(targetFile)
776
+ scaffolded += 1
777
+ }
316
778
 
317
- > What works, what's left, current status.
779
+ /* ── Scaffold project-owned files ───────────────────────────── */
318
780
 
319
- - **Working**: {{what's implemented and working}}
320
- - **To Build**: {{what remains}}
321
- - **Status**: {{overall project status}}
322
- - **Known Issues**: {{list known issues}}
323
- `,
324
- }
781
+ // Create-if-absent only. The __project__ namespace is reporting metadata
782
+ // written when a file is first created by sync; pre-existing consumer files
783
+ // are intentionally absent, because there is no managed baseline to report.
784
+ // It is never resolved by the copy or prune passes and never drives an
785
+ // overwrite. Not refreshing it on later runs keeps a consumer-owned file from
786
+ // producing manifest churn on every install.
787
+ const targetPrettierIgnore = resolve(consumerRoot, '.prettierignore')
788
+ const prettierIgnoreExisted = existsSync(targetPrettierIgnore)
325
789
 
326
- const memoryBankDir = resolve(consumerRoot, 'memory-bank')
327
- let scaffolded = 0
790
+ for (const [fileName, fileContent] of Object.entries(projectOwnedStubs)) {
791
+ const targetFile = resolve(consumerRoot, fileName)
792
+ const trackedKey = `__project__/${fileName}`
793
+ let didScaffold = false
328
794
 
329
- for (const [fileName, fileContent] of Object.entries(memoryBankStubs)) {
330
- const targetFile = resolve(memoryBankDir, fileName)
331
- const trackedKey = `__memory-bank__/${fileName}`
795
+ if (!existsSync(targetFile)) {
796
+ ensureParentDirectory(targetFile)
797
+ if (isVerbose) console.error(` … scaffolding: ${fileName}`)
798
+ writeFileSync(targetFile, fileContent, 'utf8')
799
+ scaffolded += 1
800
+ didScaffold = true
801
+ }
332
802
 
333
- if (existsSync(targetFile)) {
334
- continue
803
+ // Only hash what this run created, and only if it is a regular file: a
804
+ // directory or unreadable path at this location must not abort the run.
805
+ if (didScaffold && isRegularFile(targetFile)) {
806
+ manifest.files[trackedKey] = hashFile(targetFile)
807
+ }
335
808
  }
336
809
 
337
- ensureParentDirectory(targetFile)
338
- if (isVerbose) console.error(` … scaffolding: memory-bank/${fileName}`)
339
- writeFileSync(targetFile, fileContent, 'utf8')
340
- manifest.files[trackedKey] = hashFile(targetFile)
341
- scaffolded += 1
342
- }
810
+ // An existing .prettierignore is project-owned and is never edited. Tell the
811
+ // consumer when it leaves managed files exposed, once and deterministically.
812
+ if (prettierIgnoreExisted) {
813
+ try {
814
+ const missingPatterns = missingPrettierIgnorePatterns(
815
+ readFileSync(targetPrettierIgnore, 'utf8'),
816
+ )
817
+ if (missingPatterns.length > 0) {
818
+ console.warn(prettierIgnoreWarning)
819
+ }
820
+ } catch {
821
+ // A directory or unreadable path at .prettierignore is project-owned
822
+ // state we cannot inspect. Skip the advisory rather than abort the run.
823
+ }
824
+ }
343
825
 
344
- /* ── Scaffold .agents/instructions/ directory ───────────────── */
826
+ /* ── Auto-copy opencode.mcp.example.json → opencode.json ────── */
345
827
 
346
- const agentInstructionsStubs = {
347
- 'learned-knowledge.instructions.md': `# Learned Knowledge
828
+ // Existing consumer configs are never rewritten. The example carries the
829
+ // `instructions` entry that loads AGENTS.project.md for fresh consumers.
830
+ const exampleMcpFile = resolve(packageRoot, 'opencode.mcp.example.json')
831
+ const targetMcpFile = resolve(consumerRoot, 'opencode.json')
832
+ const mcpTrackedKey = '__project__/opencode.json'
348
833
 
349
- Cross-session knowledge accumulated by agent pipelines. Each session records discoveries, patterns, gotchas, and agent tuning notes for future reference.
834
+ if (existsSync(exampleMcpFile) && !existsSync(targetMcpFile)) {
835
+ if (isVerbose) console.error(' … scaffolding: opencode.json from example')
836
+ copyFileSync(exampleMcpFile, targetMcpFile)
837
+ manifest.files[mcpTrackedKey] = hashFile(targetMcpFile)
838
+ scaffolded += 1
839
+ }
350
840
 
351
- ---
841
+ /* ── Prune stale files (opt-in via --prune) ─────────────────── */
842
+
843
+ // Every prunable manifest namespace maps onto the same package/consumer roots
844
+ // used by the copy passes above. Scaffold-only namespaces (__memory-bank__,
845
+ // __agents-instructions__) have no package source and are therefore never
846
+ // prunable — their keys fall through to the "unknown namespace" guard below.
847
+ let pruned = 0
848
+ let pruneSkipped = 0
849
+
850
+ if (prune) {
851
+ for (const manifestKey of preexistingManifestKeys) {
852
+ const entry = resolvePruneEntry(manifestKey, context)
853
+ if (!entry) {
854
+ continue
855
+ }
856
+
857
+ // Still shipped by the package? Package-owned — never prunable (CON-05, D-10).
858
+ if (entry.packagePaths.some((packagePath) => existsSync(packagePath))) {
859
+ continue
860
+ }
861
+
862
+ const displayPath = relative(consumerRoot, entry.consumerPath)
863
+
864
+ // Consumer file is already gone: drop the stale bookkeeping entry, delete nothing.
865
+ if (!existsSync(entry.consumerPath)) {
866
+ if (!dryRun) {
867
+ delete manifest.files[manifestKey]
868
+ }
869
+ continue
870
+ }
871
+
872
+ // Files only — never delete a directory, symlink target, or unreadable path.
873
+ if (!isRegularFile(entry.consumerPath)) {
874
+ continue
875
+ }
876
+
877
+ const recordedHash = manifest.files[manifestKey]
878
+ if (typeof recordedHash !== 'string' || recordedHash.length === 0) {
879
+ pruneSkipped += 1
880
+ console.warn(`[ai-workflow-template] Skipped pruning untracked file: ${displayPath}`)
881
+ continue
882
+ }
883
+
884
+ // Locally modified (or unverifiable): keep the file AND its manifest entry so
885
+ // a later run can still see it. Deleting here would destroy user work.
886
+ if (hashFile(entry.consumerPath) !== recordedHash) {
887
+ pruneSkipped += 1
888
+ console.warn(`[ai-workflow-template] Skipped pruning locally modified file: ${displayPath}`)
889
+ continue
890
+ }
891
+
892
+ pruned += 1
893
+ if (dryRun) {
894
+ console.log(`[ai-workflow-template] Would prune: ${displayPath}`)
895
+ continue
896
+ }
897
+
898
+ rmSync(entry.consumerPath, { force: true })
899
+ delete manifest.files[manifestKey]
900
+
901
+ // Directories are never deleted. Report an emptied one and leave it in place.
902
+ const parentDir = dirname(entry.consumerPath)
903
+ if (existsSync(parentDir) && readdirSync(parentDir).length === 0) {
904
+ console.log(
905
+ `[ai-workflow-template] Left empty directory in place: ${relative(consumerRoot, parentDir)}`,
906
+ )
907
+ }
908
+ }
352
909
 
353
- `,
354
- }
910
+ const summaryVerb = dryRun ? 'Would prune' : 'Pruned'
911
+ console.log(
912
+ `[ai-workflow-template] ${summaryVerb} ${pruned} stale file(s); skipped ${pruneSkipped} locally modified.`,
913
+ )
914
+ }
355
915
 
356
- for (const [fileName, fileContent] of Object.entries(agentInstructionsStubs)) {
357
- const targetFile = resolve(targetAgentsDir, 'instructions', fileName)
358
- const trackedKey = `__agents-instructions__/${fileName}`
916
+ if (!dryRun) {
917
+ writeManifest(manifestPath, manifest)
918
+ }
359
919
 
360
- if (existsSync(targetFile)) {
361
- continue
920
+ const modeLabel = forceOverwrite ? 'force' : dryRun ? 'dry-run' : 'safe'
921
+ console.log(`[ai-workflow-template] Synced ${copied} files (${added} new), scaffolded ${scaffolded} files, skipped ${skipped} modified files. Mode: ${modeLabel}.`)
922
+
923
+ return {
924
+ exitCode: 0,
925
+ copied,
926
+ added,
927
+ skipped,
928
+ scaffolded,
929
+ pruned,
930
+ pruneSkipped,
931
+ scriptsCopied,
932
+ scriptsSkipped,
362
933
  }
934
+ }
363
935
 
364
- ensureParentDirectory(targetFile)
365
- if (isVerbose) console.error(` … scaffolding: .agents/instructions/${fileName}`)
366
- writeFileSync(targetFile, fileContent, 'utf8')
367
- manifest.files[trackedKey] = hashFile(targetFile)
368
- scaffolded += 1
936
+ // Runs a complete sync pass. Accepts explicit roots/argv/env so the module can
937
+ // be exercised in-process by tests without touching the real repository.
938
+ export function runSync(options = {}) {
939
+ return syncProject(createSyncContext(options))
369
940
  }
370
941
 
371
- /* ── Auto-copy opencode.mcp.example.json → opencode.json ────── */
942
+ // True only when this module is the script Node was asked to execute. This keeps
943
+ // `node scripts/sync.js` working while making the module safe to import in tests.
944
+ // Compares symlink-resolved paths because macOS reports /var scripts as
945
+ // /private/var through import.meta.url.
946
+ export function isEntryPoint(moduleUrl = import.meta.url, argv1 = process.argv[1]) {
947
+ if (!argv1) {
948
+ return false
949
+ }
372
950
 
373
- const exampleMcpFile = resolve(packageRoot, 'opencode.mcp.example.json')
374
- const targetMcpFile = resolve(consumerRoot, 'opencode.json')
375
- const mcpTrackedKey = '__root__/opencode.json'
951
+ const modulePath = fileURLToPath(moduleUrl)
952
+ const argvPath = resolve(argv1)
953
+ if (modulePath === argvPath) {
954
+ return true
955
+ }
376
956
 
377
- if (existsSync(exampleMcpFile) && !existsSync(targetMcpFile)) {
378
- if (isVerbose) console.error(' … scaffolding: opencode.json from example')
379
- copyFileSync(exampleMcpFile, targetMcpFile)
380
- manifest.files[mcpTrackedKey] = hashFile(targetMcpFile)
381
- scaffolded += 1
957
+ try {
958
+ return realpathSync(modulePath) === realpathSync(argvPath)
959
+ } catch {
960
+ return false
961
+ }
382
962
  }
383
963
 
384
- if (!dryRun) {
385
- writeManifest(manifest)
964
+ if (isEntryPoint()) {
965
+ runSync()
386
966
  }
387
-
388
- const modeLabel = forceOverwrite ? 'force' : dryRun ? 'dry-run' : 'safe'
389
- console.log(`[ai-workflow-template] Synced ${copied} files (${added} new), scaffolded ${scaffolded} files, skipped ${skipped} modified files. Mode: ${modeLabel}.`)