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

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 (122) hide show
  1. package/AGENTS.md +1 -1
  2. package/bin/forge-cmd.js +1 -1
  3. package/bin/forge.js +5 -0
  4. package/docs/reference/COMMANDS.md +3 -1
  5. package/docs/reference/shepherd.md +72 -1
  6. package/lib/adapters/greptile-review-adapter.js +1 -1
  7. package/lib/adapters/pr-state-adapter.js +103 -8
  8. package/lib/agents-config.js +5 -0
  9. package/lib/commands/_issue.js +31 -46
  10. package/lib/commands/_manifest.js +1 -1
  11. package/lib/commands/_resolve-command-opts.js +36 -29
  12. package/lib/commands/claim.js +2 -4
  13. package/lib/commands/hooks.js +155 -8
  14. package/lib/commands/plan.js +23 -115
  15. package/lib/commands/prime.js +8 -1
  16. package/lib/commands/release.js +1 -2
  17. package/lib/commands/serve.js +5 -2
  18. package/lib/commands/setup.js +0 -1
  19. package/lib/commands/shepherd.js +36 -3
  20. package/lib/commands/skill.js +275 -0
  21. package/lib/commands/status.js +37 -32
  22. package/lib/commands/test.js +32 -0
  23. package/lib/commands/worktree.js +27 -1
  24. package/lib/core/runtime-graph.js +88 -14
  25. package/lib/forge-issues.js +13 -464
  26. package/lib/harness-capability-matrix.js +2 -2
  27. package/lib/hook-renderer.js +54 -12
  28. package/lib/issue-backend.js +42 -3
  29. package/lib/kernel/broker.js +28 -0
  30. package/lib/kernel/migrations.js +30 -2
  31. package/lib/kernel/schema.js +35 -0
  32. package/lib/kernel/sqlite-driver.js +150 -0
  33. package/lib/memory-recall.js +151 -0
  34. package/lib/orientation.js +303 -6
  35. package/lib/pr-bundle.js +6 -2
  36. package/lib/pr-monitor/reconcile-executor.js +659 -0
  37. package/lib/pr-monitor/reconcile-tick.js +138 -0
  38. package/lib/pr-monitor/reconcile.js +0 -0
  39. package/lib/pr-monitor/render-sticky.js +14 -0
  40. package/lib/pr-monitor/shepherd-lease.js +243 -0
  41. package/lib/pr-monitor/upsert-sticky.js +1 -1
  42. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  43. package/lib/pr-pull.js +70 -15
  44. package/lib/project-memory.js +8 -0
  45. package/lib/rules-sync.js +4 -0
  46. package/lib/runtime-health.js +15 -46
  47. package/lib/skill-eval.js +750 -0
  48. package/lib/status/identity.js +46 -0
  49. package/lib/status/presenter.js +0 -35
  50. package/lib/status/snapshot.js +11 -16
  51. package/lib/upgrade-safety.js +8 -9
  52. package/lib/using-forge.js +315 -0
  53. package/lib/workflow/enforce-stage.js +5 -5
  54. package/lib/workflow/state-manager.js +23 -23
  55. package/package.json +1 -1
  56. package/rules/using-forge.md +24 -0
  57. package/scripts/forge-team/index.sh +0 -5
  58. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  59. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  60. package/scripts/test.js +8 -1
  61. package/skills/claim-safety/SKILL.md +4 -0
  62. package/skills/claim-safety/evals/scorecard.json +41 -0
  63. package/skills/coverage.json +83 -0
  64. package/skills/dev/SKILL.md +4 -0
  65. package/skills/dev/evals/scorecard.json +41 -0
  66. package/skills/gates/SKILL.md +80 -0
  67. package/skills/gates/evals/evals.json +38 -0
  68. package/skills/gates/evals/scorecard.json +41 -0
  69. package/skills/hermes-forge/SKILL.md +1 -0
  70. package/skills/hermes-forge/evals/scorecard.json +41 -0
  71. package/skills/issue-basics/SKILL.md +1 -0
  72. package/skills/issue-basics/evals/scorecard.json +41 -0
  73. package/skills/kernel/SKILL.md +38 -0
  74. package/skills/kernel/evals/scorecard.json +41 -0
  75. package/skills/memory/SKILL.md +16 -1
  76. package/skills/memory/evals/scorecard.json +41 -0
  77. package/skills/parallel-deep-research/SKILL.md +1 -0
  78. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  79. package/skills/plan/SKILL.md +6 -0
  80. package/skills/plan/evals/scorecard.json +41 -0
  81. package/skills/portability/SKILL.md +47 -0
  82. package/skills/portability/evals/evals.json +34 -0
  83. package/skills/portability/evals/scorecard.json +41 -0
  84. package/skills/research/SKILL.md +1 -0
  85. package/skills/research/evals/scorecard.json +41 -0
  86. package/skills/review/SKILL.md +6 -0
  87. package/skills/review/evals/scorecard.json +41 -0
  88. package/skills/rollback/SKILL.md +1 -0
  89. package/skills/rollback/evals/scorecard.json +41 -0
  90. package/skills/setup/SKILL.md +91 -0
  91. package/skills/setup/evals/evals.json +42 -0
  92. package/skills/setup/evals/scorecard.json +41 -0
  93. package/skills/shepherd/SKILL.md +76 -36
  94. package/skills/shepherd/evals/evals.json +21 -9
  95. package/skills/shepherd/evals/scorecard.json +41 -0
  96. package/skills/ship/SKILL.md +6 -0
  97. package/skills/ship/evals/scorecard.json +41 -0
  98. package/skills/smith/SKILL.md +8 -0
  99. package/skills/smith/evals/scorecard.json +41 -0
  100. package/skills/sonarcloud/SKILL.md +1 -0
  101. package/skills/sonarcloud/evals/scorecard.json +41 -0
  102. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  103. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  104. package/skills/status/SKILL.md +3 -0
  105. package/skills/status/evals/scorecard.json +41 -0
  106. package/skills/triage-ready/SKILL.md +2 -0
  107. package/skills/triage-ready/evals/scorecard.json +41 -0
  108. package/skills/using-forge/SKILL.md +104 -0
  109. package/skills/using-forge/evals/scorecard.json +41 -0
  110. package/skills/validate/SKILL.md +4 -0
  111. package/skills/validate/evals/scorecard.json +41 -0
  112. package/skills/verify/SKILL.md +4 -0
  113. package/skills/verify/evals/scorecard.json +41 -0
  114. package/skills/worktree/SKILL.md +87 -0
  115. package/skills/worktree/evals/evals.json +38 -0
  116. package/skills/worktree/evals/scorecard.json +41 -0
  117. package/lib/adapters/beads-issue-adapter.js +0 -127
  118. package/lib/beads-nudge.js +0 -91
  119. package/lib/commands/board.js +0 -64
  120. package/lib/status/beads-snapshot.js +0 -145
  121. package/scripts/forge-team/lib/dashboard.sh +0 -316
  122. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
@@ -5,15 +5,17 @@
5
5
  * handler (the 4th arg). For issue/alias commands this:
6
6
  *
7
7
  * 1. Strips the selector tokens (`--kernel`, `--issue-backend <val>`) from the
8
- * args BEFORE they reach the handler / bd. This is load-bearing: bin
9
- * parseFlags() early-returns for issue passthrough commands, so these tokens
10
- * otherwise flow untouched into the handler and (beads path) into bd, which
11
- * rejects the unknown flag — and the positional `kernel`/`beads` value would
12
- * be mistaken for an issue id.
8
+ * args BEFORE they reach the handler. This is load-bearing: bin parseFlags()
9
+ * early-returns for issue passthrough commands, so these tokens otherwise flow
10
+ * untouched into the handler and the positional `kernel` value would be
11
+ * mistaken for an issue id.
13
12
  * 2. Resolves the backend (flag > env > config > default) via the backend
14
13
  * authority module (lib/issue-backend.js). The CLI flag tokens are mapped to
15
14
  * that module's `deps.issueBackend` signal; env/config/default precedence is
16
- * applied by the resolver itself.
15
+ * applied by the resolver itself. `kernel` is the only accepted value — a
16
+ * retired backend named on the FLAG is a hard error (the user typed it in this
17
+ * invocation, so silently ignoring it would run the wrong store), while the
18
+ * same value in env/config only warns and falls back.
17
19
  * 3. When kernel, assembles the kernel deps (driver + flag) via the CLI broker
18
20
  * factory so createKernelIssueBackend has a real driver (B1).
19
21
  *
@@ -24,7 +26,7 @@
24
26
  * @module commands/resolve-command-opts
25
27
  */
26
28
 
27
- const { resolveIssueBackend } = require('../issue-backend');
29
+ const { resolveIssueBackend, removedBackendHint } = require('../issue-backend');
28
30
  const {
29
31
  buildMigratedKernelIssueDeps: defaultBuildKernelIssueDeps,
30
32
  } = require('../kernel/cli-broker-factory');
@@ -45,14 +47,11 @@ const ISSUE_COMMANDS = new Set([
45
47
  'stale',
46
48
  'orphans',
47
49
  'lint',
48
- // Epic grouping read — must be kernel-routed (its issue.children rollup has no
49
- // verified Beads equivalent), so it belongs in ISSUE_COMMANDS like the rest.
50
+ // Epic grouping read (issue.children rollup).
50
51
  'children',
51
- // Lease-ownership verification — a kernel-only read (leases live in the Kernel),
52
- // so it is kernel-routed like the rest of the issue surface.
52
+ // Lease-ownership verification (leases live in the Kernel).
53
53
  'owns',
54
- // Active-lease listing — a kernel-only read (leases live in the Kernel), so it
55
- // is kernel-routed like the rest of the issue surface.
54
+ // Active-lease listing (leases live in the Kernel).
56
55
  'claims',
57
56
  'search',
58
57
  'stats',
@@ -101,7 +100,7 @@ function stripSelectorTokens(rawArgs = []) {
101
100
  flags.issueBackend = next;
102
101
  i += 1;
103
102
  } else {
104
- throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel or beads.`);
103
+ throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel.`);
105
104
  }
106
105
  continue;
107
106
  }
@@ -109,7 +108,7 @@ function stripSelectorTokens(rawArgs = []) {
109
108
  // Equals form: reject an empty value (`--issue-backend=`) for the same reason.
110
109
  const value = token.slice(ISSUE_BACKEND_FLAG.length + 1).trim();
111
110
  if (!value) {
112
- throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel or beads.`);
111
+ throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel.`);
113
112
  }
114
113
  flags.issueBackend = value;
115
114
  continue;
@@ -122,12 +121,14 @@ function stripSelectorTokens(rawArgs = []) {
122
121
  /**
123
122
  * Reduce the stripped selector flags to a single explicit backend value (or null
124
123
  * when no flag selects one), honoring `--kernel` / `--issue-backend` equivalence
125
- * and rejecting a mutually-exclusive conflict. Returned values are NOT validated
126
- * here the backend authority module normalizes/warns on unknown values.
124
+ * and rejecting a mutually-exclusive conflict. A RETIRED backend value is rejected
125
+ * here with the migrate pointer; any other value is NOT validated here — the backend
126
+ * authority module normalizes/warns on unknown values.
127
127
  *
128
128
  * @param {{ kernel?: boolean, issueBackend?: string }} flags
129
129
  * @returns {string|null}
130
- * @throws {Error} when --kernel and --issue-backend select different backends.
130
+ * @throws {Error} when --kernel and --issue-backend select different backends, or
131
+ * when --issue-backend names a removed backend.
131
132
  */
132
133
  function resolveFlagBackend(flags = {}) {
133
134
  const fromBackend = typeof flags.issueBackend === 'string' && flags.issueBackend.trim()
@@ -142,6 +143,19 @@ function resolveFlagBackend(flags = {}) {
142
143
  );
143
144
  }
144
145
 
146
+ // Hard error (not the resolver's warn+fallback) because the value was typed into
147
+ // THIS invocation: silently running the kernel after the user explicitly asked for
148
+ // a different store is the kind of surprise that loses writes.
149
+ if (fromBackend) {
150
+ const removedHint = removedBackendHint(fromBackend);
151
+ if (removedHint) {
152
+ throw new Error(
153
+ `${ISSUE_BACKEND_FLAG} ${fromBackend} is no longer supported: ${removedHint}. `
154
+ + `Only '${KERNEL}' is accepted.`,
155
+ );
156
+ }
157
+ }
158
+
145
159
  return fromKernel || fromBackend || null;
146
160
  }
147
161
 
@@ -188,22 +202,15 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
188
202
 
189
203
  const { args, flags } = stripSelectorTokens(rawArgs);
190
204
  const flagBackend = resolveFlagBackend(flags);
191
- const issueBackend = resolveIssueBackend({
205
+ // Still routed through the authority module (rather than hardcoding 'kernel') so a
206
+ // retired/unknown env or config value produces its warning exactly once, here, on
207
+ // the way to the kernel fallback.
208
+ resolveIssueBackend({
192
209
  deps: flagBackend ? { issueBackend: flagBackend } : {},
193
210
  env,
194
211
  projectRoot: deps.projectRoot,
195
212
  });
196
213
 
197
- if (issueBackend !== KERNEL) {
198
- return {
199
- commandOpts: {
200
- issueBackend,
201
- useKernelBroker: false,
202
- },
203
- args,
204
- };
205
- }
206
-
207
214
  // Kernel backend: assemble the driver (B1) + migrated broker (B2). The factory
208
215
  // builder constructs the driver, builds the broker, and runs initialize().
209
216
  const kernelDeps = await buildKernelIssueDeps({
@@ -3,10 +3,8 @@
3
3
  const { runIssueSubcommand } = require('./_issue');
4
4
 
5
5
  // `forge claim <id>` claims an issue through the SHARED issue dispatch
6
- // (runIssueSubcommand): backend resolution (Kernel via --kernel /
7
- // --issue-backend kernel / FORGE_ISSUE_BACKEND=kernel, Beads otherwise; the
8
- // Beads backend translates claim to `update <id> --claim`), contract
9
- // normalization, AND the check-after-write verification loop
6
+ // (runIssueSubcommand): backend resolution (the Kernel is the only backend),
7
+ // contract normalization, AND the check-after-write verification loop
10
8
  // (gate.issue_verify). This command previously inlined its own copy of the
11
9
  // dispatch, which silently BYPASSED the boundary verify — the exact surface
12
10
  // where the d71a824b phantom-claim replay lied to a losing agent — so it now
@@ -27,16 +27,81 @@ const {
27
27
  renderGlobalHookBlock,
28
28
  installGlobalHooks,
29
29
  } = require('../hook-global-installer');
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
30
32
  const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability } = require('../hook-renderer');
31
33
  const { collectDigestData, buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
32
34
  const { collectInbox, buildInboxNudge } = require('../inbox');
33
35
  const { collectDigest } = require('../pr-monitor/digest');
36
+ const { loadDispatchText } = require('../using-forge');
37
+ const projectMemory = require('../project-memory');
38
+ const { parseHookInput, selectInjection, DEFAULT_TOKEN_BUDGET, DEFAULT_SCORE_FLOOR } = require('../memory-recall');
39
+ const { fenceUntrusted } = require('../untrusted-content');
40
+ const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
41
+
42
+ // Default-ON rail; `forge gate disable rail.memory_recall` turns tier-2 off. Mirrors
43
+ // autoShepherdRailEnabled (lib/commands/ship.js): absent id = enabled, fail-open to true.
44
+ const MEMORY_RECALL_RAIL = 'rail.memory_recall';
45
+ // bm25 candidate pool to rank/floor down from. Wider than what we inject so the floor +
46
+ // dedupe have room (mirrors the research "widen before ranking" guidance).
47
+ const MEMORY_RECALL_CANDIDATES = 25;
48
+ // Cross-turn dedupe memory: how many recently-injected keys to remember per session.
49
+ const SEEN_KEYS_CAP = 40;
50
+
51
+ function memoryRecallRailEnabled(projectRoot, resolveGraph = getResolvedRuntimeGraph) {
52
+ try {
53
+ const graph = resolveGraph({ projectRoot });
54
+ const rail = [...(graph.rails || []), ...(graph.gates || [])].find(entry => entry.id === MEMORY_RECALL_RAIL);
55
+ return !(rail?.enabled === false);
56
+ } catch {
57
+ return true;
58
+ }
59
+ }
60
+
61
+ // Read the hook's stdin payload (Claude delivers UserPromptSubmit JSON on fd 0). Never
62
+ // throws — no stdin / a closed fd yields '' so the hook fails open.
63
+ function readHookStdin() {
64
+ try {
65
+ return fs.readFileSync(0, 'utf8');
66
+ } catch {
67
+ return '';
68
+ }
69
+ }
70
+
71
+ function seenPath(projectRoot, sessionId) {
72
+ const safe = String(sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_');
73
+ return path.join(projectRoot, '.forge', 'memory-recall', `${safe}.json`);
74
+ }
75
+
76
+ // Keys injected on recent turns of THIS session (cross-turn dedupe). Best-effort: any read
77
+ // error yields [] so a first turn or a corrupt file simply injects without exclusion.
78
+ function loadSeenKeys(projectRoot, sessionId) {
79
+ try {
80
+ const parsed = JSON.parse(fs.readFileSync(seenPath(projectRoot, sessionId), 'utf8'));
81
+ return Array.isArray(parsed) ? parsed.filter(k => typeof k === 'string') : [];
82
+ } catch {
83
+ return [];
84
+ }
85
+ }
86
+
87
+ // Append the just-injected keys to the session's seen-list (newest last, capped). Best-effort.
88
+ function saveSeenKeys(projectRoot, sessionId, keys) {
89
+ try {
90
+ const merged = [...loadSeenKeys(projectRoot, sessionId), ...keys].slice(-SEEN_KEYS_CAP);
91
+ const file = seenPath(projectRoot, sessionId);
92
+ fs.mkdirSync(path.dirname(file), { recursive: true });
93
+ fs.writeFileSync(file, JSON.stringify(merged), 'utf8');
94
+ } catch {
95
+ // Dedupe is a nicety, not a correctness gate — a failed write never breaks the prompt.
96
+ }
97
+ }
34
98
 
35
99
  function usage() {
36
100
  return 'Usage: forge hooks install --global [--harness codex|hermes|all] [--dry-run]\n'
37
101
  + ' forge hooks session-start --harness <claude> (machine-facing; emits SessionStart context)\n'
38
102
  + ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)\n'
39
103
  + ' forge hooks shepherd-events --harness <claude> (machine-facing; emits UserPromptSubmit PR-monitor deltas)\n'
104
+ + ' forge hooks memory-recall --harness <claude> (machine-facing; emits UserPromptSubmit query-relevant memory)\n'
40
105
  + ' forge hooks capture --harness <claude> --trigger <precompact|stop> (machine-facing; captures a session summary on exit)';
41
106
  }
42
107
 
@@ -115,17 +180,35 @@ function formatSessionStart(harness, text) {
115
180
  * @returns {Promise<{ success: boolean, output: string }>}
116
181
  */
117
182
  async function handleSessionStart(rest, projectRoot, opts = {}) {
183
+ const harness = parseHarness(rest);
184
+ if (!sessionStartCapability(harness).rendered) return { success: true, output: '' };
185
+
186
+ // NOTE: the autonomous-shepherd session-start trigger is deferred to W-S4c along with the
187
+ // per-command dispatch trigger — both need a firing-policy + containment design (a naive
188
+ // trigger spawns a session-outliving daemon that breaks test isolation).
189
+
190
+ // The using-forge dispatch bootstrap is injected FIRST so Forge skills auto-trigger from turn
191
+ // one (the Superpowers mechanism): a reasoning-driven system, not just harness description
192
+ // matching. It is a deterministic file read that survives a kernel outage. The memory digest
193
+ // (remembered notes + top open issues) is appended when present. Either alone is enough to
194
+ // inject; a total blank yields '' (the harness injects nothing). FAIL-OPEN throughout.
195
+ let dispatch = '';
196
+ try {
197
+ // No projectRoot: the dispatch skill is read from the Forge PACKAGE's canonical skills/
198
+ // (a set-up consumer project has no root skills/, only generated mirrors).
199
+ dispatch = (opts.loadDispatchText || loadDispatchText)() || '';
200
+ } catch { /* keep '' — a missing dispatch skill must not break session start */ }
201
+
202
+ let digestText = '';
118
203
  try {
119
- const harness = parseHarness(rest);
120
- if (!sessionStartCapability(harness).rendered) return { success: true, output: '' };
121
204
  const data = await collectDigestData(projectRoot, opts);
122
205
  const digest = buildMemoryDigest(data, opts);
123
- if (digest.empty) return { success: true, output: '' };
124
- return { success: true, output: formatSessionStart(harness, digest.text) };
125
- } catch {
126
- // Fail-open: a context hook must never break a session.
127
- return { success: true, output: '' };
128
- }
206
+ if (!digest.empty) digestText = digest.text;
207
+ } catch { /* fail-open: a kernel outage must not suppress the dispatch bootstrap */ }
208
+
209
+ const combined = [dispatch, digestText].filter(Boolean).join('\n\n');
210
+ if (!combined) return { success: true, output: '' };
211
+ return { success: true, output: formatSessionStart(harness, combined) };
129
212
  }
130
213
 
131
214
  /** Wrap a digest into a harness-native UserPromptSubmit payload, or '' when unsupported. */
@@ -205,6 +288,69 @@ function handleShepherdEvents(rest, projectRoot, opts = {}) {
205
288
  }
206
289
  }
207
290
 
291
+ /**
292
+ * `forge hooks memory-recall --harness <h>` — the QUERY-RELEVANT memory tier (tier-2). On each
293
+ * prompt it reads the submitted prompt from the hook's stdin, ranks stored memories by BM25
294
+ * relevance to it (NOT recency — that is the SessionStart digest's job), applies a relevance
295
+ * floor + anaphora guard + cross-turn dedupe + a hard token budget, and emits the survivors as
296
+ * harness-native UserPromptSubmit context. Complements the always-on recency digest: this one
297
+ * answers "what memory is relevant to THIS turn".
298
+ *
299
+ * Compliance + safety (verified against the Claude Code hooks contract, kernel issue 781f6f65):
300
+ * - It READS its own hook stdin (the supported input channel) — it does NOT inject into a
301
+ * running session's stdin and never drives the agent (Anthropic Usage Policy).
302
+ * - additionalContext APPENDS to history every prompt, so the injection is tiny and gated:
303
+ * below the relevance floor, or on a low-signal (anaphora) prompt, it injects NOTHING.
304
+ * - Injected memory bodies are UNTRUSTED (a planted note could carry directives), so each is
305
+ * provenance-fenced.
306
+ * - UserPromptSubmit has a 30s timeout and blocks the prompt; the read is local BM25 over
307
+ * SQLite (sub-ms) and FAIL-OPEN — any error, a disabled rail, an unsupported harness, or
308
+ * nothing relevant yields '' and the prompt proceeds untouched. NEVER throws.
309
+ * - Kill-switch: `forge gate disable rail.memory_recall`.
310
+ *
311
+ * @param {string[]} rest - args after the `memory-recall` action.
312
+ * @param {string} projectRoot
313
+ * @param {object} [opts] - injectable seams for tests: { railEnabled, readInput, search,
314
+ * loadSeen, saveSeen, scoreFloor, tokenBudget }.
315
+ * @returns {{ success: boolean, output: string }}
316
+ */
317
+ function handleMemoryRecall(rest, projectRoot, opts = {}) {
318
+ try {
319
+ const harness = parseHarness(rest);
320
+ if (!userPromptSubmitCapability(harness).rendered) return { success: true, output: '' };
321
+
322
+ const railEnabled = opts.railEnabled || memoryRecallRailEnabled;
323
+ if (!railEnabled(projectRoot)) return { success: true, output: '' };
324
+
325
+ const readInput = opts.readInput || readHookStdin;
326
+ const { prompt, sessionId } = parseHookInput(readInput());
327
+ if (!prompt) return { success: true, output: '' };
328
+
329
+ const search = opts.search
330
+ || ((root, query, limit) => projectMemory.searchRankedScored(root, query, limit));
331
+ const loadSeen = opts.loadSeen || loadSeenKeys;
332
+ const saveSeen = opts.saveSeen || saveSeenKeys;
333
+
334
+ const hits = search(projectRoot, prompt, MEMORY_RECALL_CANDIDATES) || [];
335
+ const excludeKeys = loadSeen(projectRoot, sessionId) || [];
336
+ const { lines, injectedKeys } = selectInjection({
337
+ query: prompt,
338
+ hits,
339
+ scoreFloor: typeof opts.scoreFloor === 'number' ? opts.scoreFloor : DEFAULT_SCORE_FLOOR,
340
+ tokenBudget: opts.tokenBudget || DEFAULT_TOKEN_BUDGET,
341
+ excludeKeys,
342
+ });
343
+ if (!lines.length) return { success: true, output: '' };
344
+
345
+ saveSeen(projectRoot, sessionId, injectedKeys);
346
+ const fenced = lines.map(line => fenceUntrusted(line, { source: 'memory' })).join('\n');
347
+ return { success: true, output: formatUserPromptSubmit(harness, fenced) };
348
+ } catch {
349
+ // Fail-open: a context hook must never break a prompt.
350
+ return { success: true, output: '' };
351
+ }
352
+ }
353
+
208
354
  /**
209
355
  * `forge hooks capture --harness <h> --trigger <precompact|stop>` — the CAPTURE-on-exit hook.
210
356
  * PreCompact (before context compaction) and Stop (turn end) fire it; it snapshots a bounded
@@ -356,6 +502,7 @@ async function handler(args, flags = {}, projectRoot, opts = {}) {
356
502
  if (action === 'session-start') return handleSessionStart(args.slice(1), projectRoot, opts);
357
503
  if (action === 'inbox-pickup') return handleInboxPickup(args.slice(1), projectRoot, opts);
358
504
  if (action === 'shepherd-events') return handleShepherdEvents(args.slice(1), projectRoot, opts);
505
+ if (action === 'memory-recall') return handleMemoryRecall(args.slice(1), projectRoot, opts);
359
506
  if (action === 'capture') return handleCapture(args.slice(1), projectRoot, opts);
360
507
  if (action === 'install') return handleInstall(args, flags, opts);
361
508
  return {
@@ -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,
@@ -2,6 +2,7 @@
2
2
 
3
3
  const {
4
4
  buildPrime,
5
+ collectPrimeLiveState,
5
6
  runOrientationCommand,
6
7
  } = require('../orientation');
7
8
 
@@ -9,5 +10,11 @@ module.exports = {
9
10
  name: 'prime',
10
11
  description: 'Emit session-entry bounded orientation for agents',
11
12
  usage: 'Usage: forge prime [--budget N] [--json]',
12
- handler: (args, _flags, projectRoot) => runOrientationCommand(buildPrime, args, projectRoot),
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 });
19
+ },
13
20
  };
@@ -12,8 +12,7 @@ const { normalizeArgs, normalizeIssueResult, withResolvedIssueBackend } = requir
12
12
  // `forge release <id>` releases a claimed issue; `forge release check` runs the
13
13
  // release-readiness gate. The two share the top-level verb, so this command
14
14
  // 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.
15
+ // issue dispatch (resolve backend → runIssueOperation('release') → normalize).
17
16
  const usage = 'Usage: forge release <id> | forge release check --target 0.1.0 [--json] | forge release regen-audit';
18
17
 
19
18
  async function runReleaseIssue(args, projectRoot, opts = {}) {
@@ -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).
@@ -188,7 +188,6 @@ const WORKFLOW_RUNTIME_ASSETS = Object.freeze([
188
188
  'scripts/forge-team/index.sh',
189
189
  'scripts/forge-team/lib/agent-prompt.sh',
190
190
  'scripts/forge-team/lib/claim.sh',
191
- 'scripts/forge-team/lib/dashboard.sh',
192
191
  'scripts/forge-team/lib/epic.sh',
193
192
  'scripts/forge-team/lib/hooks.sh',
194
193
  'scripts/forge-team/lib/identity.sh',