@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.
- package/.agents/config/README.md +43 -0
- package/.agents/config/domain.md +37 -0
- package/.agents/instructions/learned-knowledge.instructions.md +128 -0
- package/.agents/instructions/memory-bank.instructions.md +11 -0
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/GLOSSARY-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +74 -0
- package/.agents/skills/grill-me/SKILL.md +7 -0
- package/.agents/skills/grill-with-docs/SKILL.md +7 -0
- package/.agents/skills/grilling/SKILL.md +28 -0
- package/.agents/skills/prototype/LOGIC.md +67 -0
- package/.agents/skills/prototype/SKILL.md +26 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.github/workflows/npm-publish.yml +10 -0
- package/.github/workflows/pr-test.yml +31 -0
- package/.opencode/agents/orchestrator/orchestrator.agent.md +68 -3
- package/.opencode/agents/scope.agent.md +299 -0
- package/.prettierignore +26 -0
- package/AGENTS.md +46 -3
- package/README.md +29 -2
- package/opencode.mcp.example.json +1 -0
- package/package.json +3 -1
- package/scripts/bump-version.test.js +238 -0
- package/scripts/mcp/playwright-mcp-launcher.js +55 -29
- package/scripts/mcp/playwright-mcp-launcher.test.js +217 -0
- package/scripts/mcp-memory-server.js +93 -76
- package/scripts/mcp-memory-server.test.js +398 -0
- package/scripts/memory-cli.test.js +297 -0
- package/scripts/memory-index.js +119 -31
- package/scripts/memory-index.test.js +1173 -0
- package/scripts/normalize-memory.js +26 -1
- package/scripts/normalize-memory.test.js +753 -0
- package/scripts/setup/discover.test.js +124 -2
- package/scripts/setup/hooks.test.js +188 -1
- package/scripts/setup/husky-init.test.js +311 -0
- package/scripts/setup/index.js +35 -5
- package/scripts/setup/index.test.js +279 -0
- package/scripts/setup/knowledgebase.test.js +100 -0
- package/scripts/setup/skills-phase.js +568 -0
- package/scripts/setup/skills-phase.test.js +1274 -0
- package/scripts/setup/sync-phase.js +6 -1
- package/scripts/setup/sync-phase.test.js +180 -2
- package/scripts/setup/ui.test.js +579 -0
- package/scripts/setup/utils.test.js +586 -0
- package/scripts/sync-unit.test.js +1881 -0
- package/scripts/sync.js +816 -239
- package/scripts/sync.test.js +283 -0
- package/scripts/vocab-sync.test.js +88 -0
- package/skills-lock.json +90 -0
package/scripts/sync.js
CHANGED
|
@@ -1,28 +1,411 @@
|
|
|
1
1
|
import { createHash } from 'crypto'
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const trackedKey = relativePath
|
|
107
|
-
const recordedHash = manifest.files[trackedKey] ?? null
|
|
514
|
+
if (!relativePath) {
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
108
517
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
524
|
+
const packagePaths = [resolve(channel.packageDir, relativePath)]
|
|
119
525
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
console.warn(`[ai-workflow-template] Skipped locally modified file: ${relativePath}`)
|
|
534
|
+
return { packagePaths, consumerPath }
|
|
130
535
|
}
|
|
131
536
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
537
|
+
export function isRegularFile(filePath) {
|
|
538
|
+
try {
|
|
539
|
+
return statSync(filePath).isFile()
|
|
540
|
+
} catch {
|
|
541
|
+
return false
|
|
542
|
+
}
|
|
543
|
+
}
|
|
136
544
|
|
|
137
|
-
|
|
138
|
-
const
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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(
|
|
585
|
+
const targetFile = resolve(targetAgentsDir, relativePath)
|
|
147
586
|
const sourceHash = hashFile(sourceFile)
|
|
148
587
|
const existingHash = existsSync(targetFile) ? hashFile(targetFile) : null
|
|
149
|
-
const trackedKey =
|
|
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: .
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
609
|
+
console.warn(`[ai-workflow-template] Skipped locally modified file: ${relativePath}`)
|
|
173
610
|
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/* ── Sync scripts/ directory ──────────────────────────────── */
|
|
177
611
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
const targetScriptsDir = resolve(consumerRoot, 'scripts')
|
|
195
|
-
let scriptsCopied = 0
|
|
196
|
-
let scriptsSkipped = 0
|
|
655
|
+
/* ── Sync scripts/ directory ──────────────────────────────── */
|
|
197
656
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const targetFile = resolve(targetScriptsDir, scriptRelPath)
|
|
657
|
+
let scriptsCopied = 0
|
|
658
|
+
let scriptsSkipped = 0
|
|
201
659
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
660
|
+
for (const scriptRelPath of scriptsToSync) {
|
|
661
|
+
if (isProjectOwnedPath(scriptRelPath)) {
|
|
662
|
+
continue
|
|
663
|
+
}
|
|
205
664
|
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
copied += 1
|
|
265
|
-
continue
|
|
266
|
-
}
|
|
710
|
+
const sourceFile = resolve(packageRoot, rootFile)
|
|
711
|
+
if (!existsSync(sourceFile)) {
|
|
712
|
+
continue
|
|
713
|
+
}
|
|
267
714
|
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
738
|
+
skipped += 1
|
|
739
|
+
console.warn(`[ai-workflow-template] Skipped locally modified root file: ${rootFile}`)
|
|
740
|
+
}
|
|
278
741
|
|
|
279
|
-
-
|
|
280
|
-
- **Goals**: {{list project goals}}
|
|
281
|
-
- **Scope**: {{define scope}}
|
|
282
|
-
`,
|
|
283
|
-
'productContext.md': `# productContext
|
|
742
|
+
/* ── Scaffold memory-bank/ directory ──────────────────────────── */
|
|
284
743
|
|
|
285
|
-
|
|
744
|
+
const memoryBankDir = resolve(consumerRoot, 'memory-bank')
|
|
745
|
+
let scaffolded = 0
|
|
286
746
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
751
|
+
if (existsSync(targetFile)) {
|
|
752
|
+
continue
|
|
753
|
+
}
|
|
294
754
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
762
|
+
/* ── Scaffold .agents/instructions/ directory ───────────────── */
|
|
302
763
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
768
|
+
if (existsSync(targetFile)) {
|
|
769
|
+
continue
|
|
770
|
+
}
|
|
310
771
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
779
|
+
/* ── Scaffold project-owned files ───────────────────────────── */
|
|
318
780
|
|
|
319
|
-
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
/* ──
|
|
826
|
+
/* ── Auto-copy opencode.mcp.example.json → opencode.json ────── */
|
|
345
827
|
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
916
|
+
if (!dryRun) {
|
|
917
|
+
writeManifest(manifestPath, manifest)
|
|
918
|
+
}
|
|
359
919
|
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
|
374
|
-
const
|
|
375
|
-
|
|
951
|
+
const modulePath = fileURLToPath(moduleUrl)
|
|
952
|
+
const argvPath = resolve(argv1)
|
|
953
|
+
if (modulePath === argvPath) {
|
|
954
|
+
return true
|
|
955
|
+
}
|
|
376
956
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
957
|
+
try {
|
|
958
|
+
return realpathSync(modulePath) === realpathSync(argvPath)
|
|
959
|
+
} catch {
|
|
960
|
+
return false
|
|
961
|
+
}
|
|
382
962
|
}
|
|
383
963
|
|
|
384
|
-
if (
|
|
385
|
-
|
|
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}.`)
|