forge-workflow 0.1.0-beta.3 → 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 (196) 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 +21 -1
  5. package/bin/forge.js +16 -369
  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 +9 -4
  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 +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Plan Command - Beads Integration
2
+ * Plan Command - Kernel Issue Integration
3
3
  * Creates implementation plan after research is complete
4
4
  *
5
5
  * Security: Uses execFileSync instead of exec/execSync to prevent command injection
@@ -26,15 +26,14 @@ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
26
26
 
27
27
  /**
28
28
  * Human-readable label for the issue backend that created an issue.
29
- * Keeps printed output backend-accurate so kernel-created issues are not
30
- * mislabeled as "Beads".
29
+ * The kernel is the only backend; anything unresolved prints a neutral label
30
+ * rather than naming a store the issue did not come from.
31
31
  *
32
- * @param {string} [backend] - Resolved issue backend ('kernel' | 'beads').
33
- * @returns {string} Display label ('Kernel', 'Beads', or a neutral 'Issue').
32
+ * @param {string} [backend] - Resolved issue backend ('kernel').
33
+ * @returns {string} Display label ('Kernel' or a neutral 'Issue').
34
34
  * @private
35
35
  */
36
36
  function issueBackendLabel(backend) {
37
- if (backend === 'beads') return 'Beads';
38
37
  if (backend === 'kernel') return 'Kernel';
39
38
  return 'Issue';
40
39
  }
@@ -76,7 +75,7 @@ function validateFeatureSlug(slug) {
76
75
  }
77
76
 
78
77
  /**
79
- * Build the issue description shared by both issue backends (kernel and beads).
78
+ * Build the issue description used when creating the kernel tracking issue.
80
79
  * Strategic scope appends a design-doc pointer derived from a sanitized slug.
81
80
  *
82
81
  * @param {string} featureName
@@ -247,98 +246,12 @@ function detectScope(researchContent) {
247
246
  };
248
247
  }
249
248
 
250
- /**
251
- * Create Beads issue for the feature
252
- * Executes `bd create` command with appropriate description based on scope
253
- *
254
- * Security: Uses execFileSync (not exec) to prevent command injection
255
- *
256
- * @param {string} featureName - Feature name (human-readable)
257
- * @param {string} researchPath - Research document path (e.g., "docs/research/feature.md")
258
- * @param {'tactical'|'strategic'} scope - Scope type
259
- * @returns {{success: boolean, issueId?: string, description?: string, error?: string}} Beads creation result
260
- * @example
261
- * const result = createBeadsIssue('Payment Integration', 'docs/research/payment.md', 'strategic');
262
- * if (result.success) {
263
- * console.log('Created issue:', result.issueId);
264
- * }
265
- */
266
- function createBeadsIssue(featureName, researchPath, scope) {
267
- if (!featureName || !researchPath) {
268
- return {
269
- success: false,
270
- error: 'Feature name and research path are required',
271
- };
272
- }
273
-
274
- if (scope !== 'tactical' && scope !== 'strategic') {
275
- return {
276
- success: false,
277
- error: `Invalid scope '${scope}'. Must be 'tactical' or 'strategic'`,
278
- };
279
- }
280
-
281
- try {
282
- const built = buildFeatureIssueDescription(featureName, researchPath, scope);
283
- if (built.error) {
284
- return { success: false, error: built.error };
285
- }
286
- const description = built.description;
287
-
288
- // Execute bd create command using execFileSync for safety (OWASP A03)
289
- const result = execFileSync( // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
290
- 'bd', // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
291
- ['create', `--title=${featureName}`, `--description=${description}`, '--type=feature', '--priority=2'],
292
- getExecOptions()
293
- );
294
-
295
- // Extract issue ID from output (format: "Created issue: forge-xxx" or "forge-xxx.N" for dotted sub-IDs).
296
- // Character class is [a-z0-9] (not [a-zA-Z0-9]) because the /i flag makes A-Z redundant.
297
- const createPattern = /Created issue:\s*(forge-[a-z0-9]+(?:\.[a-z0-9]+)*)/i;
298
- const fallbackPattern = /(forge-[a-z0-9]+(?:\.[a-z0-9]+)*)/i;
299
- const match = createPattern.exec(result) || fallbackPattern.exec(result);
300
-
301
- if (!match) {
302
- return {
303
- success: false,
304
- error: 'Failed to extract issue ID from bd create output\n\nEnsure beads is installed: bunx beads init',
305
- };
306
- }
307
-
308
- return {
309
- success: true,
310
- issueId: match[1],
311
- description,
312
- };
313
- } catch (error) {
314
- // Check for timeout
315
- if (error.killed && error.signal === 'SIGTERM') {
316
- return {
317
- success: false,
318
- error: 'Beads command timed out after 2 minutes.',
319
- };
320
- }
321
-
322
- // Provide actionable error message
323
- const bdNotFound = error.message.includes('ENOENT') || error.message.includes('not found');
324
- const errorMsg = bdNotFound
325
- ? 'beads (bd) command not found. Install with: bunx beads init'
326
- : `Failed to create Beads issue: ${error.message}`;
327
-
328
- return {
329
- success: false,
330
- error: errorMsg,
331
- };
332
- }
333
- }
334
-
335
249
  /**
336
250
  * Create an issue via the Forge Kernel backend (bd-free).
337
251
  *
338
252
  * Mirrors `forge issue create` on the kernel: routes through runIssueOperation with
339
253
  * the kernel broker instead of shelling out to `bd create`. Used by `forge plan` when
340
- * the resolved issue backend is the kernel (the default), so planning needs no Beads
341
- * install. The beads path (createBeadsIssue) is preserved for backend=beads.
254
+ * the kernel is the only issue backend, so planning needs no bd binary installed.
342
255
  *
343
256
  * @param {string} featureName
344
257
  * @param {string} researchPath
@@ -413,7 +326,7 @@ async function linkExistingIssue(issueId, options = {}) {
413
326
  projectRoot,
414
327
  {
415
328
  issueBackend,
416
- useKernelBroker: issueBackend !== 'beads',
329
+ useKernelBroker: true,
417
330
  kernelBroker: options.kernelBroker,
418
331
  },
419
332
  );
@@ -584,9 +497,9 @@ async function registerBranchIssueLinkage(options, branch, issueId) {
584
497
  }
585
498
 
586
499
  /**
587
- * Resolve the tracking issue for a plan: LINK an explicit issue, else CREATE via
588
- * the active backend (the kernel default needs no Beads). Extracted to avoid a
589
- * nested ternary in executePlan.
500
+ * Resolve the tracking issue for a plan: LINK an explicit issue, else CREATE in
501
+ * the kernel (the only issue backend — planning needs no bd binary). Extracted to
502
+ * avoid a nested ternary in executePlan.
590
503
  *
591
504
  * @returns {Promise<{success: boolean, issueId?: string, error?: string}>}
592
505
  * @private
@@ -595,10 +508,7 @@ async function resolveTrackingIssue({ explicitIssueId, issueBackend, featureName
595
508
  if (explicitIssueId) {
596
509
  return linkExistingIssue(explicitIssueId, { ...options, issueBackend });
597
510
  }
598
- if (issueBackend === 'kernel') {
599
- return createKernelIssue(featureName, researchPath, scope, options);
600
- }
601
- return createBeadsIssue(featureName, researchPath, scope);
511
+ return createKernelIssue(featureName, researchPath, scope, options);
602
512
  }
603
513
 
604
514
  async function createKernelIssue(featureName, researchPath, scope, options = {}) {
@@ -952,14 +862,14 @@ function applyYAGNIFilter({ task, tasks, designDoc } = {}) {
952
862
  * Tactical workflow (quick fixes, <1 day):
953
863
  * 1. Read research document
954
864
  * 2. Detect scope (tactical)
955
- * 3. Create Beads issue
865
+ * 3. Create kernel issue
956
866
  * 4. Create feature branch
957
867
  * → Next: /dev command
958
868
  *
959
869
  * Strategic workflow (architecture changes, >1 day):
960
870
  * 1. Read research document
961
871
  * 2. Detect scope (strategic)
962
- * 3. Create Beads issue with design doc link
872
+ * 3. Create kernel issue with design doc link
963
873
  * 4. Create feature branch
964
874
  * → Next: Create design doc, then /dev command
965
875
  *
@@ -967,7 +877,7 @@ function applyYAGNIFilter({ task, tasks, designDoc } = {}) {
967
877
  * @returns {Promise<{
968
878
  * success: boolean,
969
879
  * scope?: 'tactical'|'strategic',
970
- * issueBackend?: 'kernel'|'beads',
880
+ * issueBackend?: 'kernel',
971
881
  * issueId?: string,
972
882
  * beadsIssueId?: string,
973
883
  * branchName?: string,
@@ -975,10 +885,10 @@ function applyYAGNIFilter({ task, tasks, designDoc } = {}) {
975
885
  * summary?: string,
976
886
  * nextCommand?: string,
977
887
  * error?: string
978
- * }>} Execution result. `issueId` is the created issue id under the active
979
- * backend; `issueBackend` names that backend. `beadsIssueId` is a deprecated
980
- * alias of `issueId` kept for backward compatibility (it does NOT imply the
981
- * issue came from Beads — on the kernel path it holds the kernel issue id).
888
+ * }>} Execution result. `issueId` is the created kernel issue id, and
889
+ * `issueBackend` names the backend ('kernel'). `beadsIssueId` is a deprecated
890
+ * alias of `issueId` kept for output-shape compatibility — it never implied a
891
+ * Beads store and now always holds the kernel issue id.
982
892
  * @example
983
893
  * const result = await executePlan('Payment Integration');
984
894
  * if (result.success) {
@@ -995,7 +905,7 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
995
905
  }
996
906
 
997
907
  // Resolve the active issue backend (explicit opts > env > .forge/config.yaml >
998
- // default 'kernel'). Kernel is bd-free, so planning works with no Beads install.
908
+ // default 'kernel'). The kernel is bd-free, so planning needs no bd binary.
999
909
  const issueBackend = resolveIssueBackend({
1000
910
  deps: options,
1001
911
  env: options.env || process.env,
@@ -1031,8 +941,7 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
1031
941
 
1032
942
  // Step 3: Resolve the tracking issue. `--issue <id>` LINKS an existing issue
1033
943
  // (claim-first flow) instead of creating a duplicate (B4). Otherwise create
1034
- // via the active backend — kernel (default) routes through the kernel broker
1035
- // (no bd); beads preserves the bd path.
944
+ // through the kernel broker (no bd binary involved).
1036
945
  const explicitIssueId = options.issue || options.issueId || null;
1037
946
 
1038
947
  // F4c: never link issue B onto a branch already bound to issue A.
@@ -1080,8 +989,8 @@ async function executePlan(featureName, options = {}) { // NOSONAR S3776
1080
989
  scope: scope.type,
1081
990
  issueBackend,
1082
991
  issueId: issue.issueId,
1083
- // Deprecated alias of issueId, retained for backward compatibility. It does
1084
- // NOT imply the Beads backend — on the kernel path it holds the kernel id.
992
+ // Deprecated alias of issueId, retained for output-shape compatibility.
993
+ // It never implied a Beads store and now always holds the kernel id.
1085
994
  beadsIssueId: issue.issueId,
1086
995
  branchName: branch.branchName,
1087
996
  linked: Boolean(explicitIssueId),
@@ -1151,7 +1060,6 @@ module.exports = {
1151
1060
  },
1152
1061
  readResearchDoc,
1153
1062
  detectScope,
1154
- createBeadsIssue,
1155
1063
  createKernelIssue,
1156
1064
  createFeatureBranch,
1157
1065
  extractDesignDecisions,
@@ -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
 
@@ -2,12 +2,32 @@
2
2
 
3
3
  const {
4
4
  buildPrime,
5
+ collectPrimeLiveState,
5
6
  runOrientationCommand,
6
7
  } = require('../orientation');
7
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
+
8
22
  module.exports = {
9
23
  name: 'prime',
10
- description: 'Emit session-entry bounded orientation for agents',
11
- usage: 'Usage: forge prime [--budget N] [--json]',
12
- handler: (args, _flags, projectRoot) => runOrientationCommand(buildPrime, args, projectRoot),
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);
31
+ },
32
+ renderBriefing,
13
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,13 +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
- // issue dispatch (resolve backend → runIssueOperation('release') → normalize). The
16
- // Beads backend has no release op and returns the Kernel-only contract error.
17
- const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit';
16
+ // issue dispatch (resolve backend → runIssueOperation('release') → normalize).
17
+ const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit | forge release generate-npm-workflow';
18
18
 
19
19
  async function runReleaseIssue(args, projectRoot, opts = {}) {
20
20
  const resolved = withResolvedIssueBackend(projectRoot, opts);
@@ -56,7 +56,21 @@ function parseReleaseArgs(args = []) {
56
56
  }
57
57
 
58
58
  async function handler(args, _flags, projectRoot, opts = {}) {
59
- 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
+ }
60
74
 
61
75
  if (parsed.subcommand === 'regen-audit') {
62
76
  // forge release regen-audit — rewrite the D20 kill-list from a live re-scan.
@@ -240,7 +240,7 @@ async function routeMutation(verb, args, projectRoot, deps = {}) {
240
240
  function defaultGenerate(projectRoot, dashboardDir) {
241
241
  return new Promise((resolve, reject) => {
242
242
  const script = path.join(dashboardDir, 'generate-snapshot.mjs');
243
- const child = spawn(process.execPath, [script], { cwd: projectRoot, stdio: 'ignore' });
243
+ const child = spawn(process.execPath, [script], { cwd: projectRoot, stdio: 'ignore', windowsHide: true });
244
244
  const timer = setTimeout(() => { child.kill(); reject(new Error('snapshot generation timed out')); }, GEN_TIMEOUT_MS);
245
245
  child.on('error', (err) => { clearTimeout(timer); reject(err); });
246
246
  child.on('exit', (code) => {
@@ -441,7 +441,10 @@ function browserOpener() {
441
441
  function openBrowser(url) {
442
442
  try {
443
443
  const { file, args } = browserOpener();
444
- const child = spawn(file, [...args, url], { detached: true, stdio: 'ignore' });
444
+ // windowsHide keeps this detached opener from flashing a console window on
445
+ // Windows (Node defaults windowsHide to false); background/detached spawns
446
+ // must stay silent (issue 931e7924).
447
+ const child = spawn(file, [...args, url], { detached: true, stdio: 'ignore', windowsHide: true });
445
448
  // spawn() failures (e.g. the opener binary is missing) surface ASYNChronously
446
449
  // as an 'error' event, NOT via the try/catch — an unhandled one would crash
447
450
  // `forge serve`. Opening a browser is best-effort; swallow it (URL is printed).