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
@@ -33,7 +33,7 @@ const SUBCOMMANDS = {
33
33
  },
34
34
  merge: {
35
35
  module: merge,
36
- summary: 'Opt-in conditional auto-merge, OFF by default (= forge merge --auto <pr>)',
36
+ summary: 'Opt-in guarded merge, OFF by default (= forge merge --auto <pr> --expect-head <sha> --issue <id>)',
37
37
  },
38
38
  };
39
39
 
@@ -103,7 +103,16 @@ function resolveChangeSet(exec = execFileSync, { runAll = false } = {}) {
103
103
  // Diff against the SAME base we just resolved. getChangedFiles() re-resolves
104
104
  // its own diff ref (and can fall back to a different default branch), so use
105
105
  // baseRef directly to keep the file list consistent with the resolved base.
106
- return { resolved: true, baseRef, changedFiles: changedFilesForBase(exec, baseRef) };
106
+ const changedFiles = changedFilesForBase(exec, baseRef);
107
+ if (changedFiles == null) {
108
+ return {
109
+ resolved: false,
110
+ baseRef,
111
+ changedFiles: [],
112
+ reason: `git diff failed or timed out for ${baseRef}`,
113
+ };
114
+ }
115
+ return { resolved: true, baseRef, changedFiles };
107
116
  }
108
117
 
109
118
  /** Files changed between the merge-base of HEAD and `baseRef` and HEAD. */
@@ -111,7 +120,7 @@ function changedFilesForBase(exec, baseRef) {
111
120
  const mergeBase = gitTryOut(exec, ['merge-base', 'HEAD', baseRef]);
112
121
  const range = mergeBase ? `${mergeBase}...HEAD` : `${baseRef}...HEAD`;
113
122
  const out = gitTryOut(exec, ['diff', '--name-only', range]);
114
- if (out == null) return [];
123
+ if (out == null) return null;
115
124
  return out.split(/\r?\n/).filter(Boolean);
116
125
  }
117
126
 
@@ -6,15 +6,28 @@ const {
6
6
  runOrientationCommand,
7
7
  } = require('../orientation');
8
8
 
9
+ const DEPRECATION_NOTICE =
10
+ 'forge prime is deprecated — run `forge status -v` for the same briefing.\n';
11
+
12
+ // The single briefing renderer. `forge status -v` calls this so the full
13
+ // session-entry briefing has exactly one implementation.
14
+ // Async: the briefing leads with LIVE state (stage / claims / ready / gates / one
15
+ // adoption nudge), which needs a best-effort (non-throwing) kernel read before the
16
+ // synchronous build assembles it into the bounded orientation.
17
+ async function renderBriefing(args, projectRoot) {
18
+ const liveState = await collectPrimeLiveState(projectRoot);
19
+ return runOrientationCommand(buildPrime, args, projectRoot, { liveState });
20
+ }
21
+
9
22
  module.exports = {
10
23
  name: 'prime',
11
- description: 'Emit session-entry bounded orientation for agents',
12
- usage: 'Usage: forge prime [--budget N] [--json]',
13
- // Async: prime leads with LIVE state (stage / claims / ready / gates / one adoption nudge),
14
- // which needs a best-effort (non-throwing) kernel read before the synchronous build assembles
15
- // it into the bounded orientation. All existing prime output/flags are unchanged.
16
- handler: async (args, _flags, projectRoot) => {
17
- const liveState = await collectPrimeLiveState(projectRoot);
18
- return runOrientationCommand(buildPrime, args, projectRoot, { liveState });
24
+ description: 'Deprecated alias for `forge status -v`: session-entry bounded orientation',
25
+ usage: 'Usage: forge prime [--budget N] [--json] (deprecated — use `forge status -v`)',
26
+ // Session-start hooks in consumer repos call this and consume stdout as context,
27
+ // so the notice goes to stderr and stdout stays byte-identical to `status -v`.
28
+ handler: async (args, _flags, projectRoot, options = {}) => {
29
+ (options.stderr || process.stderr).write(DEPRECATION_NOTICE);
30
+ return renderBriefing(args, projectRoot);
19
31
  },
32
+ renderBriefing,
20
33
  };
@@ -4,8 +4,8 @@ const { execFileSync, spawnSync } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
  const forgeToken = require('../../scripts/check-forge-token');
7
- const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
8
- const { autoShepherdRailEnabled } = require('./ship');
7
+ const { QUICK_LANE_ENV_VAR, QUICK_LANE_VALUE } = require('../../scripts/test');
8
+ const { fireAndForget } = require('../pr-monitor/reconcile-executor');
9
9
 
10
10
  const isWindows = process.platform === 'win32';
11
11
 
@@ -113,54 +113,49 @@ async function autoFileBackingIssueForPush(projectRoot, execFn, deps = {}) {
113
113
  }
114
114
 
115
115
  /**
116
- * Resolve the OPEN PR number for the current branch via `gh pr view`. Returns
117
- * null when there is no PR, gh is unavailable, or anything errors (fail-open).
118
- * NEVER throws.
116
+ * Best-effort wake of the repository-wide singleton after a successful push.
117
+ * The shared trigger owns all containment and gate checks and never selects a
118
+ * per-PR watcher here.
119
119
  *
120
- * @param {function} execFn - execFileSync or mock
121
- * @returns {number|null}
120
+ * @param {object} params
121
+ * @returns {{ armed: boolean, reason?: string }}
122
122
  */
123
- function resolveOpenPrNumber(execFn) {
123
+ function maybeTriggerShepherdAfterPush({
124
+ projectRoot,
125
+ fireAndForget: trigger = fireAndForget,
126
+ }) {
124
127
  try {
125
- const out = execFn('gh', ['pr', 'view', '--json', 'number', '-q', '.number'], {
126
- encoding: 'utf8', timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'],
127
- });
128
- const n = Number.parseInt(String(out).trim(), 10);
129
- return Number.isInteger(n) && n > 0 ? n : null;
130
- } catch (_err) { /* intentional: no open PR / gh missing → arm nothing */ // NOSONAR S2486
131
- return null;
128
+ trigger({ projectRoot });
129
+ return { armed: true };
130
+ } catch (err) {
131
+ return { armed: false, reason: err.message };
132
132
  }
133
133
  }
134
134
 
135
135
  /**
136
- * Best-effort, NON-BLOCKING arm of the constant PR watcher after a successful
137
- * push, when an OPEN PR exists for the current branch. This closes the gap where
138
- * PRs not born from `forge ship` (gh pr create, the GitHub UI, an earlier push)
139
- * never got a watcher. Gated by the default-ON `rail.auto_shepherd`, idempotent
140
- * via the watch loop's own PID/journal lock, and reusing the same
141
- * `startPrWatcherDetached` as ship. MUST NEVER throw into or fail the push: a
142
- * disabled rail, no PR, a gh error, or a spawn error all degrade to
143
- * `{ armed: false }`.
136
+ * Build the environment for the spawned `git push`, declaring the push lane.
144
137
  *
145
- * @param {object} params
146
- * @returns {{ armed: boolean, reason?: string, prNumber?: number }}
138
+ * `--quick` skips the test step push.js controls, but the `git push` it spawns
139
+ * still fires the lefthook pre-push hook, whose tests job runs scripts/test.js —
140
+ * so --quick was quick only up to the push. This declares the lane to that hook
141
+ * so the quick lane is lint-only end to end.
142
+ *
143
+ * The declaration is set on the child env only, and is explicitly REMOVED for a
144
+ * full push so an inherited value from an outer shell can never silently drop
145
+ * the tests from a `forge push`.
146
+ *
147
+ * @param {boolean} quickMode - Whether this is a --quick push
148
+ * @param {NodeJS.ProcessEnv} [baseEnv=process.env] - Environment to derive from
149
+ * @returns {NodeJS.ProcessEnv} Environment for the git push child process
147
150
  */
148
- function maybeArmWatcherAfterPush({
149
- projectRoot,
150
- execFn,
151
- startWatcher = startPrWatcherDetached,
152
- railEnabled = autoShepherdRailEnabled,
153
- prLookup = resolveOpenPrNumber,
154
- }) {
155
- try {
156
- if (!railEnabled(projectRoot)) return { armed: false, reason: 'rail.auto_shepherd disabled' };
157
- const prNumber = prLookup(execFn);
158
- if (!prNumber) return { armed: false, reason: 'no-open-pr' };
159
- const res = startWatcher({ prNumber, cwd: projectRoot });
160
- return { armed: !!(res && res.started), reason: res && res.reason, prNumber };
161
- } catch (err) {
162
- return { armed: false, reason: err.message };
151
+ function buildPushEnv(quickMode, baseEnv = process.env) {
152
+ const env = { ...baseEnv };
153
+ if (quickMode) {
154
+ env[QUICK_LANE_ENV_VAR] = QUICK_LANE_VALUE;
155
+ } else {
156
+ delete env[QUICK_LANE_ENV_VAR];
163
157
  }
158
+ return env;
164
159
  }
165
160
 
166
161
  /**
@@ -288,7 +283,7 @@ module.exports = {
288
283
  // Step 5: git push with passthrough args
289
284
  const gitArgs = args.filter(a => a !== '--quick');
290
285
  try {
291
- execFn('git', ['push', ...gitArgs], { stdio: 'inherit' });
286
+ execFn('git', ['push', ...gitArgs], { stdio: 'inherit', env: buildPushEnv(quickMode) });
292
287
  } catch (_pushErr) { // NOSONAR S2486
293
288
  log('git push failed.');
294
289
  return {
@@ -300,15 +295,10 @@ module.exports = {
300
295
  };
301
296
  }
302
297
 
303
- // Arm the constant PR watcher for this branch's open PR (best-effort,
304
- // gated by rail.auto_shepherd, never fails the push). Covers PRs not born
305
- // from `forge ship`.
306
- maybeArmWatcherAfterPush({
298
+ // Wake the repository-wide singleton after a successful push.
299
+ maybeTriggerShepherdAfterPush({
307
300
  projectRoot,
308
- execFn,
309
- startWatcher: deps?.startWatcher,
310
- railEnabled: deps?.railEnabled,
311
- prLookup: deps?.prLookup,
301
+ fireAndForget: deps?.fireAndForget,
312
302
  });
313
303
 
314
304
  return {
@@ -323,7 +313,7 @@ module.exports = {
323
313
  // Exposed for unit tests; not part of the CLI surface.
324
314
  _internal: {
325
315
  autoFileBackingIssueForPush,
326
- maybeArmWatcherAfterPush,
327
- resolveOpenPrNumber,
316
+ buildPushEnv,
317
+ maybeTriggerShepherdAfterPush,
328
318
  },
329
319
  };
@@ -3,8 +3,11 @@
3
3
  const memoryRouter = require('../memory/router');
4
4
  const { stripGlobalFlags } = require('../global-flags');
5
5
  const { fenceUntrusted } = require('../untrusted-content');
6
+ const { applyBudget, buildSection, estimateTokens } = require('../orientation');
7
+ const { memoryTrustStatus } = require('../memory-recall');
6
8
 
7
9
  const usage = 'Usage: forge recall [query] [--kind <type>] [--limit N] [--all] [--json]';
10
+ const RECALL_CONTENT_BUDGET = 1100;
8
11
 
9
12
  // Reserved tag prefix that `remember --kind` writes (kernel issue 8cc1db4d). A `--kind`
10
13
  // filter keeps only notes carrying this tag; the prefix is stripped when surfacing the
@@ -79,21 +82,28 @@ function withType(entry) {
79
82
 
80
83
  function formatEntry(entry) {
81
84
  const date = entry.timestamp ? entry.timestamp.slice(0, 10) : '';
82
- const prefix = date ? `${date} ` : '';
85
+ const trust = memoryTrustStatus({
86
+ tags: entry.tags,
87
+ sourceAgent: entry.sourceAgent,
88
+ value: entry.machine ? {} : entry.note,
89
+ });
90
+ const sourceAgent = entry.sourceAgent || 'unknown';
91
+ const label = `[source=${sourceAgent} trust=${trust} updated=${date || 'unknown'}] `;
83
92
  // The reserved `type:` tag renders as a leading `(kind)` marker, not as a raw tag, so the
84
93
  // displayed tags stay the user's own labels.
85
94
  const type = typeOf(entry);
86
- const userTags = (entry.tags || []).filter(t => !t.startsWith(TYPE_TAG_PREFIX));
95
+ const userTags = (entry.tags || []).filter(
96
+ t => !t.startsWith(TYPE_TAG_PREFIX) && !t.startsWith('trust:')
97
+ );
87
98
  const tagSuffix = userTags.length > 0 ? ` [${userTags.join(', ')}]` : '';
88
99
  const typeMarker = type ? `(${type}) ` : '';
89
100
  // Machine/insights records are LABELED with their source so they are never mistaken for a
90
101
  // plain human note; human `remember` notes render clean.
91
102
  const marker = entry.machine && entry.sourceAgent ? `(${entry.sourceAgent}) ` : '';
92
- // Stored note text is UNTRUSTED (a planted memory could carry injected directives),
93
- // so the human/agent-facing render is provenance-fenced. The `--json` path above
94
- // keeps the raw note so programmatic consumers/parsers are unaffected.
95
- const note = fenceUntrusted(entry.note, { source: 'memory' });
96
- return `- ${prefix}${marker}${typeMarker}${note}${tagSuffix}`;
103
+ // Stored note text is untrusted. Fence it after budgeting so a truncation cannot
104
+ // sever the close marker; the `--json` path above keeps the raw note unchanged.
105
+ const note = String(entry.note == null ? '' : entry.note);
106
+ return `- ${label}${marker}${typeMarker}${note}${tagSuffix}`;
97
107
  }
98
108
 
99
109
  async function handler(args, flags, projectRoot) {
@@ -137,23 +147,57 @@ async function handler(args, flags, projectRoot) {
137
147
  return { success: true, output: reason };
138
148
  }
139
149
 
150
+ const sections = notes
151
+ .map((entry, index) => {
152
+ const content = formatEntry(entry);
153
+ if (estimateTokens(content) > RECALL_CONTENT_BUDGET) return null;
154
+ const trust = memoryTrustStatus({
155
+ tags: entry.tags,
156
+ sourceAgent: entry.sourceAgent,
157
+ value: entry.machine ? {} : entry.note,
158
+ });
159
+ return buildSection({
160
+ id: `recall_${index}`,
161
+ title: '',
162
+ content,
163
+ priority: index,
164
+ preserve: false,
165
+ data: { trust },
166
+ });
167
+ })
168
+ .filter(Boolean);
169
+ const budgeted = applyBudget(sections, RECALL_CONTENT_BUDGET).sections
170
+ .filter(section => section.content);
171
+ for (const section of budgeted) {
172
+ section.content = fenceUntrusted(section.content, { source: 'memory' });
173
+ }
174
+ const rendered = budgeted.length;
140
175
  const noun = scope === 'all' && !query ? 'stored memory record(s)' : 'remembered note(s)';
141
176
  let header;
142
177
  if (query) {
143
178
  // BM25 returns at most `limit`; when full, signal it is the TOP-N, not the whole set.
144
- header = capped
145
- ? `Top ${notes.length} note(s) matching "${query}" (raise --limit for more):`
146
- : `${notes.length} note(s) matching "${query}":`;
147
- } else if (capped) {
148
- // Never a bare full dump: show the newest N and the true total.
149
- header = `Showing ${notes.length} of ${total} ${noun} (newest first):`;
179
+ header = capped || rendered < notes.length
180
+ ? `Top ${rendered} note(s) matching "${query}" (raise --limit for more):`
181
+ : `${rendered} note(s) matching "${query}":`;
182
+ } else if (capped || rendered < notes.length) {
183
+ // Never a bare full dump: show the rendered count and the true total.
184
+ header = `Showing ${rendered} of ${total} ${noun} (newest first):`;
150
185
  } else {
151
- header = `${total} ${noun}:`;
186
+ header = `${rendered} ${noun}:`;
152
187
  }
153
- const lines = notes.map(formatEntry);
188
+ const confirmed = budgeted.filter(section => section.data.trust === 'confirmed');
189
+ const suggested = budgeted.filter(section => section.data.trust === 'suggested');
190
+ const groups = [
191
+ confirmed.length ? ['Confirmed memory', confirmed] : null,
192
+ suggested.length ? ['Suggested memory — verify before relying', suggested] : null,
193
+ ].filter(Boolean);
194
+ const body = groups.flatMap(([title, entries]) => [
195
+ title,
196
+ ...entries.map(entry => entry.content),
197
+ ]);
154
198
  return {
155
199
  success: true,
156
- output: [header, ...lines].join('\n'),
200
+ output: [header, ...body].join('\n'),
157
201
  };
158
202
  }
159
203
 
@@ -46,7 +46,12 @@ async function handler(args, _flags, projectRoot, opts = {}) {
46
46
  }
47
47
 
48
48
  const budget = readOption(args, '--budget', undefined);
49
- const recap = buildIssueRecap(projectRoot, issueId, { budgetTokens: budget });
49
+ // `runIssueOperation` is the injectable kernel-read seam (Slice C2) — the same
50
+ // pass-through opts already carry for the grounding write below.
51
+ const recap = await buildIssueRecap(projectRoot, issueId, {
52
+ budgetTokens: budget,
53
+ runIssueOperation: opts.runIssueOperation,
54
+ });
50
55
 
51
56
  // Grounding (gate.read_first): a successful recap IS the load-the-doc action,
52
57
  // so append a `context.loaded` event that unblocks a later `forge claim <id>`.
@@ -8,12 +8,13 @@ const {
8
8
  } = require('../release-readiness');
9
9
  const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues');
10
10
  const { normalizeArgs, normalizeIssueResult, withResolvedIssueBackend } = require('./_issue');
11
+ const { generateNpmPublishWorkflow } = require('../npm-publish-workflow');
11
12
 
12
13
  // `forge release <id>` releases a claimed issue; `forge release check` runs the
13
14
  // release-readiness gate. The two share the top-level verb, so this command
14
15
  // dispatches `check` to the gate and routes everything else through the shared
15
16
  // issue dispatch (resolve backend → runIssueOperation('release') → normalize).
16
- const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit';
17
+ const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow';
17
18
 
18
19
  async function runReleaseIssue(args, projectRoot, opts = {}) {
19
20
  const resolved = withResolvedIssueBackend(projectRoot, opts);
@@ -55,7 +56,21 @@ function parseReleaseArgs(args = []) {
55
56
  }
56
57
 
57
58
  async function handler(args, _flags, projectRoot, opts = {}) {
58
- const parsed = parseReleaseArgs(args);
59
+ const parsed = parseReleaseArgs(args);
60
+
61
+ if (parsed.subcommand === 'generate-npm-workflow') {
62
+ const generated = await generateNpmPublishWorkflow(projectRoot, {
63
+ env: opts.env,
64
+ kernelDeps: opts.kernelDeps,
65
+ });
66
+ return generated.success
67
+ ? {
68
+ success: true,
69
+ generated,
70
+ output: `Generated ${generated.path} (${generated.contentHash}).\n`,
71
+ }
72
+ : generated;
73
+ }
59
74
 
60
75
  if (parsed.subcommand === 'regen-audit') {
61
76
  // forge release regen-audit — rewrite the D20 kill-list from a live re-scan.