@abarcenas/ai-workflow-template 1.44.0 → 1.46.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 (68) hide show
  1. package/.agents/config/README.md +43 -0
  2. package/.agents/config/domain.md +37 -0
  3. package/.agents/instructions/agent.instructions.md +10 -10
  4. package/.agents/instructions/learned-knowledge.instructions.md +170 -0
  5. package/.agents/instructions/memory-bank.instructions.md +11 -0
  6. package/.agents/skills/caveman-compress/README.md +2 -0
  7. package/.agents/skills/caveman-compress/SECURITY.md +3 -1
  8. package/.agents/skills/caveman-compress/SKILL.md +4 -0
  9. package/.agents/skills/caveman-compress/scripts/compress.py +9 -2
  10. package/.agents/skills/compress/SKILL.md +4 -0
  11. package/.agents/skills/compress/scripts/compress.py +9 -2
  12. package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
  13. package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
  14. package/.agents/skills/domain-modeling/SKILL.md +74 -0
  15. package/.agents/skills/grill-me/SKILL.md +7 -0
  16. package/.agents/skills/grill-with-docs/SKILL.md +7 -0
  17. package/.agents/skills/grilling/SKILL.md +28 -0
  18. package/.agents/skills/prototype/LOGIC.md +67 -0
  19. package/.agents/skills/prototype/SKILL.md +26 -0
  20. package/.agents/skills/prototype/UI.md +112 -0
  21. package/.github/workflows/npm-publish.yml +10 -0
  22. package/.github/workflows/pr-test.yml +31 -0
  23. package/.opencode/agents/architect.agent.md +0 -1
  24. package/.opencode/agents/coder.agent.md +0 -1
  25. package/.opencode/agents/deployer.agent.md +0 -1
  26. package/.opencode/agents/designer.agent.md +0 -1
  27. package/.opencode/agents/e2e-tester.agent.md +0 -1
  28. package/.opencode/agents/implementer.agent.md +0 -1
  29. package/.opencode/agents/orchestrator/feature-pipeline.agent.md +0 -1
  30. package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -4
  31. package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +0 -1
  32. package/.opencode/agents/plan.agent.md +0 -1
  33. package/.opencode/agents/researcher.agent.md +0 -1
  34. package/.opencode/agents/reviewer.agent.md +0 -1
  35. package/.opencode/agents/scope.agent.md +299 -0
  36. package/.opencode/agents/tracker.agent.md +0 -1
  37. package/.opencode/agents/unit-tester.agent.md +0 -1
  38. package/AGENTS.md +44 -5
  39. package/README.md +6 -3
  40. package/package.json +1 -1
  41. package/scripts/bump-version.js +7 -0
  42. package/scripts/bump-version.test.js +238 -0
  43. package/scripts/mcp/playwright-mcp-launcher.js +55 -29
  44. package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
  45. package/scripts/mcp-memory-server.js +93 -76
  46. package/scripts/mcp-memory-server.test.js +398 -0
  47. package/scripts/memory-cli.test.js +297 -0
  48. package/scripts/memory-index.js +119 -31
  49. package/scripts/memory-index.test.js +1173 -0
  50. package/scripts/normalize-memory.js +26 -1
  51. package/scripts/normalize-memory.test.js +753 -0
  52. package/scripts/setup/discover.test.js +124 -2
  53. package/scripts/setup/hooks.test.js +188 -1
  54. package/scripts/setup/husky-init.test.js +311 -0
  55. package/scripts/setup/index.js +35 -5
  56. package/scripts/setup/index.test.js +279 -0
  57. package/scripts/setup/knowledgebase.test.js +100 -0
  58. package/scripts/setup/skills-phase.js +568 -0
  59. package/scripts/setup/skills-phase.test.js +1274 -0
  60. package/scripts/setup/sync-phase.js +6 -1
  61. package/scripts/setup/sync-phase.test.js +180 -2
  62. package/scripts/setup/ui.test.js +579 -0
  63. package/scripts/setup/utils.test.js +586 -0
  64. package/scripts/sync-unit.test.js +1342 -0
  65. package/scripts/sync.js +540 -246
  66. package/scripts/sync.test.js +283 -0
  67. package/scripts/vocab-sync.test.js +88 -0
  68. package/skills-lock.json +90 -0
package/scripts/sync.js CHANGED
@@ -1,28 +1,197 @@
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
+ export const openCodeExcluded = new Set([
23
+ 'node_modules',
24
+ 'package.json',
25
+ 'package-lock.json',
26
+ 'bun.lock',
27
+ '.gitignore',
28
+ ])
29
+
30
+ export const rootFiles = ['AGENTS.md', 'opencode.json', 'skills-lock.json']
31
+
32
+ export const scriptsToSync = [
33
+ 'memory-cli.js',
34
+ 'memory-index.js',
35
+ 'bump-version.js',
36
+ 'validate-memory-schema.js',
37
+ 'vocab-sync.js',
38
+ 'mcp-memory-server.js',
39
+ 'mcp/playwright-mcp-launcher.js',
40
+ // Knowledgebase scripts
41
+ 'knowledgebase-cli.js',
42
+ 'knowledgebase-index.js',
43
+ 'mcp-knowledgebase-server.js',
44
+ 'knowledgebase-init.sql',
45
+ ]
46
+
47
+ export const memoryBankStubs = {
48
+ 'projectbrief.md': `# projectbrief
49
+
50
+ > Foundation document — defines core requirements and goals.
51
+
52
+ - **Purpose**: {{describe project purpose}}
53
+ - **Goals**: {{list project goals}}
54
+ - **Scope**: {{define scope}}
55
+ `,
56
+ 'productContext.md': `# productContext
57
+
58
+ > Why this project exists and what problems it solves.
59
+
60
+ - **Problem**: {{describe the problem}}
61
+ - **Solution**: {{describe the solution}}
62
+ - **User Experience**: {{describe UX goals}}
63
+ `,
64
+ 'activeContext.md': `# activeContext
65
+
66
+ > Current work focus, recent changes, next steps.
67
+
68
+ - **Current Focus**: {{current task or goal}}
69
+ - **Recent Changes**: {{list of recent changes}}
70
+ - **Next Steps**: {{next actions}}
71
+ `,
72
+ 'systemPatterns.md': `# systemPatterns
73
+
74
+ > Architecture, key decisions, design patterns.
75
+
76
+ - **Architecture**: {{describe architecture}}
77
+ - **Key Decisions**: {{list key technical decisions}}
78
+ - **Patterns**: {{list design patterns in use}}
79
+ `,
80
+ 'techContext.md': `# techContext
81
+
82
+ > Technologies, setup, constraints, dependencies.
83
+
84
+ - **Stack**: {{list technologies}}
85
+ - **Setup**: {{describe dev setup}}
86
+ - **Constraints**: {{list technical constraints}}
87
+ `,
88
+ 'progress.md': `# progress
89
+
90
+ > What works, what's left, current status.
91
+
92
+ - **Working**: {{what's implemented and working}}
93
+ - **To Build**: {{what remains}}
94
+ - **Status**: {{overall project status}}
95
+ - **Known Issues**: {{list known issues}}
96
+ `,
97
+ }
98
+
99
+ export const agentInstructionsStubs = {
100
+ 'learned-knowledge.instructions.md': `# Learned Knowledge
101
+
102
+ Cross-session knowledge accumulated by agent pipelines. Each session records discoveries, patterns, gotchas, and agent tuning notes for future reference.
103
+
104
+ ---
105
+
106
+ `,
107
+ }
108
+
109
+ // Parses the flags supported by the sync entry point. Unknown flags are ignored
110
+ // so that consumer tooling can pass extra arguments without breaking a sync.
111
+ export function parseArgs(argv = [], env = process.env) {
112
+ const args = new Set(argv)
113
+
114
+ return {
115
+ forceOverwrite: args.has('--force'),
116
+ dryRun: args.has('--dry-run'),
117
+ // Pruning is opt-in and off by default: syncing never deletes anything unless
118
+ // the caller explicitly passes --prune. Even then, only manifest-tracked files
119
+ // whose package source has disappeared AND whose content is unmodified are removed.
120
+ prune: args.has('--prune'),
121
+ isVerbose: env.AI_WORKFLOW_VERBOSE === '1',
122
+ }
123
+ }
124
+
125
+ // Resolves every directory and flag the sync pass works with. Tests pass an
126
+ // explicit packageRoot/consumerRoot; production derives the package root from
127
+ // this module's location and the consumer root from INIT_CWD (npm) or cwd.
128
+ export function createSyncContext(options = {}) {
129
+ const {
130
+ packageRoot,
131
+ consumerRoot,
132
+ manifestPath,
133
+ argv = process.argv.slice(2),
134
+ env = process.env,
135
+ } = options
136
+
137
+ const resolvedPackageRoot = packageRoot
138
+ ? resolve(packageRoot)
139
+ : defaultPackageRoot
140
+ const resolvedConsumerRoot = consumerRoot
141
+ ? resolve(consumerRoot)
142
+ : env.INIT_CWD
143
+ ? resolve(env.INIT_CWD)
144
+ : process.cwd()
145
+
146
+ const sourceAgentsDir = resolve(resolvedPackageRoot, '.agents')
147
+ const targetAgentsDir = resolve(resolvedConsumerRoot, '.agents')
148
+ const sourceOpenCodeDir = resolve(resolvedPackageRoot, '.opencode')
149
+ const sourceScriptsDir = resolve(resolvedPackageRoot, 'scripts')
150
+ const targetScriptsDir = resolve(resolvedConsumerRoot, 'scripts')
151
+
152
+ return {
153
+ packageRoot: resolvedPackageRoot,
154
+ consumerRoot: resolvedConsumerRoot,
155
+ sourceAgentsDir,
156
+ targetAgentsDir,
157
+ sourceOpenCodeDir,
158
+ sourceScriptsDir,
159
+ targetScriptsDir,
160
+ manifestPath: manifestPath
161
+ ? resolve(manifestPath)
162
+ : resolve(resolvedConsumerRoot, '.agents-sync-manifest.json'),
163
+ ...parseArgs(argv, env),
164
+ pruneChannels: {
165
+ agents: {
166
+ prefix: '',
167
+ packageDir: sourceAgentsDir,
168
+ consumerDir: targetAgentsDir,
169
+ },
170
+ opencode: {
171
+ prefix: '__opencode__/',
172
+ packageDir: sourceOpenCodeDir,
173
+ consumerDir: resolve(resolvedConsumerRoot, '.opencode'),
174
+ },
175
+ scripts: {
176
+ prefix: '__scripts__/',
177
+ packageDir: sourceScriptsDir,
178
+ consumerDir: targetScriptsDir,
179
+ },
180
+ root: {
181
+ prefix: '__root__/',
182
+ packageDir: resolvedPackageRoot,
183
+ consumerDir: resolvedConsumerRoot,
184
+ },
185
+ },
186
+ }
187
+ }
188
+
189
+ export function hashFile(filePath) {
21
190
  const contents = readFileSync(filePath)
22
191
  return createHash('sha256').update(contents).digest('hex')
23
192
  }
24
193
 
25
- function collectFiles(directory) {
194
+ export function collectFiles(directory) {
26
195
  const entries = readdirSync(directory, { withFileTypes: true })
27
196
  const files = []
28
197
 
@@ -41,7 +210,7 @@ function collectFiles(directory) {
41
210
  return files
42
211
  }
43
212
 
44
- function readManifest() {
213
+ export function readManifest(manifestPath) {
45
214
  if (!existsSync(manifestPath)) {
46
215
  return { version: 1, files: {} }
47
216
  }
@@ -62,7 +231,7 @@ function readManifest() {
62
231
  }
63
232
  }
64
233
 
65
- function writeManifest(manifest) {
234
+ export function writeManifest(manifestPath, manifest) {
66
235
  writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
67
236
  }
68
237
 
@@ -70,7 +239,7 @@ function ensureParentDirectory(filePath) {
70
239
  mkdirSync(dirname(filePath), { recursive: true })
71
240
  }
72
241
 
73
- function syncFile(sourceFile, targetFile) {
242
+ function syncFile(sourceFile, targetFile, dryRun) {
74
243
  if (dryRun) {
75
244
  return
76
245
  }
@@ -79,311 +248,436 @@ function syncFile(sourceFile, targetFile) {
79
248
  copyFileSync(sourceFile, targetFile)
80
249
  }
81
250
 
82
- const rootFiles = ['AGENTS.md', 'opencode.json', 'skills-lock.json']
251
+ // A file is safe to overwrite when the manifest recorded a hash for it and the
252
+ // consumer copy still matches that hash. Missing recorded hashes mean the file
253
+ // is not tracked, so local edits are assumed and the copy is skipped.
254
+ export function isUntouched(recordedHash, existingHash) {
255
+ return recordedHash !== null && existingHash === recordedHash
256
+ }
83
257
 
84
- if (!existsSync(sourceAgentsDir)) {
85
- console.warn('[ai-workflow-template] No .agents directory found to sync.')
86
- process.exit(0)
258
+ export function isPathInside(baseDir, candidatePath) {
259
+ const rel = relative(baseDir, candidatePath)
260
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
87
261
  }
88
262
 
89
- const manifest = readManifest()
90
- const sourceFiles = collectFiles(sourceAgentsDir)
91
- let copied = 0
92
- let skipped = 0
93
- let added = 0
263
+ // Resolves a manifest key to its package path (for the "still shipped?" check)
264
+ // and its consumer path (the prune candidate). Returns null for keys that the
265
+ // sync layer does not own, and for paths that escape their channel root.
266
+ export function resolvePruneEntry(manifestKey, { pruneChannels, packageRoot }) {
267
+ let channel
268
+ let relativePath
269
+
270
+ if (manifestKey.startsWith('__opencode__/')) {
271
+ channel = pruneChannels.opencode
272
+ relativePath = manifestKey.slice(channel.prefix.length)
273
+ // Never touch paths the .opencode copy pass deliberately ignores (e.g. node_modules).
274
+ if (
275
+ openCodeExcluded.has(relativePath) ||
276
+ openCodeExcluded.has(relativePath.split('/')[0])
277
+ ) {
278
+ return null
279
+ }
280
+ } else if (manifestKey.startsWith('__scripts__/')) {
281
+ channel = pruneChannels.scripts
282
+ relativePath = manifestKey.slice(channel.prefix.length)
283
+ } else if (manifestKey.startsWith('__root__/')) {
284
+ channel = pruneChannels.root
285
+ relativePath = manifestKey.slice(channel.prefix.length)
286
+ } else if (manifestKey.startsWith('__')) {
287
+ // Unknown internal namespace — never prune (scaffold-only or future keys).
288
+ return null
289
+ } else {
290
+ channel = pruneChannels.agents
291
+ relativePath = manifestKey
292
+ if (excludedRelativePaths.has(relativePath)) {
293
+ return null
294
+ }
295
+ }
94
296
 
95
- /* ── Sync .agents/ directory ────────────────────────────────── */
297
+ if (!relativePath) {
298
+ return null
299
+ }
96
300
 
97
- for (const sourceFile of sourceFiles) {
98
- const relativePath = relative(sourceAgentsDir, sourceFile)
99
- if (excludedRelativePaths.has(relativePath)) {
100
- continue
301
+ const packagePaths = [resolve(channel.packageDir, relativePath)]
302
+ if (channel === pruneChannels.root && relativePath === 'opencode.json') {
303
+ // opencode.json is scaffolded from the example when the package does not ship
304
+ // it directly (auto-copy pass above). Either source keeps the file alive.
305
+ packagePaths.push(resolve(packageRoot, 'opencode.mcp.example.json'))
101
306
  }
102
307
 
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
108
-
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
308
+ const consumerPath = resolve(channel.consumerDir, relativePath)
309
+ if (!isPathInside(channel.packageDir, packagePaths[0])) {
310
+ return null
311
+ }
312
+ if (!isPathInside(channel.consumerDir, consumerPath)) {
313
+ return null
116
314
  }
117
315
 
118
- const isUntouched = recordedHash !== null && existingHash === recordedHash
316
+ return { packagePaths, consumerPath }
317
+ }
119
318
 
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
319
+ export function isRegularFile(filePath) {
320
+ try {
321
+ return statSync(filePath).isFile()
322
+ } catch {
323
+ return false
126
324
  }
127
-
128
- skipped += 1
129
- console.warn(`[ai-workflow-template] Skipped locally modified file: ${relativePath}`)
130
325
  }
131
326
 
132
- /* ── Sync .opencode/ directory ────────────────────────────── */
327
+ export function syncProject(context) {
328
+ const {
329
+ packageRoot,
330
+ consumerRoot,
331
+ sourceAgentsDir,
332
+ targetAgentsDir,
333
+ sourceOpenCodeDir,
334
+ sourceScriptsDir,
335
+ targetScriptsDir,
336
+ manifestPath,
337
+ forceOverwrite,
338
+ dryRun,
339
+ prune,
340
+ isVerbose,
341
+ } = context
342
+
343
+ if (!existsSync(sourceAgentsDir)) {
344
+ console.warn('[ai-workflow-template] No .agents directory found to sync.')
345
+ return { exitCode: 0, copied: 0, added: 0, scaffolded: 0, skipped: 0 }
346
+ }
133
347
 
134
- const sourceOpenCodeDir = resolve(packageRoot, '.opencode')
135
- const openCodeExcluded = new Set(['node_modules', 'package.json', 'package-lock.json', 'bun.lock', '.gitignore'])
348
+ const manifest = readManifest(manifestPath)
349
+ // Snapshot before syncing: prune only considers entries that existed at the start
350
+ // of this run, so files scaffolded/copied now are never candidates in the same run.
351
+ const preexistingManifestKeys = Object.keys(manifest.files)
352
+ const sourceFiles = collectFiles(sourceAgentsDir)
353
+ let copied = 0
354
+ let skipped = 0
355
+ let added = 0
136
356
 
137
- if (existsSync(sourceOpenCodeDir)) {
138
- const openCodeFiles = collectFiles(sourceOpenCodeDir)
357
+ /* ── Sync .agents/ directory ────────────────────────────────── */
139
358
 
140
- for (const sourceFile of openCodeFiles) {
141
- const relativePath = relative(sourceOpenCodeDir, sourceFile)
142
- if (openCodeExcluded.has(relativePath) || openCodeExcluded.has(relativePath.split('/')[0])) {
359
+ for (const sourceFile of sourceFiles) {
360
+ const relativePath = relative(sourceAgentsDir, sourceFile)
361
+ if (excludedRelativePaths.has(relativePath)) {
143
362
  continue
144
363
  }
145
364
 
146
- const targetFile = resolve(consumerRoot, '.opencode', relativePath)
365
+ const targetFile = resolve(targetAgentsDir, relativePath)
147
366
  const sourceHash = hashFile(sourceFile)
148
367
  const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
149
- const trackedKey = `__opencode__/${relativePath}`
368
+ const trackedKey = relativePath
150
369
  const recordedHash = manifest.files[trackedKey] ?? null
151
370
 
152
371
  if (!existsSync(targetFile)) {
153
- if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
154
- syncFile(sourceFile, targetFile)
372
+ if (isVerbose) console.error(` … syncing: .agents/${relativePath}`)
373
+ syncFile(sourceFile, targetFile, dryRun)
155
374
  manifest.files[trackedKey] = sourceHash
156
375
  copied += 1
157
376
  added += 1
158
377
  continue
159
378
  }
160
379
 
161
- const isUntouched = recordedHash !== null && existingHash === recordedHash
162
-
163
- if (forceOverwrite || isUntouched) {
164
- if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
165
- syncFile(sourceFile, targetFile)
380
+ if (forceOverwrite || isUntouched(recordedHash, existingHash)) {
381
+ if (isVerbose) console.error(` … syncing: .agents/${relativePath}`)
382
+ syncFile(sourceFile, targetFile, dryRun)
166
383
  manifest.files[trackedKey] = sourceHash
167
384
  copied += 1
168
385
  continue
169
386
  }
170
387
 
171
388
  skipped += 1
172
- console.warn(`[ai-workflow-template] Skipped locally modified opencode file: ${relativePath}`)
389
+ console.warn(`[ai-workflow-template] Skipped locally modified file: ${relativePath}`)
173
390
  }
174
- }
175
-
176
- /* ── Sync scripts/ directory ──────────────────────────────── */
177
-
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
- ]
192
-
193
- const sourceScriptsDir = resolve(packageRoot, 'scripts')
194
- const targetScriptsDir = resolve(consumerRoot, 'scripts')
195
- let scriptsCopied = 0
196
- let scriptsSkipped = 0
197
-
198
- for (const scriptRelPath of scriptsToSync) {
199
- const sourceFile = resolve(sourceScriptsDir, scriptRelPath)
200
- const targetFile = resolve(targetScriptsDir, scriptRelPath)
201
391
 
202
- if (!existsSync(sourceFile)) {
203
- continue
392
+ /* ── Sync .opencode/ directory ────────────────────────────── */
393
+
394
+ if (existsSync(sourceOpenCodeDir)) {
395
+ const openCodeFiles = collectFiles(sourceOpenCodeDir)
396
+
397
+ for (const sourceFile of openCodeFiles) {
398
+ const relativePath = relative(sourceOpenCodeDir, sourceFile)
399
+ if (openCodeExcluded.has(relativePath) || openCodeExcluded.has(relativePath.split('/')[0])) {
400
+ continue
401
+ }
402
+
403
+ const targetFile = resolve(consumerRoot, '.opencode', relativePath)
404
+ const sourceHash = hashFile(sourceFile)
405
+ const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
406
+ const trackedKey = `__opencode__/${relativePath}`
407
+ const recordedHash = manifest.files[trackedKey] ?? null
408
+
409
+ if (!existsSync(targetFile)) {
410
+ if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
411
+ syncFile(sourceFile, targetFile, dryRun)
412
+ manifest.files[trackedKey] = sourceHash
413
+ copied += 1
414
+ added += 1
415
+ continue
416
+ }
417
+
418
+ if (forceOverwrite || isUntouched(recordedHash, existingHash)) {
419
+ if (isVerbose) console.error(` … syncing: .opencode/${relativePath}`)
420
+ syncFile(sourceFile, targetFile, dryRun)
421
+ manifest.files[trackedKey] = sourceHash
422
+ copied += 1
423
+ continue
424
+ }
425
+
426
+ skipped += 1
427
+ console.warn(`[ai-workflow-template] Skipped locally modified opencode file: ${relativePath}`)
428
+ }
204
429
  }
205
430
 
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
210
-
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
- }
431
+ /* ── Sync scripts/ directory ──────────────────────────────── */
220
432
 
221
- const isUntouched = recordedHash !== null && existingHash === recordedHash
433
+ let scriptsCopied = 0
434
+ let scriptsSkipped = 0
222
435
 
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
- }
436
+ for (const scriptRelPath of scriptsToSync) {
437
+ const sourceFile = resolve(sourceScriptsDir, scriptRelPath)
438
+ const targetFile = resolve(targetScriptsDir, scriptRelPath)
231
439
 
232
- scriptsSkipped += 1
233
- skipped += 1
234
- console.warn(`[ai-workflow-template] Skipped locally modified script file: scripts/${scriptRelPath}`)
235
- }
440
+ if (!existsSync(sourceFile)) {
441
+ continue
442
+ }
236
443
 
237
- for (const rootFile of rootFiles) {
238
- const sourceFile = resolve(packageRoot, rootFile)
239
- if (!existsSync(sourceFile)) {
240
- continue
241
- }
444
+ const sourceHash = hashFile(sourceFile)
445
+ const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
446
+ const trackedKey = `__scripts__/${scriptRelPath}`
447
+ const recordedHash = manifest.files[trackedKey] ?? null
242
448
 
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
- }
449
+ if (!existsSync(targetFile)) {
450
+ if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
451
+ syncFile(sourceFile, targetFile, dryRun)
452
+ manifest.files[trackedKey] = sourceHash
453
+ scriptsCopied += 1
454
+ copied += 1
455
+ added += 1
456
+ continue
457
+ }
257
458
 
258
- const isUntouched = recordedHash !== null && existingHash === recordedHash
459
+ if (forceOverwrite || isUntouched(recordedHash, existingHash)) {
460
+ if (isVerbose) console.error(` … syncing: scripts/${scriptRelPath}`)
461
+ syncFile(sourceFile, targetFile, dryRun)
462
+ manifest.files[trackedKey] = sourceHash
463
+ scriptsCopied += 1
464
+ copied += 1
465
+ continue
466
+ }
259
467
 
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
468
+ scriptsSkipped += 1
469
+ skipped += 1
470
+ console.warn(`[ai-workflow-template] Skipped locally modified script file: scripts/${scriptRelPath}`)
266
471
  }
267
472
 
268
- skipped += 1
269
- console.warn(`[ai-workflow-template] Skipped locally modified root file: ${rootFile}`)
270
- }
271
-
272
- /* ── Scaffold memory-bank/ directory ──────────────────────────── */
473
+ /* ── Sync root files ────────────────────────────────────────── */
273
474
 
274
- const memoryBankStubs = {
275
- 'projectbrief.md': `# projectbrief
276
-
277
- > Foundation document — defines core requirements and goals.
278
-
279
- - **Purpose**: {{describe project purpose}}
280
- - **Goals**: {{list project goals}}
281
- - **Scope**: {{define scope}}
282
- `,
283
- 'productContext.md': `# productContext
475
+ for (const rootFile of rootFiles) {
476
+ const sourceFile = resolve(packageRoot, rootFile)
477
+ if (!existsSync(sourceFile)) {
478
+ continue
479
+ }
284
480
 
285
- > Why this project exists and what problems it solves.
481
+ const targetFile = resolve(consumerRoot, rootFile)
482
+ const sourceHash = hashFile(sourceFile)
483
+ const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
484
+ const trackedKey = `__root__/${rootFile}`
485
+ const recordedHash = manifest.files[trackedKey] ?? null
286
486
 
287
- - **Problem**: {{describe the problem}}
288
- - **Solution**: {{describe the solution}}
289
- - **User Experience**: {{describe UX goals}}
290
- `,
291
- 'activeContext.md': `# activeContext
487
+ if (!existsSync(targetFile)) {
488
+ if (isVerbose) console.error(` … syncing: ${rootFile}`)
489
+ syncFile(sourceFile, targetFile, dryRun)
490
+ manifest.files[trackedKey] = sourceHash
491
+ copied += 1
492
+ added += 1
493
+ continue
494
+ }
292
495
 
293
- > Current work focus, recent changes, next steps.
496
+ if (forceOverwrite || isUntouched(recordedHash, existingHash)) {
497
+ if (isVerbose) console.error(` … syncing: ${rootFile}`)
498
+ syncFile(sourceFile, targetFile, dryRun)
499
+ manifest.files[trackedKey] = sourceHash
500
+ copied += 1
501
+ continue
502
+ }
294
503
 
295
- - **Current Focus**: {{current task or goal}}
296
- - **Recent Changes**: {{list of recent changes}}
297
- - **Next Steps**: {{next actions}}
298
- `,
299
- 'systemPatterns.md': `# systemPatterns
504
+ skipped += 1
505
+ console.warn(`[ai-workflow-template] Skipped locally modified root file: ${rootFile}`)
506
+ }
300
507
 
301
- > Architecture, key decisions, design patterns.
508
+ /* ── Scaffold memory-bank/ directory ──────────────────────────── */
302
509
 
303
- - **Architecture**: {{describe architecture}}
304
- - **Key Decisions**: {{list key technical decisions}}
305
- - **Patterns**: {{list design patterns in use}}
306
- `,
307
- 'techContext.md': `# techContext
510
+ const memoryBankDir = resolve(consumerRoot, 'memory-bank')
511
+ let scaffolded = 0
308
512
 
309
- > Technologies, setup, constraints, dependencies.
513
+ for (const [fileName, fileContent] of Object.entries(memoryBankStubs)) {
514
+ const targetFile = resolve(memoryBankDir, fileName)
515
+ const trackedKey = `__memory-bank__/${fileName}`
310
516
 
311
- - **Stack**: {{list technologies}}
312
- - **Setup**: {{describe dev setup}}
313
- - **Constraints**: {{list technical constraints}}
314
- `,
315
- 'progress.md': `# progress
517
+ if (existsSync(targetFile)) {
518
+ continue
519
+ }
316
520
 
317
- > What works, what's left, current status.
521
+ ensureParentDirectory(targetFile)
522
+ if (isVerbose) console.error(` … scaffolding: memory-bank/${fileName}`)
523
+ writeFileSync(targetFile, fileContent, 'utf8')
524
+ manifest.files[trackedKey] = hashFile(targetFile)
525
+ scaffolded += 1
526
+ }
318
527
 
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
- }
528
+ /* ── Scaffold .agents/instructions/ directory ───────────────── */
325
529
 
326
- const memoryBankDir = resolve(consumerRoot, 'memory-bank')
327
- let scaffolded = 0
530
+ for (const [fileName, fileContent] of Object.entries(agentInstructionsStubs)) {
531
+ const targetFile = resolve(targetAgentsDir, 'instructions', fileName)
532
+ const trackedKey = `__agents-instructions__/${fileName}`
328
533
 
329
- for (const [fileName, fileContent] of Object.entries(memoryBankStubs)) {
330
- const targetFile = resolve(memoryBankDir, fileName)
331
- const trackedKey = `__memory-bank__/${fileName}`
534
+ if (existsSync(targetFile)) {
535
+ continue
536
+ }
332
537
 
333
- if (existsSync(targetFile)) {
334
- continue
538
+ ensureParentDirectory(targetFile)
539
+ if (isVerbose) console.error(` … scaffolding: .agents/instructions/${fileName}`)
540
+ writeFileSync(targetFile, fileContent, 'utf8')
541
+ manifest.files[trackedKey] = hashFile(targetFile)
542
+ scaffolded += 1
335
543
  }
336
544
 
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
- }
545
+ /* ── Auto-copy opencode.mcp.example.json → opencode.json ────── */
343
546
 
344
- /* ── Scaffold .agents/instructions/ directory ───────────────── */
345
-
346
- const agentInstructionsStubs = {
347
- 'learned-knowledge.instructions.md': `# Learned Knowledge
547
+ const exampleMcpFile = resolve(packageRoot, 'opencode.mcp.example.json')
548
+ const targetMcpFile = resolve(consumerRoot, 'opencode.json')
549
+ const mcpTrackedKey = '__root__/opencode.json'
348
550
 
349
- Cross-session knowledge accumulated by agent pipelines. Each session records discoveries, patterns, gotchas, and agent tuning notes for future reference.
551
+ if (existsSync(exampleMcpFile) && !existsSync(targetMcpFile)) {
552
+ if (isVerbose) console.error(' … scaffolding: opencode.json from example')
553
+ copyFileSync(exampleMcpFile, targetMcpFile)
554
+ manifest.files[mcpTrackedKey] = hashFile(targetMcpFile)
555
+ scaffolded += 1
556
+ }
350
557
 
351
- ---
558
+ /* ── Prune stale files (opt-in via --prune) ─────────────────── */
559
+
560
+ // Every prunable manifest namespace maps onto the same package/consumer roots
561
+ // used by the copy passes above. Scaffold-only namespaces (__memory-bank__,
562
+ // __agents-instructions__) have no package source and are therefore never
563
+ // prunable — their keys fall through to the "unknown namespace" guard below.
564
+ let pruned = 0
565
+ let pruneSkipped = 0
566
+
567
+ if (prune) {
568
+ for (const manifestKey of preexistingManifestKeys) {
569
+ const entry = resolvePruneEntry(manifestKey, context)
570
+ if (!entry) {
571
+ continue
572
+ }
573
+
574
+ // Still shipped by the package? Package-owned — never prunable (CON-05, D-10).
575
+ if (entry.packagePaths.some((packagePath) => existsSync(packagePath))) {
576
+ continue
577
+ }
578
+
579
+ const displayPath = relative(consumerRoot, entry.consumerPath)
580
+
581
+ // Consumer file is already gone: drop the stale bookkeeping entry, delete nothing.
582
+ if (!existsSync(entry.consumerPath)) {
583
+ if (!dryRun) {
584
+ delete manifest.files[manifestKey]
585
+ }
586
+ continue
587
+ }
588
+
589
+ // Files only — never delete a directory, symlink target, or unreadable path.
590
+ if (!isRegularFile(entry.consumerPath)) {
591
+ continue
592
+ }
593
+
594
+ const recordedHash = manifest.files[manifestKey]
595
+ if (typeof recordedHash !== 'string' || recordedHash.length === 0) {
596
+ pruneSkipped += 1
597
+ console.warn(`[ai-workflow-template] Skipped pruning untracked file: ${displayPath}`)
598
+ continue
599
+ }
600
+
601
+ // Locally modified (or unverifiable): keep the file AND its manifest entry so
602
+ // a later run can still see it. Deleting here would destroy user work.
603
+ if (hashFile(entry.consumerPath) !== recordedHash) {
604
+ pruneSkipped += 1
605
+ console.warn(`[ai-workflow-template] Skipped pruning locally modified file: ${displayPath}`)
606
+ continue
607
+ }
608
+
609
+ pruned += 1
610
+ if (dryRun) {
611
+ console.log(`[ai-workflow-template] Would prune: ${displayPath}`)
612
+ continue
613
+ }
614
+
615
+ rmSync(entry.consumerPath, { force: true })
616
+ delete manifest.files[manifestKey]
617
+
618
+ // Directories are never deleted. Report an emptied one and leave it in place.
619
+ const parentDir = dirname(entry.consumerPath)
620
+ if (existsSync(parentDir) && readdirSync(parentDir).length === 0) {
621
+ console.log(
622
+ `[ai-workflow-template] Left empty directory in place: ${relative(consumerRoot, parentDir)}`,
623
+ )
624
+ }
625
+ }
352
626
 
353
- `,
354
- }
627
+ const summaryVerb = dryRun ? 'Would prune' : 'Pruned'
628
+ console.log(
629
+ `[ai-workflow-template] ${summaryVerb} ${pruned} stale file(s); skipped ${pruneSkipped} locally modified.`,
630
+ )
631
+ }
355
632
 
356
- for (const [fileName, fileContent] of Object.entries(agentInstructionsStubs)) {
357
- const targetFile = resolve(targetAgentsDir, 'instructions', fileName)
358
- const trackedKey = `__agents-instructions__/${fileName}`
633
+ if (!dryRun) {
634
+ writeManifest(manifestPath, manifest)
635
+ }
359
636
 
360
- if (existsSync(targetFile)) {
361
- continue
637
+ const modeLabel = forceOverwrite ? 'force' : dryRun ? 'dry-run' : 'safe'
638
+ console.log(`[ai-workflow-template] Synced ${copied} files (${added} new), scaffolded ${scaffolded} files, skipped ${skipped} modified files. Mode: ${modeLabel}.`)
639
+
640
+ return {
641
+ exitCode: 0,
642
+ copied,
643
+ added,
644
+ skipped,
645
+ scaffolded,
646
+ pruned,
647
+ pruneSkipped,
648
+ scriptsCopied,
649
+ scriptsSkipped,
362
650
  }
651
+ }
363
652
 
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
653
+ // Runs a complete sync pass. Accepts explicit roots/argv/env so the module can
654
+ // be exercised in-process by tests without touching the real repository.
655
+ export function runSync(options = {}) {
656
+ return syncProject(createSyncContext(options))
369
657
  }
370
658
 
371
- /* ── Auto-copy opencode.mcp.example.json → opencode.json ────── */
659
+ // True only when this module is the script Node was asked to execute. This keeps
660
+ // `node scripts/sync.js` working while making the module safe to import in tests.
661
+ // Compares symlink-resolved paths because macOS reports /var scripts as
662
+ // /private/var through import.meta.url.
663
+ export function isEntryPoint(moduleUrl = import.meta.url, argv1 = process.argv[1]) {
664
+ if (!argv1) {
665
+ return false
666
+ }
372
667
 
373
- const exampleMcpFile = resolve(packageRoot, 'opencode.mcp.example.json')
374
- const targetMcpFile = resolve(consumerRoot, 'opencode.json')
375
- const mcpTrackedKey = '__root__/opencode.json'
668
+ const modulePath = fileURLToPath(moduleUrl)
669
+ const argvPath = resolve(argv1)
670
+ if (modulePath === argvPath) {
671
+ return true
672
+ }
376
673
 
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
674
+ try {
675
+ return realpathSync(modulePath) === realpathSync(argvPath)
676
+ } catch {
677
+ return false
678
+ }
382
679
  }
383
680
 
384
- if (!dryRun) {
385
- writeManifest(manifest)
681
+ if (isEntryPoint()) {
682
+ runSync()
386
683
  }
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}.`)