forge-workflow 0.1.0-beta.4 → 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 (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -1,26 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- /**
5
- * Pre-commit auto-sync for the committed `.agents/skills` mirror.
6
- *
7
- * `.agents/skills` is Codex's repo-local discovery path and the ONE skill mirror
8
- * committed to the repo (so a teammate who clones WITHOUT running `forge setup`
9
- * still gets Forge skills/stages auto-discovered). It must stay byte-identical to
10
- * the canonical `skills/` source, which the structural drift gate enforces.
11
- *
12
- * This hook removes the regen friction: whenever `skills/**` is staged, it
13
- * regenerates `.agents/skills` from `skills/` (reusing the same
14
- * `populateCodexRepoSkills` / `populateAgentSkills` sync the drift gate checks
15
- * against) and re-stages it, so the committed mirror never drifts from canonical.
16
- *
17
- * It is intentionally scoped to `skills/**` via lefthook's `glob`, so it only runs
18
- * when the canonical source actually changes.
19
- */
4
+ /** Command-owned pre-commit sync for the committed `.agents/skills` mirror. */
20
5
 
6
+ const fs = require('node:fs');
21
7
  const path = require('node:path');
22
8
  const { execFileSync } = require('node:child_process');
23
9
  const { populateCodexRepoSkills, resolveCodexRepoSkillsDir } = require('../lib/codex-skills');
10
+ const { gitIgnoredCanonicalPaths, isValidSkillName, listCanonicalSkills } = require('../lib/skills-sync');
11
+ const {
12
+ completeGeneratedHarnessSkillAuthorization,
13
+ issueGeneratedHarnessSkillAuthorization,
14
+ } = require('../lib/protected-state-authority');
15
+
16
+ function strictIgnoredCanonicalPaths(root, runGit) {
17
+ return gitIgnoredCanonicalPaths(root, runGit, { strict: true });
18
+ }
24
19
 
25
20
  function repoRoot() {
26
21
  try {
@@ -33,30 +28,334 @@ function repoRoot() {
33
28
  }
34
29
  }
35
30
 
36
- function main() {
37
- const root = repoRoot();
38
- let written = [];
39
- try {
40
- // clean: true so skills removed from canonical are dropped from the mirror too.
41
- ({ written } = populateCodexRepoSkills({ sourceRoot: root, projectRoot: root, clean: true }));
42
- } catch (error) {
43
- console.error(`sync-agent-skills: failed to regenerate .agents/skills — ${error.message}`);
44
- process.exit(1);
31
+ function actorFromEnv(env) {
32
+ return env.FORGE_ACTOR || env.USER || env.USERNAME || 'unknown';
33
+ }
34
+
35
+ function parseGitPaths(output) {
36
+ return output.split('\0').filter(Boolean);
37
+ }
38
+
39
+ function toRepoPath(value) {
40
+ return path.sep === '\\' ? value.replace(/\\/g, '/') : value;
41
+ }
42
+
43
+ function generatedDriftPaths(root, runGit) {
44
+ const commands = [
45
+ ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMRDT', '--', '.agents/skills'],
46
+ ['diff', '--name-only', '-z', '--diff-filter=ACMRDT', '--', '.agents/skills'],
47
+ ['ls-files', '-z', '--others', '--exclude-standard', '--', '.agents/skills'],
48
+ ];
49
+ return new Set(commands.flatMap(args => parseGitPaths(runGit('git', args, {
50
+ cwd: root,
51
+ encoding: 'utf8',
52
+ stdio: ['ignore', 'pipe', 'pipe'],
53
+ }))));
54
+ }
55
+
56
+ function gitObjectHash(root, args, content, runGit) {
57
+ const hash = runGit('git', ['hash-object', ...args, '--stdin'], {
58
+ cwd: root,
59
+ encoding: 'utf8',
60
+ input: content,
61
+ stdio: ['pipe', 'pipe', 'pipe'],
62
+ }).trim();
63
+ if (!/^[0-9a-f]{40,64}$/.test(hash)) throw new Error('Git returned an invalid object hash while inspecting canonical skill parity');
64
+ return hash;
65
+ }
66
+
67
+ function worktreeMatchesIndex(root, repoPath, indexedContent, worktreeContent, runGit) {
68
+ return gitObjectHash(root, [], indexedContent, runGit)
69
+ === gitObjectHash(root, [`--path=${repoPath}`], worktreeContent, runGit);
70
+ }
71
+
72
+ function normalizedRepoPath(repoPath) {
73
+ return repoPath.normalize('NFC');
74
+ }
75
+
76
+ function recordNormalizedPath(paths, repoPath) {
77
+ const key = normalizedRepoPath(repoPath);
78
+ const existing = paths.get(key);
79
+ if (existing && existing !== repoPath) throw new Error(`Unicode-equivalent canonical skill paths are ambiguous: ${existing}, ${repoPath}`);
80
+ paths.set(key, repoPath);
81
+ }
82
+
83
+ function ambiguousCanonicalPaths(root, runGit) {
84
+ const indexedEntries = parseGitPaths(runGit('git', ['ls-files', '-t', '-z', '--', 'skills'], {
85
+ cwd: root,
86
+ encoding: 'utf8',
87
+ stdio: ['ignore', 'pipe', 'pipe'],
88
+ }));
89
+ const mirrorSkipWorktree = new Set(parseGitPaths(runGit('git', ['ls-files', '-t', '-z', '--', '.agents/skills'], {
90
+ cwd: root,
91
+ encoding: 'utf8',
92
+ stdio: ['ignore', 'pipe', 'pipe'],
93
+ })).filter(entry => entry.startsWith('S ')).map(entry => normalizedRepoPath(entry.slice(2))));
94
+ const ignoredWorktree = strictIgnoredCanonicalPaths(root, runGit);
95
+ const filesystemPrefixes = listCanonicalSkills(root).map(({ name }) => `skills/${name}/`);
96
+ const canonicalPrefixKeys = new Set(filesystemPrefixes.map(normalizedRepoPath));
97
+ for (const entry of indexedEntries) {
98
+ const match = /^skills\/([^/]+)\/SKILL\.md$/.exec(entry.slice(2));
99
+ if (match && isValidSkillName(match[1])) canonicalPrefixKeys.add(normalizedRepoPath(`skills/${match[1]}/`));
100
+ }
101
+ const participatesInMirror = repoPath => [...canonicalPrefixKeys].some(prefix => normalizedRepoPath(repoPath).startsWith(prefix));
102
+ const indexed = new Map();
103
+ for (const repoPath of indexedEntries.map(entry => entry.slice(2)).filter(participatesInMirror)) recordNormalizedPath(indexed, repoPath);
104
+ const skipWorktree = new Map();
105
+ for (const repoPath of indexedEntries
106
+ .filter(entry => entry.startsWith('S '))
107
+ .map(entry => entry.slice(2))
108
+ .filter(participatesInMirror)) recordNormalizedPath(skipWorktree, repoPath);
109
+ const worktree = new Map();
110
+ for (const prefix of filesystemPrefixes) {
111
+ const sourceRoot = path.join(root, prefix);
112
+ walkRegularFiles(
113
+ sourceRoot,
114
+ (_file, relative) => recordNormalizedPath(worktree, `${prefix}${relative}`),
115
+ sourceRoot,
116
+ (relative, entry) => entry.isSymbolicLink()
117
+ && ignoredWorktree.has(normalizedRepoPath(`${prefix}${relative}`)),
118
+ );
119
+ }
120
+ for (const key of ignoredWorktree) {
121
+ if (!indexed.has(key)) worktree.delete(key);
45
122
  }
123
+ for (const repoPath of skipWorktree.values()) {
124
+ const mirrorPath = `.agents/${repoPath}`;
125
+ if (!fs.existsSync(path.join(root, repoPath))
126
+ && (!mirrorSkipWorktree.has(normalizedRepoPath(mirrorPath)) || fs.existsSync(path.join(root, mirrorPath)))) {
127
+ throw new Error(`asymmetric sparse checkout exposes mirror without canonical: ${repoPath}`);
128
+ }
129
+ }
130
+ const paths = new Set([...indexed.keys(), ...worktree.keys()]);
131
+ return new Set([...paths].filter(key => {
132
+ const indexedPath = indexed.get(key);
133
+ const worktreePath = worktree.get(key);
134
+ const indexedContent = indexedPath ? indexedFile(root, indexedPath, runGit) : null;
135
+ let worktreeContent = null;
136
+ try {
137
+ if (worktreePath) worktreeContent = fs.readFileSync(path.join(root, worktreePath));
138
+ } catch (error) {
139
+ if (error.code !== 'ENOENT') throw error;
140
+ }
141
+ return indexedContent === null
142
+ ? worktreeContent !== null
143
+ : (worktreeContent === null && !skipWorktree.has(key))
144
+ || (worktreeContent !== null && !worktreeMatchesIndex(root, indexedPath, indexedContent, worktreeContent, runGit));
145
+ }).map(key => indexed.get(key) || worktree.get(key)));
146
+ }
147
+
148
+ function indexedFile(root, repoPath, runGit) {
149
+ const tracked = parseGitPaths(runGit('git', ['ls-files', '-z', '--', `:(literal)${repoPath}`], {
150
+ cwd: root,
151
+ encoding: 'utf8',
152
+ stdio: ['ignore', 'pipe', 'pipe'],
153
+ }));
154
+ const trackedPath = tracked.find(candidate => normalizedRepoPath(candidate) === normalizedRepoPath(repoPath));
155
+ if (!trackedPath) return null;
156
+ return runGit('git', ['show', `:${trackedPath}`], {
157
+ cwd: root,
158
+ encoding: null,
159
+ stdio: ['ignore', 'pipe', 'pipe'],
160
+ });
161
+ }
162
+
163
+ function walkRegularFiles(root, visit, current = root, shouldSkip = () => false) {
164
+ if (!fs.existsSync(current)) return;
165
+ const currentStat = fs.lstatSync(current);
166
+ if (!currentStat.isDirectory() || currentStat.isSymbolicLink()) throw new Error(`skill sync path is not a real directory: ${current}`);
167
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
168
+ const entryPath = path.join(current, entry.name);
169
+ const relative = toRepoPath(path.relative(root, entryPath));
170
+ if (shouldSkip(relative, entry)) continue;
171
+ const stat = fs.lstatSync(entryPath);
172
+ if (stat.isSymbolicLink()) throw new Error(`skill sync refuses symlink: ${entryPath}`);
173
+ if (stat.isDirectory()) walkRegularFiles(root, visit, entryPath, shouldSkip);
174
+ else if (stat.isFile()) visit(entryPath, relative);
175
+ }
176
+ }
46
177
 
178
+ function rejectIgnoredSkillDescriptors(root, ignoredCanonical) {
179
+ for (const { name } of listCanonicalSkills(root)) {
180
+ const descriptorRepoPath = `skills/${name}/SKILL.md`;
181
+ if (ignoredCanonical.has(normalizedRepoPath(descriptorRepoPath))) {
182
+ throw new Error(`ignored canonical skill descriptor: ${descriptorRepoPath}`);
183
+ }
184
+ }
185
+ }
186
+
187
+ function changedSkillFiles(root, runGit = execFileSync, ignoredCanonical = strictIgnoredCanonicalPaths(root, runGit)) {
47
188
  const mirror = resolveCodexRepoSkillsDir(root);
48
- try {
49
- // Stage the regenerated mirror (and any deletions within it).
50
- execFileSync('git', ['add', '--all', '--', mirror], {
51
- cwd: root,
52
- stdio: ['ignore', 'ignore', 'inherit'],
189
+ const drift = generatedDriftPaths(root, runGit);
190
+ const changed = [];
191
+ rejectIgnoredSkillDescriptors(root, ignoredCanonical);
192
+ for (const { name, sourcePath: sourceRoot } of listCanonicalSkills(root)) {
193
+ walkRegularFiles(sourceRoot, (sourcePath, relative) => {
194
+ const canonicalRepoPath = `skills/${name}/${relative}`;
195
+ if (ignoredCanonical.has(normalizedRepoPath(canonicalRepoPath))) {
196
+ const mirrorRepoPath = `.agents/${canonicalRepoPath}`;
197
+ if (indexedFile(root, mirrorRepoPath, runGit) !== null || fs.existsSync(path.join(root, mirrorRepoPath))) {
198
+ throw new Error(`ignored canonical skill path still has a mirror: ${canonicalRepoPath}`);
199
+ }
200
+ return;
201
+ }
202
+ const canonical = fs.readFileSync(sourcePath);
203
+ const mirrorPath = path.join(mirror, name, relative);
204
+ const repoPath = `.agents/skills/${name}/${relative}`;
205
+ const indexedCanonical = indexedFile(root, canonicalRepoPath, runGit);
206
+ if (indexedCanonical !== null
207
+ && gitObjectHash(root, [], indexedCanonical, runGit)
208
+ !== gitObjectHash(root, [`--path=${repoPath}`], canonical, runGit)) {
209
+ throw new Error(`canonical and generated mirror clean filters differ: ${canonicalRepoPath}`);
210
+ }
211
+ try {
212
+ if (canonical.equals(fs.readFileSync(mirrorPath)) && !drift.has(repoPath)) return;
213
+ } catch (error) {
214
+ if (error.code !== 'ENOENT') throw error;
215
+ }
216
+ changed.push({ path: repoPath, canonical, writeIntent: 'update' });
217
+ }, sourceRoot, (relative, entry) => entry.isSymbolicLink()
218
+ && ignoredCanonical.has(normalizedRepoPath(`skills/${name}/${relative}`)));
219
+ }
220
+ walkRegularFiles(mirror, (mirrorPath, relative) => {
221
+ const canonicalPath = path.join(root, 'skills', relative);
222
+ if (!fs.existsSync(canonicalPath)) {
223
+ changed.push({
224
+ path: `.agents/skills/${relative}`,
225
+ canonical: fs.readFileSync(mirrorPath),
226
+ writeIntent: 'delete',
227
+ });
228
+ }
229
+ });
230
+ const seen = new Set(changed.map(file => file.path));
231
+ for (const repoPath of drift) {
232
+ if (!repoPath.startsWith('.agents/skills/') || seen.has(repoPath)) continue;
233
+ const relative = repoPath.slice('.agents/skills/'.length);
234
+ const canonicalPath = path.join(root, 'skills', relative);
235
+ const mirrorPath = path.join(root, repoPath);
236
+ if (fs.existsSync(canonicalPath) || fs.existsSync(mirrorPath)) continue;
237
+ let priorContent;
238
+ try {
239
+ priorContent = runGit('git', ['show', `HEAD:${repoPath}`], {
240
+ cwd: root,
241
+ encoding: null,
242
+ stdio: ['ignore', 'pipe', 'pipe'],
243
+ });
244
+ } catch {
245
+ continue;
246
+ }
247
+ changed.push({ path: repoPath, canonical: priorContent, writeIntent: 'delete' });
248
+ }
249
+ return changed;
250
+ }
251
+
252
+ function sameSkillChanges(expected, actual) {
253
+ if (expected.length !== actual.length) return false;
254
+ const byPath = new Map(actual.map(file => [normalizedRepoPath(file.path), file]));
255
+ if (byPath.size !== actual.length) return false;
256
+ return expected.every(file => {
257
+ const candidate = byPath.get(normalizedRepoPath(file.path));
258
+ return candidate
259
+ && candidate.writeIntent === file.writeIntent
260
+ && candidate.canonical.equals(file.canonical);
261
+ });
262
+ }
263
+
264
+ async function syncAgentSkills(options = {}) {
265
+ const root = options.root || repoRoot();
266
+ const env = options.env || process.env;
267
+ const runGit = options.execFileSync || execFileSync;
268
+ const issueAuthorization = options.issueAuthorization || issueGeneratedHarnessSkillAuthorization;
269
+ const completeAuthorization = options.completeAuthorization || completeGeneratedHarnessSkillAuthorization;
270
+ const actor = actorFromEnv(env);
271
+ const sourceHead = runGit('git', ['rev-parse', '--verify', 'HEAD^{commit}'], {
272
+ cwd: root,
273
+ encoding: 'utf8',
274
+ stdio: ['ignore', 'pipe', 'pipe'],
275
+ }).trim();
276
+ if (!/^[0-9a-f]{40}$/.test(sourceHead)) throw new Error('source HEAD is not a full lowercase commit SHA');
277
+
278
+ const ignoredCanonical = strictIgnoredCanonicalPaths(root, runGit);
279
+ rejectIgnoredSkillDescriptors(root, ignoredCanonical);
280
+ const ambiguousCanonical = ambiguousCanonicalPaths(root, runGit);
281
+ for (const canonicalPath of ambiguousCanonical) {
282
+ const indexedCanonical = indexedFile(root, canonicalPath, runGit);
283
+ const indexedMirror = indexedFile(root, `.agents/${canonicalPath}`, runGit);
284
+ if (indexedCanonical === null ? indexedMirror !== null : indexedMirror === null || !indexedCanonical.equals(indexedMirror)) {
285
+ throw new Error(`canonical index differs from generated mirror index: ${canonicalPath}`);
286
+ }
287
+ }
288
+ if (ambiguousCanonical.size > 0) {
289
+ throw new Error(`unstaged canonical skill changes must be reconciled before mirror sync: ${[...ambiguousCanonical].join(', ')}`);
290
+ }
291
+
292
+ const changed = changedSkillFiles(root, runGit, ignoredCanonical);
293
+ const authorizations = [];
294
+ for (const file of changed) {
295
+ const authorization = await issueAuthorization(root, {
296
+ actor,
297
+ path: file.path,
298
+ sourceHead,
299
+ writeIntent: file.writeIntent,
300
+ ...(file.writeIntent === 'delete' ? { priorContent: file.canonical } : {}),
53
301
  });
54
- } catch (error) {
55
- console.error(`sync-agent-skills: failed to stage .agents/skills — ${error.message}`);
56
- process.exit(1);
302
+ if (!authorization.success) throw new Error(`authorization failed for ${file.path}: ${authorization.error}`);
303
+ authorizations.push({ ...file, capabilityId: authorization.capabilityId });
304
+ }
305
+
306
+ const refreshedIgnoredCanonical = strictIgnoredCanonicalPaths(root, runGit);
307
+ rejectIgnoredSkillDescriptors(root, refreshedIgnoredCanonical);
308
+ const refreshedAmbiguousCanonical = ambiguousCanonicalPaths(root, runGit);
309
+ if (refreshedAmbiguousCanonical.size > 0) {
310
+ throw new Error(`canonical skill state changed during authorization: ${[...refreshedAmbiguousCanonical].join(', ')}`);
311
+ }
312
+ const refreshedChanged = changedSkillFiles(root, runGit, refreshedIgnoredCanonical);
313
+ if (!sameSkillChanges(changed, refreshedChanged)) {
314
+ throw new Error('canonical skill state changed during authorization');
315
+ }
316
+
317
+ const excludeRelativePaths = new Set([...refreshedIgnoredCanonical]
318
+ .map(repoPath => repoPath.slice('skills/'.length)));
319
+ const { written } = populateCodexRepoSkills({
320
+ sourceRoot: root,
321
+ projectRoot: root,
322
+ clean: true,
323
+ excludeRelativePaths,
324
+ });
325
+ for (const file of authorizations) {
326
+ const mirrorExists = fs.existsSync(path.join(root, file.path));
327
+ if (file.writeIntent === 'delete' ? mirrorExists : !file.canonical.equals(fs.readFileSync(path.join(root, file.path)))) {
328
+ throw new Error(`canonical mirror intent failed for ${file.path}`);
329
+ }
330
+ const completion = await completeAuthorization(root, {
331
+ actor,
332
+ path: file.path,
333
+ sourceHead,
334
+ capabilityId: file.capabilityId,
335
+ writeIntent: file.writeIntent,
336
+ ...(file.writeIntent === 'delete' ? { priorContent: file.canonical } : {}),
337
+ });
338
+ if (!completion.success) throw new Error(`completion failed for ${file.path}: ${completion.error}`);
57
339
  }
58
340
 
341
+ const mirror = resolveCodexRepoSkillsDir(root);
342
+ runGit('git', ['add', '--all', '--', mirror], {
343
+ cwd: root,
344
+ stdio: ['ignore', 'ignore', 'inherit'],
345
+ });
59
346
  console.log(`sync-agent-skills: .agents/skills in sync with skills/ (${written.length} skills)`);
347
+ return { written, changed: changed.map(file => file.path), deferred: [], sourceHead };
60
348
  }
61
349
 
62
- main();
350
+ async function main() {
351
+ try {
352
+ await syncAgentSkills();
353
+ } catch (error) {
354
+ console.error(`sync-agent-skills: failed — ${error.message}`);
355
+ process.exitCode = 1;
356
+ }
357
+ }
358
+
359
+ if (require.main === module) main();
360
+
361
+ module.exports = { actorFromEnv, changedSkillFiles, parseGitPaths, syncAgentSkills };
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Pre-commit auto-heal for the D20 bd call-site kill-list artifact.
6
+ *
7
+ * The `d20-audit-artifact-current` release gate compares the checked-in kill-list
8
+ * against a live re-scan, so any commit that shifts the bd call-site census leaves the
9
+ * artifact stale until someone runs `forge release regen-audit`. Five builders in a row
10
+ * forgot, and each found out a full CI round later (kernel issue 4cf2c43d).
11
+ *
12
+ * This removes the regen from the human's list, the same way scripts/sync-agent-skills.js
13
+ * removed the `.agents/skills` mirror regen: gate on a cheap changed-path predicate,
14
+ * regenerate, re-stage, succeed quietly. The release gate stays as the backstop for
15
+ * anything committed without this hook.
16
+ *
17
+ * The predicate is `isBdCensusPath`, exported by the gate's own module, so the hook and
18
+ * the gate always agree on which files count. The artifact itself is not a census path,
19
+ * so re-staging it cannot re-trigger the hook.
20
+ *
21
+ * The guarantee, stated honestly: the auto-heal applies when the counted staged paths
22
+ * match the working tree. It decides from the index but regenerates from the working tree
23
+ * (`auditBdCallSites` walks the filesystem), so those two agree only when the counted paths
24
+ * are staged whole. When any of them is partially staged the hook regenerates nothing, says
25
+ * so on stderr, and defers to a manual `forge release regen-audit` plus the
26
+ * `d20-audit-artifact-current` release gate — which still fails a genuinely stale artifact.
27
+ */
28
+
29
+ const { execFileSync } = require('node:child_process');
30
+ const path = require('node:path');
31
+ const { AUDIT_ARTIFACT, isBdCensusPath, writeAuditArtifact } = require('../lib/release-readiness');
32
+
33
+ function repoRoot(exec = execFileSync) {
34
+ try {
35
+ return exec('git', ['rev-parse', '--show-toplevel'], {
36
+ encoding: 'utf8',
37
+ stdio: ['ignore', 'pipe', 'ignore'],
38
+ }).trim();
39
+ } catch {
40
+ return path.resolve(__dirname, '..');
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Parses `git diff --cached --name-status -z`: a flat NUL-delimited token stream of a
46
+ * status followed by its path — `A\0path\0`, and `R100\0old\0new\0` for a rename or copy,
47
+ * which is read from both sides because both shift the census (the old path leaves it and
48
+ * the new one may not enter it).
49
+ *
50
+ * NUL delimiting is what makes this safe. Git's default output quotes and backslash-
51
+ * escapes pathnames with non-ASCII or special characters, and no line/tab split can
52
+ * represent a pathname that itself contains a tab or a newline. Under -z the pathnames are
53
+ * verbatim, so nothing here may trim them — a mis-read path silently drops a
54
+ * census-impacting change and skips the regen this hook exists to perform.
55
+ */
56
+ function parseStagedNameStatus(output) {
57
+ const tokens = output.split('\0');
58
+ const files = [];
59
+
60
+ for (let index = 0; index < tokens.length; index++) {
61
+ const status = tokens[index];
62
+ if (status.length === 0) {
63
+ continue;
64
+ }
65
+ const pathCount = /^[RC]/.test(status) ? 2 : 1;
66
+ files.push(...tokens.slice(index + 1, index + 1 + pathCount).filter(Boolean));
67
+ index += pathCount;
68
+ }
69
+
70
+ return [...new Set(files)];
71
+ }
72
+
73
+ function stagedPaths(projectRoot, exec = execFileSync) {
74
+ return parseStagedNameStatus(
75
+ exec('git', ['-C', projectRoot, 'diff', '--cached', '--name-status', '-z', '--diff-filter=ACMRDT'], {
76
+ encoding: 'utf8',
77
+ stdio: ['ignore', 'pipe', 'ignore'],
78
+ }),
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Parses a bare NUL-delimited path list (`git diff --name-only -z`). Same rule as
84
+ * parseStagedNameStatus: under -z the pathnames are verbatim, so nothing here may trim them.
85
+ */
86
+ function parseNulPaths(output) {
87
+ return output.split('\0').filter(Boolean);
88
+ }
89
+
90
+ /**
91
+ * Of the given paths, the ones whose working-tree content differs from their staged content.
92
+ * `git diff` without `--cached` is index-vs-worktree, which is exactly the question the
93
+ * auto-heal must answer before regenerating an artifact it builds from the working tree.
94
+ */
95
+ function worktreeModifiedPaths(projectRoot, paths, exec = execFileSync) {
96
+ if (paths.length === 0) {
97
+ return [];
98
+ }
99
+
100
+ return parseNulPaths(
101
+ exec('git', ['-C', projectRoot, 'diff', '--name-only', '-z', '--', ...paths], {
102
+ encoding: 'utf8',
103
+ stdio: ['ignore', 'pipe', 'ignore'],
104
+ }),
105
+ );
106
+ }
107
+
108
+ function stageArtifact(projectRoot, relativePath) {
109
+ execFileSync('git', ['-C', projectRoot, 'add', '--', relativePath], {
110
+ stdio: ['ignore', 'ignore', 'inherit'],
111
+ });
112
+ }
113
+
114
+ function run(deps) {
115
+ const { projectRoot, staged, worktreeModified, isCensusPath, writeArtifact, stage, log, warn } = deps;
116
+
117
+ const triggers = staged().filter(filePath => isCensusPath(projectRoot, filePath));
118
+ if (triggers.length === 0) {
119
+ return { regenerated: false, triggers: [], partiallyStaged: [] };
120
+ }
121
+
122
+ const partiallyStaged = worktreeModified(projectRoot, triggers);
123
+ if (partiallyStaged.length > 0) {
124
+ warn(
125
+ `sync-d20-audit: SKIPPED — the index is partially staged, so ${AUDIT_ARTIFACT} was NOT regenerated.\n` +
126
+ ` Staged and separately modified in the working tree: ${partiallyStaged.join(', ')}\n` +
127
+ ' The artifact is built from the working tree, so regenerating it now would commit a\n' +
128
+ ' census of content this commit does not contain.\n' +
129
+ ` Run \`forge release regen-audit\` and stage ${AUDIT_ARTIFACT} yourself if this commit\n` +
130
+ ' shifts the bd call-site census; the d20-audit-artifact-current gate checks it either way.',
131
+ );
132
+ return { regenerated: false, triggers, partiallyStaged };
133
+ }
134
+
135
+ writeArtifact(projectRoot);
136
+ stage(projectRoot, AUDIT_ARTIFACT);
137
+ log(`sync-d20-audit: regenerated ${AUDIT_ARTIFACT} (census paths staged: ${triggers.join(', ')})`);
138
+ return { regenerated: true, triggers, partiallyStaged: [] };
139
+ }
140
+
141
+ function main() {
142
+ const projectRoot = repoRoot();
143
+ try {
144
+ run({
145
+ projectRoot,
146
+ staged: () => stagedPaths(projectRoot),
147
+ worktreeModified: (root, paths) => worktreeModifiedPaths(root, paths),
148
+ isCensusPath: isBdCensusPath,
149
+ writeArtifact: root => writeAuditArtifact(root),
150
+ stage: stageArtifact,
151
+ log: message => console.log(message),
152
+ warn: message => console.error(message),
153
+ });
154
+ } catch (error) {
155
+ console.error(`sync-d20-audit: failed to regenerate ${AUDIT_ARTIFACT} — ${error.message}`);
156
+ console.error('Run `forge release regen-audit` and stage the result, or fix the error above.');
157
+ process.exit(1);
158
+ }
159
+ }
160
+
161
+ if (require.main === module) {
162
+ main();
163
+ }
164
+
165
+ module.exports = {
166
+ run,
167
+ parseStagedNameStatus,
168
+ parseNulPaths,
169
+ stagedPaths,
170
+ worktreeModifiedPaths,
171
+ repoRoot,
172
+ };