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
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Doc-assertion index — which test suites assert on which tracked markdown.
5
+ *
6
+ * Several suites read repository markdown and assert on its CONTENT (the README
7
+ * package-size badge, the AGENTS.md convention section, docs/ consistency). A
8
+ * markdown-only PR is classified as "docs" and skips the code matrix, so without
9
+ * this index those suites never run on the PR that breaks them — they fail later,
10
+ * on master, for the next unrelated code PR. That happened twice (kernel issue
11
+ * 63556816): the README size badge (#307/#310) and the AGENTS.md convention test
12
+ * after #325.
13
+ *
14
+ * The mapping is DERIVED by scanning the test sources rather than hardcoded, so a
15
+ * newly added markdown-reading suite is selected automatically instead of drifting
16
+ * out of a stale list.
17
+ *
18
+ * Detection is deliberately anchored, not literal-matching: only markdown reached
19
+ * from the REPOSITORY ROOT counts. Tests that write a throwaway `AGENTS.md` into a
20
+ * temp fixture directory join a tmpdir, not the repo root, so they are not selected.
21
+ *
22
+ * Suites that DISCOVER markdown (directory traversal, a glob, a `.md` filter) name
23
+ * no file to attribute, so they are selected on ANY markdown change instead — see
24
+ * `scansMarkdownDynamically`.
25
+ *
26
+ * @module doc-assertions
27
+ */
28
+
29
+ const defaultFs = require('node:fs');
30
+ const path = require('node:path');
31
+
32
+ /** `path.join(...)` / `path.resolve(...)` (or bare `join`/`resolve`) with a flat argument list. */
33
+ const PATH_CALL = /(?:path\s*\.\s*)?\b(?:join|resolve)\s*\(([^()]*)\)/g;
34
+
35
+ /** A `const ROOT = path.resolve(__dirname, '..')`-style anchor declaration. */
36
+ const ANCHOR_DECL = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:path\s*\.\s*)?(?:join|resolve)\s*\(([^()]*)\)/g;
37
+
38
+ /** A quoted string literal ending in `.md`. */
39
+ const MARKDOWN_LITERAL = /['"`]([^'"`\n]*\.md)['"`]/g;
40
+
41
+ /** A call that enumerates a directory or expands a glob instead of naming files. */
42
+ // `.sync` member forms matter: `glob.sync('**/*.md')` has a `.` where this pattern would
43
+ // otherwise expect `(`, so without the optional `.sync` the scanner is invisible and the
44
+ // suite is under-selected — the exact failure this module exists to prevent.
45
+ const DIRECTORY_SCAN_CALL = /\b(?:readdir|readdirSync|opendir|opendirSync|glob(?:Sync|\.sync)?|globby(?:\.sync)?|fastGlob(?:\.sync)?|walk|walkSync)\s*\(/;
46
+
47
+ /** `.md` used as an EXTENSION test rather than a filename: glob, regex, or bare extension. */
48
+ const MARKDOWN_EXTENSION_FILTER = /\*\.md\b|\\\.md\b|['"`]\.md['"`]/;
49
+
50
+ /**
51
+ * Reports whether a repository-relative path is a markdown file.
52
+ *
53
+ * @param {string} file Repository-relative path.
54
+ * @returns {boolean} True for markdown paths.
55
+ */
56
+ function isMarkdownPath(file) {
57
+ return typeof file === 'string' && file.toLowerCase().endsWith('.md');
58
+ }
59
+
60
+ /** Converts an absolute path to a forward-slash repository-relative path. */
61
+ function toRepoRelative(repoRoot, absolutePath) {
62
+ return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
63
+ }
64
+
65
+ /** Returns the value of a single-quoted/double-quoted/backtick string literal, or null. */
66
+ function readStringLiteral(token) {
67
+ const match = /^'([^']*)'$|^"([^"]*)"$|^`([^`$]*)`$/.exec(token.trim());
68
+ if (!match) return null;
69
+ return match[1] ?? match[2] ?? match[3];
70
+ }
71
+
72
+ /** Splits a flat call-argument list into trimmed, non-empty tokens. */
73
+ function splitArguments(rawArgs) {
74
+ return rawArgs.split(',').map((token) => token.trim()).filter(Boolean);
75
+ }
76
+
77
+ /**
78
+ * Collects every path anchor a test file can resolve against, keyed by identifier.
79
+ *
80
+ * Starts from `__dirname` and follows literal-only `path.join`/`path.resolve`
81
+ * declarations (e.g. `const ROOT = path.resolve(__dirname, '..')`).
82
+ *
83
+ * @param {string} source Test file source.
84
+ * @param {string} testDir Absolute directory containing the test file.
85
+ * @returns {Map<string, string>} Identifier → absolute directory.
86
+ */
87
+ function collectAnchors(source, testDir) {
88
+ const anchors = new Map([['__dirname', testDir]]);
89
+ let match;
90
+ ANCHOR_DECL.lastIndex = 0;
91
+ while ((match = ANCHOR_DECL.exec(source)) !== null) {
92
+ const args = splitArguments(match[2]);
93
+ if (args.length === 0) continue;
94
+ const base = anchors.get(args[0]);
95
+ if (!base) continue;
96
+ const segments = args.slice(1).map(readStringLiteral);
97
+ if (segments.some((segment) => segment === null)) continue;
98
+ anchors.set(match[1], path.resolve(base, ...segments));
99
+ }
100
+ return anchors;
101
+ }
102
+
103
+ /**
104
+ * Finds markdown a single test file reads from the repository root.
105
+ *
106
+ * Two shapes are recognized:
107
+ * 1. A fully literal anchored path, e.g. `path.resolve(__dirname, '..', 'AGENTS.md')`.
108
+ * 2. A root-relative read helper, e.g. `const ROOT = join(__dirname, '..')` plus
109
+ * `readFileSync(join(ROOT, relPath))`. Because the path is assembled at run
110
+ * time, every markdown literal in the file that resolves to a real repository
111
+ * file is attributed to that test (this is how `test/docs-consistency.test.js`
112
+ * reads its documents).
113
+ *
114
+ * @param {string} repoRoot Absolute repository root.
115
+ * @param {string} testFile Absolute path to the test file.
116
+ * @param {string} source Test file source.
117
+ * @param {Object} fs Injected fs module.
118
+ * @returns {Set<string>} Repository-relative markdown paths the test asserts on.
119
+ */
120
+ function findAssertedMarkdown(repoRoot, testFile, source, fs) {
121
+ const anchors = collectAnchors(source, path.dirname(testFile));
122
+ const found = new Set();
123
+
124
+ const addIfTracked = (relativePath) => {
125
+ if (!isMarkdownPath(relativePath)) return;
126
+ if (relativePath.startsWith('..')) return;
127
+ if (!fs.existsSync(path.join(repoRoot, relativePath))) return;
128
+ found.add(relativePath);
129
+ };
130
+
131
+ let match;
132
+ PATH_CALL.lastIndex = 0;
133
+ while ((match = PATH_CALL.exec(source)) !== null) {
134
+ const args = splitArguments(match[1]);
135
+ if (args.length === 0) continue;
136
+ const base = anchors.get(args[0]);
137
+ if (!base) continue;
138
+ const segments = args.slice(1).map(readStringLiteral);
139
+ if (segments.some((segment) => segment === null)) continue;
140
+ addIfTracked(toRepoRelative(repoRoot, path.resolve(base, ...segments)));
141
+ }
142
+
143
+ const rootAnchors = [...anchors]
144
+ .filter(([, directory]) => path.resolve(directory) === path.resolve(repoRoot))
145
+ .map(([identifier]) => identifier);
146
+ const readsRootRelativePaths = rootAnchors.some((identifier) => new RegExp(
147
+ `\\b(?:join|resolve)\\s*\\(\\s*${identifier}\\s*,\\s*[A-Za-z_$]`,
148
+ ).test(source));
149
+
150
+ if (readsRootRelativePaths) {
151
+ MARKDOWN_LITERAL.lastIndex = 0;
152
+ while ((match = MARKDOWN_LITERAL.exec(source)) !== null) {
153
+ addIfTracked(match[1].replace(/^\.\//, ''));
154
+ }
155
+ }
156
+
157
+ return found;
158
+ }
159
+
160
+ /**
161
+ * Reports whether a test DISCOVERS markdown instead of naming it.
162
+ *
163
+ * `findAssertedMarkdown` can only attribute markdown that appears as a literal, so
164
+ * a suite that walks a directory (or expands a glob) and filters on `.md` maps to
165
+ * nothing and would never be selected — the false green this module exists to
166
+ * remove (`test/cleanup/dropped-agent-docs.test.js` is exactly that shape).
167
+ *
168
+ * Deliberately biased to OVER-selection: the two signals need not be on the same
169
+ * expression, because a few extra suites cost CI seconds while under-selection is
170
+ * what broke master twice. No attempt is made to resolve traversal roots into a
171
+ * file set — that would silently under-select again.
172
+ *
173
+ * @param {string} source Test file source.
174
+ * @returns {boolean} True when the test reaches markdown it does not name.
175
+ */
176
+ function scansMarkdownDynamically(source) {
177
+ return DIRECTORY_SCAN_CALL.test(source) && MARKDOWN_EXTENSION_FILTER.test(source);
178
+ }
179
+
180
+ /** Recursively lists `*.test.js` files under a directory. */
181
+ function listTestFiles(directory, fs, collected = []) {
182
+ let entries;
183
+ try {
184
+ entries = fs.readdirSync(directory, { withFileTypes: true });
185
+ } catch (_e) { // NOSONAR S2486
186
+ /* intentional: missing test directory yields no suites */
187
+ return collected;
188
+ }
189
+ for (const entry of entries) {
190
+ const entryPath = path.join(directory, entry.name);
191
+ if (entry.isDirectory()) {
192
+ listTestFiles(entryPath, fs, collected);
193
+ } else if (entry.name.endsWith('.test.js')) {
194
+ collected.push(entryPath);
195
+ }
196
+ }
197
+ return collected;
198
+ }
199
+
200
+ /** Sorts test paths deterministically. */
201
+ function sortPaths(paths) {
202
+ return [...paths].sort((a, b) => a.localeCompare(b));
203
+ }
204
+
205
+ /**
206
+ * Scans the test tree once, splitting suites into the two ways they reach markdown.
207
+ *
208
+ * @param {string} repoRoot Absolute repository root.
209
+ * @param {Object} fs Injected fs module.
210
+ * @returns {{index: Map<string, string[]>, scanners: string[]}} Named-markdown index
211
+ * plus the suites that discover markdown dynamically.
212
+ */
213
+ function scanTestSuites(repoRoot, fs) {
214
+ const index = new Map();
215
+ const scanners = new Set();
216
+
217
+ for (const testFile of listTestFiles(path.join(repoRoot, 'test'), fs)) {
218
+ let source;
219
+ try {
220
+ source = fs.readFileSync(testFile, 'utf8');
221
+ } catch (_e) { // NOSONAR S2486
222
+ /* intentional: unreadable test file contributes nothing */
223
+ continue;
224
+ }
225
+ const testPath = toRepoRelative(repoRoot, testFile);
226
+ for (const docPath of findAssertedMarkdown(repoRoot, testFile, source, fs)) {
227
+ if (!index.has(docPath)) index.set(docPath, new Set());
228
+ index.get(docPath).add(testPath);
229
+ }
230
+ if (scansMarkdownDynamically(source)) scanners.add(testPath);
231
+ }
232
+
233
+ return {
234
+ index: new Map([...index].map(([docPath, suites]) => [docPath, sortPaths(suites)])),
235
+ scanners: sortPaths(scanners),
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Builds the markdown → test-suite index for a checkout.
241
+ *
242
+ * Only covers suites that NAME their markdown; suites that discover it are returned
243
+ * by {@link findDynamicMarkdownScanners} because they map to no specific file.
244
+ *
245
+ * @param {string} repoRoot Absolute repository root.
246
+ * @param {Object} [fs=defaultFs] Injected fs module.
247
+ * @returns {Map<string, string[]>} Repository-relative markdown path → sorted test files.
248
+ */
249
+ function buildDocAssertionIndex(repoRoot, fs = defaultFs) {
250
+ return scanTestSuites(repoRoot, fs).index;
251
+ }
252
+
253
+ /**
254
+ * Lists the suites that read markdown they never name (traversal, glob, `.md` filter).
255
+ *
256
+ * @param {string} repoRoot Absolute repository root.
257
+ * @param {Object} [fs=defaultFs] Injected fs module.
258
+ * @returns {string[]} Sorted test file paths.
259
+ */
260
+ function findDynamicMarkdownScanners(repoRoot, fs = defaultFs) {
261
+ return scanTestSuites(repoRoot, fs).scanners;
262
+ }
263
+
264
+ /**
265
+ * Selects the suites that assert on the changed markdown.
266
+ *
267
+ * Returns an empty selection when no markdown changed, so the index is only built
268
+ * for changes that can actually break a doc-asserting suite.
269
+ *
270
+ * @param {string[]} changedFiles Repository-relative changed paths.
271
+ * @param {string} repoRoot Absolute repository root.
272
+ * @param {Object} [fs=defaultFs] Injected fs module.
273
+ * @returns {string[]} Sorted, de-duplicated test file paths.
274
+ */
275
+ function selectDocAssertingTests(changedFiles, repoRoot, fs = defaultFs) {
276
+ const changedMarkdown = (changedFiles || []).filter(isMarkdownPath);
277
+ if (changedMarkdown.length === 0) return [];
278
+
279
+ const { index, scanners } = scanTestSuites(repoRoot, fs);
280
+ // Scanners discover markdown by traversal, so no changed path maps to them —
281
+ // any markdown change selects all of them.
282
+ const selected = new Set(scanners);
283
+ for (const docPath of changedMarkdown) {
284
+ for (const suite of index.get(docPath) || []) {
285
+ selected.add(suite);
286
+ }
287
+ }
288
+ return sortPaths(selected);
289
+ }
290
+
291
+ module.exports = {
292
+ buildDocAssertionIndex,
293
+ findDynamicMarkdownScanners,
294
+ isMarkdownPath,
295
+ scansMarkdownDynamically,
296
+ selectDocAssertingTests,
297
+ };
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Detect a repo's PRE-EXISTING pre-commit TDD / source-test coupling gate, so `forge setup`
3
+ * can DEFER to it instead of stacking Forge's own `rail.tdd_intent` gate on top.
4
+ *
5
+ * The in-the-wild beta.3 adoption report (kernel 5b425a85, predecessor 2699b234): a repo that
6
+ * already ships e.g. `scripts/check-source-test-coupling.mjs` on pre-commit ended up with TWO
7
+ * TDD gates blocking the same commit, with no detection and no reconciliation.
8
+ *
9
+ * Detection is by MECHANISM, not by a filename list: we enumerate the pre-commit COMMANDS that
10
+ * whatever pre-commit runner is actually installed (lefthook / husky / the `pre-commit`
11
+ * framework / a raw `.git/hooks/pre-commit`) will really execute, then classify those command
12
+ * strings. A repo's gate can be named anything, so the mechanism is what we key on.
13
+ *
14
+ * @module lib/existing-tdd-gate
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const YAML = require('yaml');
22
+
23
+ const {
24
+ FORGE_NATIVE_HOOK_SENTINEL,
25
+ resolveGitHooksDir,
26
+ } = require('./lefthook-wiring');
27
+
28
+ // A pre-commit command is a TDD/coupling gate when its text carries one of these narrow
29
+ // tokens: a `tdd` word, a source↔test coupling phrase, or an explicit require/check/enforce
30
+ // tests task. Deliberately narrow — a plain test RUNNER (`npm test`) or a formatter is not a
31
+ // TDD-intent gate and must not trigger a deferral.
32
+ const TDD_GATE_PATTERNS = [
33
+ /(^|[^a-z0-9])tdd([^a-z0-9]|$)/i,
34
+ /(source[-_. ]?test|test[-_. ]?source)/i,
35
+ /((test|spec)[-_. ]?coupling|coupling[-_. ]?(test|spec|check))/i,
36
+ /(require|enforce|verify|guard)[-_. ]?(tests?|specs?)([^a-z0-9]|$)/i,
37
+ /check[-_. ]?(tests?|specs?)([^a-z0-9]|$)/i,
38
+ ];
39
+
40
+ // Commands that ARE Forge's own gate. Excluded before classification so a second `forge setup`
41
+ // never "detects" the gate its own first run installed and defers to itself.
42
+ const FORGE_OWN_COMMAND_MARKERS = ['check-tdd.js', 'forge-native-hook.js'];
43
+
44
+ // Signatures of a hook lefthook GENERATED (a disposable artifact that only dispatches to
45
+ // lefthook.yml, which we read directly) — mirrors lib/lefthook-wiring.js's own classifier.
46
+ const LEFTHOOK_GENERATED_MARKERS = ['call_lefthook', 'LEFTHOOK_BIN'];
47
+
48
+ const LEFTHOOK_CONFIG_FILES = [
49
+ 'lefthook.yml',
50
+ 'lefthook.yaml',
51
+ '.lefthook.yml',
52
+ '.lefthook.yaml',
53
+ 'lefthook-local.yml',
54
+ 'lefthook-local.yaml',
55
+ ];
56
+
57
+ const PRE_COMMIT_FRAMEWORK_FILES = ['.pre-commit-config.yaml', '.pre-commit-config.yml'];
58
+
59
+ function isForgeOwnCommand(text) {
60
+ return FORGE_OWN_COMMAND_MARKERS.some((marker) => text.includes(marker));
61
+ }
62
+
63
+ function looksLikeTddGate(text) {
64
+ return TDD_GATE_PATTERNS.some((pattern) => pattern.test(text));
65
+ }
66
+
67
+ function readFileOrNull(absolute) {
68
+ try {
69
+ return fs.readFileSync(absolute, 'utf8');
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // One candidate = one command string a pre-commit runner will execute, plus where it came from.
76
+ function candidate(source, command) {
77
+ return { source, command: String(command).trim() };
78
+ }
79
+
80
+ // Shell-script hooks (husky, raw .git/hooks): every meaningful line is a command.
81
+ function commandsFromScript(source, body) {
82
+ return body
83
+ .split(/\r?\n/)
84
+ .map((line) => line.trim())
85
+ .filter((line) => line.length > 0 && !line.startsWith('#'))
86
+ .map((line) => candidate(source, line));
87
+ }
88
+
89
+ // lefthook pre-commit: `commands.<name>.run`, `scripts.<name>`, and the v1.11+ `jobs` list.
90
+ // The job NAME carries signal too (a job called `tdd-guard`), so both are collected.
91
+ function commandsFromLefthookPreCommit(source, preCommit) {
92
+ const out = [];
93
+ if (!preCommit || typeof preCommit !== 'object') return out;
94
+
95
+ for (const [name, job] of Object.entries(preCommit.commands || {})) {
96
+ out.push(candidate(source, `${name}: ${(job && job.run) || ''}`));
97
+ }
98
+ for (const [name, job] of Object.entries(preCommit.scripts || {})) {
99
+ out.push(candidate(source, `${name} ${(job && job.runner) || ''}`));
100
+ }
101
+ for (const job of Array.isArray(preCommit.jobs) ? preCommit.jobs : []) {
102
+ if (job && typeof job === 'object') {
103
+ out.push(candidate(source, `${job.name || ''}: ${job.run || ''}`));
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ // Returns { candidates } normally, or { unknown: true } when a pre-commit mechanism is present
110
+ // but its content cannot be read/parsed — we cannot rule out a TDD gate, so the caller defers.
111
+ function collectFromLefthookConfigs(projectRoot) {
112
+ const candidates = [];
113
+ for (const name of LEFTHOOK_CONFIG_FILES) {
114
+ const absolute = path.join(projectRoot, name);
115
+ if (!fs.existsSync(absolute)) continue;
116
+ const body = readFileOrNull(absolute);
117
+ if (body === null) return { unknown: true, source: name };
118
+
119
+ let doc;
120
+ try {
121
+ doc = YAML.parse(body);
122
+ } catch {
123
+ // Unparseable: only ambiguous if it actually declares a pre-commit section.
124
+ if (body.includes('pre-commit')) return { unknown: true, source: name };
125
+ continue;
126
+ }
127
+ if (!doc || typeof doc !== 'object') continue;
128
+ candidates.push(...commandsFromLefthookPreCommit(name, doc['pre-commit']));
129
+ }
130
+ return { candidates };
131
+ }
132
+
133
+ function collectFromHusky(projectRoot) {
134
+ const relative = '.husky/pre-commit'; // display label — always POSIX-style, never OS-separated
135
+ const absolute = path.join(projectRoot, '.husky', 'pre-commit');
136
+ if (!fs.existsSync(absolute)) return { candidates: [] };
137
+ const body = readFileOrNull(absolute);
138
+ if (body === null) return { unknown: true, source: relative };
139
+ return { candidates: commandsFromScript(relative, body) };
140
+ }
141
+
142
+ function collectFromPreCommitFramework(projectRoot) {
143
+ const candidates = [];
144
+ for (const name of PRE_COMMIT_FRAMEWORK_FILES) {
145
+ const absolute = path.join(projectRoot, name);
146
+ if (!fs.existsSync(absolute)) continue;
147
+ const body = readFileOrNull(absolute);
148
+ if (body === null) return { unknown: true, source: name };
149
+
150
+ let doc;
151
+ try {
152
+ doc = YAML.parse(body);
153
+ } catch {
154
+ return { unknown: true, source: name };
155
+ }
156
+ for (const repo of (doc && Array.isArray(doc.repos)) ? doc.repos : []) {
157
+ for (const hook of (repo && Array.isArray(repo.hooks)) ? repo.hooks : []) {
158
+ if (!hook || typeof hook !== 'object') continue;
159
+ candidates.push(candidate(name, `${hook.id || ''} ${hook.name || ''} ${hook.entry || ''}`));
160
+ }
161
+ }
162
+ }
163
+ return { candidates };
164
+ }
165
+
166
+ // The raw hook git will actually run. Forge's own native hook and a lefthook-GENERATED
167
+ // dispatcher are skipped: neither is a third-party gate (the latter's real jobs live in
168
+ // lefthook.yml, which collectFromLefthookConfigs already read).
169
+ function collectFromNativeHook(projectRoot) {
170
+ const hooksDir = resolveGitHooksDir(projectRoot);
171
+ if (!hooksDir) return { candidates: [] };
172
+ const absolute = path.join(hooksDir, 'pre-commit');
173
+ if (!fs.existsSync(absolute)) return { candidates: [] };
174
+ const body = readFileOrNull(absolute);
175
+ if (body === null) return { unknown: true, source: 'pre-commit (git hooks dir)' };
176
+ if (body.includes(FORGE_NATIVE_HOOK_SENTINEL)) return { candidates: [] };
177
+ if (LEFTHOOK_GENERATED_MARKERS.some((marker) => body.includes(marker))) return { candidates: [] };
178
+ return { candidates: commandsFromScript('pre-commit (git hooks dir)', body) };
179
+ }
180
+
181
+ /**
182
+ * Detect a pre-existing pre-commit TDD / source-test coupling gate in the project.
183
+ *
184
+ * @param {string} projectRoot - Absolute path to the project root.
185
+ * @returns {{ found: boolean, source?: string, command?: string, unknown?: boolean }}
186
+ * `found:true` with `source`/`command` for a classified gate; `found:true, unknown:true`
187
+ * when a pre-commit mechanism exists but could not be read (defer — the safe side);
188
+ * `found:false` when nothing pre-existing enforces TDD.
189
+ */
190
+ function detectExistingTddGate(projectRoot) {
191
+ const collectors = [
192
+ collectFromLefthookConfigs,
193
+ collectFromHusky,
194
+ collectFromPreCommitFramework,
195
+ collectFromNativeHook,
196
+ ];
197
+
198
+ let ambiguous = null;
199
+ const candidates = [];
200
+ for (const collect of collectors) {
201
+ let result;
202
+ try {
203
+ result = collect(projectRoot);
204
+ } catch {
205
+ continue; // a collector failure must never break setup
206
+ }
207
+ if (result.unknown) {
208
+ ambiguous = ambiguous || { source: result.source };
209
+ continue;
210
+ }
211
+ candidates.push(...(result.candidates || []));
212
+ }
213
+
214
+ for (const item of candidates) {
215
+ if (isForgeOwnCommand(item.command)) continue;
216
+ if (looksLikeTddGate(item.command)) {
217
+ return { found: true, source: item.source, command: item.command };
218
+ }
219
+ }
220
+
221
+ if (ambiguous) {
222
+ return { found: true, unknown: true, source: ambiguous.source, command: '' };
223
+ }
224
+ return { found: false };
225
+ }
226
+
227
+ /**
228
+ * The user-facing deferral report. Silent stacking and silent skipping are BOTH wrong — the
229
+ * adopter must see what was detected, what Forge did not install, and how to choose otherwise.
230
+ *
231
+ * @param {{ found: boolean, source?: string, command?: string, unknown?: boolean }} detection
232
+ * @returns {string|null} Report text, or null when there is nothing to defer to.
233
+ */
234
+ function describeExistingGateDeferral(detection) {
235
+ if (!detection || !detection.found) return null;
236
+
237
+ const what = detection.unknown
238
+ ? `an existing pre-commit hook Forge could not read (${detection.source})`
239
+ : `an existing pre-commit TDD/coupling gate in ${detection.source}:\n ${detection.command}`;
240
+
241
+ return [
242
+ ` ⚠ Detected ${what}`,
243
+ ' Forge did NOT install its own TDD gate on pre-commit — deferring to yours, so you',
244
+ ' do not get two gates blocking the same commit. rail.tdd_intent is off in .forge/config.yaml.',
245
+ " Prefer Forge's gate instead? Remove or disable your own, then run:",
246
+ ' forge gate enable rail.tdd_intent',
247
+ ].join('\n');
248
+ }
249
+
250
+ module.exports = {
251
+ detectExistingTddGate,
252
+ describeExistingGateDeferral,
253
+ };
@@ -0,0 +1,9 @@
1
+ # Forge 0.1.0-beta.5 compatibility corpus v1
2
+
3
+ This immutable corpus is derived from release commit
4
+ `ebeb4e5b31fc2dacdf23c5936c02fb2656990f49`. Contract snapshots retain only
5
+ public package, binary, command, alias, and workflow facts. State rows are
6
+ synthetic and contain no user, device, prompt, tool-log, or repository data.
7
+
8
+ `manifest.json` hashes every corpus member. Change requires a new corpus
9
+ version; never rewrite v1 after it becomes release evidence.
@@ -0,0 +1,26 @@
1
+ {
2
+ "commands": ["adapter", "add", "audit", "blocked", "claim", "claims", "clean", "close", "comment", "control", "create", "dev", "doc-gate", "doctor", "explain", "export", "gate", "hooks", "inbox", "init", "insights", "issue", "issues", "lint", "list", "memory", "merge", "migrate", "new", "options", "orient", "orphans", "patch", "plan", "pr", "preflight", "prime", "push", "ready", "recall", "recap", "recommend", "release", "remember", "role", "serve", "setup", "shepherd", "ship", "show", "skill", "stage", "stale", "status", "sync", "team", "test", "update", "upgrade", "validate", "worktree"],
3
+ "aliases": {
4
+ "blocked": { "canonical": "issue blocked", "visible": false },
5
+ "claim": { "canonical": "issue claim", "visible": false },
6
+ "claims": { "canonical": "issue claims", "visible": false },
7
+ "close": { "canonical": "issue close", "visible": false },
8
+ "create": { "canonical": "issue create", "visible": false },
9
+ "doc-gate": { "canonical": "gate doc", "visible": false },
10
+ "insights": { "canonical": "memory insights", "visible": true },
11
+ "issues": { "canonical": "issue list", "visible": false },
12
+ "lint": { "canonical": "issue lint", "visible": false },
13
+ "list": { "canonical": "issue list", "visible": false },
14
+ "merge": { "canonical": "pr merge", "visible": false },
15
+ "orphans": { "canonical": "issue orphans", "visible": false },
16
+ "preflight": { "canonical": "pr preflight", "visible": true },
17
+ "ready": { "canonical": "issue ready", "visible": false },
18
+ "recall": { "canonical": "memory recall", "visible": true },
19
+ "remember": { "canonical": "memory add", "visible": true },
20
+ "shepherd": { "canonical": "pr shepherd", "visible": false },
21
+ "ship": { "canonical": "pr ship", "visible": true },
22
+ "show": { "canonical": "issue show", "visible": false },
23
+ "stale": { "canonical": "issue stale", "visible": false },
24
+ "update": { "canonical": "issue update", "visible": false }
25
+ }
26
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "forge-workflow",
3
+ "version": "0.1.0-beta.5",
4
+ "release_commit": "ebeb4e5b31fc2dacdf23c5936c02fb2656990f49",
5
+ "binaries": {
6
+ "forge": "bin/forge.js",
7
+ "forge-workflow": "bin/forge.js",
8
+ "forge-preflight": "bin/forge-preflight.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=22.16.0"
12
+ }
13
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "critical": ["plan", "dev", "validate", "ship", "review", "verify"],
3
+ "standard": ["plan", "dev", "validate", "ship", "review"],
4
+ "refactor": ["plan", "dev", "validate", "ship"],
5
+ "simple": ["dev", "validate", "ship"],
6
+ "hotfix": ["dev", "validate", "ship"],
7
+ "docs": ["verify", "ship"]
8
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "schema_version": "forge.beta5-corpus.v1",
3
+ "corpus_version": 1,
4
+ "release_version": "0.1.0-beta.5",
5
+ "release_commit": "ebeb4e5b31fc2dacdf23c5936c02fb2656990f49",
6
+ "source_blobs": {
7
+ "package.json": "6f3f112341cd50e0e76fad615beb007b9e6dff2c",
8
+ "lib/commands/_manifest.js": "62595c38b66b139244ca1f54cb034f67d849247d",
9
+ "lib/commands/_aliases.js": "2b16d61d1cdb9ef3e594b5a5093206d05844b460",
10
+ "lib/workflow/stages.js": "0b7ae32bddcbb9bd6dd52a07ca22e43364621918"
11
+ },
12
+ "hash_algorithm": "sha256",
13
+ "content_hash": "c6978eed757c6255f2d25ea6d49ada44e5b3da91830d348016267981d13afd18",
14
+ "files": [
15
+ { "path": "README.md", "sha256": "3cfcc78856e397f2cfd3fee9fa2fffb630e27ea3e76ee8d8b5d9a77cb366a709" },
16
+ { "path": "contract/command-contract.json", "sha256": "8b4f885091fa8afe72b7b5a5a8039869c871f4fb1dc3344728b2c4322f491a84" },
17
+ { "path": "contract/package-contract.json", "sha256": "a65280ede51d62c1913140c83b40e6c3312c991c7a173e7a9ad52d3d5ea0fd47" },
18
+ { "path": "contract/workflow-stage-matrix.json", "sha256": "2aadafdb5f5c1817c8289dd6134d034e3ccc6579438935520bd3c6b90401e5bf" },
19
+ { "path": "state/comments.jsonl", "sha256": "ae4f694f207a11a22b6798943cc6d07487d1dc2be83a78ceadad947a3ff40113" },
20
+ { "path": "state/config.yaml", "sha256": "0b877d9180cf14951657254b50cd771e131970e92582a7c0337f36418a35e1c0" },
21
+ { "path": "state/dependencies.jsonl", "sha256": "cb43cc053d6dd885254a26f6d6b024883f770eb68f50b715cc2f0f05e2cd31e5" },
22
+ { "path": "state/issues.jsonl", "sha256": "425567c5c7b70fd6bbdf9ead3aff78c09a77ebe7b78d3c856c045743b8cb924e" },
23
+ { "path": "state/kernel.sql", "sha256": "4208aabcadc6cec3234ebb5abd4cbf32cc74805433d04a05801e0b6354b53d27" }
24
+ ]
25
+ }
@@ -0,0 +1 @@
1
+ {"id":"synthetic-comment-1","issue_id":"synthetic-issue-1","body":"Synthetic comment","actor":"fixture"}
@@ -0,0 +1,6 @@
1
+ workflow:
2
+ profile: default
3
+ gates:
4
+ gate.issue_verify: true
5
+ rails:
6
+ rail.kernel_tracking: true
@@ -0,0 +1 @@
1
+ {"issue_id":"synthetic-issue-1","blocks_issue_id":"synthetic-issue-2"}
@@ -0,0 +1,2 @@
1
+ {"id":"synthetic-issue-1","title":"Synthetic issue","status":"open","entity_revision":2}
2
+ {"id":"synthetic-issue-2","title":"Synthetic dependency","status":"done","entity_revision":1}