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

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 +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +21 -1
  5. package/bin/forge.js +16 -369
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +9 -4
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -5,6 +5,7 @@ const {
5
5
  assertProtectedWriteAllowed,
6
6
  recordProtectedStateAuditEvent,
7
7
  } = require('../lib/protected-state-surfaces');
8
+ const { authorizeAndConsumeProtectedStateWrites } = require('../lib/protected-state-authority');
8
9
 
9
10
  function parseNameStatus(output) {
10
11
  const files = [];
@@ -41,35 +42,60 @@ function getStagedFiles() {
41
42
  return parseNameStatus(output);
42
43
  }
43
44
 
44
- function getAllowedSurfaces() {
45
- return new Set(
46
- String(process.env.FORGE_PROTECTED_STATE_ALLOWED_SURFACES || '')
47
- .split(',')
48
- .map(surface => surface.trim())
49
- .filter(Boolean),
50
- );
45
+ function getStagedContent(file) {
46
+ if (process.env.FORGE_PROTECTED_STATE_STAGED_CONTENTS_JSON) {
47
+ const contents = JSON.parse(process.env.FORGE_PROTECTED_STATE_STAGED_CONTENTS_JSON);
48
+ return Object.prototype.hasOwnProperty.call(contents, file) ? contents[file] : null;
49
+ }
50
+
51
+ try {
52
+ return execFileSync('git', ['show', `:${file}`], {
53
+ encoding: null,
54
+ stdio: ['ignore', 'pipe', 'pipe'],
55
+ });
56
+ } catch (_error) {
57
+ return null;
58
+ }
51
59
  }
52
60
 
53
- function main() {
61
+ async function main() {
54
62
  const actor =
55
63
  process.env.FORGE_PROTECTED_STATE_ACTOR ||
56
64
  process.env.FORGE_ACTOR ||
57
65
  process.env.USER ||
58
66
  process.env.USERNAME ||
59
67
  'unknown';
60
- const allowedSurfaces = getAllowedSurfaces();
61
- const decisions = getStagedFiles()
68
+ const probes = getStagedFiles()
62
69
  .map(file => {
63
70
  const probe = assertProtectedWriteAllowed(file, { actor, operation: 'staged_edit' });
64
- if (probe.requiredSurface && allowedSurfaces.has(probe.requiredSurface)) {
65
- return assertProtectedWriteAllowed(file, {
71
+ if (!probe.requiredSurface) return { probe };
72
+
73
+ const content = getStagedContent(probe.path);
74
+ if (content === null) return { probe };
75
+ return {
76
+ probe,
77
+ request: {
66
78
  actor,
67
- operation: 'staged_edit',
68
- viaForgeApi: true,
69
79
  surface: probe.requiredSurface,
70
- });
71
- }
72
- return probe;
80
+ path: probe.path,
81
+ content,
82
+ operation: 'staged_edit',
83
+ },
84
+ };
85
+ });
86
+ const protectedProbes = probes.filter(entry => entry.request);
87
+ const authorization = await authorizeAndConsumeProtectedStateWrites(
88
+ process.cwd(),
89
+ protectedProbes.map(entry => entry.request),
90
+ );
91
+ let authorizationIndex = 0;
92
+ const decisions = probes
93
+ .map(entry => {
94
+ if (!entry.request) return entry.probe;
95
+ const trustedDecision = authorization.decisions[authorizationIndex++];
96
+ return trustedDecision.allowed
97
+ ? trustedDecision
98
+ : { ...entry.probe, ...trustedDecision, repairHint: entry.probe.repairHint };
73
99
  })
74
100
  .filter(decision => !decision.allowed);
75
101
 
@@ -89,16 +115,15 @@ function main() {
89
115
  for (const decision of decisions) {
90
116
  console.error(` - ${decision.path} [${decision.requiredSurface}]`);
91
117
  console.error(` Decision: ${decision.decision}`);
118
+ console.error(` Reason: ${decision.reason}`);
92
119
  console.error(` Repair: ${decision.repairHint}`);
93
120
  }
94
121
  console.error('');
95
- console.error('Use the owning Forge or Beads API surface, then stage the generated result if that command explicitly owns it.');
122
+ console.error('Use the owning Forge API surface, then stage the generated result if that command explicitly owns it.');
96
123
  process.exit(1);
97
124
  }
98
125
 
99
- try {
100
- main();
101
- } catch (error) {
126
+ main().catch(error => {
102
127
  console.error(`Protected state check failed: ${error.message}`);
103
128
  process.exit(1);
104
- }
129
+ });
@@ -20,6 +20,7 @@ const {
20
20
  } = require('./lib/eval-runner');
21
21
  const { gradeTranscript } = require('./lib/grading');
22
22
  const { saveEvalResult } = require('./lib/eval-storage');
23
+ const { verifyEvalReplay } = require('./lib/eval-evidence');
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // parseArgs
@@ -89,6 +90,7 @@ function runShellCommand(command, worktreePath) {
89
90
  * @param {string} [options._basePath] — eval-logs base path for testing
90
91
  * @param {boolean} [options._skipWorktree=false] — skip worktree creation for unit tests
91
92
  * @param {Function} [options._executeOverride] — injectable command executor for testing
93
+ * @param {{envelope: object, inputs: {skill: string, tool: string}}} [options.replay] — exact-SHA replay binding
92
94
  * @returns {Promise<{ command: string, results: Array, overall_score: number, passed: boolean, duration_ms: number }>}
93
95
  */
94
96
  async function runEvalPipeline(evalSetPath, options = {}) {
@@ -98,6 +100,8 @@ async function runEvalPipeline(evalSetPath, options = {}) {
98
100
  const execOverride = options._executeOverride || null;
99
101
  const invokeGrader = options._invokeGrader || null;
100
102
  const basePath = options._basePath || undefined;
103
+ const hasReplay = Object.hasOwn(options, 'replay');
104
+ const replay = options.replay;
101
105
 
102
106
  const startTime = Date.now();
103
107
 
@@ -105,10 +109,34 @@ async function runEvalPipeline(evalSetPath, options = {}) {
105
109
  const evalSet = loadEvalSet(evalSetPath);
106
110
  const { command, queries } = evalSet;
107
111
 
112
+ let replayEvidence = null;
113
+ if (hasReplay) {
114
+ if (!replay || typeof replay !== 'object' || Array.isArray(replay)) throw new Error('replay must be an object');
115
+ for (const field of Object.keys(replay)) {
116
+ if (!['envelope', 'inputs'].includes(field)) throw new Error(`Unknown field 'replay.${field}'`);
117
+ }
118
+ if (!Object.hasOwn(replay, 'envelope')) throw new Error('replay.envelope is required');
119
+ if (!replay.inputs || typeof replay.inputs !== 'object' || Array.isArray(replay.inputs)) {
120
+ throw new Error('replay.inputs must be an object');
121
+ }
122
+ for (const field of Object.keys(replay.inputs)) {
123
+ if (!['skill', 'tool'].includes(field)) throw new Error(`Unknown field 'replay.inputs.${field}'`);
124
+ }
125
+ for (const field of ['skill', 'tool']) {
126
+ if (!Object.hasOwn(replay.inputs, field)) throw new Error(`replay.inputs.${field} is required`);
127
+ }
128
+ replayEvidence = verifyEvalReplay(replay.envelope, {
129
+ evalSet,
130
+ skill: replay.inputs.skill,
131
+ tool: replay.inputs.tool,
132
+ });
133
+ if (skipWorktree) throw new Error('Replay worktree verification cannot be skipped');
134
+ }
135
+
108
136
  // 2. Create eval worktree (unless skipped for testing)
109
137
  let worktreePath = null;
110
138
  if (!skipWorktree) {
111
- const wt = await createEvalWorktree();
139
+ const wt = await createEvalWorktree(replayEvidence ? replayEvidence.head_sha : undefined);
112
140
  worktreePath = wt.path;
113
141
  }
114
142
 
@@ -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
+ };
@@ -13,6 +13,8 @@ const {
13
13
  readNewestProfile,
14
14
  walkTests,
15
15
  } = require('./test-ci-shard');
16
+ const { createProcessTree, signalExitCode } = require('./process-tree');
17
+ const { stripGitHookEnv } = require('./test');
16
18
 
17
19
  const rootDir = path.join(__dirname, '..');
18
20
  const reportDir = path.join(rootDir, 'test-results');
@@ -81,6 +83,29 @@ function walkAllTests(dir) {
81
83
  return results;
82
84
  }
83
85
 
86
+ function assertExactShardAssignment(allTests, shardSpecs) {
87
+ const expectedFiles = new Set(allTests);
88
+ const assignedFiles = new Set();
89
+
90
+ for (const shard of shardSpecs) {
91
+ for (const file of shard.files) {
92
+ if (!expectedFiles.has(file)) {
93
+ throw new Error(`Test file ${file} is not part of the full suite`);
94
+ }
95
+ if (assignedFiles.has(file)) {
96
+ throw new Error(`Test file ${file} belongs to more than exactly one shard`);
97
+ }
98
+ assignedFiles.add(file);
99
+ }
100
+ }
101
+
102
+ for (const file of expectedFiles) {
103
+ if (!assignedFiles.has(file)) {
104
+ throw new Error(`Test file ${file} was omitted from the shard assignment`);
105
+ }
106
+ }
107
+ }
108
+
84
109
  function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
85
110
  const specs = [];
86
111
  for (let shardIndex = 0; shardIndex < shardTotal; shardIndex += 1) {
@@ -100,63 +125,247 @@ function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
100
125
  source: plan.source,
101
126
  });
102
127
  }
128
+ assertExactShardAssignment(allTests, specs);
103
129
  return specs;
104
130
  }
105
131
 
132
+ function buildShardTestArgs({ junitPath, files, root = rootDir }) {
133
+ return [
134
+ 'test',
135
+ '--timeout',
136
+ '30000',
137
+ '--reporter=junit',
138
+ '--reporter-outfile',
139
+ junitPath,
140
+ ...files.map((file) => path.resolve(root, file)),
141
+ ];
142
+ }
143
+
144
+ function parseShardReceipt(output) {
145
+ if (typeof output !== 'string') return null;
146
+ const root = output.match(/^\s*(?:<\?xml\b[^?]*\?>\s*)?<testsuites\b([^>]*)>[\s\S]*<\/testsuites\s*>\s*$/);
147
+ const openingTags = output.match(/<testsuites\b/g) || [];
148
+ const closingTags = output.match(/<\/testsuites\s*>/g) || [];
149
+ if (!root || openingTags.length !== 1 || closingTags.length !== 1) return null;
150
+
151
+ const readAttribute = (name) => root[1].match(new RegExp('\\b' + name + '="(\\d+)"'));
152
+ const requiredAttributes = ['tests', 'assertions', 'failures', 'skipped'];
153
+ const values = requiredAttributes.map(readAttribute);
154
+ const hasInvalidRequiredAttribute = requiredAttributes.some((name, index) => {
155
+ const occurrences = root[1].match(new RegExp('\\b' + name + '\\s*=', 'g')) || [];
156
+ return occurrences.length !== 1 || !values[index];
157
+ });
158
+ const errorsOccurrences = root[1].match(/\berrors\s*=/g) || [];
159
+ const errorsAttribute = readAttribute('errors');
160
+ if (hasInvalidRequiredAttribute
161
+ || errorsOccurrences.length > 1
162
+ || (errorsOccurrences.length === 1 && !errorsAttribute)) return null;
163
+
164
+ const tests = Number.parseInt(values[0][1], 10);
165
+ const assertions = Number.parseInt(values[1][1], 10);
166
+ const failed = Number.parseInt(values[2][1], 10);
167
+ const errors = Number.parseInt(errorsAttribute?.[1] || '0', 10);
168
+ const skipped = Number.parseInt(values[3][1], 10);
169
+ const passed = tests - failed - errors - skipped;
170
+ if (tests === 0 || passed < 0) return null;
171
+ return { assertions, errors, failed, passed, skipped, tests };
172
+ }
173
+
174
+ function aggregateShardReceipts(receipts, expectedCount) {
175
+ const totals = {
176
+ assertions: 0,
177
+ errors: 0,
178
+ failed: 0,
179
+ passed: 0,
180
+ skipped: 0,
181
+ tests: 0,
182
+ };
183
+ const seen = new Set();
184
+ let incomplete = receipts.length !== expectedCount;
185
+ let failedProcess = false;
186
+
187
+ for (const receipt of receipts) {
188
+ if (receipt === null || typeof receipt !== 'object'
189
+ || !Number.isInteger(receipt.index)
190
+ || receipt.index < 0
191
+ || receipt.index >= expectedCount
192
+ || seen.has(receipt.index)) {
193
+ incomplete = true;
194
+ continue;
195
+ }
196
+ seen.add(receipt.index);
197
+ if (!Number.isInteger(receipt.code)) {
198
+ incomplete = true;
199
+ } else {
200
+ failedProcess ||= receipt.code !== 0;
201
+ }
202
+
203
+ const parsed = parseShardReceipt(receipt.output);
204
+ if (!parsed) {
205
+ incomplete = true;
206
+ continue;
207
+ }
208
+ for (const key of Object.keys(totals)) totals[key] += parsed[key];
209
+ }
210
+
211
+ incomplete ||= seen.size !== expectedCount;
212
+ const status = incomplete
213
+ ? 'INCOMPLETE'
214
+ : (failedProcess || totals.failed > 0 || totals.errors > 0 ? 'FAIL' : 'PASS');
215
+ return { ...totals, exitCode: status === 'PASS' ? 0 : 1, status };
216
+ }
217
+
106
218
  function spawnShard(shard, options = {}) {
107
219
  const spawn = options.spawn || defaultSpawn;
108
220
  const env = options.env || process.env;
109
221
  const bunCommand = options.bunCommand || env.BUN_EXE || process.env.BUN_EXE || 'bun';
110
222
  const labelPrefix = options.labelPrefix || 'local-full';
111
- fs.mkdirSync(reportDir, { recursive: true });
112
- const junitPath = path.join(reportDir, `${labelPrefix}-shard-${shard.index}.xml`);
223
+ const targetReportDir = options.reportDirectory || reportDir;
224
+ const platform = options.platform || process.platform;
225
+ const processTree = options.processTree || createProcessTree({ env, platform });
226
+ const resolvedReportDir = path.resolve(targetReportDir);
227
+ const junitPath = path.resolve(resolvedReportDir, `${labelPrefix}-shard-${shard.index}.xml`);
228
+ if (path.dirname(junitPath) !== resolvedReportDir) {
229
+ return Promise.reject(new Error('label prefix must produce a receipt directly inside test-results'));
230
+ }
231
+ fs.mkdirSync(resolvedReportDir, { recursive: true });
113
232
 
114
233
  return new Promise((resolve, reject) => {
115
- const child = spawn(bunCommand, [
116
- 'test',
117
- '--timeout',
118
- '30000',
119
- '--reporter=junit',
120
- '--reporter-outfile',
121
- junitPath,
122
- ...shard.files,
123
- ], {
124
- cwd: rootDir,
125
- env,
126
- shell: false,
127
- stdio: 'inherit',
234
+ const reservation = processTree.reserveChild({
235
+ command: bunCommand,
236
+ kind: 'test-shard',
237
+ label: `${labelPrefix}-shard-${shard.index}`,
128
238
  });
239
+ if (!reservation) {
240
+ reject(new Error('test shard ownership manifest is unavailable'));
241
+ return;
242
+ }
243
+
244
+ let child;
245
+ let settled = false;
246
+ const finish = (code, output) => {
247
+ if (settled) return;
248
+ settled = true;
249
+ processTree.unregisterChild(reservation);
250
+ resolve({ code, index: shard.index, output });
251
+ };
252
+ try {
253
+ fs.rmSync(junitPath, { force: true });
254
+ child = spawn(bunCommand, buildShardTestArgs({
255
+ junitPath,
256
+ files: shard.files,
257
+ }), {
258
+ cwd: rootDir,
259
+ env,
260
+ shell: false,
261
+ stdio: 'inherit',
262
+ detached: platform !== 'win32',
263
+ windowsHide: true,
264
+ });
265
+ child.on('error', (error) => {
266
+ if (settled) return;
267
+ settled = true;
268
+ processTree.unregisterChild(reservation);
269
+ reject(error);
270
+ });
271
+ if (!processTree.registerChild(reservation, child)) {
272
+ settled = true;
273
+ if (typeof processTree.abortChild === 'function') {
274
+ processTree.abortChild(reservation, child);
275
+ } else {
276
+ try {
277
+ child.kill?.('SIGKILL');
278
+ } finally {
279
+ processTree.cleanup?.('SIGKILL');
280
+ processTree.unregisterChild(reservation);
281
+ }
282
+ }
283
+ reject(new Error('test shard process could not be registered'));
284
+ return;
285
+ }
286
+ } catch (error) {
287
+ if (!settled) processTree.unregisterChild(reservation);
288
+ reject(error);
289
+ return;
290
+ }
129
291
 
130
- child.on('error', reject);
131
292
  child.on('close', (code) => {
132
- resolve(code ?? 1);
293
+ let output = null;
294
+ try {
295
+ output = fs.readFileSync(junitPath, 'utf8');
296
+ } catch {}
297
+ finish(code ?? 1, output);
133
298
  });
134
299
  });
135
300
  }
136
301
 
137
302
  async function runFullSuiteInParallel(args = {}, deps = {}) {
138
- const allTests = deps.allTests || listAllFullSuiteTests();
139
- const shardTotal = Number.isInteger(args.shards) && args.shards > 0
140
- ? args.shards
141
- : getDefaultShardCount(deps.cpuCount);
142
- const profile = deps.profile || readNewestProfile(reportDir);
143
- const durationMap = deps.durationMap || createDurationMap(profile);
144
- const shardSpecs = buildShardSpecs(allTests, shardTotal, durationMap);
145
-
146
- if (shardSpecs.length === 0) {
147
- console.log('No unit test files discovered for local full-suite run');
148
- return 0;
149
- }
303
+ const env = deps.env || process.env;
304
+ const platform = deps.platform || process.platform;
305
+ const processTree = deps.processTree || createProcessTree({ env, platform });
306
+ let signal = null;
307
+ let completed = false;
308
+ const removeSignalHandlers = processTree.installSignalHandlers((received) => {
309
+ signal = received;
310
+ });
311
+
312
+ try {
313
+ const allTests = deps.allTests || listAllFullSuiteTests();
314
+ const shardTotal = Number.isInteger(args.shards) && args.shards > 0
315
+ ? args.shards
316
+ : getDefaultShardCount(deps.cpuCount);
317
+ const profile = deps.profile || readNewestProfile(reportDir);
318
+ const durationMap = deps.durationMap || createDurationMap(profile);
319
+ const shardSpecs = buildShardSpecs(allTests, shardTotal, durationMap);
150
320
 
151
- console.log(`Running local full suite in ${shardSpecs.length} shard(s)`);
152
- const results = await Promise.all(shardSpecs.map((shard) => spawnShard(shard, {
153
- bunCommand: deps.bunCommand,
154
- env: deps.env,
155
- labelPrefix: args.labelPrefix,
156
- spawn: deps.spawn,
157
- })));
321
+ if (shardSpecs.length === 0) {
322
+ const exitCode = signal ? signalExitCode(signal) : 1;
323
+ console.log('Full suite aggregate: status=INCOMPLETE tests=0 assertions=0 passed=0 failed=0 errors=0 skipped=0');
324
+ console.log('Full suite exit: ' + exitCode);
325
+ completed = true;
326
+ return exitCode;
327
+ }
158
328
 
159
- return results.some((code) => code !== 0) ? 1 : 0;
329
+ fs.mkdirSync(reportDir, { recursive: true });
330
+ const runReportDir = fs.mkdtempSync(path.join(reportDir, 'full-suite-'));
331
+
332
+ console.log(`Running local full suite in ${shardSpecs.length} shard(s)`);
333
+ const childEnv = stripGitHookEnv(
334
+ typeof processTree.envFor === 'function' ? processTree.envFor(env) : env,
335
+ );
336
+ let results;
337
+ try {
338
+ results = await Promise.all(shardSpecs.map((shard) => spawnShard(shard, {
339
+ bunCommand: deps.bunCommand,
340
+ env: childEnv,
341
+ labelPrefix: args.labelPrefix,
342
+ reportDirectory: runReportDir,
343
+ spawn: deps.spawn,
344
+ platform,
345
+ processTree,
346
+ })));
347
+ } catch (error) {
348
+ console.error('Full suite shard execution failed:', error);
349
+ const exitCode = signal ? signalExitCode(signal) : 1;
350
+ console.log('Full suite aggregate: status=INCOMPLETE tests=0 assertions=0 passed=0 failed=0 errors=0 skipped=0');
351
+ console.log('Full suite exit: ' + exitCode);
352
+ return exitCode;
353
+ }
354
+
355
+ const aggregate = aggregateShardReceipts(results, shardSpecs.length);
356
+ const exitCode = signal ? signalExitCode(signal) : aggregate.exitCode;
357
+ if (signal) aggregate.status = 'INCOMPLETE';
358
+ if (aggregate.status === 'PASS' && exitCode === 0) {
359
+ fs.rmSync(runReportDir, { force: true, recursive: true });
360
+ }
361
+ console.log(`Full suite aggregate: status=${aggregate.status} tests=${aggregate.tests} assertions=${aggregate.assertions} passed=${aggregate.passed} failed=${aggregate.failed} errors=${aggregate.errors} skipped=${aggregate.skipped}`);
362
+ console.log(`Full suite exit: ${exitCode}`);
363
+ completed = true;
364
+ return exitCode;
365
+ } finally {
366
+ removeSignalHandlers();
367
+ processTree.cleanup(signal || !completed ? 'SIGKILL' : 'SIGTERM');
368
+ }
160
369
  }
161
370
 
162
371
  async function main(argv = process.argv.slice(2), deps = {}) {
@@ -175,6 +384,9 @@ if (require.main === module) {
175
384
  }
176
385
 
177
386
  module.exports = {
387
+ aggregateShardReceipts,
388
+ assertExactShardAssignment,
389
+ buildShardTestArgs,
178
390
  buildShardSpecs,
179
391
  getDefaultShardCount,
180
392
  listAllFullSuiteTests,