skillcaddy 0.27.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 (99) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/CONTEXT.md +149 -0
  3. package/LICENSE +21 -0
  4. package/README.md +499 -0
  5. package/README_CN.md +482 -0
  6. package/collection-metadata/github/mattpocock.json +16 -0
  7. package/lib/canonicalJson.js +11 -0
  8. package/lib/claudeStore.js +135 -0
  9. package/lib/collectionEnablement.js +115 -0
  10. package/lib/collectionSetup.js +199 -0
  11. package/lib/enablePlan.js +116 -0
  12. package/lib/enablementScope.js +8 -0
  13. package/lib/hermesStore.js +21 -0
  14. package/lib/libraryImage.js +1785 -0
  15. package/lib/managerInstall.js +109 -0
  16. package/lib/managerSourceWorkflow.js +234 -0
  17. package/lib/projectActions.js +24 -0
  18. package/lib/projectPath.js +51 -0
  19. package/lib/roots.js +99 -0
  20. package/lib/skillLinkStore.js +68 -0
  21. package/lib/skillMetadata.js +191 -0
  22. package/lib/skillStore.js +541 -0
  23. package/lib/sourceAcquisition.js +475 -0
  24. package/lib/sourceAcquisitionAdapter.js +47 -0
  25. package/lib/sourceAcquisitionError.js +18 -0
  26. package/lib/sourceAcquisitionPolicy.js +139 -0
  27. package/lib/sourceArchive.js +363 -0
  28. package/lib/sourceArchiveWorkspace.js +36 -0
  29. package/lib/sourceGit.js +95 -0
  30. package/lib/sourceGitAcquisitionAdapter.js +145 -0
  31. package/lib/sourceGitBatchUpdate.js +168 -0
  32. package/lib/sourceGitRepair.js +227 -0
  33. package/lib/sourceGitStaging.js +139 -0
  34. package/lib/sourceGitUpgradeAdapter.js +583 -0
  35. package/lib/sourceGitUrl.js +170 -0
  36. package/lib/sourceHttp.js +302 -0
  37. package/lib/sourceLocal.js +200 -0
  38. package/lib/sourceLocalAcquisitionAdapter.js +107 -0
  39. package/lib/sourceManager.js +242 -0
  40. package/lib/sourceMigration.js +172 -0
  41. package/lib/sourceMigrationIssue.js +6 -0
  42. package/lib/sourcePath.js +6 -0
  43. package/lib/sourcePolicy.js +53 -0
  44. package/lib/sourceRecord.js +295 -0
  45. package/lib/sourceRegistry.js +155 -0
  46. package/lib/sourceRemoteArchiveAcquisitionAdapter.js +147 -0
  47. package/lib/sourceRemoteFileAcquisitionAdapter.js +95 -0
  48. package/lib/sourceRemoteFilePolicy.js +62 -0
  49. package/lib/sourceRemoteFileWorkspace.js +55 -0
  50. package/lib/sourceSkillDiscovery.js +117 -0
  51. package/lib/sourceTree.js +185 -0
  52. package/lib/sourceUpgrade.js +596 -0
  53. package/lib/sourceUpgradeAdapter.js +41 -0
  54. package/lib/sourceUpgradePolicy.js +170 -0
  55. package/lib/sourceUpgradeTransaction.js +202 -0
  56. package/lib/sourceValidation.js +120 -0
  57. package/lib/sourceWorkspace.js +37 -0
  58. package/lib/specGate.js +235 -0
  59. package/lib/tuiActions.js +169 -0
  60. package/lib/tuiInstall.js +133 -0
  61. package/lib/tuiLayout.js +94 -0
  62. package/lib/version.js +17 -0
  63. package/lib/webManager.js +370 -0
  64. package/package.json +74 -0
  65. package/public/agentsUi.js +138 -0
  66. package/public/app.js +1431 -0
  67. package/public/claude.css +2 -0
  68. package/public/claudeUi.js +119 -0
  69. package/public/emptyState.js +12 -0
  70. package/public/enabled.css +303 -0
  71. package/public/favicon.svg +5 -0
  72. package/public/index.html +199 -0
  73. package/public/skillcaddy_CN.png +0 -0
  74. package/public/skillcaddy_EN.png +0 -0
  75. package/public/styles.css +1611 -0
  76. package/scripts/cli.js +647 -0
  77. package/scripts/cron-sync-update-git.sh +128 -0
  78. package/scripts/pull-github.sh +22 -0
  79. package/scripts/source.js +638 -0
  80. package/scripts/tui.js +1058 -0
  81. package/server.js +196 -0
  82. package/skills/skillcaddy-manager/SKILL.md +93 -0
  83. package/skills/skillcaddy-manager/agents/openai.yaml +7 -0
  84. package/skills/skillcaddy-manager/references/METADATA.md +75 -0
  85. package/skills/skillcaddy-manager/references/OPERATIONS.md +315 -0
  86. package/skills/skillcaddy-manager/references/RECOMMENDATION_GUIDE.md +51 -0
  87. package/skills/skillcaddy-manager/references/featured-skills.json +564 -0
  88. package/skills/skillcaddy-manager/references/skill-platforms.json +48 -0
  89. package/skills/skillcaddy-manager/scripts/check-conflicts.cjs +117 -0
  90. package/skills/skillcaddy-manager/scripts/check-global-skills.cjs +169 -0
  91. package/skills/skillcaddy-manager/scripts/translate-skill-notes.cjs +297 -0
  92. package/skills/skillcaddy-manager/scripts/version-manager.cjs +146 -0
  93. package/skills/skillcaddy-manager/scripts/view-recommendations.cjs +279 -0
  94. package/skills/skillcaddy-manager/skillcaddy.json +9 -0
  95. package/skills/spec-pipeline/spec-direction-gate/SKILL.md +119 -0
  96. package/skills/spec-pipeline/spec-direction-gate/check-approval.py +110 -0
  97. package/skills/spec-pipeline/spec-draft-intake/SKILL.md +116 -0
  98. package/skills/spec-pipeline/spec-role-contract/SKILL.md +113 -0
  99. package/skills/spec-pipeline/spec-role-contract/check-roles.py +108 -0
@@ -0,0 +1,583 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import {
3
+ mkdtemp,
4
+ rm
5
+ } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import path from 'node:path';
8
+ import { setTimeout as wait } from 'node:timers/promises';
9
+ import { promisify } from 'node:util';
10
+ import { SourceAcquisitionError } from './sourceAcquisitionError.js';
11
+ import { canonicalGitRepositoryLocation } from './sourceGitUrl.js';
12
+ import { SOURCE_INSTALLING_MARKER } from './sourcePolicy.js';
13
+ import { sanitizeGitRemote } from './sourceRecord.js';
14
+ import { sourcePathExists, validateSourceTree } from './sourceTree.js';
15
+ import { validateStagedSource } from './sourceValidation.js';
16
+ import { defineSourceUpgradeAdapter } from './sourceUpgradeAdapter.js';
17
+
18
+ const execFile = promisify(execFileCallback);
19
+ const DEFAULT_FETCH_RETRY_DELAYS_MS = Object.freeze([2000, 5000]);
20
+
21
+ export function createGitUpgradeAdapter(context, request, record) {
22
+ if (request?.input !== undefined) {
23
+ throw new Error('Git source update does not accept replacement input');
24
+ }
25
+ assertGitSourceRecord(record);
26
+
27
+ return defineSourceUpgradeAdapter({
28
+ stalePlanMessage: 'Git source changed since the update plan',
29
+ kind: 'git',
30
+ inspect: () => inspectGitCandidate(context, record),
31
+ prepare: () => inspectGitCandidate(context, record)
32
+ });
33
+ }
34
+
35
+ export async function inspectGitRegistryRepair(context, record) {
36
+ const { rootDir } = context;
37
+ const destination = path.resolve(rootDir, record.installPath);
38
+ if (!(await sourcePathExists(destination))) {
39
+ throw gitUpgradeError(
40
+ 'source-collision',
41
+ `Registered source content is missing: ${record.sourceId}`,
42
+ 3
43
+ );
44
+ }
45
+ if (!record.origin.commit) {
46
+ throw gitUpgradeError(
47
+ 'unresolved-identity',
48
+ `Git source registry repair requires a registered commit: ${record.sourceId}`,
49
+ 3
50
+ );
51
+ }
52
+ if (!record.origin.ref) {
53
+ throw gitUpgradeError(
54
+ 'unresolved-identity',
55
+ `Git source registry repair requires a tracked ref: ${record.sourceId}`,
56
+ 3
57
+ );
58
+ }
59
+
60
+ const currentCommit = await readGitOutput(
61
+ destination,
62
+ ['rev-parse', 'HEAD'],
63
+ record.sourceId
64
+ );
65
+ await assertTrackedRef(destination, record);
66
+ await assertRegisteredRemote(destination, record);
67
+
68
+ const status = await readGitOutput(
69
+ destination,
70
+ ['status', '--porcelain'],
71
+ record.sourceId
72
+ );
73
+ if (status) {
74
+ return {
75
+ status: 'dirty',
76
+ currentCommit,
77
+ skills: record.skills,
78
+ warnings: []
79
+ };
80
+ }
81
+
82
+ await validateSourceTree(rootDir, destination);
83
+ const validated = await validateStagedSource(destination);
84
+ assertFocusContainsSkill(validated.skills, record.focus);
85
+
86
+ if (currentCommit === record.origin.commit) {
87
+ if (JSON.stringify(record.skills) !== JSON.stringify(validated.skills)) {
88
+ throw gitUpgradeError(
89
+ 'source-collision',
90
+ `Registered Git source skills do not match its source record: ${record.sourceId}`,
91
+ 3
92
+ );
93
+ }
94
+ return {
95
+ status: 'current',
96
+ currentCommit,
97
+ skills: validated.skills,
98
+ warnings: validated.warnings
99
+ };
100
+ }
101
+
102
+ await fetchGit(
103
+ context,
104
+ destination,
105
+ ['fetch', '--no-tags', 'origin', `refs/heads/${record.origin.ref}`],
106
+ record.sourceId,
107
+ 'Could not fetch Git source for registry repair'
108
+ );
109
+ const remoteCommit = await readGitOutput(
110
+ destination,
111
+ ['rev-parse', 'FETCH_HEAD'],
112
+ record.sourceId
113
+ );
114
+ if (currentCommit !== remoteCommit) {
115
+ throw gitUpgradeError(
116
+ 'source-not-synchronized',
117
+ `Git source checkout is not at origin/${record.origin.ref}; run git pull before registry repair: ${record.sourceId}`
118
+ );
119
+ }
120
+ if (!await isAncestor(destination, record.origin.commit, currentCommit, record.sourceId)) {
121
+ throw gitUpgradeError(
122
+ 'non-fast-forward',
123
+ `Git source registry cannot adopt a non-fast-forward checkout: ${record.sourceId}`
124
+ );
125
+ }
126
+
127
+ return {
128
+ status: 'ready',
129
+ currentCommit,
130
+ skills: validated.skills,
131
+ warnings: validated.warnings
132
+ };
133
+ }
134
+
135
+ async function inspectGitCandidate(context, record) {
136
+ const { rootDir } = context;
137
+ const destination = path.resolve(rootDir, record.installPath);
138
+ if (!(await sourcePathExists(destination))) {
139
+ throw gitUpgradeError(
140
+ 'source-collision',
141
+ `Registered source content is missing: ${record.sourceId}`,
142
+ 3
143
+ );
144
+ }
145
+
146
+ const currentCommit = await readGitOutput(
147
+ destination,
148
+ ['rev-parse', 'HEAD'],
149
+ record.sourceId
150
+ );
151
+ await assertTrackedRef(destination, record);
152
+ await assertRegisteredRemote(destination, record);
153
+
154
+ const status = await readGitOutput(
155
+ destination,
156
+ ['status', '--porcelain'],
157
+ record.sourceId
158
+ );
159
+ if (status) {
160
+ return buildCandidate({
161
+ record,
162
+ status: 'dirty',
163
+ currentCommit,
164
+ incomingCommit: currentCommit,
165
+ skills: record.skills,
166
+ warnings: []
167
+ });
168
+ }
169
+
170
+ const fetchContext = record.origin.commit === currentCommit
171
+ ? context
172
+ : { ...context, gitFetchRetryDelaysMs: [] };
173
+ try {
174
+ await fetchGit(
175
+ fetchContext,
176
+ destination,
177
+ ['fetch', '--no-tags', 'origin', `refs/heads/${record.origin.ref}`],
178
+ record.sourceId,
179
+ 'Could not fetch Git source'
180
+ );
181
+ } catch (error) {
182
+ if (record.origin.commit !== currentCommit) {
183
+ assertRecordedCommit(record, currentCommit);
184
+ }
185
+ throw error;
186
+ }
187
+ const incomingCommit = await readGitOutput(
188
+ destination,
189
+ ['rev-parse', 'FETCH_HEAD'],
190
+ record.sourceId
191
+ );
192
+ if (incomingCommit === currentCommit) {
193
+ assertRecordedCommit(record, currentCommit);
194
+ return buildCandidate({
195
+ record,
196
+ status: 'current',
197
+ currentCommit,
198
+ incomingCommit,
199
+ skills: record.skills,
200
+ warnings: []
201
+ });
202
+ }
203
+
204
+ if (!await isAncestor(
205
+ destination,
206
+ currentCommit,
207
+ incomingCommit,
208
+ record.sourceId
209
+ )) {
210
+ throw gitUpgradeError(
211
+ 'non-fast-forward',
212
+ `Git source cannot advance by fast-forward: ${record.sourceId}`
213
+ );
214
+ }
215
+ assertRecordedCommit(record, currentCommit);
216
+ const prepared = await inspectGitTrees(
217
+ destination,
218
+ currentCommit,
219
+ incomingCommit,
220
+ record
221
+ );
222
+ if (JSON.stringify(prepared.current.skills) !== JSON.stringify(record.skills)) {
223
+ throw gitUpgradeError(
224
+ 'source-collision',
225
+ `Registered Git source skills do not match its source record: ${
226
+ record.sourceId
227
+ }`,
228
+ 3
229
+ );
230
+ }
231
+ const changedFiles = await readChangedFiles(
232
+ destination,
233
+ currentCommit,
234
+ incomingCommit,
235
+ record.sourceId
236
+ );
237
+ return buildCandidate({
238
+ record,
239
+ status: 'ready',
240
+ currentCommit,
241
+ incomingCommit,
242
+ skills: prepared.incoming.skills,
243
+ warnings: prepared.incoming.warnings,
244
+ skillChanges: classifySkillChanges(
245
+ prepared.current.skills,
246
+ prepared.incoming.skills,
247
+ changedFiles
248
+ )
249
+ });
250
+ }
251
+
252
+ async function assertTrackedRef(destination, record) {
253
+ const branch = await readGitOutput(
254
+ destination,
255
+ ['symbolic-ref', '--quiet', '--short', 'HEAD'],
256
+ record.sourceId
257
+ );
258
+ if (branch !== record.origin.ref) {
259
+ throw gitUpgradeError(
260
+ 'source-collision',
261
+ `Registered Git source is not on its tracked ref: ${record.sourceId}`,
262
+ 3
263
+ );
264
+ }
265
+ }
266
+
267
+ async function assertRegisteredRemote(destination, record) {
268
+ const configuredRemote = sanitizeGitRemote(
269
+ await readGitOutput(
270
+ destination,
271
+ ['config', '--get', 'remote.origin.url'],
272
+ record.sourceId
273
+ )
274
+ );
275
+ if (
276
+ canonicalGitRepositoryLocation(configuredRemote) !==
277
+ canonicalGitRepositoryLocation(record.origin.remote)
278
+ ) {
279
+ throw gitUpgradeError(
280
+ 'source-collision',
281
+ `Registered Git source origin does not match its source record: ${
282
+ record.sourceId
283
+ }`,
284
+ 3
285
+ );
286
+ }
287
+ }
288
+
289
+ function assertRecordedCommit(record, currentCommit) {
290
+ if (record.origin.commit !== currentCommit) {
291
+ throw gitUpgradeError(
292
+ 'source-collision',
293
+ `Registered Git source commit does not match its source record: ${
294
+ record.sourceId
295
+ }`,
296
+ 3
297
+ );
298
+ }
299
+ }
300
+
301
+ async function inspectGitTrees(
302
+ destination,
303
+ currentCommit,
304
+ incomingCommit,
305
+ record
306
+ ) {
307
+ const stagingRoot = await mkdtemp(
308
+ path.join(tmpdir(), 'skillcaddy-git-update-')
309
+ );
310
+ const contentRoot = path.join(stagingRoot, 'repository');
311
+ try {
312
+ await execGit(
313
+ stagingRoot,
314
+ ['clone', '--quiet', '--no-checkout', '--', destination, contentRoot],
315
+ record.sourceId,
316
+ 'Could not inspect incoming Git source'
317
+ );
318
+ const current = await checkoutAndValidateTree({
319
+ stagingRoot,
320
+ contentRoot,
321
+ commit: currentCommit,
322
+ record
323
+ });
324
+ const incoming = currentCommit === incomingCommit
325
+ ? current
326
+ : await checkoutAndValidateTree({
327
+ stagingRoot,
328
+ contentRoot,
329
+ commit: incomingCommit,
330
+ record
331
+ });
332
+ return { current, incoming };
333
+ } finally {
334
+ await rm(stagingRoot, { recursive: true, force: true });
335
+ }
336
+ }
337
+
338
+ async function checkoutAndValidateTree({
339
+ stagingRoot,
340
+ contentRoot,
341
+ commit,
342
+ record
343
+ }) {
344
+ await execGit(
345
+ contentRoot,
346
+ ['checkout', '--quiet', '--detach', commit],
347
+ record.sourceId,
348
+ 'Could not inspect Git source tree'
349
+ );
350
+ if (await sourcePathExists(path.join(contentRoot, SOURCE_INSTALLING_MARKER))) {
351
+ throw gitUpgradeError(
352
+ 'source-validation',
353
+ `Git source contains reserved publication marker: ${
354
+ SOURCE_INSTALLING_MARKER
355
+ }`
356
+ );
357
+ }
358
+ await validateSourceTree(stagingRoot, contentRoot);
359
+ const validated = await validateStagedSource(contentRoot);
360
+ assertFocusContainsSkill(validated.skills, record.focus);
361
+ return validated;
362
+ }
363
+
364
+ function buildCandidate({
365
+ record,
366
+ status,
367
+ currentCommit,
368
+ incomingCommit,
369
+ skills,
370
+ warnings,
371
+ skillChanges
372
+ }) {
373
+ return {
374
+ status,
375
+ input: {
376
+ type: 'git',
377
+ remote: record.origin.remote
378
+ },
379
+ currentCommit,
380
+ incomingCommit,
381
+ skills,
382
+ warnings,
383
+ ...(skillChanges ? { skillChanges } : {})
384
+ };
385
+ }
386
+
387
+ async function readChangedFiles(
388
+ destination,
389
+ currentCommit,
390
+ incomingCommit,
391
+ sourceId
392
+ ) {
393
+ const result = await execGit(
394
+ destination,
395
+ [
396
+ 'diff',
397
+ '--name-only',
398
+ '--no-renames',
399
+ '-z',
400
+ currentCommit,
401
+ incomingCommit,
402
+ '--'
403
+ ],
404
+ sourceId,
405
+ 'Could not summarize Git source changes'
406
+ );
407
+ return result.stdout.split('\0').filter(Boolean);
408
+ }
409
+
410
+ function classifySkillChanges(previousSkills, nextSkills, changedFiles) {
411
+ const previous = new Set(previousSkills);
412
+ const next = new Set(nextSkills);
413
+ const changedSkill = (skillPath) => changedFiles.some((filePath) =>
414
+ skillPath === '.' || filePath === skillPath || filePath.startsWith(`${skillPath}/`)
415
+ );
416
+
417
+ return {
418
+ added: nextSkills.filter((skillPath) => !previous.has(skillPath)),
419
+ edited: nextSkills.filter((skillPath) =>
420
+ previous.has(skillPath) && changedSkill(skillPath)
421
+ ),
422
+ deleted: previousSkills.filter((skillPath) => !next.has(skillPath))
423
+ };
424
+ }
425
+
426
+ function assertGitSourceRecord(record) {
427
+ if (record.type !== 'git') {
428
+ throw gitUpgradeError(
429
+ 'unsupported-source',
430
+ `Git update requires a Git source: ${record.sourceId}`
431
+ );
432
+ }
433
+ if (!record.origin.ref || !record.origin.commit) {
434
+ throw gitUpgradeError(
435
+ 'unresolved-identity',
436
+ `Git source record requires a tracked ref and commit: ${record.sourceId}`,
437
+ 3
438
+ );
439
+ }
440
+ }
441
+
442
+ async function readGitOutput(directory, args, sourceId) {
443
+ const result = await execGit(
444
+ directory,
445
+ args,
446
+ sourceId,
447
+ 'Could not inspect Git source'
448
+ );
449
+ return result.stdout.trim();
450
+ }
451
+
452
+ async function isAncestor(directory, ancestor, descendant, sourceId) {
453
+ try {
454
+ await execFile('git', [
455
+ '-C',
456
+ directory,
457
+ 'merge-base',
458
+ '--is-ancestor',
459
+ ancestor,
460
+ descendant
461
+ ]);
462
+ return true;
463
+ } catch (error) {
464
+ if (error.code === 1) return false;
465
+ throw gitUpgradeError(
466
+ 'git-update',
467
+ `Could not compare Git source commits: ${sourceId}`
468
+ );
469
+ }
470
+ }
471
+
472
+ async function execGit(directory, args, sourceId, action) {
473
+ try {
474
+ return await execFile('git', ['-C', directory, ...args]);
475
+ } catch (error) {
476
+ throw gitUpgradeError(
477
+ 'git-update',
478
+ formatGitFailure(action, sourceId, error)
479
+ );
480
+ }
481
+ }
482
+
483
+ async function fetchGit(context, directory, args, sourceId, action) {
484
+ const retryDelays = fetchRetryDelays(context);
485
+ let attempts = 0;
486
+ let lastError;
487
+
488
+ while (attempts <= retryDelays.length) {
489
+ attempts += 1;
490
+ try {
491
+ return await execFile('git', ['-C', directory, ...args]);
492
+ } catch (error) {
493
+ lastError = error;
494
+ if (!isTransientGitFailure(error) || attempts > retryDelays.length) break;
495
+ await wait(retryDelays[attempts - 1]);
496
+ }
497
+ }
498
+
499
+ const category = isTransientGitFailure(lastError)
500
+ ? 'git-network'
501
+ : 'git-update';
502
+ throw gitUpgradeError(
503
+ category,
504
+ formatGitFailure(action, sourceId, lastError, attempts)
505
+ );
506
+ }
507
+
508
+ function fetchRetryDelays(context) {
509
+ const configured = context?.gitFetchRetryDelaysMs;
510
+ if (configured === undefined) return DEFAULT_FETCH_RETRY_DELAYS_MS;
511
+ if (
512
+ !Array.isArray(configured) ||
513
+ configured.some((delay) => !Number.isFinite(delay) || delay < 0)
514
+ ) {
515
+ throw new TypeError('gitFetchRetryDelaysMs must contain non-negative numbers');
516
+ }
517
+ return configured;
518
+ }
519
+
520
+ function isTransientGitFailure(error) {
521
+ const detail = rawGitError(error);
522
+ return [
523
+ /could not resolve host/i,
524
+ /temporary failure in name resolution/i,
525
+ /network is unreachable/i,
526
+ /failed to connect/i,
527
+ /connection (?:reset|timed out|refused|closed)/i,
528
+ /operation timed out/i,
529
+ /recv failure|send failure/i,
530
+ /empty reply from server/i,
531
+ /(?:tls|gnutls|ssl).*(?:error|failed|terminated|timed out)/i,
532
+ /http\/2 stream.*(?:closed|reset|cancel)/i,
533
+ /rpc failed/i,
534
+ /unexpected disconnect|remote end hung up|early eof/i,
535
+ /(?:http |requested url returned error: )(?:429|500|502|503|504)/i
536
+ ].some((pattern) => pattern.test(detail));
537
+ }
538
+
539
+ function formatGitFailure(action, sourceId, error, attempts) {
540
+ const detail = safeGitError(error);
541
+ const metadata = [
542
+ detail,
543
+ attempts === undefined ? '' : `attempts=${attempts}`
544
+ ].filter(Boolean).join('; ');
545
+ return `${action}: ${sourceId}${metadata ? ` (${metadata})` : ''}`;
546
+ }
547
+
548
+ function rawGitError(error) {
549
+ if (typeof error?.stderr === 'string' && error.stderr.trim()) {
550
+ return error.stderr.trim();
551
+ }
552
+ return String(error?.message || '');
553
+ }
554
+
555
+ function safeGitError(error) {
556
+ const lines = rawGitError(error)
557
+ .replace(/(?:https?|ssh|git):\/\/[^\s'"]+/gi, '[sanitized Git URL]')
558
+ .replace(/(?:[^\s@'"]+@)?[^\s:/'"]+:[^\s'"]+/g, '[sanitized Git URL]')
559
+ .split('\n')
560
+ .map((line) => line.trim())
561
+ .filter(Boolean);
562
+ const selected = lines.length <= 4
563
+ ? lines
564
+ : [lines[0], ...lines.slice(-3)];
565
+ return selected.join(' | ').slice(0, 600);
566
+ }
567
+
568
+ function assertFocusContainsSkill(skills, focus) {
569
+ if (!focus?.path) return;
570
+ if (!skills.some(
571
+ (skillPath) => skillPath === focus.path ||
572
+ skillPath.startsWith(`${focus.path}/`)
573
+ )) {
574
+ throw gitUpgradeError(
575
+ 'source-validation',
576
+ `Git source focus contains no scanner-visible skill: ${focus.path}`
577
+ );
578
+ }
579
+ }
580
+
581
+ function gitUpgradeError(category, message, exitCode = 1) {
582
+ return new SourceAcquisitionError(category, message, exitCode);
583
+ }