forge-workflow 0.1.0-beta.5 → 0.1.0-beta.6

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 (128) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +36 -0
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/bin/forge.js +12 -1
  6. package/docs/guides/MIGRATION.md +3 -3
  7. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  8. package/docs/reference/INSTALL.md +4 -0
  9. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  10. package/docs/reference/RELEASE.md +4 -4
  11. package/docs/reference/github-accounts.md +134 -0
  12. package/docs/reference/shepherd.md +63 -13
  13. package/lib/adapters/pr-state-adapter.js +15 -2
  14. package/lib/base-remote.js +138 -0
  15. package/lib/beta5-compatibility-evidence.js +1093 -0
  16. package/lib/bun-lockfile-proof.js +413 -0
  17. package/lib/bun-workflow-pins.js +461 -0
  18. package/lib/capabilities/index.js +9 -0
  19. package/lib/capabilities/model.js +141 -0
  20. package/lib/capabilities/probes.js +347 -0
  21. package/lib/codex-skills.js +2 -2
  22. package/lib/commands/_manifest.js +1 -0
  23. package/lib/commands/_registry.js +48 -18
  24. package/lib/commands/clean.js +57 -1
  25. package/lib/commands/doctor.js +37 -6
  26. package/lib/commands/gate.js +197 -27
  27. package/lib/commands/github.js +215 -0
  28. package/lib/commands/hooks.js +54 -6
  29. package/lib/commands/memory.js +66 -2
  30. package/lib/commands/merge.js +720 -73
  31. package/lib/commands/plan.js +33 -2
  32. package/lib/commands/pr.js +2 -0
  33. package/lib/commands/preflight.js +10 -2
  34. package/lib/commands/push.js +108 -6
  35. package/lib/commands/recall.js +95 -61
  36. package/lib/commands/release.js +23 -2
  37. package/lib/commands/remember.js +28 -4
  38. package/lib/commands/serve.js +26 -9
  39. package/lib/commands/setup.js +132 -4
  40. package/lib/commands/shepherd.js +578 -72
  41. package/lib/commands/ship.js +15 -69
  42. package/lib/commands/skill.js +8 -0
  43. package/lib/commands/team.js +47 -8
  44. package/lib/commands/test.js +163 -4
  45. package/lib/commands/validate.js +65 -21
  46. package/lib/commands/worktree.js +155 -19
  47. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  48. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  49. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  50. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  51. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  52. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  53. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  54. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  55. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  56. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  57. package/lib/forge-issues.js +78 -0
  58. package/lib/gate-events.js +98 -10
  59. package/lib/github-context.js +308 -0
  60. package/lib/global-flags.js +1 -0
  61. package/lib/hook-renderer.js +29 -1
  62. package/lib/issue-render.js +19 -0
  63. package/lib/kernel/broker.js +723 -31
  64. package/lib/kernel/claim-reconciler.js +238 -0
  65. package/lib/kernel/lease-enforcer.js +9 -4
  66. package/lib/kernel/legacy-claim-repair.js +442 -0
  67. package/lib/kernel/live-claim-projection.js +26 -0
  68. package/lib/kernel/migrations.js +118 -3
  69. package/lib/kernel/readiness-model.js +184 -12
  70. package/lib/kernel/schema.js +49 -1
  71. package/lib/kernel/sqlite-driver.js +3322 -183
  72. package/lib/kernel/taxonomy-validator.js +4 -1
  73. package/lib/kernel/windows-private-acl.js +239 -0
  74. package/lib/memory/hygiene.js +191 -0
  75. package/lib/memory/router.js +94 -27
  76. package/lib/memory/usage-evidence.js +4 -0
  77. package/lib/memory-digest.js +59 -0
  78. package/lib/merge-rules.js +135 -17
  79. package/lib/npm-publish-workflow.js +233 -40
  80. package/lib/package-root.js +2 -0
  81. package/lib/pr-monitor/auto-actions.js +169 -28
  82. package/lib/pr-monitor/differ.js +110 -4
  83. package/lib/pr-monitor/events.js +0 -0
  84. package/lib/pr-monitor/flow-monitor.js +1424 -0
  85. package/lib/pr-monitor/gather.js +251 -44
  86. package/lib/pr-monitor/journal.js +0 -37
  87. package/lib/pr-monitor/monitor.js +117 -10
  88. package/lib/pr-monitor/process-identity.js +117 -0
  89. package/lib/pr-monitor/reconcile-executor.js +1101 -625
  90. package/lib/pr-monitor/reconcile.js +0 -0
  91. package/lib/pr-monitor/render-summary.js +121 -24
  92. package/lib/pr-monitor/review-preflight.js +269 -0
  93. package/lib/pr-monitor/shepherd-lease.js +28 -19
  94. package/lib/pr-monitor/verdict.js +438 -0
  95. package/lib/pr-monitor/watch-lifecycle.js +144 -38
  96. package/lib/pr-monitor/watch-owner.js +1414 -0
  97. package/lib/pr-monitor/watch.js +129 -58
  98. package/lib/pr-shepherd.js +17 -3
  99. package/lib/project-memory.js +145 -3
  100. package/lib/protected-state-authority.js +799 -4
  101. package/lib/protected-state-surfaces.js +181 -3
  102. package/lib/release-readiness.js +2 -3
  103. package/lib/review-adapter.js +65 -0
  104. package/lib/skills-sync.js +65 -32
  105. package/lib/validation/risk-manifest.js +339 -0
  106. package/lib/workflow/enforce-stage.js +44 -0
  107. package/lib/workflow/plan-authority.js +225 -0
  108. package/package.json +8 -4
  109. package/scripts/commitlint.js +13 -15
  110. package/scripts/generate-risk-manifest.js +91 -0
  111. package/scripts/github-context-bridge.sh +10 -0
  112. package/scripts/legacy-claim-repair.js +145 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  114. package/scripts/process-tree.js +14 -2
  115. package/scripts/protected-state-check.js +440 -17
  116. package/scripts/sync-agent-skills.js +333 -34
  117. package/scripts/test-full-suite.js +704 -18
  118. package/scripts/test-profile.js +13 -3
  119. package/scripts/test.js +95 -14
  120. package/skills/coverage.json +1 -0
  121. package/skills/review/SKILL.md +2 -0
  122. package/skills/review/evals/scorecard.json +2 -2
  123. package/skills/setup/SKILL.md +18 -0
  124. package/skills/setup/evals/scorecard.json +3 -3
  125. package/skills/shepherd/SKILL.md +19 -2
  126. package/skills/shepherd/evals/scorecard.json +3 -3
  127. package/skills/validate/SKILL.md +3 -0
  128. package/skills/validate/evals/scorecard.json +1 -1
@@ -25,7 +25,15 @@ function hashProtectedContent(content) {
25
25
  return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
26
26
  }
27
27
 
28
- function createProtectedStateAuditRecord({ actor, surface, path: filePath, content, operation = 'generate' }) {
28
+ function createProtectedStateAuditRecord({
29
+ actor,
30
+ surface,
31
+ path: filePath,
32
+ content,
33
+ operation = 'generate',
34
+ viaForgeApi = false,
35
+ sourceHead,
36
+ }) {
29
37
  if (!actor || !surface || !filePath || content === undefined) {
30
38
  throw new TypeError('Protected state audit record requires actor, surface, path, and content');
31
39
  }
@@ -38,6 +46,8 @@ function createProtectedStateAuditRecord({ actor, surface, path: filePath, conte
38
46
  requiredSurface: surface,
39
47
  declaredSurface: surface,
40
48
  operation,
49
+ viaForgeApi: viaForgeApi === true,
50
+ ...(sourceHead ? { sourceHead } : {}),
41
51
  contentHash: hashProtectedContent(content),
42
52
  reason: `Forge API generated content for protected surface: ${surface}.`,
43
53
  repairHint: null,
@@ -296,6 +306,89 @@ function assertProtectedWriteAllowed(filePath, options = {}) {
296
306
  };
297
307
  }
298
308
 
309
+ function fileIdentity(stat) {
310
+ return `${stat.dev}:${stat.ino}`;
311
+ }
312
+
313
+ function blockedAtomicDecision(decision, reason) {
314
+ return {
315
+ ...decision,
316
+ allowed: false,
317
+ decision: 'blocked',
318
+ reason,
319
+ repairHint: 'Retry through the owning Forge command after confirming the protected path is unchanged.',
320
+ };
321
+ }
322
+
323
+ function captureParentBoundary(root, target) {
324
+ const parent = path.dirname(target);
325
+ const stat = lstatIfPresent(parent);
326
+ if (!stat?.isDirectory() || stat.isSymbolicLink()) {
327
+ return { ok: false, reason: `Protected write parent boundary changed: ${parent}` };
328
+ }
329
+ const realRoot = fs.realpathSync(root);
330
+ const realParent = fs.realpathSync(parent);
331
+ if (!pathStaysInsideRoot(realRoot, realParent)) {
332
+ return { ok: false, reason: `Protected write parent boundary changed outside project root: ${parent}` };
333
+ }
334
+ return { ok: true, parent, realParent, identity: fileIdentity(stat) };
335
+ }
336
+
337
+ function captureTargetState(target) {
338
+ const before = lstatIfPresent(target);
339
+ if (!before) return { ok: true, exists: false };
340
+ if (!before.isFile() || before.isSymbolicLink()) {
341
+ return { ok: false, reason: `Protected write target boundary changed: ${target}` };
342
+ }
343
+ const content = fs.readFileSync(target);
344
+ const after = fs.lstatSync(target);
345
+ if (fileIdentity(before) !== fileIdentity(after) || after.isSymbolicLink()) {
346
+ return { ok: false, reason: `Protected write target boundary changed while reading: ${target}` };
347
+ }
348
+ return {
349
+ ok: true,
350
+ exists: true,
351
+ identity: fileIdentity(after),
352
+ content,
353
+ contentHash: hashProtectedContent(content),
354
+ };
355
+ }
356
+
357
+ function sameParentBoundary(left, right) {
358
+ return left.ok && right.ok && left.realParent === right.realParent && left.identity === right.identity;
359
+ }
360
+
361
+ function sameTargetState(left, right) {
362
+ if (!left.ok || !right.ok || left.exists !== right.exists) return false;
363
+ if (!left.exists) return true;
364
+ return left.identity === right.identity && left.contentHash === right.contentHash;
365
+ }
366
+
367
+ function expectedTargetMatches(state, expectedContent) {
368
+ if (expectedContent === null) return !state.exists;
369
+ if (!state.exists) return false;
370
+ const expected = Buffer.isBuffer(expectedContent)
371
+ ? expectedContent
372
+ : Buffer.from(String(expectedContent), 'utf8');
373
+ return state.content.equals(expected);
374
+ }
375
+
376
+ function expectedTargetChanged(state, options) {
377
+ return Object.prototype.hasOwnProperty.call(options, 'expectedContent')
378
+ && !expectedTargetMatches(state, options.expectedContent);
379
+ }
380
+
381
+ function cleanupOwnedTemp(tempPath, identity) {
382
+ try {
383
+ const current = fs.lstatSync(tempPath);
384
+ if (current.isFile() && !current.isSymbolicLink() && fileIdentity(current) === identity) {
385
+ fs.unlinkSync(tempPath);
386
+ }
387
+ } catch (error) {
388
+ if (error.code !== 'ENOENT') throw error;
389
+ }
390
+ }
391
+
299
392
  function writeProtectedFile(projectRoot, filePath, content, options = {}) {
300
393
  const resolved = resolveRepoRelativePath(projectRoot, filePath);
301
394
  if (!resolved.insideRoot) {
@@ -334,8 +427,88 @@ function writeProtectedFile(projectRoot, filePath, content, options = {}) {
334
427
  };
335
428
  }
336
429
 
337
- fs.writeFileSync(resolved.target, content, options.encoding || 'utf8');
338
- return { ...decision, contentHash: hashProtectedContent(content), fullPath: resolved.target };
430
+ const initialParent = captureParentBoundary(resolved.root, resolved.target);
431
+ if (!initialParent.ok) return blockedAtomicDecision(decision, initialParent.reason);
432
+ const initialTarget = captureTargetState(resolved.target);
433
+ if (!initialTarget.ok) return blockedAtomicDecision(decision, initialTarget.reason);
434
+ if (expectedTargetChanged(initialTarget, options)) {
435
+ return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to overwrite it.');
436
+ }
437
+
438
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), options.encoding || 'utf8');
439
+ const tempPath = path.join(
440
+ initialParent.parent,
441
+ `.${path.basename(resolved.target)}.forge-${process.pid}-${crypto.randomUUID()}.tmp`,
442
+ );
443
+ let tempIdentity;
444
+ try {
445
+ const flags = fs.constants.O_NOFOLLOW
446
+ ? fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
447
+ : 'wx';
448
+ const fd = fs.openSync(tempPath, flags, 0o600);
449
+ try {
450
+ fs.writeFileSync(fd, bytes);
451
+ fs.fsyncSync(fd);
452
+ tempIdentity = fileIdentity(fs.fstatSync(fd));
453
+ } finally {
454
+ fs.closeSync(fd);
455
+ }
456
+
457
+ if (typeof options.beforeAtomicCommit === 'function') {
458
+ options.beforeAtomicCommit({ parent: initialParent.parent, target: resolved.target, tempPath });
459
+ }
460
+
461
+ const finalParent = captureParentBoundary(resolved.root, resolved.target);
462
+ const finalTarget = captureTargetState(resolved.target);
463
+ if (!sameParentBoundary(initialParent, finalParent) || !sameTargetState(initialTarget, finalTarget)) {
464
+ return blockedAtomicDecision(decision, 'Protected write boundary changed before atomic replacement.');
465
+ }
466
+ if (expectedTargetChanged(finalTarget, options)) {
467
+ return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to overwrite it.');
468
+ }
469
+ const tempState = fs.lstatSync(tempPath);
470
+ if (!tempState.isFile() || tempState.isSymbolicLink() || fileIdentity(tempState) !== tempIdentity) {
471
+ return blockedAtomicDecision(decision, 'Protected write temporary-file identity changed before atomic replacement.');
472
+ }
473
+
474
+ fs.renameSync(tempPath, resolved.target);
475
+ tempIdentity = null;
476
+ return { ...decision, contentHash: hashProtectedContent(bytes), fullPath: resolved.target };
477
+ } catch (error) {
478
+ return blockedAtomicDecision(decision, `Protected atomic write failed: ${error.message}`);
479
+ } finally {
480
+ if (tempIdentity) cleanupOwnedTemp(tempPath, tempIdentity);
481
+ }
482
+ }
483
+
484
+ function removeProtectedFile(projectRoot, filePath, options = {}) {
485
+ const resolved = resolveRepoRelativePath(projectRoot, filePath);
486
+ if (!resolved.insideRoot) {
487
+ return blockedAtomicDecision({}, `Protected remove path escapes project root: ${filePath}`);
488
+ }
489
+ const decision = assertProtectedWriteAllowed(resolved.relativePath, {
490
+ ...options,
491
+ operation: options.operation || 'remove',
492
+ });
493
+ if (!decision.allowed) return decision;
494
+ const initialParent = captureParentBoundary(resolved.root, resolved.target);
495
+ const initialTarget = captureTargetState(resolved.target);
496
+ if (!initialParent.ok || !initialTarget.ok) {
497
+ return blockedAtomicDecision(decision, initialParent.reason || initialTarget.reason);
498
+ }
499
+ if (!Object.prototype.hasOwnProperty.call(options, 'expectedContent') || !expectedTargetMatches(initialTarget, options.expectedContent)) {
500
+ return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to remove it.');
501
+ }
502
+ if (typeof options.beforeAtomicCommit === 'function') {
503
+ options.beforeAtomicCommit({ parent: initialParent.parent, target: resolved.target });
504
+ }
505
+ const finalParent = captureParentBoundary(resolved.root, resolved.target);
506
+ const finalTarget = captureTargetState(resolved.target);
507
+ if (!sameParentBoundary(initialParent, finalParent) || !sameTargetState(initialTarget, finalTarget)) {
508
+ return blockedAtomicDecision(decision, 'Protected remove boundary changed before exact-content removal.');
509
+ }
510
+ fs.unlinkSync(resolved.target);
511
+ return { ...decision, removed: true, fullPath: resolved.target };
339
512
  }
340
513
 
341
514
  function buildProtectedStateAuditEvent(decision) {
@@ -351,6 +524,8 @@ function buildProtectedStateAuditEvent(decision) {
351
524
  requiredSurface: decision.requiredSurface,
352
525
  declaredSurface: decision.declaredSurface || null,
353
526
  operation: decision.operation || 'write',
527
+ viaForgeApi: decision.viaForgeApi === true,
528
+ ...(decision.sourceHead ? { sourceHead: decision.sourceHead } : {}),
354
529
  contentHash: decision.contentHash || null,
355
530
  reason: decision.reason,
356
531
  repairHint: decision.repairHint,
@@ -358,6 +533,8 @@ function buildProtectedStateAuditEvent(decision) {
358
533
  actor: decision.actor || 'unknown',
359
534
  path: decision.path,
360
535
  operation: decision.operation || 'write',
536
+ viaForgeApi: decision.viaForgeApi === true,
537
+ ...(decision.sourceHead ? { sourceHead: decision.sourceHead } : {}),
361
538
  contentHash: decision.contentHash || null,
362
539
  requiredSurface: decision.requiredSurface,
363
540
  declaredSurface: decision.declaredSurface || null,
@@ -402,6 +579,7 @@ module.exports = {
402
579
  classifyProtectedPath,
403
580
  assertProtectedWriteAllowed,
404
581
  writeProtectedFile,
582
+ removeProtectedFile,
405
583
  buildProtectedStateAuditEvent,
406
584
  recordProtectedStateAuditEvent,
407
585
  };
@@ -510,9 +510,8 @@ function addCallSites(groups, group, relativePath, lineEntries) {
510
510
  function trackedRepoFiles(projectRoot) {
511
511
  let output;
512
512
  try {
513
- // NOSONAR S4036 — `git` is resolved from PATH by design (cross-platform CLI; the repo
514
- // spawns git this way throughout and cannot hardcode a portable absolute path).
515
- output = execFileSync('git', ['-C', projectRoot, 'ls-files', '-z'], {
513
+ // `git` is resolved from PATH by design; Forge cannot hardcode a portable absolute path.
514
+ output = execFileSync('git', ['-C', projectRoot, 'ls-files', '-z'], { // NOSONAR S4036 -- Fixed local Git executable with structured non-shell arguments is the trusted repository toolchain boundary.
516
515
  encoding: 'utf8',
517
516
  maxBuffer: 64 * 1024 * 1024,
518
517
  });
@@ -8,6 +8,68 @@ const REQUIRED_REVIEW_ADAPTER_METHODS = [
8
8
  'score',
9
9
  ];
10
10
 
11
+ const REVIEW_EVIDENCE_LIMITS = Object.freeze({
12
+ maxTextChars: 256,
13
+ });
14
+
15
+ const SECRET_PATTERNS = [
16
+ /\bBearer\s+\S+/gi,
17
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
18
+ /\bgithub_pat_\w{20,}\b/gi,
19
+ /\b(?:sk_(?:live|test)_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9]{16,})\b/g,
20
+ /\bAKIA[0-9A-Z]{16}\b/gi,
21
+ /\b(?:api[_ -]?key|authorization|credential|password|private[_ -]?key|secret|token)\s*[:=]\s*\S{8,}/gi,
22
+ ];
23
+ const PRIVATE_PATH_PATTERNS = [
24
+ /[a-z]:\\Users\\[^\\\s]+(?:\\[^\s]*)?/gi,
25
+ /\/(?:Users|home)\/[^/\s]+(?:\/[^\s]*)?/g,
26
+ /\/root\/[^\s"'<>{}[\]]+/g,
27
+ ];
28
+
29
+ function stripControlCharacters(value) {
30
+ let output = '';
31
+ for (const character of value) {
32
+ const code = character.codePointAt(0);
33
+ const control = (code >= 0 && code <= 8)
34
+ || code === 11
35
+ || code === 12
36
+ || (code >= 14 && code <= 31)
37
+ || code === 127;
38
+ output += control ? ' ' : character;
39
+ }
40
+ return output;
41
+ }
42
+
43
+ function normalizeEvidenceText(value, options = {}) {
44
+ const requestedLimit = options.maxChars ?? REVIEW_EVIDENCE_LIMITS.maxTextChars;
45
+ const maxChars = Number.isInteger(requestedLimit) && requestedLimit > 0
46
+ ? requestedLimit
47
+ : REVIEW_EVIDENCE_LIMITS.maxTextChars;
48
+ let text = stripControlCharacters(String(value ?? ''))
49
+ .replace(/[\r\n\t]+/g, ' ');
50
+ for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, '[REDACTED]');
51
+ for (const pattern of PRIVATE_PATH_PATTERNS) text = text.replace(pattern, '[REDACTED_PATH]');
52
+ text = text.replace(/\s+/g, ' ').trim();
53
+ return text.length <= maxChars ? text : `${text.slice(0, Math.max(0, maxChars - 1))}…`;
54
+ }
55
+
56
+ function classifyReviewActor(value) {
57
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return 'unknown';
58
+ const typename = value.authorTypename
59
+ || value.authorType
60
+ || value.actorTypename
61
+ || value.actorType
62
+ || value.author?.__typename;
63
+ const actorKinds = {
64
+ Bot: 'bot',
65
+ User: 'user',
66
+ Organization: 'organization',
67
+ Mannequin: 'mannequin',
68
+ EnterpriseUserAccount: 'enterprise-user',
69
+ };
70
+ return actorKinds[typename] || 'unknown';
71
+ }
72
+
11
73
  class ReviewAdapter {
12
74
  constructor(options = {}) {
13
75
  this.id = options.id || 'review-adapter';
@@ -62,7 +124,10 @@ function validateReviewAdapter(adapter) {
62
124
  }
63
125
 
64
126
  module.exports = {
127
+ REVIEW_EVIDENCE_LIMITS,
65
128
  ReviewAdapter,
66
129
  REQUIRED_REVIEW_ADAPTER_METHODS,
130
+ classifyReviewActor,
131
+ normalizeEvidenceText,
67
132
  validateReviewAdapter,
68
133
  };
@@ -34,6 +34,7 @@
34
34
 
35
35
  const fs = require('node:fs');
36
36
  const path = require('node:path');
37
+ const { execFileSync } = require('node:child_process');
37
38
  const { parseFrontmatter } = require('./using-forge');
38
39
 
39
40
  /** Canonical skills live under this directory at the repo/package root. */
@@ -134,6 +135,23 @@ function normalizeContent(buffer) {
134
135
  return buffer.toString('utf8').replace(/\r\n/g, '\n');
135
136
  }
136
137
 
138
+ function gitIgnoredCanonicalPaths(repoRoot, runGit = execFileSync, { strict = false } = {}) {
139
+ if (!fs.existsSync(path.join(repoRoot, '.git'))) return new Set();
140
+ try {
141
+ const output = runGit('git', [
142
+ 'ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', 'skills',
143
+ ], {
144
+ cwd: repoRoot,
145
+ encoding: 'utf8',
146
+ stdio: ['ignore', 'pipe', 'pipe'],
147
+ });
148
+ return new Set(output.split('\0').filter(Boolean).map(repoPath => repoPath.normalize('NFC')));
149
+ } catch (error) {
150
+ if (strict) throw error;
151
+ return new Set();
152
+ }
153
+ }
154
+
137
155
  /**
138
156
  * Remove a symlink sitting at a target path so a real directory can be written
139
157
  * in its place. Only ever removes the link entry itself — never a real
@@ -177,13 +195,18 @@ function clearSymlinkAtPath(targetPath) {
177
195
  * @param {string} sourcePath - Source skill directory.
178
196
  * @param {string} targetPath - Target skill directory.
179
197
  */
180
- function copySkillDir(sourcePath, targetPath) {
198
+ function copySkillDir(sourcePath, targetPath, excludeRelativePaths = new Set(), prefix = '') {
181
199
  fs.mkdirSync(targetPath, { recursive: true });
182
200
  for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) {
201
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
183
202
  const src = path.join(sourcePath, entry.name);
184
203
  const dest = path.join(targetPath, entry.name);
204
+ if (excludeRelativePaths.has(relative.normalize('NFC'))) {
205
+ fs.rmSync(dest, { recursive: true, force: true });
206
+ continue;
207
+ }
185
208
  if (entry.isDirectory()) {
186
- copySkillDir(src, dest);
209
+ copySkillDir(src, dest, excludeRelativePaths, relative);
187
210
  } else if (entry.isFile()) {
188
211
  fs.copyFileSync(src, dest);
189
212
  }
@@ -204,7 +227,12 @@ function copySkillDir(sourcePath, targetPath) {
204
227
  * @param {boolean} [params.clean=false] - Remove canonical-managed stale dirs first.
205
228
  * @returns {{written: string[]}} Names of skills written.
206
229
  */
207
- function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false }) {
230
+ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false, excludeRelativePaths }) {
231
+ if (excludeRelativePaths === undefined) {
232
+ excludeRelativePaths = new Set([...gitIgnoredCanonicalPaths(sourceRoot)]
233
+ .map(repoPath => repoPath.slice('skills/'.length))
234
+ .filter(relative => !/^[^/]+\/SKILL\.md$/.test(relative)));
235
+ }
208
236
  const skills = listCanonicalSkills(sourceRoot, { only });
209
237
  fs.mkdirSync(targetSkillsDir, { recursive: true });
210
238
 
@@ -225,7 +253,7 @@ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false
225
253
  // Defensive: clear a pre-existing (possibly dangling) symlink so the copy
226
254
  // doesn't fail. Only symlinks are removed — real dirs are overwritten in place.
227
255
  clearSymlinkAtPath(targetPath);
228
- copySkillDir(skill.sourcePath, targetPath);
256
+ copySkillDir(skill.sourcePath, targetPath, excludeRelativePaths, skill.name);
229
257
  written.push(skill.name);
230
258
  }
231
259
  return { written };
@@ -238,9 +266,12 @@ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false
238
266
  * @param {string} targetPath - Target (generated) skill dir.
239
267
  * @returns {{file: string, status: 'missing'|'changed'|'extra'}[]} Drift entries.
240
268
  */
241
- function diffSkillDir(sourcePath, targetPath) {
269
+ function diffSkillDir(sourcePath, targetPath, excludeRelativePaths = new Set(), prefix = '') {
242
270
  const drift = [];
243
- const sourceFiles = new Set(listFilesRecursive(sourcePath));
271
+ const sourceFiles = new Set(listFilesRecursive(sourcePath).filter(rel => {
272
+ const relative = prefix ? `${prefix}/${rel}` : rel;
273
+ return !excludeRelativePaths.has(relative.normalize('NFC'));
274
+ }));
244
275
  const targetFiles = new Set(listFilesRecursive(targetPath));
245
276
 
246
277
  for (const rel of sourceFiles) {
@@ -264,6 +295,28 @@ function diffSkillDir(sourcePath, targetPath) {
264
295
  return drift;
265
296
  }
266
297
 
298
+ function collectAgentSkillDrift({ repoRoot, rel, skills, canonicalByName, excludeRelativePaths }) {
299
+ const agentDir = path.join(repoRoot, rel);
300
+ const drift = [];
301
+ const targetNames = fs.readdirSync(agentDir, { withFileTypes: true })
302
+ .filter((entry) => entry.isDirectory())
303
+ .map((entry) => entry.name);
304
+ for (const skill of skills) {
305
+ const targetPath = path.join(agentDir, skill.name);
306
+ if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
307
+ drift.push({ agent: rel, skill: skill.name, file: 'SKILL.md', status: 'missing' });
308
+ continue;
309
+ }
310
+ for (const entry of diffSkillDir(skill.sourcePath, targetPath, excludeRelativePaths, skill.name)) {
311
+ drift.push({ agent: rel, skill: skill.name, ...entry });
312
+ }
313
+ }
314
+ for (const name of targetNames) {
315
+ if (!canonicalByName.has(name)) drift.push({ agent: rel, skill: name, file: '*', status: 'stale' });
316
+ }
317
+ return drift;
318
+ }
319
+
267
320
  /**
268
321
  * Check that existing agent skill mirrors match the canonical source.
269
322
  *
@@ -279,9 +332,12 @@ function diffSkillDir(sourcePath, targetPath) {
279
332
  * @param {string[]} [params.agentSkillDirs] - Override the agent dirs to scan.
280
333
  * @returns {{inSync: boolean, checkedAgents: string[], drift: object[]}}
281
334
  */
282
- function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS }) {
335
+ function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS, runGit = execFileSync }) {
283
336
  const skills = listCanonicalSkills(repoRoot, { only });
284
337
  const canonicalByName = new Map(skills.map((s) => [s.name, s]));
338
+ const excludeRelativePaths = new Set([...gitIgnoredCanonicalPaths(repoRoot, runGit)]
339
+ .map(repoPath => repoPath.slice('skills/'.length))
340
+ .filter(relative => !/^[^/]+\/SKILL\.md$/.test(relative)));
285
341
  const drift = [];
286
342
  const checkedAgents = [];
287
343
 
@@ -290,31 +346,7 @@ function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS })
290
346
  if (!fs.existsSync(agentDir)) continue; // populated at setup time; absence ≠ drift
291
347
  checkedAgents.push(rel);
292
348
 
293
- const targetNames = fs
294
- .readdirSync(agentDir, { withFileTypes: true })
295
- .filter((e) => e.isDirectory())
296
- .map((e) => e.name);
297
-
298
- // Skills present canonically but missing/changed in the mirror.
299
- for (const skill of skills) {
300
- const targetPath = path.join(agentDir, skill.name);
301
- // A regular file at the skill path is corruption, not a valid mirror:
302
- // treat it as drift instead of letting diffSkillDir() throw ENOTDIR.
303
- if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
304
- drift.push({ agent: rel, skill: skill.name, file: 'SKILL.md', status: 'missing' });
305
- continue;
306
- }
307
- for (const entry of diffSkillDir(skill.sourcePath, targetPath)) {
308
- drift.push({ agent: rel, skill: skill.name, ...entry });
309
- }
310
- }
311
-
312
- // Skill dirs in the mirror with no canonical source = stale.
313
- for (const name of targetNames) {
314
- if (!canonicalByName.has(name)) {
315
- drift.push({ agent: rel, skill: name, file: '*', status: 'stale' });
316
- }
317
- }
349
+ drift.push(...collectAgentSkillDrift({ repoRoot, rel, skills, canonicalByName, excludeRelativePaths }));
318
350
  }
319
351
 
320
352
  return { inSync: drift.length === 0, checkedAgents, drift };
@@ -327,6 +359,7 @@ module.exports = {
327
359
  listCanonicalSkills,
328
360
  listFilesRecursive,
329
361
  clearSymlinkAtPath,
362
+ gitIgnoredCanonicalPaths,
330
363
  populateAgentSkills,
331
364
  diffSkillDir,
332
365
  checkSkillsSync,