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

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 (57) hide show
  1. package/.forge/hooks/check-tdd.js +79 -5
  2. package/.forge/hooks/forge-native-hook.js +194 -8
  3. package/AGENTS.md +1 -0
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge.js +90 -19
  8. package/docs/guides/SETUP.md +4 -1
  9. package/docs/guides/SUPPORT.md +5 -0
  10. package/docs/reference/COMMANDS.md +9 -0
  11. package/docs/reference/shepherd.md +42 -2
  12. package/lib/activation/ensure-forge-home.js +135 -0
  13. package/lib/adapters/beads-kernel-compat.js +67 -0
  14. package/lib/adoption-profiles.js +17 -4
  15. package/lib/beads-detect.js +60 -0
  16. package/lib/beads-nudge.js +91 -0
  17. package/lib/commands/_aliases.js +248 -0
  18. package/lib/commands/_issue.js +39 -0
  19. package/lib/commands/_manifest.js +2 -0
  20. package/lib/commands/_registry.js +14 -0
  21. package/lib/commands/_resolve-command-opts.js +0 -31
  22. package/lib/commands/gate.js +19 -2
  23. package/lib/commands/hooks.js +139 -4
  24. package/lib/commands/init.js +26 -20
  25. package/lib/commands/memory.js +81 -0
  26. package/lib/commands/migrate.js +0 -161
  27. package/lib/commands/plan.js +48 -8
  28. package/lib/commands/pr.js +88 -0
  29. package/lib/commands/push.js +66 -0
  30. package/lib/commands/recall.js +67 -12
  31. package/lib/commands/recap.js +18 -4
  32. package/lib/commands/release.js +14 -1
  33. package/lib/commands/remember.js +86 -20
  34. package/lib/commands/setup.js +135 -72
  35. package/lib/commands/shepherd.js +67 -2
  36. package/lib/commands/ship.js +40 -4
  37. package/lib/commands/worktree.js +60 -4
  38. package/lib/core/runtime-graph.js +34 -3
  39. package/lib/gate-events.js +54 -55
  40. package/lib/global-flags.js +30 -0
  41. package/lib/grounding/context-events.js +230 -0
  42. package/lib/grounding/read-first.js +112 -0
  43. package/lib/hook-renderer.js +93 -3
  44. package/lib/kernel/backing-issue.js +7 -1
  45. package/lib/kernel/owned-kernel.js +43 -0
  46. package/lib/kernel/sqlite-driver.js +37 -1
  47. package/lib/pr-monitor/auto-actions.js +175 -0
  48. package/lib/pr-monitor/digest.js +206 -0
  49. package/lib/pr-monitor/render-sticky.js +43 -8
  50. package/lib/pr-monitor/upsert-sticky.js +169 -0
  51. package/lib/pr-pull.js +43 -2
  52. package/lib/release-readiness.js +17 -1
  53. package/lib/upgrade-safety.js +53 -1
  54. package/lib/workflow/enforce-stage.js +59 -2
  55. package/package.json +2 -2
  56. package/scripts/pr-auto-actions.js +93 -0
  57. package/scripts/pr-verdict-label.js +50 -0
@@ -14,6 +14,70 @@ const fs = require("node:fs");
14
14
  const path = require("node:path");
15
15
  const readline = require("node:readline");
16
16
 
17
+ // ── Config-honest enforcement (issue eda6d866) ──────────────────────────────
18
+ // This pre-commit gate must be INERT when the TDD rail is disabled in
19
+ // .forge/config.yaml. The hook is self-contained (target projects have
20
+ // .forge/hooks/*.js but NOT lib/), so it reads the config directly. `forge gate
21
+ // disable rail.tdd_intent` writes workflow.gates['rail.tdd_intent']; the `full`
22
+ // profile writes top-level rails.tdd_intent — honor either. Missing/unparseable
23
+ // config FAILS TOWARD enforcement (returns true) so a gate the user did not
24
+ // disable is never silently dropped.
25
+ function isTddEnabled(projectRoot) {
26
+ let raw;
27
+ try {
28
+ raw = fs.readFileSync(path.join(projectRoot, ".forge", "config.yaml"), "utf8");
29
+ } catch {
30
+ return true; // no config → enforce
31
+ }
32
+ if (!raw || !raw.trim()) return true;
33
+
34
+ const railDisabled = (config) =>
35
+ isExplicitlyDisabled(config?.workflow?.gates?.["rail.tdd_intent"]) ||
36
+ isExplicitlyDisabled(config?.rails?.tdd_intent);
37
+
38
+ let YAML;
39
+ try {
40
+ YAML = require("yaml");
41
+ } catch {
42
+ // The yaml MODULE is genuinely unavailable → conservative raw-text scan for the
43
+ // disabled block. Parser presence and parse success are split so a MALFORMED file
44
+ // never reaches this fuzzy scan.
45
+ return !(rawKeyDisabled(raw, "rail.tdd_intent") || rawKeyDisabled(raw, "tdd_intent"));
46
+ }
47
+ try {
48
+ const parsed = YAML.parse(raw);
49
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return true;
50
+ return !railDisabled(parsed);
51
+ } catch {
52
+ // MALFORMED YAML (module present, parse threw) → FAIL TOWARD ENFORCEMENT (return
53
+ // true). Never fall to the raw-text scan: a broken file with a `rail.tdd_intent:
54
+ // enabled: false` fragment must not switch the gate off (issue eda6d866).
55
+ return true;
56
+ }
57
+ }
58
+
59
+ function isExplicitlyDisabled(node) {
60
+ return Boolean(node) && typeof node === "object" && node.enabled === false;
61
+ }
62
+
63
+ // Scan raw YAML for a `<key>:` block whose immediate child is `enabled: false`.
64
+ function rawKeyDisabled(raw, key) {
65
+ const lines = String(raw).split(/\r?\n/);
66
+ const keyRe = new RegExp(`^(\\s*)"?${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"?\\s*:\\s*$`);
67
+ for (let i = 0; i < lines.length; i += 1) {
68
+ const m = lines[i].match(keyRe);
69
+ if (!m) continue;
70
+ const parentIndent = m[1].length;
71
+ for (let j = i + 1; j < lines.length; j += 1) {
72
+ if (!lines[j].trim()) continue;
73
+ const childIndent = lines[j].match(/^\s*/)[0].length;
74
+ if (childIndent <= parentIndent) break;
75
+ if (/^\s*enabled\s*:\s*false\s*$/.test(lines[j])) return true;
76
+ }
77
+ }
78
+ return false;
79
+ }
80
+
17
81
  // Get staged files using git diff --cached
18
82
  function getStagedFiles() {
19
83
  try {
@@ -163,6 +227,12 @@ function promptUser(question, options) {
163
227
 
164
228
  // Main hook logic
165
229
  async function main() {
230
+ // Project root is two levels up from this installed hook (<root>/.forge/hooks/).
231
+ // When the TDD rail is disabled in config, the gate is inert — allow the commit.
232
+ if (!isTddEnabled(path.resolve(__dirname, "..", ".."))) {
233
+ process.exit(0);
234
+ }
235
+
166
236
  console.log("🔍 TDD Check: Verifying test coverage for staged files...\n");
167
237
 
168
238
  const stagedFiles = getStagedFiles();
@@ -236,8 +306,12 @@ async function main() {
236
306
  }
237
307
  }
238
308
 
239
- // Run with error handling
240
- main().catch((error) => {
241
- console.error("Error in TDD check hook:", error.message);
242
- process.exit(1);
243
- });
309
+ module.exports = { isTddEnabled };
310
+
311
+ // Run with error handling (only as a script, not when required by tests).
312
+ if (require.main === module) {
313
+ main().catch((error) => {
314
+ console.error("Error in TDD check hook:", error.message);
315
+ process.exit(1);
316
+ });
317
+ }
@@ -32,6 +32,7 @@
32
32
  */
33
33
 
34
34
  const { execFileSync } = require('node:child_process');
35
+ const fs = require('node:fs');
35
36
  const path = require('node:path');
36
37
 
37
38
  // Conservative protected-path set, mirroring .forge/protected-paths.yaml categories:
@@ -52,6 +53,174 @@ const PROTECTED_PATTERNS = [
52
53
  /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lock[b]?)$/i,
53
54
  ];
54
55
 
56
+ // ── Config-honest enforcement ───────────────────────────────────────────────
57
+ // A DISABLED gate/rail in .forge/config.yaml must make its hook genuinely inert
58
+ // (issue eda6d866). These hooks are self-contained (target projects have
59
+ // .forge/hooks/*.js but NOT lib/), so we read + interpret the config here rather
60
+ // than through the resolver. The `yaml` package is a Forge dependency present in
61
+ // any project that ran `forge setup`; when it is somehow absent we degrade to a
62
+ // conservative raw-text scan. Unparseable/missing config FAILS TOWARD enforcement
63
+ // (default ON) so we never silently drop a gate the user did not disable.
64
+
65
+ /** Load `.forge/config.yaml` into an object, or `{ __raw }` for a text-scan fallback, or null. */
66
+ function loadConfigObject(projectRoot) {
67
+ let raw;
68
+ try {
69
+ raw = fs.readFileSync(path.join(projectRoot, '.forge', 'config.yaml'), 'utf8');
70
+ } catch {
71
+ return null; // no config file → caller defaults to enforcement ON
72
+ }
73
+ if (!raw || !raw.trim()) return {};
74
+ let YAML;
75
+ try {
76
+ YAML = require('yaml');
77
+ } catch {
78
+ // The yaml MODULE is genuinely unavailable → degrade to a conservative raw-text
79
+ // scan (the only case that keeps { __raw }). Parser presence and parse success are
80
+ // deliberately separated so a MALFORMED file never reaches the fuzzy scan below.
81
+ return { __raw: raw };
82
+ }
83
+ try {
84
+ const parsed = YAML.parse(raw);
85
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
86
+ } catch {
87
+ // MALFORMED YAML (module present, parse threw) → FAIL TOWARD ENFORCEMENT. Return {}
88
+ // so resolveEnforcement defaults to TDD ON + built-in protected paths. We must NOT
89
+ // fall through to the raw-text scan: a broken file containing a `rail.tdd_intent:
90
+ // enabled: false` fragment could otherwise switch enforcement OFF (issue eda6d866).
91
+ return {};
92
+ }
93
+ }
94
+
95
+ /** A primitive is "disabled" only when its `enabled` is explicitly boolean false. */
96
+ function isExplicitlyDisabled(node) {
97
+ return Boolean(node) && typeof node === 'object' && node.enabled === false;
98
+ }
99
+
100
+ // Overly-broad patterns the runtime graph rejects (lib/core/runtime-graph.js
101
+ // validateProtectedPaths). Kept in sync here because this hook is self-contained
102
+ // and cannot import from lib/.
103
+ const OVERLY_BROAD_PROTECTED_PATTERNS = ['*', '**', '**/*', '.', './', '/'];
104
+
105
+ /**
106
+ * Validate a config-supplied protectedPaths list with the SAME rules the runtime
107
+ * graph enforces: every entry must be a non-empty string and not an overly-broad
108
+ * pattern. A list with ANY invalid entry is rejected WHOLESALE (returns null →
109
+ * caller falls back to the built-in protected set), so an invalid or overly-broad
110
+ * config can never become authoritative and WEAKEN protection — it fails toward
111
+ * enforcement, matching the rest of this hook's fail-safe stance (issue eda6d866).
112
+ * @returns {string[]|null} validated list (may be empty = deliberately inert), or
113
+ * null when the list is absent/invalid.
114
+ */
115
+ function validateConfiguredProtectedPaths(list) {
116
+ if (!Array.isArray(list)) return null;
117
+ const validated = [];
118
+ for (const entry of list) {
119
+ if (typeof entry !== 'string' || entry.trim() === '') return null;
120
+ const pattern = entry.trim();
121
+ if (OVERLY_BROAD_PROTECTED_PATTERNS.includes(pattern)) return null;
122
+ validated.push(pattern);
123
+ }
124
+ return validated;
125
+ }
126
+
127
+ /** Scan raw YAML for a `<key>:` block whose immediate child is `enabled: false`. */
128
+ function rawKeyDisabled(raw, key) {
129
+ const lines = String(raw).split(/\r?\n/);
130
+ const keyRe = new RegExp(`^(\\s*)"?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"?\\s*:\\s*$`);
131
+ for (let i = 0; i < lines.length; i += 1) {
132
+ const m = lines[i].match(keyRe);
133
+ if (!m) continue;
134
+ const parentIndent = m[1].length;
135
+ for (let j = i + 1; j < lines.length; j += 1) {
136
+ if (!lines[j].trim()) continue;
137
+ const childIndent = lines[j].match(/^\s*/)[0].length;
138
+ if (childIndent <= parentIndent) break; // left the block
139
+ if (/^\s*enabled\s*:\s*false\s*$/.test(lines[j])) return true;
140
+ }
141
+ }
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * Resolve the enforcement state the installed hooks must honor.
147
+ * @returns {{ tddEnabled: boolean, protectedPaths: string[]|null }}
148
+ * tddEnabled — false only when rail.tdd_intent is explicitly disabled.
149
+ * protectedPaths — the configured list (may be []), or null when unset (→ built-in set).
150
+ */
151
+ function resolveEnforcement(projectRoot) {
152
+ const config = loadConfigObject(projectRoot);
153
+ if (!config) return { tddEnabled: true, protectedPaths: null };
154
+
155
+ let tddDisabled;
156
+ let protectedPaths = null;
157
+ if (config.__raw) {
158
+ tddDisabled = rawKeyDisabled(config.__raw, 'rail.tdd_intent') || rawKeyDisabled(config.__raw, 'tdd_intent');
159
+ if (/^\s*protectedPaths\s*:\s*\[\s*\]\s*$/m.test(config.__raw)) protectedPaths = [];
160
+ } else {
161
+ // `forge gate disable` writes workflow.gates['rail.tdd_intent']; the `full`
162
+ // profile writes top-level rails.tdd_intent. Honor either shape.
163
+ const gates = config.workflow && config.workflow.gates;
164
+ const rails = config.rails;
165
+ tddDisabled = isExplicitlyDisabled(gates && gates['rail.tdd_intent'])
166
+ || isExplicitlyDisabled(rails && rails.tdd_intent);
167
+ // Validate before overriding built-in protection: a bad/overly-broad list must
168
+ // fall back to the built-in set (null), never become authoritative (T2).
169
+ protectedPaths = validateConfiguredProtectedPaths(config.protectedPaths);
170
+ }
171
+ return { tddEnabled: !tddDisabled, protectedPaths };
172
+ }
173
+
174
+ /**
175
+ * The SINGLE resolved predicate each hook gates on — flag-agnostic on purpose.
176
+ * Whatever config flag ultimately governs an enforcement kind resolves inside
177
+ * resolveEnforcement(); callers ask only "is this active?". This is the one place
178
+ * the TDD off-switch is read, so a future flag change lands here with no rework.
179
+ * @param {'tdd'|'protected-path'} kind
180
+ */
181
+ function isEnforcementActive(kind, projectRoot) {
182
+ const { tddEnabled, protectedPaths } = resolveEnforcement(projectRoot);
183
+ if (kind === 'tdd') return tddEnabled;
184
+ if (kind === 'protected-path') return protectedPaths === null || protectedPaths.length > 0;
185
+ return true;
186
+ }
187
+
188
+ // Translate a config protectedPaths entry (a path or glob like `.github/workflows/**`)
189
+ // into an anchored matcher. `**` spans path separators, `*` stays within one segment.
190
+ function globToRegExp(pattern) {
191
+ const norm = normalize(pattern);
192
+ let re = '';
193
+ for (let i = 0; i < norm.length; i += 1) {
194
+ const c = norm[i];
195
+ if (c === '*') {
196
+ if (norm[i + 1] === '*') { re += '.*'; i += 1; if (norm[i + 1] === '/') i += 1; }
197
+ else re += '[^/]*';
198
+ } else if ('\\^$.|?+()[]{}'.includes(c)) {
199
+ re += `\\${c}`;
200
+ } else {
201
+ re += c;
202
+ }
203
+ }
204
+ return new RegExp(`(^|/)${re}(/|$)`);
205
+ }
206
+
207
+ /**
208
+ * Build the protected-path matcher from resolved config (config is the source of
209
+ * truth — the hardcoded PROTECTED_PATTERNS set is only the fallback when config
210
+ * omits protectedPaths entirely, so no gate is silently dropped):
211
+ * null → unset → built-in default set (fail toward enforcement / back-compat)
212
+ * [] → explicitly empty → nothing protected (inert)
213
+ * list → protect exactly those paths/globs
214
+ */
215
+ function buildProtectedMatcher(protectedPaths) {
216
+ if (protectedPaths === null) {
217
+ return p => Boolean(p) && PROTECTED_PATTERNS.some(re => re.test(normalize(p)));
218
+ }
219
+ if (protectedPaths.length === 0) return () => false;
220
+ const regexes = protectedPaths.map(globToRegExp);
221
+ return p => Boolean(p) && regexes.some(re => re.test(normalize(p)));
222
+ }
223
+
55
224
  /** Parse `--intent <id> --harness <id>` from an argv slice. */
56
225
  function parseArgs(argv) {
57
226
  const out = { intent: null, harness: null };
@@ -84,7 +253,7 @@ function normalize(p) {
84
253
  return String(p).replace(/\\/g, '/').replace(/^\.\//, '');
85
254
  }
86
255
 
87
- /** True when a path falls inside Forge's protected set. */
256
+ /** True when a path falls inside Forge's built-in protected set (the fallback matcher). */
88
257
  function isProtectedPath(p) {
89
258
  if (!p) return false;
90
259
  const n = normalize(p);
@@ -105,13 +274,13 @@ const WRITE_INTENT_RE = /(^|[\s;|&(])(rm|mv|cp|tee|truncate|chmod|chown|ln|sed|p
105
274
  * (rm/mv/sed/tee/redirection/...), then (2) token-scan (split on whitespace +
106
275
  * shell operators, strip quotes) for a protected path. Never throws.
107
276
  */
108
- function commandTouchesProtectedPath(command) {
277
+ function commandTouchesProtectedPath(command, isProtected = isProtectedPath) {
109
278
  if (typeof command !== 'string' || !command) return false;
110
279
  if (!WRITE_INTENT_RE.test(command)) return false;
111
280
  const tokens = command.split(/[\s;|&<>()]+/);
112
281
  for (const raw of tokens) {
113
282
  const token = raw.replace(/^["']+|["']+$/g, '');
114
- if (token && !token.startsWith('-') && isProtectedPath(token)) return true;
283
+ if (token && !token.startsWith('-') && isProtected(token)) return true;
115
284
  }
116
285
  return false;
117
286
  }
@@ -133,14 +302,22 @@ function runInstalledTddCheck() {
133
302
  }
134
303
  }
135
304
 
305
+ // Fully-ON default keeps back-compat: callers that pass no `enforcement` (and the
306
+ // existing test suite) get the original always-enforce behavior.
307
+ const ENFORCEMENT_ON = Object.freeze({ tddEnabled: true, protectedPaths: null });
308
+
136
309
  /**
137
- * Core enforcement decision. `runTddCheck` is injectable for deterministic tests.
310
+ * Core enforcement decision. `runTddCheck` is injectable for deterministic tests;
311
+ * `enforcement` (from resolveEnforcement) makes a DISABLED gate/rail inert.
138
312
  * @returns {{ decision: 'allow'|'deny', reason?: string }}
139
313
  */
140
- function decide({ intent, input, runTddCheck = runInstalledTddCheck }) {
314
+ function decide({ intent, input, runTddCheck = runInstalledTddCheck, enforcement = ENFORCEMENT_ON }) {
141
315
  if (intent === 'protected-path') {
316
+ // Config is the source of truth: matcher is built from the resolved
317
+ // protectedPaths list (empty → inert; unset → built-in fallback set).
318
+ const matchesProtected = buildProtectedMatcher(enforcement.protectedPaths);
142
319
  const target = extractPath(input);
143
- if (isProtectedPath(target)) {
320
+ if (matchesProtected(target)) {
144
321
  return {
145
322
  decision: 'deny',
146
323
  reason: `Forge-protected path '${normalize(target)}' — edit it through the owning Forge CLI/skill, not a raw write.`,
@@ -151,7 +328,7 @@ function decide({ intent, input, runTddCheck = runInstalledTddCheck }) {
151
328
  // so the deny-capable shell surface actually protects instead of no-oping.
152
329
  if (!target) {
153
330
  const command = extractCommand(input);
154
- if (commandTouchesProtectedPath(command)) {
331
+ if (commandTouchesProtectedPath(command, matchesProtected)) {
155
332
  return {
156
333
  decision: 'deny',
157
334
  reason: 'Shell command writes to a Forge-protected path — use the owning Forge CLI/skill instead.',
@@ -162,6 +339,8 @@ function decide({ intent, input, runTddCheck = runInstalledTddCheck }) {
162
339
  }
163
340
 
164
341
  if (intent === 'tdd-gate') {
342
+ // TDD rail disabled in config → inert: never run the check, never block.
343
+ if (!enforcement.tddEnabled) return { decision: 'allow' };
165
344
  const command = extractCommand(input);
166
345
  if (!isGitCommit(command)) return { decision: 'allow' };
167
346
  const code = runTddCheck();
@@ -222,7 +401,10 @@ function readStdin() {
222
401
  function main() {
223
402
  const { intent, harness } = parseArgs(process.argv.slice(2));
224
403
  const input = readStdin();
225
- const decision = decide({ intent, input });
404
+ // Project root is two levels up from this installed hook (<root>/.forge/hooks/),
405
+ // so enforcement resolves against the project's config regardless of cwd.
406
+ const enforcement = resolveEnforcement(path.resolve(__dirname, '..', '..'));
407
+ const decision = decide({ intent, input, enforcement });
226
408
  const output = formatOutput(harness || 'claude', decision);
227
409
  if (output) process.stdout.write(output);
228
410
  // Exit 0 always: the decision travels in the JSON body, not the exit code, so a
@@ -232,6 +414,10 @@ function main() {
232
414
 
233
415
  module.exports = {
234
416
  PROTECTED_PATTERNS,
417
+ resolveEnforcement,
418
+ isEnforcementActive,
419
+ globToRegExp,
420
+ buildProtectedMatcher,
235
421
  parseArgs,
236
422
  extractPath,
237
423
  extractCommand,
package/AGENTS.md CHANGED
@@ -150,6 +150,7 @@ This project uses the **Professional Git Workflow** with Lefthook for automated
150
150
  - Blocks commits if source code modified without test files
151
151
  - Offers guided recovery (add tests now, skip with tech debt tracking, emergency override)
152
152
  - No AI decision required - automatic validation
153
+ - **Strong default, not a hard floor.** The TDD gate is the default-ON `rail.tdd_intent` rail; turn it off with `forge gate disable rail.tdd_intent` (the `minimal` adoption profile ships it off). The installed hooks read the resolved config at run time, so a disabled rail makes them genuinely inert — enforcement honestly follows your config.
153
154
 
154
155
  **Pre-push hook validates tests:**
155
156
  - Branch protection: blocks direct push to `main`/`master`
package/CHANGELOG.md CHANGED
@@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.1.0-beta.3] - 2026-07-17
13
+
14
+ The **adoption-unblocking wave** — the changes beta testers hit on their very first real session. `forge ship`, the hook-enforcement verdict, and `forge worktree create` no longer dead-end a fresh or in-flight branch, and the PR shepherd now arms itself for any agent. Every change below was adversarially reviewed before merge.
15
+
16
+ ### Fixed
17
+
18
+ - **`forge ship` / `forge review` no longer hard-fail on a fresh or in-flight branch (B1).** They previously threw `Stage ship requires authoritative workflow state` whenever no `/plan → /dev → /validate → /ship` history had been walked — blocking the three most common adoption paths: a fresh setup, an in-flight branch, and a manual commit. The stage gate now degrades to a loud stderr warning and seeds stage history in the kernel, so ship works incrementally and future gating gets real data. The rework/contradiction guard (e.g. `validate` started-but-not-done) stays **fail-closed**, and `FORGE_STAGE_GATE=strict` restores the legacy hard block. (#413)
19
+ - **`forge setup` / `forge init --profile minimal` report hook enforcement honestly.** A deliberately-disabled TDD gate no longer prints `✓ Git hook enforcement active` or exits `1` with a `TDD ENFORCEMENT IS NOT ACTIVE` banner — the human-facing verdict and exit code now honor the resolved config (the hook scripts already honored it at run time; this closes the reporting/exit half). A disabled gate reports as intentionally inert with the re-enable command; a corrupt config still fails toward enforcement. (#414, #399)
20
+ - **`forge worktree create` bases new branches on the default branch, not the current checkout (B2).** A worktree created while on a WIP branch previously forked from the current HEAD and silently inherited unrelated commits. New worktrees now fork from the repository default branch (`origin/<default>` when the remote ref exists, else the local default); a new `--base <ref>` flag overrides it (invalid refs error and create nothing), and `create` prints the base it used so the fork point is never silent. (#415)
21
+ - **`forge plan` no longer moves the shared checkout's HEAD.** Creating the feature branch during planning switched the shared working checkout, colliding with parallel work; it now creates the branch without switching HEAD. (#396)
22
+ - **CI and grounding hardening.** Closed a Windows `EBUSY` by closing the kernel driver after context-event I/O (#411); removed an invalid `pull_request_review_thread` Actions trigger from the PR monitor (#404); stopped the pr-monitor red-X and auto-regenerated the D20 kill-list (#394); and cleared five residual B1 stage-state/worktree-linkage rows (#393). The kernel-driver close/ownership invariant is now regression-tested (#412).
23
+
24
+ ### Added
25
+
26
+ - **Agent-agnostic auto-shepherd PR monitor.** The PR watcher now arms itself on any open PR (`forge push` + `watch --adopt`), gated on the default-ON `rail.auto_shepherd` rail so **any** harness gets PR tracking without a per-agent monitor (#408, #407). It surfaces PR-monitor events into each turn via shepherd-events hooks (#409), lands an actionable `pr-verdict:*` label on the PR (#403), and takes Tier-2 **safe** auto-actions — update-branch when behind, re-run a flaky required check (#406).
27
+ - **Grounding read-first gate.** A P1 **fail-closed** `gate.read_first` blocks a claim about a file the agent has not read. (#410)
28
+ - **Unified command surface — noun verbs + aliases.** Declarative command-alias infrastructure (#401), `forge memory` shortcuts with a passthrough fix (#402), and a `forge pr` noun (`ship`/`preflight`/`shepherd`/`merge`) with gate docs (#405) — the CLI now groups by how people conventionally use it, aliases preserved.
29
+ - **Memory capture-on-exit + unified `forge memory`.** Memory commands are consolidated under `forge memory` with typed notes (#392), and a PreCompact/Stop hook plus `--session-summary` captures session memory on exit so nothing is lost to a compaction. (#397)
30
+ - **Global-plugin front door + lazy `.forge` home.** A global activation entry point that creates the `.forge` home lazily on first use. (#400)
31
+ - **Guided 0.0.10 → current upgrade.** `forge upgrade` walks the breaking Beads → Kernel upgrade from 0.0.10 and nudges the issue-path migration. (#398)
32
+ - **Consolidated opt-in Beads → Kernel migrator.** A single explicit-only migrator with an honest field-gap report. (#391)
33
+
34
+ ### Changed
35
+
36
+ - **TypeScript dev dependency bumped 5.4.5 → 7.0.2** (dev-deps group; no runtime change). (#371)
37
+ - Install docs now point at `forge-workflow@beta` so users land on the current prerelease. (#395)
38
+ - Bumped the GitHub Actions dependency group (3 updates). (#390)
39
+
12
40
  ## [0.1.0-beta.2] - 2026-07-15
13
41
 
14
42
  The **beta-blocker hardening wave** — Forge's advertised loop now composes end to end, its quality gates fail closed, its control surfaces describe themselves honestly, and the runtime is Beads-free. Every change below was adversarially reviewed before merge. (v0.1.0-beta.1 was tagged but never reached npm — its publish token had expired; this release switches to OIDC Trusted Publishing and is the first npm beta.)
package/QUICKSTART.md CHANGED
@@ -14,12 +14,16 @@ This guide gets Forge installed and visible to an AI coding agent without assumi
14
14
 
15
15
  ```bash
16
16
  # Bun
17
- bun add -D forge-workflow
17
+ bun add -D forge-workflow@beta
18
18
 
19
19
  # npm
20
- npm install --save-dev forge-workflow
20
+ npm install --save-dev forge-workflow@beta
21
21
  ```
22
22
 
23
+ > **Note:** the current release is a **prerelease** published under the `beta`
24
+ > dist-tag, so install with `@beta`. A bare `forge-workflow` resolves to the
25
+ > older `latest` (stable) version.
26
+
23
27
  You can also run one-off commands with `bunx forge ...` (or `npx forge ...`).
24
28
 
25
29
  Terms used below:
package/README.md CHANGED
@@ -188,7 +188,9 @@ actually works, and update it as you grow.
188
188
 
189
189
  ```bash
190
190
  # Add to your project
191
- bun add -D forge-workflow # or: npm install --save-dev forge-workflow
191
+ bun add -D forge-workflow@beta # or: npm install --save-dev forge-workflow@beta
192
+ # The current release is a prerelease under the `beta` dist-tag; a bare
193
+ # `forge-workflow` resolves to the older stable `latest`.
192
194
 
193
195
  # Install for your agent(s) and configure the workflow
194
196
  bunx forge setup --agents claude --yes
package/bin/forge.js CHANGED
@@ -60,10 +60,21 @@ const {
60
60
  } = require('../lib/docs-command');
61
61
  const { resetSoft, resetHard, reinstall } = require('../lib/reset');
62
62
  const { loadCommands, executeCommand } = require('../lib/commands/_registry');
63
+ const {
64
+ isAlias,
65
+ isHiddenAlias,
66
+ isVisibleAlias,
67
+ resolveAlias,
68
+ resolveDispatch,
69
+ maybeWarnDeprecation,
70
+ passthroughAliasNames,
71
+ visibleAliasNames,
72
+ } = require('../lib/commands/_aliases');
63
73
  const { resolveCommandOpts } = require('../lib/commands/_resolve-command-opts');
64
74
  const { getPackageRoot } = require('../lib/package-root');
65
75
  const { enforceStageEntry } = require('../lib/workflow/enforce-stage');
66
76
  const { normalizeStageId } = require('../lib/workflow/stages');
77
+ const { firstPositionalIndex } = require('../lib/global-flags');
67
78
 
68
79
  // Load enhanced onboarding modules (static relative requires — bundleable)
69
80
  const contextMerge = require('../lib/context-merge');
@@ -2315,16 +2326,12 @@ async function _interactiveSetup() {
2315
2326
  }
2316
2327
 
2317
2328
  // Parse CLI flags
2318
- // Issue command aliases hidden from `forge --help`: the bare passthroughs that
2319
- // duplicate `forge issue <sub>` plus the plural `issues`. They stay registered and
2320
- // routable (back-compat), but are omitted from help so `forge issue` reads as the
2321
- // single canonical issue surface (kernel issue 450c6e34). The canonical `issue` is
2322
- // deliberately NOT listed here it stays documented.
2323
- const ISSUE_ALIAS_COMMANDS = [
2324
- 'create', 'update', 'claim', 'close', 'show', 'list',
2325
- 'ready', 'blocked', 'stale', 'orphans', 'lint', 'claims', 'issues',
2326
- ];
2327
-
2329
+ // The curated back-compat alias allowlist (formerly the inline
2330
+ // ISSUE_ALIAS_COMMANDS array) now lives in lib/commands/_aliases.js as the single
2331
+ // declarative source of truth. `aliasNames()` returns the bare verbs that stay
2332
+ // registered + routable but are hidden from `forge --help` (kernel issue 450c6e34),
2333
+ // so `forge issue` reads as the single canonical issue surface. The canonical
2334
+ // `issue` is deliberately not an alias — it stays documented.
2328
2335
  function parseFlags() {
2329
2336
  const flags = {
2330
2337
  quick: false,
@@ -2349,8 +2356,11 @@ function parseFlags() {
2349
2356
 
2350
2357
  // Issue passthrough commands delegate all flags to bd.
2351
2358
  // Skip global parsing so flags like --type, -p, --help reach the handler intact.
2352
- // Canonical `issue` plus the hidden back-compat aliases (single source of truth).
2353
- const issuePassthroughCommands = [...ISSUE_ALIAS_COMMANDS, 'issue'];
2359
+ // Canonical `issue` plus the ISSUE-canonical back-compat aliases only. Non-issue
2360
+ // aliases (e.g. the memory shortcuts remember/recall/insights) are excluded so
2361
+ // their global flags still parse exactly as their standalone commands' did —
2362
+ // keeping bare `recall -p <dir>` / `recall --help` byte-identical.
2363
+ const issuePassthroughCommands = [...passthroughAliasNames(), 'issue'];
2354
2364
  if (issuePassthroughCommands.includes(args[0])) {
2355
2365
  return flags;
2356
2366
  }
@@ -2610,15 +2620,30 @@ function showHelp() {
2610
2620
  console.log(' Run `forge init --help` for all profile/classification/harness flags.');
2611
2621
  console.log('');
2612
2622
 
2623
+ // Shortcuts block: VISIBLE back-compat aliases for a canonical `<noun> <sub>`
2624
+ // form (e.g. `remember` -> `forge memory add`). The bare verb keeps working and
2625
+ // is documented here; the noun form is canonical. Rendered as its own block ABOVE
2626
+ // "Additional commands" (and trimmed from that enumeration below) so the noun
2627
+ // surface reads clean.
2628
+ const shortcutNames = visibleAliasNames();
2629
+ if (shortcutNames.length > 0) {
2630
+ console.log('Shortcuts (bare aliases for canonical noun subcommands):');
2631
+ const shortcutWidth = Math.max(...shortcutNames.map(name => name.length));
2632
+ for (const name of shortcutNames) {
2633
+ console.log(` ${name.padEnd(shortcutWidth)} -> forge ${resolveAlias(name).canonical}`);
2634
+ }
2635
+ console.log('');
2636
+ }
2637
+
2613
2638
  // Append auto-discovered registry commands. Hidden issue aliases (the bare
2614
2639
  // passthroughs + plural `issues`, plus any command self-declaring `hidden: true`)
2615
- // still route and execute, but are trimmed from this enumeration so `forge issue`
2616
- // reads as the single canonical issue surface. This registry instance is loaded
2617
- // solely to render help, so deleting from its Map only affects the printed list —
2618
- // command dispatch (main) uses a separate registry and is unaffected.
2640
+ // AND the visible noun shortcuts rendered above are trimmed from this enumeration
2641
+ // so the canonical noun surface reads clean; both remain routable. This registry
2642
+ // instance is loaded solely to render help, so deleting from its Map only affects
2643
+ // the printed list — command dispatch (main) uses a separate registry.
2619
2644
  const helpRegistry = loadCommands(path.join(__dirname, '..', 'lib', 'commands'));
2620
2645
  for (const [name, cmd] of [...helpRegistry.commands]) {
2621
- if (ISSUE_ALIAS_COMMANDS.includes(name) || cmd.hidden === true) {
2646
+ if (isHiddenAlias(name) || isVisibleAlias(name) || cmd.hidden === true) {
2622
2647
  helpRegistry.commands.delete(name);
2623
2648
  }
2624
2649
  }
@@ -3945,7 +3970,7 @@ async function handleExternalServices(skipExternal, selectedAgents) {
3945
3970
  }
3946
3971
 
3947
3972
  async function main() {
3948
- const command = args[0];
3973
+ let command = args[0];
3949
3974
  const flags = parseFlags();
3950
3975
  const suppressJsonIntrospectionOutput = ['options', 'explain'].includes(command) && args.includes('--json');
3951
3976
  const suppressCommandJsonOutput = args.includes('--json');
@@ -4032,6 +4057,52 @@ async function main() {
4032
4057
  }
4033
4058
  }
4034
4059
 
4060
+ // Back-compat alias handling (lib/commands/_aliases.js, the single declarative
4061
+ // source of truth generalised from the former ISSUE_ALIAS_COMMANDS array). Emit
4062
+ // an opt-in deprecation hint (stderr only, gated on FORGE_DEPRECATION_WARNINGS +
4063
+ // the alias being marked deprecated — never on stdout, so `--json` stays clean),
4064
+ // then resolve any bare alias whose name is NOT itself a registered command to
4065
+ // its canonical `<noun> <sub>` handler. For P0 every alias is also a registered
4066
+ // command file and none are deprecated, so nothing warns and nothing is
4067
+ // rewritten — dispatch stays byte-identical. Registered commands skip the
4068
+ // rewrite via the first clause; this activates only when a later phase folds a
4069
+ // bare verb into a noun handler.
4070
+ maybeWarnDeprecation(command);
4071
+ let dispatchArgv = args;
4072
+ if (!registry.commands.has(command) && isAlias(command)) {
4073
+ const resolved = resolveDispatch(command, args, (name) => registry.commands.has(name));
4074
+ command = resolved.command;
4075
+ dispatchArgv = resolved.args;
4076
+ }
4077
+
4078
+ // A workflow stage reached through its canonical noun form (e.g. `pr ship`,
4079
+ // whose bare alias `ship` IS a stage) must dispatch through the SAME top-level
4080
+ // stage path as the bare verb — stage-entry enforcement, kernel stage-run
4081
+ // recording, and ensureForgeHome all key on the stage token, which sits at the
4082
+ // subcommand position under a noun. Rewrite `<noun> <stage> [args]` to the
4083
+ // top-level `<stage> [args]` so behavior is byte-identical to the bare stage
4084
+ // alias. Detected precisely via the declarative alias map: the subcommand is a
4085
+ // stage AND its bare alias's canonical is exactly `<command> <sub>` (so only a
4086
+ // genuine noun→stage form matches; non-stage pr/gate subcommands route via
4087
+ // their noun handler as usual). Only `pr ship` matches today.
4088
+ //
4089
+ // The stage token is located as the first POSITIONAL after the noun (scanning
4090
+ // past any global flags and their values), so `forge pr --path /tmp ship ...`
4091
+ // shares the same stage path as `forge pr ship ...` instead of falling through
4092
+ // to pr.handler and missing stage-entry enforcement. Intervening global flags
4093
+ // are preserved into the rewritten argv so the stage handler still sees them.
4094
+ const stageIdx = firstPositionalIndex(dispatchArgv, 1);
4095
+ const nounSub = stageIdx >= 0 ? dispatchArgv[stageIdx] : undefined;
4096
+ if (nounSub && normalizeStageId(nounSub) && registry.commands.has(nounSub)) {
4097
+ const bareAlias = resolveAlias(nounSub);
4098
+ if (bareAlias && bareAlias.canonical === `${command} ${nounSub}`) {
4099
+ command = nounSub;
4100
+ // Drop only the consumed stage token (the noun at index 0 is dropped by the
4101
+ // registry dispatch's own `.slice(1)`); keep the intervening global flags.
4102
+ dispatchArgv = [nounSub, ...dispatchArgv.slice(1, stageIdx), ...dispatchArgv.slice(stageIdx + 1)];
4103
+ }
4104
+ }
4105
+
4035
4106
  // Registry command dispatch — auto-discovered commands take priority
4036
4107
  if (registry.commands.has(command)) {
4037
4108
  try {
@@ -4041,7 +4112,7 @@ async function main() {
4041
4112
  // also assembles the driver + migrated broker (B1/B2).
4042
4113
  const { commandOpts, args: dispatchArgs } = await resolveCommandOpts(
4043
4114
  command,
4044
- args.slice(1),
4115
+ dispatchArgv.slice(1),
4045
4116
  { env: process.env, projectRoot },
4046
4117
  );
4047
4118
  const result = await executeCommand(
@@ -12,9 +12,12 @@ This guide covers supported Forge adoption paths. Use [Quickstart](../../QUICKST
12
12
  ## Install
13
13
 
14
14
  ```bash
15
- bun add -D forge-workflow
15
+ bun add -D forge-workflow@beta
16
16
  ```
17
17
 
18
+ > The current release is a prerelease under the `beta` dist-tag — install with
19
+ > `@beta`. A bare `forge-workflow` resolves to the older stable `latest`.
20
+
18
21
  The package exposes `forge`, `forge-workflow`, and `forge-preflight`.
19
22
 
20
23
  `install.sh` is a thin bootstrapper. It installs or invokes `forge-workflow` and delegates setup to the package; it is not a separate implementation of setup behavior.
@@ -108,6 +108,11 @@ Create isolated work:
108
108
  forge worktree create <slug> --branch <branch-name>
109
109
  ```
110
110
 
111
+ The new branch is forked from the repository's default branch (`origin/<default>`
112
+ when present, else the local default), not the current checkout — so a worktree
113
+ made from a WIP branch does not inherit unrelated commits. Override with
114
+ `--base <ref>` to fork from a specific ref; `create` prints the base it used.
115
+
111
116
  Remove it:
112
117
 
113
118
  ```bash