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
package/scripts/test.js CHANGED
@@ -4,15 +4,27 @@
4
4
  *
5
5
  * It runs only the tests affected by known changes when possible and falls back
6
6
  * to the full suite for package-level or unknown-file changes.
7
+ *
8
+ * Quick lane: `forge push --quick` sets FORGE_PUSH_LANE=quick on the `git push`
9
+ * it spawns, declaring the lint-only review-cycle lane. The pre-push entry point
10
+ * honors that declaration and skips the test run with a loud notice, so --quick
11
+ * is quick end to end instead of only in the step push.js controls. This is not
12
+ * a hook bypass: branch protection and lint still run, local validation
13
+ * (`--validate`) ignores the variable entirely, and CI runs the full matrix.
7
14
  */
8
15
 
9
- const { execFileSync: defaultExecFileSync, spawnSync: defaultSpawnSync } = require('node:child_process');
16
+ const { EventEmitter } = require('node:events');
17
+ const {
18
+ execFileSync: defaultExecFileSync,
19
+ spawn: defaultSpawn,
20
+ } = require('node:child_process');
10
21
  const fs = require('node:fs');
11
22
 
12
23
  const {
13
24
  getAffectedTestFiles,
14
25
  getChangedFiles,
15
26
  } = require('../lib/commands/test');
27
+ const { createProcessTree, signalExitCode } = require('./process-tree');
16
28
 
17
29
  const PACKAGE_LEVEL_PATHS = new Set([
18
30
  'package.json',
@@ -64,6 +76,21 @@ const DEFAULT_FULL_SUITE_TIMEOUT_MS = 10 * 60 * 1000;
64
76
  // Conventional shell exit code for a command terminated by a timeout.
65
77
  const TIMEOUT_EXIT_CODE = 124;
66
78
 
79
+ // Lane declaration set by `forge push --quick` on the spawned `git push`.
80
+ // Only this exact value opts into the lint-only lane.
81
+ const QUICK_LANE_ENV_VAR = 'FORGE_PUSH_LANE';
82
+ const QUICK_LANE_VALUE = 'quick';
83
+
84
+ /**
85
+ * Reports whether the environment declares the lint-only quick push lane.
86
+ *
87
+ * @param {NodeJS.ProcessEnv} [env=process.env] Environment to inspect.
88
+ * @returns {boolean} True when the quick lane is explicitly declared.
89
+ */
90
+ function isQuickPushLane(env = process.env) {
91
+ return env[QUICK_LANE_ENV_VAR] === QUICK_LANE_VALUE;
92
+ }
93
+
67
94
  /**
68
95
  * Reads and validates the FORGE_TEST_TIMEOUT_MS override, if any.
69
96
  *
@@ -134,9 +161,11 @@ function detectPackageManager() {
134
161
  function stripGitHookEnv(sourceEnv = process.env) {
135
162
  const env = { ...sourceEnv };
136
163
  for (const key of Object.keys(env)) {
137
- if (key === 'GIT_DIR' || key === 'GIT_WORK_TREE' || key === 'GIT_INDEX_FILE'
138
- || key === 'GIT_OBJECT_DIRECTORY' || key === 'GIT_ALTERNATE_OBJECT_DIRECTORIES'
139
- || key === 'GIT_QUARANTINE_PATH') {
164
+ const normalizedKey = key.toUpperCase();
165
+ if (normalizedKey === 'GIT_DIR' || normalizedKey === 'GIT_WORK_TREE'
166
+ || normalizedKey === 'GIT_INDEX_FILE' || normalizedKey === 'GIT_OBJECT_DIRECTORY'
167
+ || normalizedKey === 'GIT_ALTERNATE_OBJECT_DIRECTORIES'
168
+ || normalizedKey === 'GIT_QUARANTINE_PATH') {
140
169
  delete env[key];
141
170
  }
142
171
  }
@@ -156,6 +185,8 @@ function isKnownTargetablePath(file) {
156
185
 
157
186
  if (file === 'README.md'
158
187
  || file === 'bin/forge.js'
188
+ || file === 'bin/forge-cmd.js'
189
+ || file === 'bin/forge-preflight.js'
159
190
  || file === 'QUICKSTART.md'
160
191
  || file === 'CHANGELOG.md'
161
192
  || file === 'AGENTS.md'
@@ -166,7 +197,12 @@ function isKnownTargetablePath(file) {
166
197
  || file.startsWith('docs/guides/')
167
198
  || file.startsWith('docs/plans/')
168
199
  || file.startsWith('docs/reference/')
169
- || file.startsWith('docs/work/')) {
200
+ || file.startsWith('docs/work/')
201
+ // Skill sources + their committed mirror map to the skill suite (see
202
+ // SKILL_TEST_TARGETS in lib/commands/test.js); a skills-only PR stays on the
203
+ // targeted lane instead of the full suite.
204
+ || file.startsWith('skills/')
205
+ || file.startsWith('.agents/skills/')) {
170
206
  return true;
171
207
  }
172
208
 
@@ -310,31 +346,78 @@ function classifyPushTests(projectRoot, execFileSync = defaultExecFileSync) {
310
346
  *
311
347
  * @param {string} command Executable name.
312
348
  * @param {string[]} args Command arguments.
313
- * @param {import('node:child_process').SpawnSyncOptions} [options={}] Spawn options.
314
- * @param {typeof defaultSpawnSync} [spawnSync=defaultSpawnSync] Process runner.
315
- * @returns {number} Process exit status, or 1 when no status is reported.
349
+ * @param {import('node:child_process').SpawnOptions} [options={}] Spawn options.
350
+ * @param {typeof defaultSpawn} [spawn=defaultSpawn] Process runner.
351
+ * @returns {Promise<number>} Process exit status, or 1 when no status is reported.
316
352
  */
317
- function runCommand(command, args, options = {}, spawnSync = defaultSpawnSync) {
318
- const result = spawnSync(command, args, {
319
- stdio: 'inherit',
320
- shell: isWindows,
321
- ...options,
322
- });
323
-
324
- if (result.error) {
325
- if (result.error.code === 'ETIMEDOUT') {
326
- console.error('');
327
- console.error('Test lane exceeded its wall-clock ceiling and was terminated.');
328
- console.error('A single test likely hung (e.g. a spawned git/bash call that never returns).');
329
- console.error('Adjust the ceiling with FORGE_TEST_TIMEOUT_MS. Failing the run instead of');
330
- console.error('blocking the push. See issue 8aef79e8.');
331
- console.error('');
332
- return TIMEOUT_EXIT_CODE;
353
+ function runCommand(command, args, options = {}, spawn = defaultSpawn) {
354
+ const { processTree, timeout, killSignal: _killSignal, ...spawnOptions } = options;
355
+ return new Promise((resolve, reject) => {
356
+ let child;
357
+ let settled = false;
358
+ let timer = null;
359
+ const reservation = processTree?.reserveChild?.({ kind: 'test-lane', label: command }) || null;
360
+ let registered = false;
361
+ const release = () => {
362
+ if (registered) {
363
+ processTree?.unregisterChild?.(reservation);
364
+ registered = false;
365
+ }
366
+ };
367
+ const finish = (status) => {
368
+ if (settled) return;
369
+ settled = true;
370
+ release();
371
+ resolve(status ?? 1);
372
+ };
373
+ const fail = (error) => {
374
+ if (settled) return;
375
+ if (timer) clearTimeout(timer);
376
+ if (error?.code === 'ETIMEDOUT') {
377
+ processTree?.cleanup?.('SIGKILL');
378
+ console.error('');
379
+ console.error('Test lane exceeded its wall-clock ceiling and was terminated.');
380
+ console.error('A single test likely hung (e.g. a spawned git/bash call that never returns).');
381
+ console.error('Adjust the ceiling with FORGE_TEST_TIMEOUT_MS. Failing the run instead of');
382
+ console.error('blocking the push. See issue 8aef79e8.');
383
+ console.error('');
384
+ finish(TIMEOUT_EXIT_CODE);
385
+ return;
386
+ }
387
+ settled = true;
388
+ release();
389
+ reject(error);
390
+ };
391
+ try {
392
+ child = spawn(command, args, { stdio: 'inherit', shell: isWindows, ...spawnOptions });
393
+ } catch (error) {
394
+ fail(error);
395
+ return;
333
396
  }
334
- throw result.error;
335
- }
336
-
337
- return result.status ?? 1;
397
+ child.once?.('error', fail);
398
+ child.once?.('close', (status, signal) => {
399
+ if (timer) clearTimeout(timer);
400
+ finish(status ?? signalExitCode(signal));
401
+ });
402
+ if (reservation && typeof processTree?.registerChild === 'function') {
403
+ registered = true;
404
+ if (!processTree.registerChild(reservation, child)) {
405
+ processTree.abortChild?.(reservation, child);
406
+ release();
407
+ fail(new Error('test lane process could not be registered'));
408
+ return;
409
+ }
410
+ }
411
+ timer = timeout > 0 ? setTimeout(() => {
412
+ processTree?.cleanup?.('SIGKILL');
413
+ try { child.kill?.('SIGKILL'); } catch { /* best effort */ }
414
+ finish(TIMEOUT_EXIT_CODE);
415
+ }, timeout) : null;
416
+ if (!child.once) {
417
+ if (timer) clearTimeout(timer);
418
+ finish(child.status ?? 1);
419
+ }
420
+ });
338
421
  }
339
422
 
340
423
  /**
@@ -344,51 +427,92 @@ function runCommand(command, args, options = {}, spawnSync = defaultSpawnSync) {
344
427
  * @param {Object} [deps={}] Runtime dependencies for tests.
345
428
  * @returns {number} Exit status for the executed plan.
346
429
  */
347
- function runTestExecutionPlan(plan, deps = {}) {
348
- const spawnSync = deps.spawnSync || defaultSpawnSync;
430
+ async function runTestExecutionPlan(plan, deps = {}) {
431
+ const usesSpawnSyncAdapter = !deps.spawn && typeof deps.spawnSync === 'function';
432
+ const spawn = deps.spawn || (usesSpawnSyncAdapter ? (...args) => {
433
+ const child = new EventEmitter();
434
+ let result;
435
+ try {
436
+ result = deps.spawnSync(...args);
437
+ } catch (error) {
438
+ process.nextTick(() => child.emit('error', error));
439
+ return child;
440
+ }
441
+ child.pid = result?.pid || process.pid;
442
+ process.nextTick(() => {
443
+ if (result?.error) child.emit('error', result.error);
444
+ else child.emit('close', result?.status ?? 1, result?.signal);
445
+ });
446
+ return child;
447
+ } : defaultSpawn);
349
448
  const pkgManager = deps.pkgManager || detectPackageManager();
350
449
  const env = deps.env || stripGitHookEnv(process.env);
450
+ // The injected synchronous runner has already exited when its adapter returns,
451
+ // so there is no live child to register or reap through host process probes.
452
+ const processTree = deps.processTree || (usesSpawnSyncAdapter ? {} : createProcessTree({
453
+ env,
454
+ platform: deps.platform,
455
+ }));
456
+ let signal = null;
457
+ const removeSignalHandlers = typeof processTree.installSignalHandlers === 'function'
458
+ ? processTree.installSignalHandlers((received) => {
459
+ signal = received;
460
+ })
461
+ : () => {};
462
+ const childEnv = typeof processTree.envFor === 'function' ? processTree.envFor(env) : env;
351
463
  const bunCommand = deps.bunCommand || env.BUN_EXE || process.env.BUN_EXE || 'bun';
352
464
  const label = deps.label || 'tests';
353
465
  const timeout = resolveCommandTimeoutMs(env);
354
- const laneOptions = { env, killSignal: 'SIGKILL', timeout };
466
+ const laneOptions = { env: childEnv, killSignal: 'SIGKILL', timeout, processTree };
355
467
  // The full-suite fallback gets a larger, validation-aligned budget so a
356
468
  // healthy-but-slow full run is not failed fast by the targeted-lane ceiling.
357
- const fullSuiteOptions = { env, killSignal: 'SIGKILL', timeout: resolveFullSuiteTimeoutMs(env) };
469
+ const fullSuiteOptions = {
470
+ env: childEnv,
471
+ killSignal: 'SIGKILL',
472
+ timeout: resolveFullSuiteTimeoutMs(env),
473
+ processTree,
474
+ };
358
475
 
359
476
  console.log(`Running ${label} (${pkgManager})...`);
360
477
 
361
478
  try {
362
479
  if (plan.runFullSuite) {
363
480
  console.log(` Mode: full suite (${plan.reason})`);
364
- const status = runCommand('node', ['scripts/test-full-suite.js'], fullSuiteOptions, spawnSync);
481
+ const status = await runCommand('node', ['scripts/test-full-suite.js'], fullSuiteOptions, spawn);
482
+ if (signal) return signalExitCode(signal);
365
483
  if (status !== 0) return status;
366
484
  } else if (plan.testTargets.length > 0) {
367
485
  console.log(` Mode: targeted (${plan.testTargets.length} test file${plan.testTargets.length === 1 ? '' : 's'})`);
368
486
  const command = pkgManager === 'bun' ? bunCommand : pkgManager;
369
- const status = runCommand(command, ['run', 'test', ...plan.testTargets], laneOptions, spawnSync);
487
+ const status = await runCommand(command, ['run', 'test', ...plan.testTargets], laneOptions, spawn);
488
+ if (signal) return signalExitCode(signal);
370
489
  if (status !== 0) return status;
371
490
  }
372
491
 
373
492
  if (plan.runE2E) {
374
493
  console.log(' Extra: running affected e2e tests');
375
- const status = runCommand(bunCommand, ['test', '--timeout', '15000', 'test/e2e/'], laneOptions, spawnSync);
494
+ const status = await runCommand(bunCommand, ['test', '--timeout', '15000', 'test/e2e/'], laneOptions, spawn);
495
+ if (signal) return signalExitCode(signal);
376
496
  if (status !== 0) return status;
377
497
  }
378
498
 
379
499
  if (!plan.runFullSuite && plan.runTestEnv) {
380
500
  console.log(' Extra: running affected edge-case tests');
381
- const status = runCommand(bunCommand, ['test', '--timeout', '15000', 'test-env/'], laneOptions, spawnSync);
501
+ const status = await runCommand(bunCommand, ['test', '--timeout', '15000', 'test-env/'], laneOptions, spawn);
502
+ if (signal) return signalExitCode(signal);
382
503
  if (status !== 0) return status;
383
504
  }
384
505
 
385
506
  console.log('Relevant tests passed');
386
- return 0;
507
+ return signal ? signalExitCode(signal) : 0;
387
508
  } catch (error) {
388
509
  console.error('');
389
510
  console.error(`Failed to run ${label}: ${error.message}`);
390
511
  console.error('');
391
- return 1;
512
+ return signal ? signalExitCode(signal) : 1;
513
+ } finally {
514
+ removeSignalHandlers();
515
+ processTree.cleanup?.(signal ? 'SIGKILL' : 'SIGTERM');
392
516
  }
393
517
  }
394
518
 
@@ -399,7 +523,15 @@ function runTestExecutionPlan(plan, deps = {}) {
399
523
  * @param {Object} [deps={}] Runtime dependencies for tests.
400
524
  * @returns {number} Exit status for pre-push tests.
401
525
  */
402
- function runPrePushTests(projectRoot = process.cwd(), deps = {}) {
526
+ async function runPrePushTests(projectRoot = process.cwd(), deps = {}) {
527
+ if (isQuickPushLane(deps.env || process.env)) {
528
+ console.log('');
529
+ console.log(' quick lane: tests skipped locally — CI runs the full matrix');
530
+ console.log(' Run `forge push` (no --quick) to test before merge.');
531
+ console.log('');
532
+ return 0;
533
+ }
534
+
403
535
  const execFileSync = deps.execFileSync || defaultExecFileSync;
404
536
  const plan = classifyPushTests(projectRoot, execFileSync);
405
537
  return runTestExecutionPlan(plan, { ...deps, label: 'pre-push tests' });
@@ -412,26 +544,34 @@ function runPrePushTests(projectRoot = process.cwd(), deps = {}) {
412
544
  * @param {Object} [deps={}] Runtime dependencies for tests.
413
545
  * @returns {number} Exit status for local validation tests.
414
546
  */
415
- function runLocalValidationTests(projectRoot = process.cwd(), deps = {}) {
547
+ async function runLocalValidationTests(projectRoot = process.cwd(), deps = {}) {
416
548
  const execFileSync = deps.execFileSync || defaultExecFileSync;
417
549
  const plan = buildTestExecutionPlan(projectRoot, execFileSync, { sinceUpstream: true });
418
550
  return runTestExecutionPlan(plan, { ...deps, label: 'local validation tests' });
419
551
  }
420
552
 
421
553
  if (require.main === module) {
422
- const exitCode = process.argv.includes('--validate')
423
- ? runLocalValidationTests()
424
- : runPrePushTests();
425
- process.exit(exitCode);
554
+ (async () => {
555
+ const exitCode = process.argv.includes('--validate')
556
+ ? await runLocalValidationTests()
557
+ : await runPrePushTests();
558
+ process.exit(exitCode);
559
+ })().catch((error) => {
560
+ console.error(error);
561
+ process.exit(1);
562
+ });
426
563
  }
427
564
 
428
565
  module.exports = {
429
566
  ALWAYS_RUN_RISK_TEST_TARGETS,
430
567
  DEFAULT_FULL_SUITE_TIMEOUT_MS,
431
568
  DEFAULT_TEST_COMMAND_TIMEOUT_MS,
569
+ QUICK_LANE_ENV_VAR,
570
+ QUICK_LANE_VALUE,
432
571
  buildTestExecutionPlan,
433
572
  classifyPushTests,
434
573
  detectPackageManager,
574
+ isQuickPushLane,
435
575
  resolveCommandTimeoutMs,
436
576
  resolveFullSuiteTimeoutMs,
437
577
  runLocalValidationTests,
@@ -14,10 +14,14 @@ description: >
14
14
  and NOT for read-only selecting or ranking the next ready issue without claiming (that is
15
15
  triage-ready).
16
16
  allowed-tools: Read, Bash(forge:*)
17
+ next: dev
18
+ terminal: false
17
19
  ---
18
20
 
19
21
  # Claim safety — claim, then prove you own the lease
20
22
 
23
+ > **Chain:** once you have PROVEN the lease (`forge issue owns <id>` exits 0), continue to `dev` to work the issue (`smith` proceeds into plan/dev after this proof). Usable standalone whenever ownership is in question.
24
+
21
25
  Claiming is not owning. The Forge kernel keys a claim's idempotency on
22
26
  `claim.create:<issue_id>:<actor>`, so a **same-key duplicate replay returns
23
27
  `ok:true`** echoing the *current* call's `claim_id`. A genuine cross-actor
@@ -0,0 +1,41 @@
1
+ {
2
+ "skill": "claim-safety",
3
+ "fixtures": "present",
4
+ "static": {
5
+ "token_cost": {
6
+ "desc_chars": 995,
7
+ "body_lines": 88,
8
+ "score": 43
9
+ },
10
+ "caps": {
11
+ "desc_within": true,
12
+ "body_within": true,
13
+ "score": 100
14
+ },
15
+ "description_quality": {
16
+ "has_trigger_cues": true,
17
+ "has_disambiguation_cues": true,
18
+ "adequate_length": true,
19
+ "score": 100
20
+ }
21
+ },
22
+ "router_reachability": {
23
+ "has_curated_rule": true,
24
+ "router_exempt": false,
25
+ "fixtures": "present",
26
+ "fixtures_total": 6,
27
+ "fixtures_best_hit": 2,
28
+ "reachable": true,
29
+ "keyword_alignment": 0.33
30
+ },
31
+ "behavioral": {
32
+ "trigger_recall": null,
33
+ "trigger_precision": null,
34
+ "disambiguation": null,
35
+ "chain_correctness": null,
36
+ "outcome_quality": null,
37
+ "variance": null,
38
+ "note": "behavioral — W5 (LLM judge): semantic recall/precision/chain/outcome/variance, not deterministic"
39
+ },
40
+ "composite": 83
41
+ }
@@ -0,0 +1,83 @@
1
+ {
2
+ "version": 1,
3
+ "description": "Command -> owning-skill coverage map (plan 2026-07-20-next-phase-plan.md §3.3). Every registered user-facing command MUST map to an existing skill OR carry an explicit { exempt: <reason> }. Enforced by the coverage gate (lib/skill-eval.js evaluateCoverage, surfaced by `forge skill coverage`/`forge skill scores`, gated in test/skill-eval.test.js). Exempts reference the pending skill from plan §3.2 and are tightened as those skills land.",
4
+ "commands": {
5
+ "plan": "plan",
6
+ "dev": "dev",
7
+ "validate": "validate",
8
+ "ship": "ship",
9
+ "shepherd": "shepherd",
10
+ "status": "status",
11
+ "prime": "status",
12
+ "orient": "hermes-forge",
13
+ "recap": "hermes-forge",
14
+ "remember": "memory",
15
+ "recall": "memory",
16
+ "memory": "memory",
17
+ "ready": "triage-ready",
18
+ "blocked": "triage-ready",
19
+ "claim": "claim-safety",
20
+ "claims": "claim-safety",
21
+ "issue": "issue-basics",
22
+ "issues": "issue-basics",
23
+ "create": "issue-basics",
24
+ "close": "issue-basics",
25
+ "comment": "issue-basics",
26
+ "show": "issue-basics",
27
+ "update": "issue-basics",
28
+ "list": "issue-basics",
29
+ "skill": "using-forge",
30
+
31
+ "worktree": "worktree",
32
+ "clean": "worktree",
33
+ "orphans": { "exempt": "full ownership (command-name routing + `forge issue orphans` guidance in the issue-basics body) pending — issue e1e33cf8; issue-basics does not yet document it, so mapping now would be a hollow claim" },
34
+ "rollback": "rollback",
35
+ "review": "review",
36
+ "verify": "verify",
37
+ "check": { "exempt": "legacy alias for `validate` (renamed in PR #50) — bin/forge-cmd.js VALID_COMMANDS only; owned by the `validate` skill" },
38
+ "docs": { "exempt": "bin/forge.js-direct; AGENTS.md/docs management — owning skill pending (coverage pass, plan §3.2)" },
39
+ "reset": "setup",
40
+ "reinstall": "setup",
41
+
42
+ "gate": "gates",
43
+ "control": "gates",
44
+ "doc-gate": "gates",
45
+
46
+ "adapter": { "exempt": "extensions skill pending (plan §3.2 B: new `extensions` skill)" },
47
+ "add": { "exempt": "extensions skill pending (plan §3.2 B: new `extensions` skill)" },
48
+ "new": { "exempt": "extensions skill pending (plan §3.2 B: new `extensions` skill)" },
49
+ "audit": { "exempt": "extensions skill pending (plan §3.2 B: new `extensions` skill)" },
50
+ "role": { "exempt": "extensions skill pending — role/skill binding (plan §3.2 B)" },
51
+
52
+ "export": "portability",
53
+ "migrate": "portability",
54
+ "sync": { "exempt": "cloud-native external-platform sync (ADR-0004); a local no-op with no value until a server backend is configured — skill deferred (issue 666f4b4c)" },
55
+
56
+ "inbox": { "exempt": "team skill pending (plan §3.2 B: new `team` skill)" },
57
+ "serve": { "exempt": "team skill pending (plan §3.2 B: new `team` skill)" },
58
+ "team": { "exempt": "team skill pending (plan §3.2 B: new `team` skill)" },
59
+
60
+ "setup": "setup",
61
+ "init": "setup",
62
+ "upgrade": "setup",
63
+ "doctor": "setup",
64
+ "hooks": "setup",
65
+ "recommend": "setup",
66
+
67
+ "push": { "exempt": "ship skill push/preflight extension pending (plan §3.2 B)" },
68
+ "preflight": { "exempt": "ship skill push/preflight extension pending (plan §3.2 B)" },
69
+ "pr": { "exempt": "ship/shepherd PR-lifecycle extension pending (plan §3.2)" },
70
+ "merge": { "exempt": "ship/shepherd PR-lifecycle extension pending (plan §3.2)" },
71
+
72
+ "patch": { "exempt": "kernel skill fold pending (plan §3.2 C: fold into `kernel`)" },
73
+ "stage": { "exempt": "kernel skill fold pending (plan §3.2 C: fold into `kernel`)" },
74
+ "explain": { "exempt": "kernel skill fold pending (plan §3.2 C: fold into `kernel`)" },
75
+ "options": { "exempt": "kernel skill fold pending (plan §3.2 C: fold into `kernel`)" },
76
+ "insights": { "exempt": "kernel skill fold pending — kernel evidence inspection (plan §3.2 C)" },
77
+
78
+ "lint": { "exempt": "internal quality plumbing; skill assignment pending (coverage pass)" },
79
+ "release": { "exempt": "internal release plumbing; skill assignment pending (coverage pass)" },
80
+ "test": { "exempt": "internal test-runner plumbing; skill assignment pending (coverage pass)" },
81
+ "stale": { "exempt": "internal stale-detection plumbing; skill assignment pending (coverage pass)" }
82
+ }
83
+ }
@@ -13,12 +13,16 @@ description: >
13
13
  opening the PR (ship), for addressing PR review feedback (review), or for orchestrating
14
14
  several stages / taking an issue end-to-end to a merged PR (smith).
15
15
  allowed-tools: Bash, Read, Write, Edit, Grep, Glob
16
+ next: validate
17
+ terminal: false
16
18
  ---
17
19
 
18
20
  Implement each task from the /plan task list using a subagent-driven loop: implementer → spec compliance reviewer → code quality reviewer per task.
19
21
 
20
22
  # Dev
21
23
 
24
+ > **Chain (HARD-GATE):** the ONLY skill you invoke after `dev` is `validate`. Never open a PR straight from `dev`.
25
+
22
26
  This skill reads the task list created by `/plan` and implements each task using a three-stage subagent loop. TDD is enforced inside each implementer subagent.
23
27
 
24
28
  ## Usage
@@ -0,0 +1,41 @@
1
+ {
2
+ "skill": "dev",
3
+ "fixtures": "present",
4
+ "static": {
5
+ "token_cost": {
6
+ "desc_chars": 962,
7
+ "body_lines": 328,
8
+ "score": 20
9
+ },
10
+ "caps": {
11
+ "desc_within": true,
12
+ "body_within": true,
13
+ "score": 100
14
+ },
15
+ "description_quality": {
16
+ "has_trigger_cues": true,
17
+ "has_disambiguation_cues": true,
18
+ "adequate_length": true,
19
+ "score": 100
20
+ }
21
+ },
22
+ "router_reachability": {
23
+ "has_curated_rule": true,
24
+ "router_exempt": false,
25
+ "fixtures": "present",
26
+ "fixtures_total": 6,
27
+ "fixtures_best_hit": 2,
28
+ "reachable": true,
29
+ "keyword_alignment": 0.33
30
+ },
31
+ "behavioral": {
32
+ "trigger_recall": null,
33
+ "trigger_precision": null,
34
+ "disambiguation": null,
35
+ "chain_correctness": null,
36
+ "outcome_quality": null,
37
+ "variance": null,
38
+ "note": "behavioral — W5 (LLM judge): semantic recall/precision/chain/outcome/variance, not deterministic"
39
+ },
40
+ "composite": 76
41
+ }
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: gates
3
+ description: >
4
+ Toggle Forge's workflow gates and rails — strong-but-toggleable enforcement. `forge gate
5
+ enable|disable <gate-id>` flips `workflow.gates.<id>.enabled` in `.forge/config.yaml`; the
6
+ installed git hooks read resolved config at run time, so disabling a rail makes them
7
+ genuinely inert. `forge gate approve|reject <issue> <gate>` records durable human-gate
8
+ approval events; `forge gate status`/`check` query them; `forge control <id>
9
+ <mandatory|optional|permission>` sets tri-state DECLARED intent (writes the same `enabled`
10
+ field — no independent runtime enforcement). Use when the user says "disable the gate",
11
+ "turn off TDD enforcement", "the tdd intent rail is blocking me", "toggle or enable a
12
+ gate", "loosen enforcement", or "approve a human gate". Common default-ON toggleable rails:
13
+ `rail.tdd_intent`, `rail.kernel_tracking`, `rail.auto_shepherd` — e.g. `forge gate disable
14
+ rail.tdd_intent`. NOT for addressing PR review feedback (review), NOT the status snapshot
15
+ of work in flight (status).
16
+ allowed-tools: Bash, Read, Grep, Glob
17
+ terminal: true
18
+ ---
19
+
20
+ Forge's gates and rails are **default-strong but toggleable**. The `gates` skill is how you flip one off (or back on), record a human-gate approval, and see enforcement state. The stored truth is one field — `workflow.gates.<id>.enabled` — and the installed hooks read the resolved config at run time, so a disabled rail is genuinely inert, not cosmetically off.
21
+
22
+ # Toggling gates and rails
23
+
24
+ ## When to use
25
+
26
+ - "Disable the gate", "turn off TDD enforcement", "the tdd intent rail is blocking me".
27
+ - "Toggle / enable a gate", "loosen enforcement for this repo".
28
+ - "Approve a human gate" (record a durable approval event on an issue).
29
+
30
+ ## Toggle a gate or rail (the enforcement switch)
31
+
32
+ ```bash
33
+ forge gate disable <gate-id> # set workflow.gates.<id>.enabled = false in .forge/config.yaml
34
+ forge gate enable <gate-id> # set it back to true
35
+ ```
36
+
37
+ An unknown gate id — or disabling a **locked** gate — errors **before** anything is written, never mid-run. Because the hooks resolve config at run time, the flip takes effect immediately with no reinstall.
38
+
39
+ ### Common rails (default-ON, toggleable)
40
+
41
+ | Rail | What it enforces | Turn off with |
42
+ | --- | --- | --- |
43
+ | `rail.tdd_intent` | Pre-commit TDD gate (source changed ⇒ tests changed). The `minimal` adoption profile ships it off. | `forge gate disable rail.tdd_intent` |
44
+ | `rail.kernel_tracking` | "File every issue" — nothing discussed goes missing. | `forge gate disable rail.kernel_tracking` |
45
+ | `rail.auto_shepherd` | The autonomous PR-shepherd daemon fire. | `forge gate disable rail.auto_shepherd` |
46
+
47
+ The `gate.*` and `rail.*` id namespaces are disjoint, so `forge gate enable|disable` governs both through one flat surface.
48
+
49
+ ## Human-gate approval events
50
+
51
+ ```bash
52
+ forge gate approve <issue-id> <gate-id> # record a durable gate.approved event
53
+ forge gate reject <issue-id> <gate-id> # record gate.rejected
54
+ forge gate status <issue-id> # list recorded events for that issue (resume-safe)
55
+ forge gate check <issue-id> <gate-id> # exit 0 iff gate DISABLED or an approval exists on that issue
56
+ ```
57
+
58
+ `check` is the reusable enforcement primitive a stage skill calls: it passes when the gate is disabled or an approval event has been recorded for that specific issue id (approvals are issue-scoped, so pass the same `<issue-id>` you approved against). Events are durable on the issue, so they survive a compaction or crash.
59
+
60
+ ## Tri-state control (declared intent)
61
+
62
+ ```bash
63
+ forge control <gate-id|rail-id> <mandatory|optional|permission>
64
+ forge control status [--json]
65
+ ```
66
+
67
+ `forge control` sets the **declared intent** vocabulary and writes the **same** `enabled` field that `forge gate` writes — there is deliberately no parallel key. It is a view/intent layer: today no runtime consumer denies purely on a control flag (MCP/rules/skills are presence-only and refused). For actually turning enforcement off, use `forge gate disable`.
68
+
69
+ ## The doc-update gate
70
+
71
+ ```bash
72
+ forge gate doc <detect|check|init|...> # = forge doc-gate (run `forge doc-gate --help`)
73
+ ```
74
+
75
+ The doc-update gate folds under this noun as `forge gate doc`; bare `forge doc-gate` stays as a back-compat alias.
76
+
77
+ ## Adjacent skills
78
+
79
+ - Addressing PR review feedback / resolving threads → `review`.
80
+ - The snapshot of where the project stands and what's in flight → `status`.
@@ -0,0 +1,38 @@
1
+ [
2
+ {
3
+ "query": "Disable the tdd gate for this repo.",
4
+ "should_trigger": true
5
+ },
6
+ {
7
+ "query": "Turn off TDD enforcement — the tdd intent rail is blocking my commit.",
8
+ "should_trigger": true
9
+ },
10
+ {
11
+ "query": "Toggle the kernel tracking rail off for now.",
12
+ "should_trigger": true
13
+ },
14
+ {
15
+ "query": "Enable a gate that I disabled earlier and loosen enforcement.",
16
+ "should_trigger": true
17
+ },
18
+ {
19
+ "query": "Approve the human gate on this issue so the stage can proceed.",
20
+ "should_trigger": true
21
+ },
22
+ {
23
+ "query": "Disable the auto shepherd rail so no PR daemon fires.",
24
+ "should_trigger": true
25
+ },
26
+ {
27
+ "query": "Address the CodeRabbit review feedback and resolve the threads.",
28
+ "should_trigger": false
29
+ },
30
+ {
31
+ "query": "Where am I in the workflow and what work is in flight right now?",
32
+ "should_trigger": false
33
+ },
34
+ {
35
+ "query": "Run the tests and lint before I push.",
36
+ "should_trigger": false
37
+ }
38
+ ]