forge-workflow 0.1.0-beta.4 → 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 (119) 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 +20 -0
  5. package/bin/forge.js +16 -374
  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 +8 -5
  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 +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -12,7 +12,7 @@ const { execFileSync } = require('node:child_process');
12
12
  const fs = require('node:fs');
13
13
  const path = require('node:path');
14
14
 
15
- const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
15
+ const { fireAndForget } = require('../pr-monitor/reconcile-executor');
16
16
  const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
17
17
 
18
18
  const AUTO_SHEPHERD_RAIL = 'rail.auto_shepherd';
@@ -511,34 +511,31 @@ async function createPR(options) { // NOSONAR S3776
511
511
  }
512
512
 
513
513
  /**
514
- * Best-effort, non-blocking auto-start of the constant PR monitor once a real PR
515
- * exists. Skipped on a dry run or when no PR number is known. MUST NEVER fail
516
- * ship: `startWatcher` (startPrWatcherDetached) already never throws, and this
517
- * guard keeps even a surprise error from surfacing to the ship caller.
514
+ * Best-effort wake of the repository-wide singleton after a successful real ship.
515
+ * The shared trigger owns containment and gate checks; dry-run skips before it.
518
516
  *
519
- * Gated by the default-ON `rail.auto_shepherd` rail: when a maintainer has
520
- * disabled it (`forge gate disable rail.auto_shepherd`), the watcher is skipped
521
- * so the auto-start is honestly toggleable. The rail check is fail-open and
522
- * wrapped in the same try/catch, so neither a disabled rail nor a config-read
523
- * error ever fails ship.
524
- *
525
- * @param {{ dryRun: boolean, prNumber?: string|number, startWatcher: Function, railEnabled?: Function }} params
517
+ * @param {{ dryRun: boolean, projectRoot?: string, fireAndForget?: Function }} params
526
518
  * @returns {{ started: boolean, reason?: string }}
527
519
  */
528
- function maybeStartPrWatcher({ dryRun, prNumber, startWatcher, railEnabled = autoShepherdRailEnabled }) {
529
- if (dryRun || !prNumber) return { started: false, reason: 'skipped' };
520
+ function maybeTriggerShepherdAfterShip({ dryRun, projectRoot = process.cwd(), fireAndForget: trigger = fireAndForget }) {
521
+ if (dryRun) return { started: false, reason: 'skipped' };
530
522
  try {
531
- if (!railEnabled(process.cwd())) {
532
- return { started: false, reason: 'rail.auto_shepherd disabled' };
533
- }
534
- return startWatcher({ prNumber, cwd: process.cwd() });
523
+ trigger({ projectRoot, dryRun });
524
+ return { started: true };
535
525
  } catch (err) {
536
526
  return { started: false, reason: err.message };
537
527
  }
538
528
  }
539
529
 
540
530
  async function executeShip(options) {
541
- const { featureSlug, title, dryRun = false, startWatcher = startPrWatcherDetached, railEnabled = autoShepherdRailEnabled } = options || {};
531
+ const {
532
+ featureSlug,
533
+ title,
534
+ dryRun = false,
535
+ projectRoot = process.cwd(),
536
+ fireAndForget: trigger = fireAndForget,
537
+ createPr = createPR,
538
+ } = options || {};
542
539
 
543
540
  // Validate feature slug
544
541
  if (!featureSlug || typeof featureSlug !== 'string' || featureSlug.trim() === '') {
@@ -588,9 +585,9 @@ async function executeShip(options) {
588
585
  testScenarios,
589
586
  coverage,
590
587
  });
591
- const result = await createPR({ title, body: prBody, dryRun });
588
+ const result = await createPr({ title, body: prBody, dryRun });
592
589
  if (!result.success) return result;
593
- maybeStartPrWatcher({ dryRun, prNumber: result.prNumber, startWatcher, railEnabled });
590
+ maybeTriggerShepherdAfterShip({ dryRun, projectRoot, fireAndForget: trigger });
594
591
  return {
595
592
  success: true,
596
593
  prUrl: result.prUrl,
@@ -605,11 +602,13 @@ async function executeShip(options) {
605
602
  module.exports = {
606
603
  name: 'ship',
607
604
  description: 'Create a pull request from validated feature work',
608
- handler: async (args, flags = {}) => {
605
+ handler: async (args, flags = {}, projectRoot = process.cwd(), deps = {}) => {
609
606
  const result = await executeShip({
610
607
  featureSlug: args[0],
611
608
  title: args[1],
612
609
  dryRun: Boolean(flags.dryRun || flags['--dry-run']),
610
+ projectRoot,
611
+ fireAndForget: deps.fireAndForget,
613
612
  });
614
613
  if (!result.success) {
615
614
  return result;
@@ -623,7 +622,7 @@ module.exports = {
623
622
  output: lines.join('\n'),
624
623
  };
625
624
  },
626
- maybeStartPrWatcher,
625
+ maybeTriggerShepherdAfterShip,
627
626
  autoShepherdRailEnabled,
628
627
  extractKeyDecisions,
629
628
  extractTestScenarios,
@@ -19,9 +19,18 @@ const fs = require('node:fs');
19
19
  const path = require('node:path');
20
20
  const { routeSkill, loadSkillCatalog } = require('../using-forge');
21
21
  const skillEval = require('../skill-eval');
22
+ const { runBehavioralEvaluation } = require('../../scripts/lib/behavioral-eval-runner');
23
+ const { resolveBehavioralEvaluation } = require('../../scripts/lib/behavioral-eval-runtime');
24
+ const { loadPromotionEvidence } = require('../../scripts/lib/promotion-evidence-loader');
25
+ const { scorePromotion } = require('../../scripts/lib/promotion-scorecard');
26
+
27
+ const BEHAVIORAL_TIERS = Object.freeze([30, 100, 300]);
28
+ const BEHAVIORAL_TIER_VALUES = new Set(BEHAVIORAL_TIERS.map(String));
29
+ const BEHAVIORAL_TIER_USAGE = BEHAVIORAL_TIERS.join('|');
22
30
 
23
31
  const USAGE = 'Usage: forge skill for "<situation>" [--json]\n' +
24
32
  ' forge skill eval [name] --static [--json]\n' +
33
+ ` forge skill eval <name> --full --tier ${BEHAVIORAL_TIER_USAGE} [--json]\n` +
25
34
  ' forge skill scores [--json]\n' +
26
35
  ' forge skill coverage [--json]';
27
36
 
@@ -77,12 +86,108 @@ function writeScorecard(skillsDir, name, card) {
77
86
  fs.writeFileSync(path.join(dir, 'scorecard.json'), JSON.stringify(card, null, 2) + '\n');
78
87
  }
79
88
 
89
+ function readFlagValue(rest, flags, name) {
90
+ const direct = flags[name] ?? flags[`--${name}`];
91
+ if (direct !== undefined && direct !== true) return direct;
92
+ const exact = rest.indexOf(`--${name}`);
93
+ if (exact >= 0) return rest[exact + 1];
94
+ const prefix = `--${name}=`;
95
+ const joined = rest.find(arg => typeof arg === 'string' && arg.startsWith(prefix));
96
+ return joined ? joined.slice(prefix.length) : undefined;
97
+ }
98
+
99
+ function hasFlag(rest, flags, name) {
100
+ return flags[name] === true || flags[`--${name}`] === true ||
101
+ rest.includes(`--${name}`) || rest.some(arg => typeof arg === 'string' && arg.startsWith(`--${name}=`));
102
+ }
103
+
104
+ function positionalArgs(rest, valueFlags = []) {
105
+ const positional = [];
106
+ for (let index = 0; index < rest.length; index += 1) {
107
+ const arg = rest[index];
108
+ if (typeof arg !== 'string') continue;
109
+ if (!arg.startsWith('--')) {
110
+ positional.push(arg);
111
+ continue;
112
+ }
113
+ const flagName = arg.slice(2).split('=', 1)[0];
114
+ if (valueFlags.includes(flagName) && !arg.includes('=')) index += 1;
115
+ }
116
+ return positional;
117
+ }
118
+
119
+ async function handleFullEval(rest, flags, projectRoot, opts) {
120
+ const json = hasFlag(rest, flags, 'json');
121
+ const tierValue = readFlagValue(rest, flags, 'tier');
122
+ const name = positionalArgs(rest, ['tier'])[0];
123
+ if (!name) return { success: false, error: 'Behavioral evaluation requires a skill name.\n' + USAGE };
124
+ const tier = Number(tierValue);
125
+ if (!BEHAVIORAL_TIER_VALUES.has(String(tierValue))) {
126
+ return { success: false, error: `Behavioral evaluation requires --tier ${BEHAVIORAL_TIER_USAGE}.\n` + USAGE };
127
+ }
128
+
129
+ const ctx = skillEval.resolveSkillsContext(projectRoot);
130
+ if (!ctx || !fs.existsSync(path.join(ctx.skillsDir, name, 'SKILL.md'))) {
131
+ return { success: false, error: `Skill '${name}' not found.` };
132
+ }
133
+
134
+ const scoreResult = (result) => {
135
+ const loaded = loadPromotionEvidence({ tier, findings: result.findings });
136
+ const scorecard = scorePromotion({ tier, pairs: loaded.ok ? loaded.pairs : [] });
137
+ if (!loaded.ok) scorecard.reasons = [loaded.reason];
138
+ return { ...result, scorecard };
139
+ };
140
+ const formatResult = (result) => {
141
+ const successfulScore = tier === BEHAVIORAL_TIERS[0] || result.scorecard.status === 'PASS';
142
+ const success = result.status === 'PASS' && successfulScore;
143
+ let error;
144
+ if (!success) {
145
+ error = result.status !== 'PASS'
146
+ ? `Behavioral evaluation ${result.status}.`
147
+ : `Promotion ${result.scorecard.phase || 'evaluation'} ${result.scorecard.status}.`;
148
+ }
149
+ const output = json
150
+ ? JSON.stringify(result, null, 2) + '\n'
151
+ : `Behavioral evaluation: ${result.status}\nTier: ${tier}\n` +
152
+ `Completed: ${result.completedRuns}/${result.expectedRuns}\n` +
153
+ `Promotion: ${result.scorecard.phase || 'unavailable'} / ${result.scorecard.status}`;
154
+ return { success, error, behavioral: result, output };
155
+ };
156
+
157
+ const runner = opts.runBehavioralEvaluation || runBehavioralEvaluation;
158
+ let behavioralOptions = opts.behavioralOptions || {};
159
+ if (!opts.runBehavioralEvaluation || opts.resolveBehavioralEvaluation) {
160
+ const resolver = opts.resolveBehavioralEvaluation || resolveBehavioralEvaluation;
161
+ const resolved = await resolver({
162
+ projectRoot,
163
+ skillName: name,
164
+ skillPath: path.join(ctx.skillsDir, name, 'SKILL.md'),
165
+ tier,
166
+ env: opts.env || process.env,
167
+ });
168
+ if (!resolved.ok) return formatResult(scoreResult(resolved.result));
169
+ behavioralOptions = { ...resolved.options, ...behavioralOptions };
170
+ }
171
+ const result = scoreResult(await runner({
172
+ ...behavioralOptions,
173
+ projectRoot,
174
+ skillName: name,
175
+ tier,
176
+ }));
177
+ return formatResult(result);
178
+ }
179
+
80
180
  /**
81
181
  * "forge skill eval [name] --static [--json]" -- compute + persist the DETERMINISTIC scorecard(s).
82
- * All skills when no name. --static is the only tier today (behavioral is W5); it is accepted (and
83
- * implied) so the flag reads honestly and future tiers can branch here.
182
+ * All skills when no name. --static is accepted and implied; behavioral execution is isolated
183
+ * behind the explicit --full branch above so existing static scoring remains unchanged.
84
184
  */
85
- function handleEval(rest, flags, projectRoot) {
185
+ function handleEval(rest, flags, projectRoot, opts = {}) {
186
+ const full = hasFlag(rest, flags, 'full');
187
+ if (full) return handleFullEval(rest, flags, projectRoot, opts);
188
+ if (readFlagValue(rest, flags, 'tier') !== undefined) {
189
+ return { success: false, error: '--tier is valid only with --full.\n' + USAGE };
190
+ }
86
191
  const json = flags.json === true || flags['--json'] === true || rest.includes('--json');
87
192
  const positional = rest.filter(a => typeof a === 'string' && !a.startsWith('--'));
88
193
  const name = positional[0];
@@ -116,7 +221,7 @@ function handleEval(rest, flags, projectRoot) {
116
221
  }
117
222
  const lines = ['Static scorecards written (deterministic tier):'];
118
223
  for (const t of written) lines.push(' ' + t + ' composite=' + cards[t].composite);
119
- lines.push('', 'Behavioral tier (recall/precision/chains/outcome/variance) is W5.');
224
+ lines.push('', 'Use --full --tier 30|100|300 for controlled behavioral evidence.');
120
225
  return { success: true, cards, output: lines.join('\n') };
121
226
  }
122
227
 
@@ -243,18 +348,21 @@ module.exports = {
243
348
  usage: USAGE,
244
349
  flags: {
245
350
  '--json': 'Emit the machine-readable result',
246
- '--static': 'Score only the deterministic static tier (the only tier today; behavioral is W5)',
351
+ '--static': 'Score only the deterministic static tier',
352
+ '--full': 'Run the controlled behavioral evaluation tier',
353
+ '--tier': `Behavioral corpus tier: ${BEHAVIORAL_TIER_USAGE}`,
247
354
  },
248
- // flags is the LAST declared param so its `= {}` default is trailing (SonarCloud S1788). The
249
- // registry still passes (args, flags, projectRoot, opts) — the router reads the canonical catalog
250
- // from the package root; eval/scores read the canonical skills/ source dir.
251
- handler: (args, flags = {}, projectRoot) => {
355
+ // The registry passes (args, flags, projectRoot, opts). Only the trailing `opts` carries a
356
+ // default so SonarCloud S1788 stays satisfied; `flags` is normalized in the body. The router
357
+ // reads the canonical catalog from the package root; eval/scores read the canonical skills dir.
358
+ handler: (args, rawFlags, projectRoot, opts = {}) => {
359
+ const flags = rawFlags || {};
252
360
  const verb = args[0];
253
361
  if (verb === 'for') {
254
362
  return handleFor(args.slice(1), flags);
255
363
  }
256
364
  if (verb === 'eval') {
257
- return handleEval(args.slice(1), flags, projectRoot);
365
+ return handleEval(args.slice(1), flags, projectRoot, opts);
258
366
  }
259
367
  if (verb === 'scores') {
260
368
  return handleScores(args.slice(1), flags, projectRoot);
@@ -271,5 +379,5 @@ module.exports = {
271
379
  };
272
380
  },
273
381
  // Exposed for unit tests; not part of the CLI surface.
274
- _internal: { parseForArgs, formatRouting, handleFor, handleEval, handleScores, handleCoverage, formatScores, formatCoverage },
382
+ _internal: { parseForArgs, formatRouting, handleFor, handleEval, handleFullEval, handleScores, handleCoverage, formatScores, formatCoverage },
275
383
  };
@@ -751,12 +751,28 @@ function formatStatus(result) {
751
751
  module.exports = {
752
752
  name: 'status',
753
753
  description: 'One-glance orientation: where you are, what to run next, and your work',
754
- usage: 'forge status [--full] [--json]',
754
+ usage: 'forge status [-v|--verbose] [--full] [--json]',
755
755
  flags: {
756
+ '-v, --verbose': 'Print the full session-entry briefing (replaces the deprecated `forge prime`)',
756
757
  '--full': 'Also show blocked, stale, and recently completed issues',
757
758
  '--json': 'Emit the machine-readable status envelope',
758
759
  },
759
760
  handler: async (args, flags, projectRoot) => {
761
+ // git-status-v semantics: bare status is the short pulse, -v is the long form.
762
+ // `--verbose` also arrives as flags.verbose from the global CLI flag parser.
763
+ const statusArgs = Array.isArray(args) ? args : [];
764
+ if (flags?.verbose === true || statusArgs.includes('-v') || statusArgs.includes('--verbose')) {
765
+ // Required lazily so the short pulse never loads the briefing's module graph.
766
+ const { renderBriefing } = require('./prime');
767
+ // The briefing reads --json from args (same as prime), so fold in the flag
768
+ // forms the short pulse accepts or `-v` would silently drop them.
769
+ const wantsJson = flags?.json === true || flags?.['--json'] === true;
770
+ const briefingArgs = wantsJson && !statusArgs.includes('--json')
771
+ ? [...statusArgs, '--json']
772
+ : statusArgs;
773
+ return renderBriefing(briefingArgs, projectRoot || process.cwd());
774
+ }
775
+
760
776
  // Injectable snapshot read, same convention as setup.js's `options.commandRunner`
761
777
  // (lib/commands/setup.js:223): a real default with a test-supplied override. Tests
762
778
  // need a snapshot with controlled ids and orderings, which a seeded kernel cannot
@@ -5,7 +5,6 @@
5
5
  *
6
6
  * Wraps test execution with smart defaults:
7
7
  * - Auto-detects package manager from lockfiles
8
- * - Checks Beads (Dolt) connectivity, sets BEADS_SKIP_TESTS if unavailable
9
8
  * - Supports --affected flag to run only tests for changed files
10
9
  *
11
10
  * Security: Uses execFileSync for subprocess calls (OWASP A03)
@@ -17,6 +16,8 @@ const { execFileSync: defaultExecFileSync, spawnSync: defaultSpawnSync } = requi
17
16
  const defaultFs = require('node:fs');
18
17
  const path = require('node:path');
19
18
 
19
+ const { selectDocAssertingTests } = require('../doc-assertions');
20
+
20
21
  /** @type {Array<[string, string]>} Lockfile → package manager mapping (order matters) */
21
22
  const LOCKFILE_MAP = [
22
23
  ['bun.lockb', 'bun'],
@@ -26,12 +27,10 @@ const LOCKFILE_MAP = [
26
27
  ];
27
28
 
28
29
  const DEFAULT_TIMEOUT = 120000;
29
- const BEADS_CHECK_TIMEOUT = 3000;
30
30
  const DIRECT_TEST_CANDIDATES = Object.freeze({
31
31
  'bin/forge.js': [
32
32
  'test/cli-flags.test.js',
33
33
  'test/forge-cli-registry.test.js',
34
- 'test/setup-github-sync.test.js',
35
34
  'test/setup-runtime-flags.test.js',
36
35
  ],
37
36
  // The second CLI surface (command descriptions + shepherd wiring). Without an
@@ -41,6 +40,13 @@ const DIRECT_TEST_CANDIDATES = Object.freeze({
41
40
  'test/forge-cmd-shepherd.test.js',
42
41
  ],
43
42
  'bin/forge-preflight.js': ['test/bin/forge-preflight.test.js'],
43
+ // The CI entry point is covered by the doc-assertion regression suite; without
44
+ // this entry it resolves to zero tests and forces the full suite.
45
+ // (lib/doc-assertions.js resolves via the lib/ -> test/ convention below.)
46
+ 'scripts/doc-asserting-tests.js': [
47
+ 'test/doc-assertions.test.js',
48
+ 'test/scripts/doc-asserting-tests.test.js',
49
+ ],
44
50
  'lib/lefthook-check.js': ['test/lefthook-check.test.js', 'test/runtime-health.test.js'],
45
51
  'lib/runtime-health.js': ['test/runtime-health.test.js'],
46
52
  // skill-eval hosts the accuracy-lint detectors (auditCommandDocumentation /
@@ -84,22 +90,6 @@ function detectPackageManager(projectRoot, fs) {
84
90
  return 'npm';
85
91
  }
86
92
 
87
- /**
88
- * Check if Beads (bd CLI) is reachable.
89
- *
90
- * @param {Function} execFileSync - Injected execFileSync
91
- * @returns {boolean} true if bd is available
92
- */
93
- function checkBeadsConnectivity(execFileSync) {
94
- try {
95
- execFileSync('bd', ['list', '--limit=1'], { timeout: BEADS_CHECK_TIMEOUT });
96
- return true;
97
- } catch (_e) { // NOSONAR S2486
98
- /* intentional: bd not installed or unreachable */
99
- return false;
100
- }
101
- }
102
-
103
93
  function resolveBaseBranch(execFileSync) {
104
94
  let baseBranch = 'main';
105
95
  try {
@@ -196,6 +186,16 @@ function getAffectedTestFiles(projectRoot, execFileSync, fs = defaultFs, options
196
186
  }
197
187
  }
198
188
 
189
+ // Markdown changes must also run the suites that assert on that markdown's
190
+ // CONTENT (README size badge, AGENTS.md convention, docs consistency). The
191
+ // static mapping above only knows the docs-consistency suite, so a docs PR
192
+ // used to merge green and break master for the next code PR (issue 63556816).
193
+ // The set is derived from the test sources, so new markdown-reading suites are
194
+ // picked up without editing a list here.
195
+ for (const candidate of selectDocAssertingTests(changedFiles, projectRoot, fs)) {
196
+ testFiles.add(candidate);
197
+ }
198
+
199
199
  return Array.from(testFiles).sort((left, right) => left.localeCompare(right));
200
200
  }
201
201
 
@@ -291,7 +291,7 @@ module.exports = {
291
291
  * @param {Object} [deps.fs] - fs module
292
292
  * @param {Function} [deps.execFileSync] - child_process.execFileSync
293
293
  * @param {Function} [deps.spawnSync] - child_process.spawnSync
294
- * @returns {Promise<{ success: boolean, exitCode: number, beadsSkipped: boolean }>}
294
+ * @returns {Promise<{ success: boolean, exitCode: number }>}
295
295
  */
296
296
  async handler(_args, flags, projectRoot, deps = {}) {
297
297
  const fs = deps.fs || defaultFs;
@@ -313,19 +313,10 @@ module.exports = {
313
313
  }
314
314
  } catch (_e) { /* intentional: package.json missing or unreadable, use default timeout */ } // NOSONAR S2486
315
315
 
316
- // 3. Check Beads connectivity
317
- const beadsAvailable = checkBeadsConnectivity(execFileSync);
318
- const beadsSkipped = !beadsAvailable;
319
-
320
- const extraEnv = {};
321
- if (beadsSkipped) {
322
- extraEnv.BEADS_SKIP_TESTS = '1';
323
- }
324
-
325
- // 4. Build test command args
316
+ // 3. Build test command args
326
317
  let testArgs = ['run', 'test'];
327
318
 
328
- // 5. --affected flag: find changed test files
319
+ // 4. --affected flag: find changed test files
329
320
  if (flags['--affected'] || flags.affected) {
330
321
  const affectedTests = getAffectedTestFiles(projectRoot, execFileSync, fs, {
331
322
  sinceUpstream: flags.sinceUpstream || flags['--since-upstream'],
@@ -336,9 +327,9 @@ module.exports = {
336
327
  // If no affected tests found, fall back to running all tests
337
328
  }
338
329
 
339
- // 6. Run tests
330
+ // 5. Run tests
340
331
  const result = spawnSync(pkgManager, testArgs, {
341
- env: { ...process.env, ...extraEnv },
332
+ env: { ...process.env },
342
333
  timeout,
343
334
  stdio: 'inherit',
344
335
  shell: process.platform === 'win32',
@@ -349,7 +340,6 @@ module.exports = {
349
340
  return {
350
341
  success: exitCode === 0,
351
342
  exitCode,
352
- beadsSkipped,
353
343
  };
354
344
  },
355
345
  };