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
@@ -13,6 +13,10 @@ const path = require('node:path');
13
13
  const { execFileSync } = require('node:child_process');
14
14
  const { resolveIssueBackend } = require('../issue-backend');
15
15
  const { runIssueOperation } = require('../forge-issues');
16
+ const {
17
+ findPlanWorkFolder,
18
+ reconcilePlanAuthority,
19
+ } = require('../workflow/plan-authority');
16
20
 
17
21
  // Constants for security
18
22
  // Note: cwd is resolved at call-time via getExecOptions() to avoid stale require-time snapshots
@@ -483,7 +487,7 @@ async function registerBranchIssueLinkage(options, branch, issueId) {
483
487
  branch,
484
488
  actor: null,
485
489
  issue_id: issueId,
486
- work_folder: null,
490
+ work_folder: options.workFolder || null,
487
491
  registered_at: new Date().toISOString(),
488
492
  state: 'active',
489
493
  });
@@ -976,11 +980,38 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
976
980
  };
977
981
  }
978
982
 
983
+ const workFolder = options.workFolder || findPlanWorkFolder(options.projectRoot || process.cwd(), featureSlug);
984
+
979
985
  // F1: persist the branch->issue linkage so a plan-created branch resolves
980
986
  // to its issue for kernel-authoritative stage state (dev/validate/ship).
981
987
  // Kernel-only, best-effort.
982
988
  if (issueBackend === 'kernel') {
983
- await registerBranchIssueLinkage({ ...options, issueBackend }, branch.branchName, issue.issueId);
989
+ await registerBranchIssueLinkage({ ...options, issueBackend, workFolder }, branch.branchName, issue.issueId);
990
+ await withPlanDriver(options, driver => {
991
+ if (!driver || typeof driver.loadPlanSnapshot !== 'function') return null;
992
+ let existing;
993
+ try {
994
+ existing = driver.loadPlanSnapshot({ issue_id: issue.issueId }, {});
995
+ } catch (error) {
996
+ // Injectable issue runners used by embedding callers may not persist into
997
+ // this driver's store. With no repository artifacts there is nothing to
998
+ // reconcile; preserve that legacy embedding contract.
999
+ if (/^Issue .* not found in the kernel$/i.test(error.message)) {
1000
+ if (!workFolder) return null;
1001
+ throw new Error(`Issue ${issue.issueId} is not available for plan authority persistence`);
1002
+ }
1003
+ throw error;
1004
+ }
1005
+ if (!existing && !workFolder) return null;
1006
+ return reconcilePlanAuthority({
1007
+ driver,
1008
+ issueId: issue.issueId,
1009
+ projectRoot: options.projectRoot || process.cwd(),
1010
+ workFolder,
1011
+ mode: 'plan',
1012
+ repairCommand: `forge plan ${JSON.stringify(featureName)} --issue ${issue.issueId}`,
1013
+ });
1014
+ });
984
1015
  }
985
1016
 
986
1017
  // Build result summary
@@ -33,7 +33,7 @@ const SUBCOMMANDS = {
33
33
  },
34
34
  merge: {
35
35
  module: merge,
36
- summary: 'Opt-in conditional auto-merge, OFF by default (= forge merge --auto <pr>)',
36
+ summary: 'Opt-in guarded merge, OFF by default (= forge merge --auto <pr> --expect-head <sha> --issue <id>)',
37
37
  },
38
38
  };
39
39
 
@@ -81,6 +81,8 @@ async function handler(args, flags, projectRoot, opts) {
81
81
 
82
82
  module.exports = {
83
83
  name: 'pr',
84
+ githubAuth: (args = []) => ['ship', 'merge', 'shepherd']
85
+ .includes(stripGlobalFlags(args).find(arg => !arg.startsWith('-'))),
84
86
  description:
85
87
  'Unified pull-request surface: forge pr ship|preflight|shepherd|merge (wraps ship/preflight/shepherd/merge)',
86
88
  usage,
@@ -88,11 +88,19 @@ function resolveBaseRef(exec) {
88
88
  * @param {{ runAll?: boolean }} [opts]
89
89
  * @returns {{ resolved: boolean, changedFiles: string[], reason?: string, baseRef?: string }}
90
90
  */
91
- function resolveChangeSet(exec = execFileSync, { runAll = false } = {}) {
91
+ function resolveChangeSet(exec = execFileSync, { runAll = false, baseRef: explicitBaseRef = null } = {}) {
92
92
  if (runAll) {
93
93
  return { resolved: true, changedFiles: [], reason: 'whole-tree scope (--all)' };
94
94
  }
95
- const baseRef = resolveBaseRef(exec);
95
+ const baseRef = explicitBaseRef || resolveBaseRef(exec);
96
+ if (explicitBaseRef && !gitVerifyRef(exec, explicitBaseRef)) {
97
+ return {
98
+ resolved: false,
99
+ baseRef: explicitBaseRef,
100
+ changedFiles: [],
101
+ reason: `explicit base branch ${explicitBaseRef} is unavailable`,
102
+ };
103
+ }
96
104
  if (!baseRef) {
97
105
  return {
98
106
  resolved: false,
@@ -103,7 +111,16 @@ function resolveChangeSet(exec = execFileSync, { runAll = false } = {}) {
103
111
  // Diff against the SAME base we just resolved. getChangedFiles() re-resolves
104
112
  // its own diff ref (and can fall back to a different default branch), so use
105
113
  // baseRef directly to keep the file list consistent with the resolved base.
106
- return { resolved: true, baseRef, changedFiles: changedFilesForBase(exec, baseRef) };
114
+ const changedFiles = changedFilesForBase(exec, baseRef);
115
+ if (changedFiles == null) {
116
+ return {
117
+ resolved: false,
118
+ baseRef,
119
+ changedFiles: [],
120
+ reason: `git diff failed or timed out for ${baseRef}`,
121
+ };
122
+ }
123
+ return { resolved: true, baseRef, changedFiles };
107
124
  }
108
125
 
109
126
  /** Files changed between the merge-base of HEAD and `baseRef` and HEAD. */
@@ -111,7 +128,7 @@ function changedFilesForBase(exec, baseRef) {
111
128
  const mergeBase = gitTryOut(exec, ['merge-base', 'HEAD', baseRef]);
112
129
  const range = mergeBase ? `${mergeBase}...HEAD` : `${baseRef}...HEAD`;
113
130
  const out = gitTryOut(exec, ['diff', '--name-only', range]);
114
- if (out == null) return [];
131
+ if (out == null) return null;
115
132
  return out.split(/\r?\n/).filter(Boolean);
116
133
  }
117
134
 
@@ -6,15 +6,28 @@ const {
6
6
  runOrientationCommand,
7
7
  } = require('../orientation');
8
8
 
9
+ const DEPRECATION_NOTICE =
10
+ 'forge prime is deprecated — run `forge status -v` for the same briefing.\n';
11
+
12
+ // The single briefing renderer. `forge status -v` calls this so the full
13
+ // session-entry briefing has exactly one implementation.
14
+ // Async: the briefing leads with LIVE state (stage / claims / ready / gates / one
15
+ // adoption nudge), which needs a best-effort (non-throwing) kernel read before the
16
+ // synchronous build assembles it into the bounded orientation.
17
+ async function renderBriefing(args, projectRoot) {
18
+ const liveState = await collectPrimeLiveState(projectRoot);
19
+ return runOrientationCommand(buildPrime, args, projectRoot, { liveState });
20
+ }
21
+
9
22
  module.exports = {
10
23
  name: 'prime',
11
- description: 'Emit session-entry bounded orientation for agents',
12
- usage: 'Usage: forge prime [--budget N] [--json]',
13
- // Async: prime leads with LIVE state (stage / claims / ready / gates / one adoption nudge),
14
- // which needs a best-effort (non-throwing) kernel read before the synchronous build assembles
15
- // it into the bounded orientation. All existing prime output/flags are unchanged.
16
- handler: async (args, _flags, projectRoot) => {
17
- const liveState = await collectPrimeLiveState(projectRoot);
18
- return runOrientationCommand(buildPrime, args, projectRoot, { liveState });
24
+ description: 'Deprecated alias for `forge status -v`: session-entry bounded orientation',
25
+ usage: 'Usage: forge prime [--budget N] [--json] (deprecated — use `forge status -v`)',
26
+ // Session-start hooks in consumer repos call this and consume stdout as context,
27
+ // so the notice goes to stderr and stdout stays byte-identical to `status -v`.
28
+ handler: async (args, _flags, projectRoot, options = {}) => {
29
+ (options.stderr || process.stderr).write(DEPRECATION_NOTICE);
30
+ return renderBriefing(args, projectRoot);
19
31
  },
32
+ renderBriefing,
20
33
  };
@@ -4,8 +4,8 @@ const { execFileSync, spawnSync } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const forgeToken = require('../../scripts/check-forge-token');
7
- const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
8
- const { autoShepherdRailEnabled } = require('./ship');
7
+ const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE, resolveFullSuiteTimeoutMs } = require('../../scripts/test');
8
+ const { fireAndForget } = require('../pr-monitor/reconcile-executor');
9
9
 
10
10
  const isWindows = process.platform === 'win32';
11
11
 
@@ -113,54 +113,49 @@ async function autoFileBackingIssueForPush(projectRoot, execFn, deps = {}) {
113
113
  }
114
114
 
115
115
  /**
116
- * Resolve the OPEN PR number for the current branch via `gh pr view`. Returns
117
- * null when there is no PR, gh is unavailable, or anything errors (fail-open).
118
- * NEVER throws.
116
+ * Best-effort wake of the repository-wide singleton after a successful push.
117
+ * The shared trigger owns all containment and gate checks and never selects a
118
+ * per-PR watcher here.
119
119
  *
120
- * @param {function} execFn - execFileSync or mock
121
- * @returns {number|null}
120
+ * @param {object} params
121
+ * @returns {{ armed: boolean, reason?: string }}
122
122
  */
123
- function resolveOpenPrNumber(execFn) {
123
+ function maybeTriggerShepherdAfterPush({
124
+ projectRoot,
125
+ fireAndForget: trigger = fireAndForget,
126
+ }) {
124
127
  try {
125
- const out = execFn('gh', ['pr', 'view', '--json', 'number', '-q', '.number'], {
126
- encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
127
- });
128
- const n = Number.parseInt(String(out).trim(), 10);
129
- return Number.isInteger(n) && n > 0 ? n : null;
130
- } catch (_err) { /* intentional: no open PR / gh missing → arm nothing */ // NOSONAR S2486
131
- return null;
128
+ trigger({ projectRoot });
129
+ return { armed: true };
130
+ } catch {
131
+ return { armed: false, reason: 'shepherd-launch-failed' };
132
132
  }
133
133
  }
134
134
 
135
135
  /**
136
- * Best-effort, NON-BLOCKING arm of the constant PR watcher after a successful
137
- * push, when an OPEN PR exists for the current branch. This closes the gap where
138
- * PRs not born from `forge ship` (gh pr create, the GitHub UI, an earlier push)
139
- * never got a watcher. Gated by the default-ON `rail.auto_shepherd`, idempotent
140
- * via the watch loop's own PID/journal lock, and reusing the same
141
- * `startPrWatcherDetached` as ship. MUST NEVER throw into or fail the push: a
142
- * disabled rail, no PR, a gh error, or a spawn error all degrade to
143
- * `{ armed: false }`.
136
+ * Build the environment for the spawned `git push`, declaring the push lane.
144
137
  *
145
- * @param {object} params
146
- * @returns {{ armed: boolean, reason?: string, prNumber?: number }}
138
+ * `--quick` skips the test step push.js controls, but the `git push` it spawns
139
+ * still fires the lefthook pre-push hook, whose tests job runs scripts/test.js —
140
+ * so --quick was quick only up to the push. This declares the lane to that hook
141
+ * so the quick lane is lint-only end to end.
142
+ *
143
+ * The declaration is set on the child env only, and is explicitly REMOVED for a
144
+ * full push so an inherited value from an outer shell can never silently drop
145
+ * the tests from a `forge push`.
146
+ *
147
+ * @param {boolean} quickMode - Whether this is a --quick push
148
+ * @param {NodeJS.ProcessEnv} [baseEnv=process.env] - Environment to derive from
149
+ * @returns {NodeJS.ProcessEnv} Environment for the git push child process
147
150
  */
148
- function maybeArmWatcherAfterPush({
149
- projectRoot,
150
- execFn,
151
- startWatcher = startPrWatcherDetached,
152
- railEnabled = autoShepherdRailEnabled,
153
- prLookup = resolveOpenPrNumber,
154
- }) {
155
- try {
156
- if (!railEnabled(projectRoot)) return { armed: false, reason: 'rail.auto_shepherd disabled' };
157
- const prNumber = prLookup(execFn);
158
- if (!prNumber) return { armed: false, reason: 'no-open-pr' };
159
- const res = startWatcher({ prNumber, cwd: projectRoot });
160
- return { armed: !!(res && res.started), reason: res && res.reason, prNumber };
161
- } catch (err) {
162
- return { armed: false, reason: err.message };
151
+ function buildPushEnv(quickMode, baseEnv = process.env) {
152
+ const env = { ...baseEnv };
153
+ if (quickMode) {
154
+ env[QUICK_LANE_ENV_VAR] = QUICK_LANE_VALUE;
155
+ } else {
156
+ delete env[QUICK_LANE_ENV_VAR];
163
157
  }
158
+ return env;
164
159
  }
165
160
 
166
161
  /**
@@ -196,6 +191,94 @@ function runLint(spawnFn, pkgManager, projectRoot) {
196
191
  return lintResult.status === 0;
197
192
  }
198
193
 
194
+ /**
195
+ * Reports the kill signal a timed-out test run is terminated with. Shared by
196
+ * the spawn options and the timeout detector so the two cannot drift.
197
+ */
198
+ const TEST_RUN_KILL_SIGNAL = 'SIGKILL';
199
+
200
+ /**
201
+ * Reads the terminating signal under either field name.
202
+ *
203
+ * `node:child_process.spawnSync` reports `signal`; native `Bun.spawnSync`
204
+ * reports `signalCode`. Read both rather than assuming one.
205
+ *
206
+ * @param {Object} testResult - spawnSync result object
207
+ * @returns {string|null} Signal name, or null when none was reported
208
+ */
209
+ function terminationSignalOf(testResult) {
210
+ return testResult.signal || testResult.signalCode || null;
211
+ }
212
+
213
+ /**
214
+ * Reports whether a test run was killed by its own wall-clock budget.
215
+ *
216
+ * Requires AFFIRMATIVE evidence. Verified against pinned Bun 1.3.12 on Windows:
217
+ * a `node:child_process` spawnSync timeout returns `status: null`,
218
+ * `signal: <killSignal>`, AND an error whose `code` is `ETIMEDOUT`. Native
219
+ * `Bun.spawnSync` instead sets `exitedDueToTimeout: true` with `signalCode`.
220
+ * Those two markers are the only proof of a timeout, and they are checked
221
+ * BEFORE the generic error branch — testing `error` first swallowed the timeout
222
+ * diagnostic in exactly the case it was written for.
223
+ *
224
+ * A bare kill signal with no marker is deliberately NOT treated as a timeout:
225
+ * an OOM kill or an operator `kill -9` produces the identical status/signal
226
+ * shape, and since a genuine budget kill always carries a marker, inferring
227
+ * from the signal alone buys nothing and costs a misdiagnosis (it would claim
228
+ * the budget elapsed and send the user to raise FORGE_TEST_TIMEOUT_MS).
229
+ *
230
+ * @param {Object} testResult - spawnSync result object
231
+ * @returns {boolean} True when the run hit its budget
232
+ */
233
+ function isTimeoutTermination(testResult) {
234
+ if (testResult.exitedDueToTimeout === true) return true;
235
+ return Boolean(testResult.error) && testResult.error.code === 'ETIMEDOUT';
236
+ }
237
+
238
+ /**
239
+ * Explain a test run that never produced an exit status.
240
+ *
241
+ * `spawnSync` reports `status: null` when the child was killed by a signal
242
+ * (timeout kill included) or never started at all. Without this the push
243
+ * aborted with no summary at all, which is indistinguishable from a genuine
244
+ * test failure.
245
+ *
246
+ * @param {Object} testResult - spawnSync result object
247
+ * @param {number} timeoutMs - Wall-clock budget applied to the run
248
+ * @param {function} log - Logger function
249
+ */
250
+ function reportTestRunTermination(testResult, timeoutMs, log) {
251
+ const signal = terminationSignalOf(testResult);
252
+
253
+ if (isTimeoutTermination(testResult)) {
254
+ log(
255
+ `Test run timed out after ${Math.round(timeoutMs / 1000)}s `
256
+ + `(killed by ${signal || 'timeout'}) — push aborted.`,
257
+ );
258
+ log('Raise the budget with FORGE_TEST_TIMEOUT_MS if this machine is slower than the default.');
259
+ return;
260
+ }
261
+
262
+ if (testResult.error) {
263
+ log(`Test run could not complete: ${testResult.error.message}`);
264
+ return;
265
+ }
266
+
267
+ if (signal) {
268
+ // Signalled without timeout evidence: report the fact and the likely
269
+ // causes. Do NOT blame the budget — that sends the user after the wrong
270
+ // lever and hides the real failure.
271
+ log(
272
+ `Test run was terminated by ${signal} before finishing — push aborted. `
273
+ + `The run had a ${Math.round(timeoutMs / 1000)}s budget but reported no timeout, `
274
+ + 'so this is most likely an external kill or an out-of-memory kill.',
275
+ );
276
+ return;
277
+ }
278
+
279
+ log('Test run ended without an exit status — push aborted.');
280
+ }
281
+
199
282
  /**
200
283
  * Run tests unless in quick mode, logging warnings for first push.
201
284
  * @param {function} spawnFn - spawnSync or mock
@@ -204,9 +287,10 @@ function runLint(spawnFn, pkgManager, projectRoot) {
204
287
  * @param {string} projectRoot - Absolute path to project root
205
288
  * @param {boolean} quickMode - Whether to skip tests
206
289
  * @param {function} log - Logger function
290
+ * @param {NodeJS.ProcessEnv} [env] - Environment used to resolve the test budget
207
291
  * @returns {boolean|undefined} True if tests passed, undefined if skipped
208
292
  */
209
- function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log) {
293
+ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, env = process.env) {
210
294
  if (quickMode) {
211
295
  log('Tests skipped (--quick) — CI will run full suite on GitHub');
212
296
  const branch = getCurrentBranch(execFn);
@@ -216,12 +300,25 @@ function runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log) {
216
300
  return undefined;
217
301
  }
218
302
 
303
+ // `forge push` runs the package-level `test` script, i.e. the whole suite.
304
+ // Budget it from the shared full-suite budget in scripts/test.js — which is
305
+ // sized as ~2x the measured healthy full-suite runtime and overridable with
306
+ // FORGE_TEST_TIMEOUT_MS — instead of an arbitrary local cap: the old fixed
307
+ // 120s killed a healthy full run mid-suite and pushed nothing. Never
308
+ // reintroduce a local constant here; change the shared budget with a fresh
309
+ // measurement instead.
310
+ const timeoutMs = resolveFullSuiteTimeoutMs(env);
219
311
  const testResult = spawnFn(pkgManager, ['run', 'test'], {
220
312
  stdio: 'inherit',
221
313
  shell: isWindows,
222
314
  cwd: projectRoot,
223
- timeout: 120000,
315
+ timeout: timeoutMs,
316
+ killSignal: TEST_RUN_KILL_SIGNAL,
224
317
  });
318
+ if (testResult.status === null || testResult.status === undefined) {
319
+ reportTestRunTermination(testResult, timeoutMs, log);
320
+ return false;
321
+ }
225
322
  return testResult.status === 0;
226
323
  }
227
324
 
@@ -271,7 +368,7 @@ module.exports = {
271
368
  }
272
369
 
273
370
  // Step 3: Tests (skip in quick mode)
274
- const testsPassed = runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log);
371
+ const testsPassed = runTests(spawnFn, execFn, pkgManager, projectRoot, quickMode, log, deps?.env || process.env);
275
372
  if (!quickMode && !testsPassed) {
276
373
  return { success: false, quickMode, lintPassed: true, testsPassed: false, pushed: false };
277
374
  }
@@ -288,7 +385,7 @@ module.exports = {
288
385
  // Step 5: git push with passthrough args
289
386
  const gitArgs = args.filter(a => a !== '--quick');
290
387
  try {
291
- execFn('git', ['push', ...gitArgs], { stdio: 'inherit' });
388
+ execFn('git', ['push', ...gitArgs], { stdio: 'inherit', env: buildPushEnv(quickMode) });
292
389
  } catch (_pushErr) { // NOSONAR S2486
293
390
  log('git push failed.');
294
391
  return {
@@ -300,15 +397,10 @@ module.exports = {
300
397
  };
301
398
  }
302
399
 
303
- // Arm the constant PR watcher for this branch's open PR (best-effort,
304
- // gated by rail.auto_shepherd, never fails the push). Covers PRs not born
305
- // from `forge ship`.
306
- maybeArmWatcherAfterPush({
400
+ // Wake the repository-wide singleton after a successful push.
401
+ maybeTriggerShepherdAfterPush({
307
402
  projectRoot,
308
- execFn,
309
- startWatcher: deps?.startWatcher,
310
- railEnabled: deps?.railEnabled,
311
- prLookup: deps?.prLookup,
403
+ fireAndForget: deps?.fireAndForget,
312
404
  });
313
405
 
314
406
  return {
@@ -323,7 +415,7 @@ module.exports = {
323
415
  // Exposed for unit tests; not part of the CLI surface.
324
416
  _internal: {
325
417
  autoFileBackingIssueForPush,
326
- maybeArmWatcherAfterPush,
327
- resolveOpenPrNumber,
418
+ buildPushEnv,
419
+ maybeTriggerShepherdAfterPush,
328
420
  },
329
421
  };