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
@@ -1,10 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  const memoryRouter = require('../memory/router');
4
+ const projectMemory = require('../project-memory');
5
+ const { types: { isProxy } } = require('node:util');
4
6
  const { stripGlobalFlags } = require('../global-flags');
5
7
  const { fenceUntrusted } = require('../untrusted-content');
8
+ const { applyBudget, buildSection, estimateTokens } = require('../orientation');
9
+ const { memoryTrustStatus } = require('../memory-recall');
6
10
 
7
11
  const usage = 'Usage: forge recall [query] [--kind <type>] [--limit N] [--all] [--json]';
12
+ const RECALL_CONTENT_BUDGET = 1100;
8
13
 
9
14
  // Reserved tag prefix that `remember --kind` writes (kernel issue 8cc1db4d). A `--kind`
10
15
  // filter keeps only notes carrying this tag; the prefix is stripped when surfacing the
@@ -12,10 +17,8 @@ const usage = 'Usage: forge recall [query] [--kind <type>] [--limit N] [--all] [
12
17
  // `--type`): `--type` is a reserved GLOBAL flag hard-validated to workflow classifications.
13
18
  const TYPE_TAG_PREFIX = 'type:';
14
19
 
15
- // When a `--kind` filter is active the tag filter runs in the command layer (the store is
16
- // not reimplemented), so scan a generous window of recent notes before filtering rather than
20
+ // Kind filtering is pushed into the Kernel read so limits and capped metadata stay truthful.
17
21
  // the small default page — otherwise the type match could fall outside the default limit.
18
- const TYPE_FILTER_SCAN = 1000;
19
22
 
20
23
  /**
21
24
  * Separate the optional positional query from `--kind <type>`, `--limit N`, and `--json`.
@@ -77,26 +80,129 @@ function withType(entry) {
77
80
  return type ? { ...entry, type } : entry;
78
81
  }
79
82
 
83
+ function privateCommandOption(options, field) {
84
+ if (!options || typeof options !== 'object' || isProxy(options)) return undefined;
85
+ let descriptor;
86
+ try { descriptor = Object.getOwnPropertyDescriptor(options, field); } catch { return undefined; }
87
+ return descriptor && Object.hasOwn(descriptor, 'value') ? descriptor.value : undefined;
88
+ }
89
+
80
90
  function formatEntry(entry) {
81
91
  const date = entry.timestamp ? entry.timestamp.slice(0, 10) : '';
82
- const prefix = date ? `${date} ` : '';
92
+ const trust = memoryTrustStatus({
93
+ tags: entry.tags,
94
+ sourceAgent: entry.sourceAgent,
95
+ value: entry.machine ? {} : entry.note,
96
+ });
97
+ const sourceAgent = entry.sourceAgent || 'unknown';
98
+ const label = `[source=${sourceAgent} trust=${trust} updated=${date || 'unknown'}] `;
83
99
  // The reserved `type:` tag renders as a leading `(kind)` marker, not as a raw tag, so the
84
100
  // displayed tags stay the user's own labels.
85
101
  const type = typeOf(entry);
86
- const userTags = (entry.tags || []).filter(t => !t.startsWith(TYPE_TAG_PREFIX));
102
+ const userTags = (entry.tags || []).filter(
103
+ t => !t.startsWith(TYPE_TAG_PREFIX) && !t.startsWith('trust:')
104
+ );
87
105
  const tagSuffix = userTags.length > 0 ? ` [${userTags.join(', ')}]` : '';
88
106
  const typeMarker = type ? `(${type}) ` : '';
89
107
  // Machine/insights records are LABELED with their source so they are never mistaken for a
90
108
  // plain human note; human `remember` notes render clean.
91
109
  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}`;
110
+ // Stored note text is untrusted. Fence it after budgeting so a truncation cannot
111
+ // sever the close marker; the `--json` path above keeps the raw note unchanged.
112
+ const note = String(entry.note == null ? '' : entry.note);
113
+ return `- ${label}${marker}${typeMarker}${note}${tagSuffix}`;
114
+ }
115
+
116
+ function createUsageRecorder(projectRoot, recallStore, commandOpts) {
117
+ const invocationId = privateCommandOption(commandOpts, 'invocationId');
118
+ const usageStore = privateCommandOption(commandOpts, 'usageStore');
119
+ const onUsageEvidence = privateCommandOption(commandOpts, 'onUsageEvidence');
120
+ const invocationStartedAt = privateCommandOption(commandOpts, 'invocationStartedAt')
121
+ || privateCommandOption(commandOpts, 'now')
122
+ || new Date().toISOString();
123
+ const evidenceCallback = typeof onUsageEvidence === 'function' && !isProxy(onUsageEvidence)
124
+ ? onUsageEvidence
125
+ : undefined;
126
+ return selected => {
127
+ const observation = projectMemory.recordRecallUsage(projectRoot, selected, {
128
+ invocationId,
129
+ usageStore: usageStore || recallStore,
130
+ invocationStartedAt,
131
+ });
132
+ if (evidenceCallback) {
133
+ // Observability is deliberately aggregate-only: callers never receive query, note,
134
+ // path, secret, or raw durable identifiers.
135
+ try { evidenceCallback(observation); } catch { /* advisory test seam */ }
136
+ }
137
+ };
138
+ }
139
+
140
+ function buildRecallSections(notes) {
141
+ return notes
142
+ .map((entry, index) => {
143
+ const content = formatEntry(entry);
144
+ if (estimateTokens(content) > RECALL_CONTENT_BUDGET) return null;
145
+ const trust = memoryTrustStatus({
146
+ tags: entry.tags,
147
+ sourceAgent: entry.sourceAgent,
148
+ value: entry.machine ? {} : entry.note,
149
+ });
150
+ return buildSection({
151
+ id: `recall_${index}`,
152
+ title: '',
153
+ content,
154
+ priority: index,
155
+ preserve: false,
156
+ data: { trust, memoryId: entry.id },
157
+ });
158
+ })
159
+ .filter(Boolean);
160
+ }
161
+
162
+ function humanRecallHeader(query, capped, rendered, noteCount, total, scope) {
163
+ const noun = scope === 'all' && !query ? 'stored memory record(s)' : 'remembered note(s)';
164
+ if (query) {
165
+ return capped || rendered < noteCount
166
+ ? `Top ${rendered} note(s) matching "${query}" (raise --limit for more):`
167
+ : `${rendered} note(s) matching "${query}":`;
168
+ }
169
+ if (capped || rendered < noteCount) {
170
+ return `Showing ${rendered} of ${total} ${noun} (newest first):`;
171
+ }
172
+ return `${rendered} ${noun}:`;
173
+ }
174
+
175
+ function renderHumanRecall(notes, result, recordUsage) {
176
+ const sections = buildRecallSections(notes);
177
+ const budgeted = applyBudget(sections, RECALL_CONTENT_BUDGET).sections
178
+ .filter(section => section.content);
179
+ for (const section of budgeted) {
180
+ section.content = fenceUntrusted(section.content, { source: 'memory' });
181
+ }
182
+ recordUsage(budgeted.map(section => ({ id: section.data.memoryId })));
183
+ const rendered = budgeted.length;
184
+ const header = humanRecallHeader(result.query, result.capped, rendered, notes.length, result.total, result.scope);
185
+ const confirmed = budgeted.filter(section => section.data.trust === 'confirmed');
186
+ const suggested = budgeted.filter(section => section.data.trust === 'suggested');
187
+ const groups = [
188
+ confirmed.length ? ['Confirmed memory', confirmed] : null,
189
+ suggested.length ? ['Suggested memory — verify before relying', suggested] : null,
190
+ ].filter(Boolean);
191
+ const body = groups.flatMap(([title, entries]) => [
192
+ title,
193
+ ...entries.map(entry => entry.content),
194
+ ]);
195
+ return { success: true, output: [header, ...body].join('\n') };
97
196
  }
98
197
 
99
- async function handler(args, flags, projectRoot) {
198
+ function emptyRecall(query) {
199
+ const reason = query
200
+ ? `No notes match "${query}".`
201
+ : 'No notes remembered yet. Use "forge remember <note>" to add one.';
202
+ return { success: true, output: reason };
203
+ }
204
+
205
+ async function handler(args, flags, projectRoot, commandOpts = {}) {
100
206
  const { query, limit, type, json } = parseArgs(args);
101
207
  // `--all` is a GLOBAL boolean flag: in production bin/forge.js strips it from
102
208
  // args and sets flags.all; on a direct handler call it may still be in args.
@@ -105,56 +211,28 @@ async function handler(args, flags, projectRoot) {
105
211
  // A `--type` filter scans a generous recent window, then keeps only matching notes — the
106
212
  // read stays entirely in the existing store (no schema change). `--limit` is re-applied
107
213
  // AFTER filtering so it caps the typed result set, not the pre-filter scan.
108
- const recallLimit = type ? Math.max(limit ?? 0, TYPE_FILTER_SCAN) : limit;
109
- const result = memoryRouter.recall(projectRoot, { query, limit: recallLimit, all });
110
- let notes = result.notes.map(withType);
111
- let { total, capped } = result;
112
- const { scope } = result;
113
- if (type) {
114
- notes = notes.filter(entry => entry.type === type);
115
- total = notes.length;
116
- if (limit && notes.length > limit) {
117
- notes = notes.slice(0, limit);
118
- capped = true;
119
- } else {
120
- capped = false;
121
- }
122
- }
214
+ // Resolve once for the complete recall: selection, count, and advisory evidence share
215
+ // one cached driver/connection rather than each opening or migrating independently.
216
+ const recallStore = projectMemory.resolveStore(projectRoot);
217
+ const result = memoryRouter.recall(projectRoot, { query, limit, all, kind: type }, { store: recallStore });
218
+ const notes = result.notes.map(withType);
219
+ const recordUsage = createUsageRecorder(projectRoot, recallStore, commandOpts);
123
220
 
124
221
  if (json) {
125
222
  // Object (not a bare array) so programmatic consumers see the total and whether the
126
223
  // result was truncated (raise --limit to page further).
224
+ recordUsage(notes);
127
225
  return {
128
226
  success: true,
129
- output: `${JSON.stringify({ notes, total, capped, scope }, null, 2)}\n`,
227
+ output: `${JSON.stringify({ notes, total: result.total, capped: result.capped, scope: result.scope }, null, 2)}\n`,
130
228
  };
131
229
  }
132
230
 
133
231
  if (notes.length === 0) {
134
- const reason = query
135
- ? `No notes match "${query}".`
136
- : 'No notes remembered yet. Use "forge remember <note>" to add one.';
137
- return { success: true, output: reason };
232
+ return emptyRecall(query);
138
233
  }
139
234
 
140
- const noun = scope === 'all' && !query ? 'stored memory record(s)' : 'remembered note(s)';
141
- let header;
142
- if (query) {
143
- // 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):`;
150
- } else {
151
- header = `${total} ${noun}:`;
152
- }
153
- const lines = notes.map(formatEntry);
154
- return {
155
- success: true,
156
- output: [header, ...lines].join('\n'),
157
- };
235
+ return renderHumanRecall(notes, result, recordUsage);
158
236
  }
159
237
 
160
238
  module.exports = {
@@ -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,14 @@ 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');
12
+ const { updateBunWorkflowPins } = require('../bun-workflow-pins');
11
13
 
12
14
  // `forge release <id>` releases a claimed issue; `forge release check` runs the
13
15
  // release-readiness gate. The two share the top-level verb, so this command
14
16
  // dispatches `check` to the gate and routes everything else through the shared
15
17
  // 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';
18
+ const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow --expect-head <full-sha> | forge release update-bun-pins --expect-head <full-sha>';
17
19
 
18
20
  async function runReleaseIssue(args, projectRoot, opts = {}) {
19
21
  const resolved = withResolvedIssueBackend(projectRoot, opts);
@@ -44,18 +46,51 @@ function parseReleaseArgs(args = []) {
44
46
  return false;
45
47
  }
46
48
  const previous = args[index - 1];
47
- return previous !== '--target';
49
+ return previous !== '--target' && previous !== '--expect-head';
48
50
  });
49
51
 
50
52
  return {
51
53
  subcommand,
52
54
  target: readOption(args, '--target', SUPPORTED_TARGET),
55
+ expectedHead: readOption(args, '--expect-head', null),
53
56
  json: args.includes('--json'),
54
57
  };
55
58
  }
56
59
 
57
60
  async function handler(args, _flags, projectRoot, opts = {}) {
58
- const parsed = parseReleaseArgs(args);
61
+ const parsed = parseReleaseArgs(args);
62
+ if (parsed.subcommand === 'update-bun-pins') {
63
+ const updater = opts.updateBunWorkflowPins || updateBunWorkflowPins;
64
+ const updated = await updater(projectRoot, {
65
+ env: opts.env,
66
+ kernelDeps: opts.kernelDeps,
67
+ expectedHead: parsed.expectedHead,
68
+ resolveHead: opts.resolveHead,
69
+ });
70
+ return updated.success
71
+ ? {
72
+ success: true,
73
+ updated,
74
+ output: `Pinned ${updated.paths.length} workflows to Bun ${updated.version}.\n`,
75
+ }
76
+ : updated;
77
+ }
78
+
79
+ if (parsed.subcommand === 'generate-npm-workflow') {
80
+ const generated = await generateNpmPublishWorkflow(projectRoot, {
81
+ env: opts.env,
82
+ kernelDeps: opts.kernelDeps,
83
+ expectedHead: parsed.expectedHead,
84
+ resolveHead: opts.resolveHead,
85
+ });
86
+ return generated.success
87
+ ? {
88
+ success: true,
89
+ generated,
90
+ output: `Generated ${generated.path} (${generated.contentHash}).\n`,
91
+ }
92
+ : generated;
93
+ }
59
94
 
60
95
  if (parsed.subcommand === 'regen-audit') {
61
96
  // forge release regen-audit — rewrite the D20 kill-list from a live re-scan.
@@ -96,6 +131,7 @@ module.exports = {
96
131
  usage,
97
132
  flags: {
98
133
  '--target <version>': 'Release target to check',
134
+ '--expect-head <full-sha>': 'Required exact HEAD for protected workflow generation',
99
135
  '--json': 'Emit the readiness report as JSON',
100
136
  },
101
137
  handler,
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('node:crypto');
3
4
  const memoryRouter = require('../memory/router');
4
5
  const { stripGlobalFlags } = require('../global-flags');
5
6
 
@@ -22,6 +23,7 @@ const STRUCTURED_FIELDS = [
22
23
  // `--kind` (NOT `--type`): `--type` is a reserved GLOBAL flag that bin/forge.js hard-validates
23
24
  // against workflow classifications (critical|standard|…), so it can never carry a note type.
24
25
  const TYPE_TAG_PREFIX = 'type:';
26
+ const CONTENT_HASH_TAG_PREFIX = 'content-hash:';
25
27
 
26
28
  /**
27
29
  * Split positional note words from `--kind`, `--tag <label>`, the structured field flags,
@@ -105,15 +107,37 @@ async function handler(args, _flags, projectRoot) {
105
107
  }
106
108
 
107
109
  // The type rides as a reserved tag so it is stored and filterable without a schema change.
108
- const allTags = type ? [...tags, `${TYPE_TAG_PREFIX}${type}`] : tags;
109
- const entry = memoryRouter.append(projectRoot, body, { tags: allTags });
110
+ const contentHash = type === 'session-summary'
111
+ ? crypto.createHash('sha256').update(JSON.stringify({
112
+ body,
113
+ tags: [...tags].sort((left, right) => left.localeCompare(right)),
114
+ type,
115
+ }), 'utf8').digest('hex')
116
+ : null;
117
+ const metadata = contentHash ? { kind: 'session-summary', content_hash: contentHash } : undefined;
118
+ const hashTag = contentHash ? `${CONTENT_HASH_TAG_PREFIX}${contentHash}` : null;
119
+ const allTags = [
120
+ ...tags,
121
+ ...(type ? [`${TYPE_TAG_PREFIX}${type}`] : []),
122
+ ...(hashTag ? [hashTag] : []),
123
+ ];
124
+ let entry;
125
+ if (hashTag) {
126
+ const matches = memoryRouter.recall(projectRoot, {
127
+ query: contentHash,
128
+ limit: 2,
129
+ all: true,
130
+ }).notes;
131
+ entry = matches.find(note => Array.isArray(note.tags) && note.tags.includes(hashTag));
132
+ }
133
+ if (!entry) entry = memoryRouter.append(projectRoot, body, { tags: allTags });
110
134
 
111
135
  if (json) {
112
- const payload = type ? { ...entry, type } : entry;
136
+ const payload = type ? { ...entry, type, metadata } : entry;
113
137
  return { success: true, output: `${JSON.stringify(payload, null, 2)}\n` };
114
138
  }
115
139
 
116
- const userTags = entry.tags.filter(tag => !tag.startsWith(TYPE_TAG_PREFIX));
140
+ const userTags = entry.tags.filter(tag => !tag.startsWith(TYPE_TAG_PREFIX) && !tag.startsWith(CONTENT_HASH_TAG_PREFIX));
117
141
  const tagSuffix = userTags.length > 0 ? ` [${userTags.join(', ')}]` : '';
118
142
  const typePrefix = type ? `(${type}) ` : '';
119
143
  return {
@@ -31,6 +31,7 @@ const fs = require('node:fs');
31
31
  const path = require('node:path');
32
32
  const crypto = require('node:crypto');
33
33
  const { spawn } = require('node:child_process');
34
+ const { githubWorkerEnvironment } = require('../pr-monitor/watch-lifecycle');
34
35
 
35
36
  // Shared-machine hardening (single-instance lock, owner-only perms + audit,
36
37
  // hash-chained mutation journal). No dependency on serve.js, so a top-level
@@ -237,12 +238,21 @@ async function routeMutation(verb, args, projectRoot, deps = {}) {
237
238
  // Snapshot (GET /data.json) — regenerate on demand, debounced.
238
239
  // ---------------------------------------------------------------------------
239
240
 
240
- function defaultGenerate(projectRoot, dashboardDir) {
241
+ function defaultGenerate(projectRoot, dashboardDir, deps = {}) {
241
242
  return new Promise((resolve, reject) => {
242
243
  const script = path.join(dashboardDir, 'generate-snapshot.mjs');
243
- const child = spawn(process.execPath, [script], { cwd: projectRoot, stdio: 'ignore', windowsHide: true });
244
+ const env = githubWorkerEnvironment(projectRoot, deps);
245
+ let child;
246
+ try {
247
+ child = (deps.spawn || spawn)(process.execPath, [script], {
248
+ cwd: projectRoot, stdio: 'ignore', windowsHide: true, ...(env ? { env } : {}),
249
+ });
250
+ } catch {
251
+ reject(new Error('snapshot generation failed'));
252
+ return;
253
+ }
244
254
  const timer = setTimeout(() => { child.kill(); reject(new Error('snapshot generation timed out')); }, GEN_TIMEOUT_MS);
245
- child.on('error', (err) => { clearTimeout(timer); reject(err); });
255
+ child.on('error', () => { clearTimeout(timer); reject(new Error('snapshot generation failed')); });
246
256
  child.on('exit', (code) => {
247
257
  clearTimeout(timer);
248
258
  if (code === 0) resolve();
@@ -409,7 +419,7 @@ function buildContext(projectRoot, dashboardDir, token, deps = {}) {
409
419
  buffer: null,
410
420
  lastGen: 0,
411
421
  inFlight: null,
412
- generate: deps.generate || defaultGenerate,
422
+ generate: deps.generate || ((root, dir) => defaultGenerate(root, dir, deps)),
413
423
  },
414
424
  };
415
425
  }
@@ -438,13 +448,16 @@ function browserOpener() {
438
448
  return { file: '/usr/bin/xdg-open', args: [] };
439
449
  }
440
450
 
441
- function openBrowser(url) {
451
+ function openBrowser(url, projectRoot = process.cwd(), deps = {}) {
442
452
  try {
443
453
  const { file, args } = browserOpener();
454
+ const env = githubWorkerEnvironment(projectRoot, deps);
444
455
  // windowsHide keeps this detached opener from flashing a console window on
445
456
  // Windows (Node defaults windowsHide to false); background/detached spawns
446
457
  // must stay silent (issue 931e7924).
447
- const child = spawn(file, [...args, url], { detached: true, stdio: 'ignore', windowsHide: true });
458
+ const child = (deps.spawn || spawn)(file, [...args, url], {
459
+ detached: true, stdio: 'ignore', windowsHide: true, ...(env ? { env } : {}),
460
+ });
448
461
  // spawn() failures (e.g. the opener binary is missing) surface ASYNChronously
449
462
  // as an 'error' event, NOT via the try/catch — an unhandled one would crash
450
463
  // `forge serve`. Opening a browser is best-effort; swallow it (URL is printed).
@@ -480,7 +493,7 @@ function announceServe(server, token, options, resolve) {
480
493
  `\nforge serve — interactive dashboard (loopback only)\n ${url}\n`
481
494
  + ' Token dies with this process. Press Ctrl-C to stop.\n\n',
482
495
  );
483
- if (options.open) openBrowser(url);
496
+ if (options.open) openBrowser(url, options.projectRoot, options.deps);
484
497
  installServeShutdown(server, resolve, options.onClose);
485
498
  }
486
499
 
@@ -535,7 +548,7 @@ function verifyJournalAtStartup(projectRoot, warn = (m) => process.stderr.write(
535
548
  return result;
536
549
  }
537
550
 
538
- async function handler(args, flags = {}, projectRoot = process.cwd(), _opts = {}) {
551
+ async function handler(args, flags = {}, projectRoot = process.cwd(), opts = {}) {
539
552
  const { port, open } = parseServeArgs(args, flags);
540
553
  const dashboardDir = path.join(projectRoot, 'web', 'dashboard');
541
554
  if (!fs.existsSync(path.join(dashboardDir, 'index.html'))) {
@@ -558,10 +571,12 @@ async function handler(args, flags = {}, projectRoot = process.cwd(), _opts = {}
558
571
  verifyJournalAtStartup(projectRoot);
559
572
 
560
573
  const token = mintToken();
561
- const ctx = buildContext(projectRoot, dashboardDir, token);
574
+ const ctx = buildContext(projectRoot, dashboardDir, token, opts);
562
575
  const server = createServeServer(ctx);
563
576
  return startListening(server, port, token, {
564
577
  open,
578
+ projectRoot,
579
+ deps: opts,
565
580
  onClose: () => security.releaseLock(projectRoot),
566
581
  });
567
582
  }
@@ -575,6 +590,8 @@ module.exports = {
575
590
  routeMutation,
576
591
  parseServeArgs,
577
592
  buildContext,
593
+ defaultGenerate,
594
+ openBrowser,
578
595
  createServeServer,
579
596
  verifyToken,
580
597
  isLoopbackRequest,