@abarcenas/ai-workflow-template 1.46.0 → 1.48.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 (27) hide show
  1. package/.agents/config/README.md +2 -2
  2. package/.agents/instructions/agent.instructions.md +16 -16
  3. package/.agents/instructions/context7.instructions.md +40 -0
  4. package/.agents/instructions/learned-knowledge.instructions.md +18 -0
  5. package/.agents/instructions/memory-bank.instructions.md +3 -3
  6. package/.opencode/agents/{architect.agent.md → architect.md} +5 -6
  7. package/.opencode/agents/{coder.agent.md → coder.md} +4 -8
  8. package/.opencode/agents/{deployer.agent.md → deployer.md} +4 -9
  9. package/.opencode/agents/{designer.agent.md → designer.md} +5 -6
  10. package/.opencode/agents/{e2e-tester.agent.md → e2e-tester.md} +4 -7
  11. package/.opencode/agents/{implementer.agent.md → implementer.md} +4 -8
  12. package/.opencode/agents/orchestrator/{feature-pipeline.agent.md → feature-pipeline.md} +16 -19
  13. package/.opencode/agents/orchestrator/{orchestrator.agent.md → orchestrator.md} +26 -34
  14. package/.opencode/agents/orchestrator/{tdd-orchestrator.agent.md → tdd-orchestrator.md} +26 -29
  15. package/.opencode/agents/{plan.agent.md → planner.md} +4 -9
  16. package/.opencode/agents/{researcher.agent.md → researcher.md} +5 -9
  17. package/.opencode/agents/{reviewer.agent.md → reviewer.md} +5 -8
  18. package/.opencode/agents/{scope.agent.md → scope.md} +5 -9
  19. package/.opencode/agents/{tracker.agent.md → tracker.md} +5 -9
  20. package/.opencode/agents/{unit-tester.agent.md → unit-tester.md} +4 -8
  21. package/.prettierignore +26 -0
  22. package/AGENTS.md +10 -6
  23. package/README.md +36 -12
  24. package/opencode.mcp.example.json +13 -12
  25. package/package.json +3 -1
  26. package/scripts/sync-unit.test.js +551 -12
  27. package/scripts/sync.js +296 -13
package/scripts/sync.js CHANGED
@@ -19,6 +19,27 @@ export const excludedRelativePaths = new Set([
19
19
  'instructions/learned-knowledge.instructions.md',
20
20
  ])
21
21
 
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
+
22
43
  export const openCodeExcluded = new Set([
23
44
  'node_modules',
24
45
  'package.json',
@@ -27,7 +48,10 @@ export const openCodeExcluded = new Set([
27
48
  '.gitignore',
28
49
  ])
29
50
 
30
- export const rootFiles = ['AGENTS.md', 'opencode.json', 'skills-lock.json']
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']
31
55
 
32
56
  export const scriptsToSync = [
33
57
  'memory-cli.js',
@@ -44,6 +68,153 @@ export const scriptsToSync = [
44
68
  'knowledgebase-init.sql',
45
69
  ]
46
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
+
47
218
  export const memoryBankStubs = {
48
219
  'projectbrief.md': `# projectbrief
49
220
 
@@ -106,6 +277,49 @@ Cross-session knowledge accumulated by agent pipelines. Each session records dis
106
277
  `,
107
278
  }
108
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
+
109
323
  // Parses the flags supported by the sync entry point. Unknown flags are ignored
110
324
  // so that consumer tooling can pass extra arguments without breaking a sync.
111
325
  export function parseArgs(argv = [], env = process.env) {
@@ -263,10 +477,16 @@ export function isPathInside(baseDir, candidatePath) {
263
477
  // Resolves a manifest key to its package path (for the "still shipped?" check)
264
478
  // and its consumer path (the prune candidate). Returns null for keys that the
265
479
  // sync layer does not own, and for paths that escape their channel root.
266
- export function resolvePruneEntry(manifestKey, { pruneChannels, packageRoot }) {
480
+ export function resolvePruneEntry(manifestKey, { pruneChannels }) {
267
481
  let channel
268
482
  let relativePath
269
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
+ }
489
+
270
490
  if (manifestKey.startsWith('__opencode__/')) {
271
491
  channel = pruneChannels.opencode
272
492
  relativePath = manifestKey.slice(channel.prefix.length)
@@ -289,22 +509,20 @@ export function resolvePruneEntry(manifestKey, { pruneChannels, packageRoot }) {
289
509
  } else {
290
510
  channel = pruneChannels.agents
291
511
  relativePath = manifestKey
292
- if (excludedRelativePaths.has(relativePath)) {
293
- return null
294
- }
295
512
  }
296
513
 
297
514
  if (!relativePath) {
298
515
  return null
299
516
  }
300
517
 
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'))
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
306
522
  }
307
523
 
524
+ const packagePaths = [resolve(channel.packageDir, relativePath)]
525
+
308
526
  const consumerPath = resolve(channel.consumerDir, relativePath)
309
527
  if (!isPathInside(channel.packageDir, packagePaths[0])) {
310
528
  return null
@@ -356,9 +574,11 @@ export function syncProject(context) {
356
574
 
357
575
  /* ── Sync .agents/ directory ────────────────────────────────── */
358
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.
359
579
  for (const sourceFile of sourceFiles) {
360
580
  const relativePath = relative(sourceAgentsDir, sourceFile)
361
- if (excludedRelativePaths.has(relativePath)) {
581
+ if (isProjectOwnedPath(relativePath)) {
362
582
  continue
363
583
  }
364
584
 
@@ -396,7 +616,11 @@ export function syncProject(context) {
396
616
 
397
617
  for (const sourceFile of openCodeFiles) {
398
618
  const relativePath = relative(sourceOpenCodeDir, sourceFile)
399
- if (openCodeExcluded.has(relativePath) || openCodeExcluded.has(relativePath.split('/')[0])) {
619
+ if (
620
+ isProjectOwnedPath(relativePath) ||
621
+ openCodeExcluded.has(relativePath) ||
622
+ openCodeExcluded.has(relativePath.split('/')[0])
623
+ ) {
400
624
  continue
401
625
  }
402
626
 
@@ -434,6 +658,10 @@ export function syncProject(context) {
434
658
  let scriptsSkipped = 0
435
659
 
436
660
  for (const scriptRelPath of scriptsToSync) {
661
+ if (isProjectOwnedPath(scriptRelPath)) {
662
+ continue
663
+ }
664
+
437
665
  const sourceFile = resolve(sourceScriptsDir, scriptRelPath)
438
666
  const targetFile = resolve(targetScriptsDir, scriptRelPath)
439
667
 
@@ -473,6 +701,12 @@ export function syncProject(context) {
473
701
  /* ── Sync root files ────────────────────────────────────────── */
474
702
 
475
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
+ }
709
+
476
710
  const sourceFile = resolve(packageRoot, rootFile)
477
711
  if (!existsSync(sourceFile)) {
478
712
  continue
@@ -542,11 +776,60 @@ export function syncProject(context) {
542
776
  scaffolded += 1
543
777
  }
544
778
 
779
+ /* ── Scaffold project-owned files ───────────────────────────── */
780
+
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)
789
+
790
+ for (const [fileName, fileContent] of Object.entries(projectOwnedStubs)) {
791
+ const targetFile = resolve(consumerRoot, fileName)
792
+ const trackedKey = `__project__/${fileName}`
793
+ let didScaffold = false
794
+
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
+ }
802
+
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
+ }
808
+ }
809
+
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
+ }
825
+
545
826
  /* ── Auto-copy opencode.mcp.example.json → opencode.json ────── */
546
827
 
828
+ // Existing consumer configs are never rewritten. The example carries the
829
+ // `instructions` entry that loads AGENTS.project.md for fresh consumers.
547
830
  const exampleMcpFile = resolve(packageRoot, 'opencode.mcp.example.json')
548
831
  const targetMcpFile = resolve(consumerRoot, 'opencode.json')
549
- const mcpTrackedKey = '__root__/opencode.json'
832
+ const mcpTrackedKey = '__project__/opencode.json'
550
833
 
551
834
  if (existsSync(exampleMcpFile) && !existsSync(targetMcpFile)) {
552
835
  if (isVerbose) console.error(' … scaffolding: opencode.json from example')