@abarcenas/ai-workflow-template 1.45.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.
- package/.agents/config/README.md +43 -0
- package/.agents/config/domain.md +37 -0
- package/.agents/instructions/learned-knowledge.instructions.md +110 -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/AGENTS.md +42 -3
- package/README.md +5 -2
- package/package.json +1 -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 +1342 -0
- package/scripts/sync.js +540 -246
- package/scripts/sync.test.js +283 -0
- package/scripts/vocab-sync.test.js +88 -0
- package/skills-lock.json +90 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// skills-phase.js — Setup phase: install pinned skills from upstream
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
//
|
|
5
|
+
// Reads `skills-lock.json`, downloads every file recorded for each *pinned*
|
|
6
|
+
// skill (an entry with a `ref` and a non-empty `files[]`), verifies each
|
|
7
|
+
// buffer's SHA-256 against the lock, and writes the verified bytes into
|
|
8
|
+
// `<consumerRoot>/.agents/skills/<name>/`.
|
|
9
|
+
//
|
|
10
|
+
// The phase converts every upstream failure into a fallback and never throws
|
|
11
|
+
// for network/timeout/non-200/hash errors. On such a failure it copies the
|
|
12
|
+
// pinned skills shipped inside this package (`<packageRoot>/.agents/skills/`)
|
|
13
|
+
// instead and prints exactly one notice (REQ-13, D-08). A destination file
|
|
14
|
+
// whose content differs from the lock is overwritten after a warning naming
|
|
15
|
+
// that file, because the pin is authoritative (REQ-14, D-09). Unexpected
|
|
16
|
+
// filesystem write errors are the caller's to handle (index.js warns).
|
|
17
|
+
//
|
|
18
|
+
// Both paths are stage-then-commit: upstream files are buffered and verified
|
|
19
|
+
// before anything is written, and shipped copies are read in full before the
|
|
20
|
+
// first file lands on disk. A skill therefore never ends up half-written.
|
|
21
|
+
//
|
|
22
|
+
// Skipping/gating:
|
|
23
|
+
// - `flags.skipSync` → `{ action: 'skipped' }` (index.js also gates the call)
|
|
24
|
+
// - no pinned entries → no-op, zero network calls
|
|
25
|
+
//
|
|
26
|
+
// The `fetch` implementation is injectable so tests never touch the network.
|
|
27
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
28
|
+
|
|
29
|
+
import { createHash } from 'crypto'
|
|
30
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
|
|
31
|
+
import { dirname, join, resolve, sep } from 'path'
|
|
32
|
+
import { fileURLToPath } from 'url'
|
|
33
|
+
|
|
34
|
+
import { stepSuccess, stepWarn } from './ui.js'
|
|
35
|
+
|
|
36
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** Name of the lock file at the package root. */
|
|
39
|
+
const LOCK_FILE = 'skills-lock.json'
|
|
40
|
+
|
|
41
|
+
/** Base URL for raw files on GitHub. */
|
|
42
|
+
const UPSTREAM_BASE = 'https://raw.githubusercontent.com'
|
|
43
|
+
|
|
44
|
+
/** Default network timeout for a single file download. */
|
|
45
|
+
const DEFAULT_TIMEOUT_MS = 5000
|
|
46
|
+
|
|
47
|
+
/** Path segments (from a project root) that contain installed skills. */
|
|
48
|
+
const SKILLS_DIR = ['.agents', 'skills']
|
|
49
|
+
|
|
50
|
+
// ── Main export ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Refreshes pinned skills from upstream, falling back to the shipped copies.
|
|
54
|
+
*
|
|
55
|
+
* Every upstream failure mode (network, timeout, non-200, hash mismatch) is
|
|
56
|
+
* converted into a fallback: the returned promise never rejects for those.
|
|
57
|
+
* Unexpected filesystem write errors propagate to the caller.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} [context={}] - Setup context object.
|
|
60
|
+
* @param {string} [context.consumerRoot] - Absolute path to the consumer project.
|
|
61
|
+
* @param {boolean} [context.dryRun=false] - When `true`, perform no writes.
|
|
62
|
+
* @param {object} [context.flags={}] - Parsed CLI flags. `skipSync` short-circuits.
|
|
63
|
+
* @param {object} [options={}] - Injectable dependencies (used by tests).
|
|
64
|
+
* @param {typeof fetch} [options.fetchImpl] - Fetch implementation.
|
|
65
|
+
* @param {string} [options.packageRoot] - Package root override.
|
|
66
|
+
* @param {string} [options.lockPath] - Lock file path override.
|
|
67
|
+
* @param {number} [options.fetchTimeoutMs] - Per-file timeout override.
|
|
68
|
+
* @returns {Promise<{
|
|
69
|
+
* action: 'completed'|'dry-run'|'skipped',
|
|
70
|
+
* message: string,
|
|
71
|
+
* pinned: number,
|
|
72
|
+
* fetched: string[],
|
|
73
|
+
* fallback: string[],
|
|
74
|
+
* skipped: string[],
|
|
75
|
+
* warnings: string[]
|
|
76
|
+
* }>} Structured phase result.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```js
|
|
80
|
+
* const result = await runSkillsPhase(context)
|
|
81
|
+
* // result.fetched → ['grilling', 'prototype']
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
export async function runSkillsPhase(context = {}, options = {}) {
|
|
85
|
+
const { consumerRoot, dryRun = false, flags = {} } = context
|
|
86
|
+
const packageRoot = options.packageRoot ?? resolvePackageRoot()
|
|
87
|
+
const lockPath = options.lockPath ?? join(packageRoot, LOCK_FILE)
|
|
88
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
|
89
|
+
const timeoutMs = options.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
90
|
+
|
|
91
|
+
const result = {
|
|
92
|
+
action: 'completed',
|
|
93
|
+
message: '',
|
|
94
|
+
pinned: 0,
|
|
95
|
+
fetched: [],
|
|
96
|
+
fallback: [],
|
|
97
|
+
skipped: [],
|
|
98
|
+
warnings: [],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 1. Defensive skip (index.js already gates on --skip-sync) ───────────
|
|
102
|
+
if (flags.skipSync) {
|
|
103
|
+
result.action = 'skipped'
|
|
104
|
+
result.message = 'Skills phase skipped (--skip-sync)'
|
|
105
|
+
stepWarn('Skills: phase skipped (--skip-sync)')
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── 2. No target project ────────────────────────────────────────────────
|
|
110
|
+
if (!consumerRoot || !isDirectory(consumerRoot)) {
|
|
111
|
+
result.message = 'Skills phase skipped \u2014 consumer root not found'
|
|
112
|
+
return result
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── 3. Read the lock and select pinned entries ──────────────────────────
|
|
116
|
+
const pinned = pinnedEntries(readLock(lockPath))
|
|
117
|
+
result.pinned = pinned.length
|
|
118
|
+
|
|
119
|
+
if (pinned.length === 0) {
|
|
120
|
+
result.message = '0 pinned skills, nothing to fetch'
|
|
121
|
+
stepSuccess('Skills: 0 pinned skills, nothing to fetch')
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── 4. Classify each pinned skill ───────────────────────────────────────
|
|
126
|
+
const toFetch = []
|
|
127
|
+
|
|
128
|
+
for (const { name, entry } of pinned) {
|
|
129
|
+
const files = []
|
|
130
|
+
|
|
131
|
+
for (const file of entry.files) {
|
|
132
|
+
const dest = destinationFor(consumerRoot, name, file, result)
|
|
133
|
+
if (!dest) continue
|
|
134
|
+
files.push({ file, dest, currentHash: hashFile(dest) })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (files.length === 0) continue // every recorded path was rejected
|
|
138
|
+
|
|
139
|
+
const pending = files.filter((f) => f.currentHash !== f.file.sha256)
|
|
140
|
+
if (pending.length === 0) {
|
|
141
|
+
result.skipped.push(name)
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (dryRun) {
|
|
146
|
+
for (const f of pending) noteLocalEdit(f, result, true)
|
|
147
|
+
result.fetched.push(name)
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
toFetch.push({ name, entry, files })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (dryRun) {
|
|
155
|
+
result.action = 'dry-run'
|
|
156
|
+
result.message = `Would fetch ${result.fetched.length} pinned skill(s) from upstream`
|
|
157
|
+
if (result.skipped.length) {
|
|
158
|
+
result.message += `; ${result.skipped.length} already up to date`
|
|
159
|
+
}
|
|
160
|
+
stepSuccess(`Skills: ${result.message}`)
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── 5. Fetch, verify, write (fallback per skill) ────────────────────────
|
|
165
|
+
let noticePrinted = false
|
|
166
|
+
let upstreamDown = false
|
|
167
|
+
|
|
168
|
+
for (const { name, entry, files } of toFetch) {
|
|
169
|
+
if (upstreamDown) {
|
|
170
|
+
copyShippedSkill(name, files, packageRoot, result)
|
|
171
|
+
result.fallback.push(name)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let verified
|
|
176
|
+
try {
|
|
177
|
+
verified = await fetchVerifiedFiles(files, entry, fetchImpl, timeoutMs)
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (!noticePrinted) {
|
|
180
|
+
stepWarn(
|
|
181
|
+
`Skills: upstream fetch unavailable (${describeError(err)}) \u2014 ` +
|
|
182
|
+
'using the copies shipped with the package.',
|
|
183
|
+
)
|
|
184
|
+
noticePrinted = true
|
|
185
|
+
}
|
|
186
|
+
if (err && err.networkFailure) upstreamDown = true
|
|
187
|
+
copyShippedSkill(name, files, packageRoot, result)
|
|
188
|
+
result.fallback.push(name)
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const f of verified) {
|
|
193
|
+
if (f.currentHash === f.file.sha256) continue
|
|
194
|
+
noteLocalEdit(f, result, false)
|
|
195
|
+
writeFile(f.dest, f.buffer)
|
|
196
|
+
}
|
|
197
|
+
result.fetched.push(name)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── 6. Report ───────────────────────────────────────────────────────────
|
|
201
|
+
const parts = []
|
|
202
|
+
if (result.fetched.length) {
|
|
203
|
+
parts.push(`Fetched ${result.fetched.length} pinned skill(s) from upstream`)
|
|
204
|
+
}
|
|
205
|
+
if (result.fallback.length) {
|
|
206
|
+
parts.push(
|
|
207
|
+
`${result.fallback.length} restored from the copies shipped with the package`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
if (result.skipped.length) {
|
|
211
|
+
parts.push(`${result.skipped.length} already up to date`)
|
|
212
|
+
}
|
|
213
|
+
result.message = parts.join('; ')
|
|
214
|
+
stepSuccess(`Skills: ${result.message}`)
|
|
215
|
+
|
|
216
|
+
return result
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Lock helpers ─────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolves the package root from this module's own location.
|
|
223
|
+
*
|
|
224
|
+
* @returns {string} Absolute path to `<packageRoot>`.
|
|
225
|
+
*/
|
|
226
|
+
function resolvePackageRoot() {
|
|
227
|
+
const thisDir = dirname(fileURLToPath(import.meta.url)) // .../scripts/setup
|
|
228
|
+
return resolve(thisDir, '..', '..')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reads and parses a JSON lock file.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} lockPath - Absolute path to `skills-lock.json`.
|
|
235
|
+
* @returns {object|null} Parsed lock, or `null` when unreadable/invalid.
|
|
236
|
+
*/
|
|
237
|
+
function readLock(lockPath) {
|
|
238
|
+
try {
|
|
239
|
+
return JSON.parse(readFileSync(lockPath, 'utf-8'))
|
|
240
|
+
} catch {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Extracts the pinned skill entries from a parsed lock file.
|
|
247
|
+
*
|
|
248
|
+
* Only usable file records survive: a malformed lock must degrade to a no-op,
|
|
249
|
+
* never to a thrown `TypeError` while building URLs or destination paths.
|
|
250
|
+
*
|
|
251
|
+
* @param {object|null} lock - Parsed lock object.
|
|
252
|
+
* @returns {Array<{ name: string, entry: object }>} Pinned entries.
|
|
253
|
+
*/
|
|
254
|
+
function pinnedEntries(lock) {
|
|
255
|
+
const skills =
|
|
256
|
+
lock && lock.skills && typeof lock.skills === 'object' ? lock.skills : {}
|
|
257
|
+
|
|
258
|
+
return Object.entries(skills)
|
|
259
|
+
.filter(([, entry]) => isPinned(entry))
|
|
260
|
+
.map(([name, entry]) => ({
|
|
261
|
+
name,
|
|
262
|
+
entry: {
|
|
263
|
+
...entry,
|
|
264
|
+
ref: entry.ref.trim(),
|
|
265
|
+
source:
|
|
266
|
+
typeof entry.source === 'string' && entry.source.trim().length > 0
|
|
267
|
+
? entry.source.trim()
|
|
268
|
+
: 'mattpocock/skills',
|
|
269
|
+
files: entry.files.filter(isUsableFile),
|
|
270
|
+
},
|
|
271
|
+
}))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Returns `true` when an entry is pinned to a ref with at least one usable file.
|
|
276
|
+
*
|
|
277
|
+
* Entries without `ref`/`files` (the caveman skills) are deliberately ignored.
|
|
278
|
+
*
|
|
279
|
+
* @param {object|undefined} entry - Lock entry.
|
|
280
|
+
* @returns {boolean} Whether the entry has a usable pin.
|
|
281
|
+
*/
|
|
282
|
+
function isPinned(entry) {
|
|
283
|
+
return Boolean(
|
|
284
|
+
entry &&
|
|
285
|
+
typeof entry.ref === 'string' &&
|
|
286
|
+
entry.ref.trim().length > 0 &&
|
|
287
|
+
Array.isArray(entry.files) &&
|
|
288
|
+
entry.files.some(isUsableFile),
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Returns `true` when a recorded file has the fields the phase needs.
|
|
294
|
+
*
|
|
295
|
+
* @param {object|undefined} file - Recorded file entry.
|
|
296
|
+
* @returns {boolean} Whether the record can be fetched and written safely.
|
|
297
|
+
*/
|
|
298
|
+
function isUsableFile(file) {
|
|
299
|
+
return Boolean(
|
|
300
|
+
file &&
|
|
301
|
+
typeof file.path === 'string' &&
|
|
302
|
+
file.path.trim().length > 0 &&
|
|
303
|
+
typeof file.sha256 === 'string' &&
|
|
304
|
+
file.sha256.trim().length > 0 &&
|
|
305
|
+
typeof file.upstreamPath === 'string' &&
|
|
306
|
+
file.upstreamPath.trim().length > 0,
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Fetch helpers ────────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Builds the pinned raw URL for one recorded file.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} entry - Pinned lock entry.
|
|
316
|
+
* @param {{ upstreamPath: string }} file - Recorded file.
|
|
317
|
+
* @returns {string} Raw GitHub URL.
|
|
318
|
+
*/
|
|
319
|
+
function upstreamUrl(entry, file) {
|
|
320
|
+
const source = entry.source || 'mattpocock/skills'
|
|
321
|
+
return `${UPSTREAM_BASE}/${source}/${entry.ref}/${file.upstreamPath}`
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Downloads and hash-verifies every file of one skill.
|
|
326
|
+
*
|
|
327
|
+
* Buffering all files before returning guarantees a skill is never written
|
|
328
|
+
* half-verified. Throws an error tagged with `networkFailure` for transport
|
|
329
|
+
* failures so the caller can short-circuit the remaining skills.
|
|
330
|
+
*
|
|
331
|
+
* @param {Array<{ file: object, dest: string, currentHash: string|null }>} files
|
|
332
|
+
* @param {object} entry - Pinned lock entry.
|
|
333
|
+
* @param {typeof fetch} fetchImpl - Fetch implementation.
|
|
334
|
+
* @param {number} timeoutMs - Per-file timeout.
|
|
335
|
+
* @returns {Promise<Array<{ file: object, dest: string, currentHash: string|null, buffer: Buffer }>>}
|
|
336
|
+
*/
|
|
337
|
+
async function fetchVerifiedFiles(files, entry, fetchImpl, timeoutMs) {
|
|
338
|
+
const verified = []
|
|
339
|
+
|
|
340
|
+
for (const f of files) {
|
|
341
|
+
let response
|
|
342
|
+
try {
|
|
343
|
+
response = await fetchImpl(upstreamUrl(entry, f.file), buildRequestInit(timeoutMs))
|
|
344
|
+
} catch (err) {
|
|
345
|
+
throw markNetworkFailure(err)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!response || !response.ok) {
|
|
349
|
+
const status = response && response.status != null ? response.status : 'unknown'
|
|
350
|
+
throw markNetworkFailure(new Error(`HTTP ${status}`))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
354
|
+
const actual = sha256(buffer)
|
|
355
|
+
|
|
356
|
+
if (actual !== f.file.sha256) {
|
|
357
|
+
const err = new Error(
|
|
358
|
+
`hash mismatch for ${f.file.path} (expected ${f.file.sha256}, got ${actual})`,
|
|
359
|
+
)
|
|
360
|
+
err.networkFailure = false
|
|
361
|
+
throw err
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
verified.push({ ...f, buffer })
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return verified
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Builds fetch options, adding an abort timeout when the runtime supports it.
|
|
372
|
+
*
|
|
373
|
+
* @param {number} timeoutMs - Timeout in milliseconds.
|
|
374
|
+
* @returns {object} `RequestInit`-compatible options.
|
|
375
|
+
*/
|
|
376
|
+
function buildRequestInit(timeoutMs) {
|
|
377
|
+
if (
|
|
378
|
+
typeof AbortSignal === 'undefined' ||
|
|
379
|
+
typeof AbortSignal.timeout !== 'function'
|
|
380
|
+
) {
|
|
381
|
+
return {}
|
|
382
|
+
}
|
|
383
|
+
return { signal: AbortSignal.timeout(timeoutMs) }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Tags an error as a transport (network) failure.
|
|
388
|
+
*
|
|
389
|
+
* @param {*} err - Thrown value.
|
|
390
|
+
* @returns {*} The same value, with `networkFailure = true` when possible.
|
|
391
|
+
*/
|
|
392
|
+
function markNetworkFailure(err) {
|
|
393
|
+
if (err && typeof err === 'object') err.networkFailure = true
|
|
394
|
+
return err
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Produces a short human-readable reason for a failure.
|
|
399
|
+
*
|
|
400
|
+
* @param {*} err - Thrown value.
|
|
401
|
+
* @returns {string} Reason text.
|
|
402
|
+
*/
|
|
403
|
+
function describeError(err) {
|
|
404
|
+
if (!err) return 'unknown error'
|
|
405
|
+
if (err.name === 'TimeoutError') return 'timed out'
|
|
406
|
+
return err.message || String(err)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Fallback / write helpers ─────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Copies one skill from the package's shipped copies into the consumer.
|
|
413
|
+
*
|
|
414
|
+
* Every recorded file is read and validated into memory before the first write,
|
|
415
|
+
* so a missing/unreadable shipped file cannot leave a half-populated skill
|
|
416
|
+
* directory. Missing or hash-mismatched shipped files produce warnings but
|
|
417
|
+
* never throw. Files whose current content differs from the pin are reported
|
|
418
|
+
* with the same local-edit warning used by the upstream path (REQ-14).
|
|
419
|
+
*
|
|
420
|
+
* @param {string} name - Skill name.
|
|
421
|
+
* @param {Array<{ file: object, dest: string, currentHash: string|null }>} files
|
|
422
|
+
* Recorded files with their resolved destinations.
|
|
423
|
+
* @param {string} packageRoot - Package root.
|
|
424
|
+
* @param {string} consumerRoot - Consumer project root.
|
|
425
|
+
* @param {object} result - Mutable phase result.
|
|
426
|
+
* @returns {number} Number of files copied.
|
|
427
|
+
*/
|
|
428
|
+
function copyShippedSkill(name, files, packageRoot, result) {
|
|
429
|
+
const sourceDir = join(packageRoot, ...SKILLS_DIR, name)
|
|
430
|
+
const staged = []
|
|
431
|
+
|
|
432
|
+
for (const { file, dest, currentHash } of files) {
|
|
433
|
+
const source = join(sourceDir, file.path)
|
|
434
|
+
let content
|
|
435
|
+
try {
|
|
436
|
+
content = readFileSync(source)
|
|
437
|
+
} catch (err) {
|
|
438
|
+
const reason =
|
|
439
|
+
err && err.code === 'ENOENT'
|
|
440
|
+
? 'Shipped copy missing'
|
|
441
|
+
: 'Shipped copy unreadable'
|
|
442
|
+
const msg = `${reason} for ${name}/${file.path}`
|
|
443
|
+
result.warnings.push(msg)
|
|
444
|
+
stepWarn(`Skills: ${msg}`)
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (sha256(content) !== file.sha256) {
|
|
449
|
+
const msg = `Shipped copy does not match the lock hash: ${name}/${file.path}`
|
|
450
|
+
result.warnings.push(msg)
|
|
451
|
+
stepWarn(`Skills: ${msg}`)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
staged.push({ file, dest, currentHash, content })
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Stage-then-commit: only write when the whole skill is available.
|
|
458
|
+
if (staged.length !== files.length) {
|
|
459
|
+
return 0
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
for (const { file, dest, currentHash, content } of staged) {
|
|
464
|
+
noteLocalEdit({ file, dest, currentHash }, result, false)
|
|
465
|
+
writeFile(dest, content)
|
|
466
|
+
}
|
|
467
|
+
} catch (err) {
|
|
468
|
+
const msg = `Could not write the shipped copy for ${name}: ${describeError(err)}`
|
|
469
|
+
result.warnings.push(msg)
|
|
470
|
+
stepWarn(`Skills: ${msg}`)
|
|
471
|
+
return 0
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return staged.length
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Resolves a destination path and rejects paths that escape the skill dir.
|
|
479
|
+
*
|
|
480
|
+
* @param {string} consumerRoot - Consumer project root.
|
|
481
|
+
* @param {string} name - Skill name.
|
|
482
|
+
* @param {{ path: string }} file - Recorded file.
|
|
483
|
+
* @param {object} result - Mutable phase result.
|
|
484
|
+
* @returns {string|null} Absolute destination, or `null` when rejected.
|
|
485
|
+
*/
|
|
486
|
+
function destinationFor(consumerRoot, name, file, result) {
|
|
487
|
+
const skillDir = join(consumerRoot, ...SKILLS_DIR, name)
|
|
488
|
+
const dest = resolve(skillDir, file.path)
|
|
489
|
+
|
|
490
|
+
if (dest !== skillDir && !dest.startsWith(skillDir + sep)) {
|
|
491
|
+
const msg = `Refusing to write outside ${skillDir}: ${file.path}`
|
|
492
|
+
result.warnings.push(msg)
|
|
493
|
+
stepWarn(`Skills: ${msg}`)
|
|
494
|
+
return null
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return dest
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Records (and logs) a warning for a file that has local edits.
|
|
502
|
+
*
|
|
503
|
+
* @param {{ file: object, dest: string, currentHash: string|null }} state
|
|
504
|
+
* @param {object} result - Mutable phase result.
|
|
505
|
+
* @param {boolean} dryRun - Whether the run is a dry run.
|
|
506
|
+
*/
|
|
507
|
+
function noteLocalEdit(state, result, dryRun) {
|
|
508
|
+
if (state.currentHash === null || state.currentHash === state.file.sha256) {
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const msg = dryRun
|
|
513
|
+
? `Local edits in ${state.dest} would be overwritten by the pinned version`
|
|
514
|
+
: `Local edits in ${state.dest} \u2014 overwriting with the pinned version`
|
|
515
|
+
result.warnings.push(msg)
|
|
516
|
+
stepWarn(`Skills: ${msg}`)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── Filesystem helpers ───────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Writes a buffer, creating parent directories.
|
|
523
|
+
*
|
|
524
|
+
* @param {string} dest - Absolute destination path.
|
|
525
|
+
* @param {Buffer|string} content - Content to write.
|
|
526
|
+
*/
|
|
527
|
+
function writeFile(dest, content) {
|
|
528
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
529
|
+
writeFileSync(dest, content)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Computes the SHA-256 hex digest of a buffer.
|
|
534
|
+
*
|
|
535
|
+
* @param {Buffer} buffer - Content to hash.
|
|
536
|
+
* @returns {string} Lowercase hex digest.
|
|
537
|
+
*/
|
|
538
|
+
function sha256(buffer) {
|
|
539
|
+
return createHash('sha256').update(buffer).digest('hex')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Hashes a file on disk.
|
|
544
|
+
*
|
|
545
|
+
* @param {string} filePath - Absolute file path.
|
|
546
|
+
* @returns {string|null} Hex digest, or `null` when the file is absent.
|
|
547
|
+
*/
|
|
548
|
+
function hashFile(filePath) {
|
|
549
|
+
try {
|
|
550
|
+
return sha256(readFileSync(filePath))
|
|
551
|
+
} catch {
|
|
552
|
+
return null
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Checks whether a path exists and is a directory.
|
|
558
|
+
*
|
|
559
|
+
* @param {string} dirPath - Path to check.
|
|
560
|
+
* @returns {boolean} `true` for an existing directory.
|
|
561
|
+
*/
|
|
562
|
+
function isDirectory(dirPath) {
|
|
563
|
+
try {
|
|
564
|
+
return statSync(dirPath).isDirectory()
|
|
565
|
+
} catch {
|
|
566
|
+
return false
|
|
567
|
+
}
|
|
568
|
+
}
|