forge-workflow 0.1.0-beta.2 → 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 (155) hide show
  1. package/.forge/hooks/check-tdd.js +79 -5
  2. package/.forge/hooks/forge-native-hook.js +194 -8
  3. package/AGENTS.md +2 -1
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge-cmd.js +1 -1
  8. package/bin/forge.js +95 -19
  9. package/docs/guides/SETUP.md +4 -1
  10. package/docs/guides/SUPPORT.md +5 -0
  11. package/docs/reference/COMMANDS.md +12 -1
  12. package/docs/reference/shepherd.md +114 -3
  13. package/lib/activation/ensure-forge-home.js +135 -0
  14. package/lib/adapters/beads-kernel-compat.js +67 -0
  15. package/lib/adapters/greptile-review-adapter.js +1 -1
  16. package/lib/adapters/pr-state-adapter.js +103 -8
  17. package/lib/adoption-profiles.js +17 -4
  18. package/lib/agents-config.js +5 -0
  19. package/lib/beads-detect.js +60 -0
  20. package/lib/commands/_aliases.js +248 -0
  21. package/lib/commands/_issue.js +65 -41
  22. package/lib/commands/_manifest.js +3 -1
  23. package/lib/commands/_registry.js +14 -0
  24. package/lib/commands/_resolve-command-opts.js +36 -60
  25. package/lib/commands/claim.js +2 -4
  26. package/lib/commands/gate.js +19 -2
  27. package/lib/commands/hooks.js +294 -12
  28. package/lib/commands/init.js +26 -20
  29. package/lib/commands/memory.js +81 -0
  30. package/lib/commands/migrate.js +0 -161
  31. package/lib/commands/plan.js +71 -123
  32. package/lib/commands/pr.js +88 -0
  33. package/lib/commands/prime.js +8 -1
  34. package/lib/commands/push.js +66 -0
  35. package/lib/commands/recall.js +67 -12
  36. package/lib/commands/recap.js +18 -4
  37. package/lib/commands/release.js +15 -3
  38. package/lib/commands/remember.js +86 -20
  39. package/lib/commands/serve.js +5 -2
  40. package/lib/commands/setup.js +135 -73
  41. package/lib/commands/shepherd.js +102 -4
  42. package/lib/commands/ship.js +40 -4
  43. package/lib/commands/skill.js +275 -0
  44. package/lib/commands/status.js +37 -32
  45. package/lib/commands/test.js +32 -0
  46. package/lib/commands/worktree.js +87 -5
  47. package/lib/core/runtime-graph.js +122 -17
  48. package/lib/forge-issues.js +13 -464
  49. package/lib/gate-events.js +54 -55
  50. package/lib/global-flags.js +30 -0
  51. package/lib/grounding/context-events.js +230 -0
  52. package/lib/grounding/read-first.js +112 -0
  53. package/lib/harness-capability-matrix.js +2 -2
  54. package/lib/hook-renderer.js +144 -12
  55. package/lib/issue-backend.js +42 -3
  56. package/lib/kernel/backing-issue.js +7 -1
  57. package/lib/kernel/broker.js +28 -0
  58. package/lib/kernel/migrations.js +30 -2
  59. package/lib/kernel/owned-kernel.js +43 -0
  60. package/lib/kernel/schema.js +35 -0
  61. package/lib/kernel/sqlite-driver.js +187 -1
  62. package/lib/memory-recall.js +151 -0
  63. package/lib/orientation.js +303 -6
  64. package/lib/pr-bundle.js +6 -2
  65. package/lib/pr-monitor/auto-actions.js +175 -0
  66. package/lib/pr-monitor/digest.js +206 -0
  67. package/lib/pr-monitor/reconcile-executor.js +659 -0
  68. package/lib/pr-monitor/reconcile-tick.js +138 -0
  69. package/lib/pr-monitor/reconcile.js +0 -0
  70. package/lib/pr-monitor/render-sticky.js +57 -8
  71. package/lib/pr-monitor/shepherd-lease.js +243 -0
  72. package/lib/pr-monitor/upsert-sticky.js +169 -0
  73. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  74. package/lib/pr-pull.js +113 -17
  75. package/lib/project-memory.js +8 -0
  76. package/lib/release-readiness.js +17 -1
  77. package/lib/rules-sync.js +4 -0
  78. package/lib/runtime-health.js +15 -46
  79. package/lib/skill-eval.js +750 -0
  80. package/lib/status/identity.js +46 -0
  81. package/lib/status/presenter.js +0 -35
  82. package/lib/status/snapshot.js +11 -16
  83. package/lib/upgrade-safety.js +52 -1
  84. package/lib/using-forge.js +315 -0
  85. package/lib/workflow/enforce-stage.js +64 -7
  86. package/lib/workflow/state-manager.js +23 -23
  87. package/package.json +2 -2
  88. package/rules/using-forge.md +24 -0
  89. package/scripts/forge-team/index.sh +0 -5
  90. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  91. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  92. package/scripts/pr-auto-actions.js +93 -0
  93. package/scripts/pr-verdict-label.js +50 -0
  94. package/scripts/test.js +8 -1
  95. package/skills/claim-safety/SKILL.md +4 -0
  96. package/skills/claim-safety/evals/scorecard.json +41 -0
  97. package/skills/coverage.json +83 -0
  98. package/skills/dev/SKILL.md +4 -0
  99. package/skills/dev/evals/scorecard.json +41 -0
  100. package/skills/gates/SKILL.md +80 -0
  101. package/skills/gates/evals/evals.json +38 -0
  102. package/skills/gates/evals/scorecard.json +41 -0
  103. package/skills/hermes-forge/SKILL.md +1 -0
  104. package/skills/hermes-forge/evals/scorecard.json +41 -0
  105. package/skills/issue-basics/SKILL.md +1 -0
  106. package/skills/issue-basics/evals/scorecard.json +41 -0
  107. package/skills/kernel/SKILL.md +38 -0
  108. package/skills/kernel/evals/scorecard.json +41 -0
  109. package/skills/memory/SKILL.md +16 -1
  110. package/skills/memory/evals/scorecard.json +41 -0
  111. package/skills/parallel-deep-research/SKILL.md +1 -0
  112. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  113. package/skills/plan/SKILL.md +6 -0
  114. package/skills/plan/evals/scorecard.json +41 -0
  115. package/skills/portability/SKILL.md +47 -0
  116. package/skills/portability/evals/evals.json +34 -0
  117. package/skills/portability/evals/scorecard.json +41 -0
  118. package/skills/research/SKILL.md +1 -0
  119. package/skills/research/evals/scorecard.json +41 -0
  120. package/skills/review/SKILL.md +6 -0
  121. package/skills/review/evals/scorecard.json +41 -0
  122. package/skills/rollback/SKILL.md +1 -0
  123. package/skills/rollback/evals/scorecard.json +41 -0
  124. package/skills/setup/SKILL.md +91 -0
  125. package/skills/setup/evals/evals.json +42 -0
  126. package/skills/setup/evals/scorecard.json +41 -0
  127. package/skills/shepherd/SKILL.md +76 -36
  128. package/skills/shepherd/evals/evals.json +21 -9
  129. package/skills/shepherd/evals/scorecard.json +41 -0
  130. package/skills/ship/SKILL.md +6 -0
  131. package/skills/ship/evals/scorecard.json +41 -0
  132. package/skills/smith/SKILL.md +8 -0
  133. package/skills/smith/evals/scorecard.json +41 -0
  134. package/skills/sonarcloud/SKILL.md +1 -0
  135. package/skills/sonarcloud/evals/scorecard.json +41 -0
  136. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  137. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  138. package/skills/status/SKILL.md +3 -0
  139. package/skills/status/evals/scorecard.json +41 -0
  140. package/skills/triage-ready/SKILL.md +2 -0
  141. package/skills/triage-ready/evals/scorecard.json +41 -0
  142. package/skills/using-forge/SKILL.md +104 -0
  143. package/skills/using-forge/evals/scorecard.json +41 -0
  144. package/skills/validate/SKILL.md +4 -0
  145. package/skills/validate/evals/scorecard.json +41 -0
  146. package/skills/verify/SKILL.md +4 -0
  147. package/skills/verify/evals/scorecard.json +41 -0
  148. package/skills/worktree/SKILL.md +87 -0
  149. package/skills/worktree/evals/evals.json +38 -0
  150. package/skills/worktree/evals/scorecard.json +41 -0
  151. package/lib/adapters/beads-issue-adapter.js +0 -127
  152. package/lib/commands/board.js +0 -64
  153. package/lib/status/beads-snapshot.js +0 -145
  154. package/scripts/forge-team/lib/dashboard.sh +0 -316
  155. 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,28 +143,20 @@ function resolveFlagBackend(flags = {}) {
142
143
  );
143
144
  }
144
145
 
145
- return fromKernel || fromBackend || null;
146
- }
147
-
148
- // Run the first-use Beads import safety net for a kernel-routed command. Resolving the
149
- // migrate module lives INSIDE the try so even a require() failure (missing/corrupt
150
- // module) can never break command-opts resolution — the whole point of the safety net.
151
- // Shared by both kernel branches (issue + KERNEL_TOOL_COMMANDS) so the two call sites
152
- // cannot drift.
153
- async function runRuntimeAutoMigrate(deps, kernelDeps) {
154
- try {
155
- const autoMigrate = deps.autoMigrateBeadsAtRuntime
156
- || require('./migrate').autoMigrateBeadsAtRuntime;
157
- await autoMigrate({
158
- projectRoot: deps.projectRoot,
159
- databasePath: kernelDeps.kernelDatabasePath,
160
- broker: kernelDeps.kernelBroker,
161
- driver: kernelDeps.kernelDriver,
162
- });
163
- } catch {
164
- // A migration-hook failure (including a require() failure) must never break the
165
- // command it rides on.
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
+ }
166
157
  }
158
+
159
+ return fromKernel || fromBackend || null;
167
160
  }
168
161
 
169
162
  /**
@@ -195,9 +188,6 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
195
188
  databasePath: deps.databasePath,
196
189
  gitCommonDir: deps.gitCommonDir,
197
190
  });
198
- // Same first-use safety net as the issue branch: an export-first upgrader would
199
- // otherwise drain an EMPTY kernel and never trigger the one-time Beads import.
200
- await runRuntimeAutoMigrate(deps, kernelDeps);
201
191
  return { commandOpts: { _broker: kernelDeps.kernelBroker }, args: rawArgs };
202
192
  } catch {
203
193
  return { commandOpts: {}, args: rawArgs };
@@ -212,22 +202,15 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
212
202
 
213
203
  const { args, flags } = stripSelectorTokens(rawArgs);
214
204
  const flagBackend = resolveFlagBackend(flags);
215
- 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({
216
209
  deps: flagBackend ? { issueBackend: flagBackend } : {},
217
210
  env,
218
211
  projectRoot: deps.projectRoot,
219
212
  });
220
213
 
221
- if (issueBackend !== KERNEL) {
222
- return {
223
- commandOpts: {
224
- issueBackend,
225
- useKernelBroker: false,
226
- },
227
- args,
228
- };
229
- }
230
-
231
214
  // Kernel backend: assemble the driver (B1) + migrated broker (B2). The factory
232
215
  // builder constructs the driver, builds the broker, and runs initialize().
233
216
  const kernelDeps = await buildKernelIssueDeps({
@@ -236,13 +219,6 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
236
219
  gitCommonDir: deps.gitCommonDir,
237
220
  });
238
221
 
239
- // Safety net: the kernel is the default backend, but onboarding auto-migrate runs
240
- // only from `forge setup`/`init`. An existing repo whose user merely upgrades forge
241
- // would read an EMPTY kernel here and their existing Beads issues would appear to
242
- // vanish. Import them ONCE on first kernel use (idempotent, gated by an in-DB marker
243
- // that shares the kernel DB lifecycle, stderr only).
244
- await runRuntimeAutoMigrate(deps, kernelDeps);
245
-
246
222
  return {
247
223
  commandOpts: {
248
224
  issueBackend: KERNEL,
@@ -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
@@ -29,11 +29,21 @@ const {
29
29
  isGateApproved,
30
30
  } = require('../gate-events');
31
31
 
32
+ // The doc-update gate folds under this noun as `gate doc` (P2, kernel issue
33
+ // 6ab3f30c) — it is a gate concern, not a `pr` one. `doc` delegates to the
34
+ // standalone doc-gate command (same code); bare `forge doc-gate` stays registered
35
+ // as a back-compat alias. Required lazily so the module graph has no cycle and the
36
+ // routed handler is resolved at dispatch time.
37
+ const docGate = require('./doc-gate');
38
+
32
39
  const TOGGLE_ACTIONS = new Set(['enable', 'disable']);
33
40
  const EVENT_ACTIONS = new Set(['approve', 'reject', 'status', 'check']);
34
41
 
35
42
  function usage() {
36
- return 'Usage: forge gate <enable|disable|approve|reject|status|check> [<issue-id>] <gate-id> [--reason <text>] [--json]';
43
+ return [
44
+ 'Usage: forge gate <enable|disable|approve|reject|status|check> [<issue-id>] <gate-id> [--reason <text>] [--json]',
45
+ ' forge gate doc <detect|check|init|okf|...> [args] (doc-update gate; = forge doc-gate, run `forge doc-gate --help`)',
46
+ ].join('\n');
37
47
  }
38
48
 
39
49
  // The known-toggle set is gates PLUS unlocked toggleable rails (e.g.
@@ -165,6 +175,13 @@ async function handleCheck(issueId, gateId, projectRoot, opts) {
165
175
  async function handler(args, flags = {}, projectRoot = process.cwd(), opts = {}) {
166
176
  const [action, ...rest] = args;
167
177
 
178
+ // `gate doc [<doc-gate sub> ...]` → the standalone doc-gate handler, with the
179
+ // consumed `doc` token dropped so its own arg shape (detect/check/init/okf …)
180
+ // and flags (`--base`/`--head`/`--json`/`--skip` …) reach it byte-identically.
181
+ if (action === 'doc') {
182
+ return docGate.handler(rest, flags, projectRoot, opts);
183
+ }
184
+
168
185
  if (TOGGLE_ACTIONS.has(action)) {
169
186
  return handleToggle(action, rest[0], projectRoot);
170
187
  }
@@ -180,7 +197,7 @@ async function handler(args, flags = {}, projectRoot = process.cwd(), opts = {})
180
197
 
181
198
  return {
182
199
  success: false,
183
- error: `Expected 'enable', 'disable', 'approve', 'reject', 'status', or 'check'.\n${usage()}`,
200
+ error: `Expected 'enable', 'disable', 'approve', 'reject', 'status', 'check', or 'doc'.\n${usage()}`,
184
201
  };
185
202
  }
186
203
 
@@ -27,14 +27,82 @@ const {
27
27
  renderGlobalHookBlock,
28
28
  installGlobalHooks,
29
29
  } = require('../hook-global-installer');
30
- const { sessionStartCapability, userPromptSubmitCapability } = require('../hook-renderer');
31
- const { collectDigestData, buildMemoryDigest } = require('../memory-digest');
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+ const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability } = require('../hook-renderer');
33
+ const { collectDigestData, buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
32
34
  const { collectInbox, buildInboxNudge } = require('../inbox');
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
+ }
33
98
 
34
99
  function usage() {
35
100
  return 'Usage: forge hooks install --global [--harness codex|hermes|all] [--dry-run]\n'
36
101
  + ' forge hooks session-start --harness <claude> (machine-facing; emits SessionStart context)\n'
37
- + ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)';
102
+ + ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)\n'
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'
105
+ + ' forge hooks capture --harness <claude> --trigger <precompact|stop> (machine-facing; captures a session summary on exit)';
38
106
  }
39
107
 
40
108
  /** Parse `--harness <h>` (defaults to claude) from a session-start arg slice. */
@@ -46,6 +114,48 @@ function parseHarness(rest) {
46
114
  return 'claude';
47
115
  }
48
116
 
117
+ /** Parse `--trigger <t>` (defaults to stop) from a capture arg slice. */
118
+ function parseTrigger(rest) {
119
+ for (let i = 0; i < rest.length; i += 1) {
120
+ if (rest[i] === '--trigger') return rest[i + 1] || 'stop';
121
+ if (rest[i].startsWith('--trigger=')) return rest[i].slice('--trigger='.length);
122
+ }
123
+ return 'stop';
124
+ }
125
+
126
+ // Capture bounds — the snapshot is a small NUDGE, not a manual. A hard issue cap + per-title
127
+ // cap + overall body cap keep the note (which is re-injected at the NEXT session's SessionStart
128
+ // digest) token-bounded. Tags mark it a session-summary typed note AND a Forge auto-capture
129
+ // (the latter is the dedupe/idempotency key that stops per-turn Stop flooding).
130
+ const CAPTURE_ISSUE_CAP = 5;
131
+ const CAPTURE_TITLE_CAP = 80;
132
+ const CAPTURE_NOTE_CAP = 1000;
133
+ const CAPTURE_AUTO_TAG = 'forge:auto-capture';
134
+ const CAPTURE_TAGS = ['type:session-summary', CAPTURE_AUTO_TAG];
135
+
136
+ /**
137
+ * Build the deterministic, token-bounded capture note body. PURE. The body deliberately
138
+ * carries NO timestamp (the store stamps its own) so an unchanged session state yields a
139
+ * byte-identical body across repeated Stops — that identity is what the dedupe keys on.
140
+ * @param {string} trigger - 'precompact' | 'stop'
141
+ * @param {object[]} issues - in-progress issues (title/id defensively resolved)
142
+ * @returns {string}
143
+ */
144
+ function buildCaptureNote(trigger, issues) {
145
+ const capped = issues.slice(0, CAPTURE_ISSUE_CAP);
146
+ const lines = capped.map(issue => {
147
+ const title = String((issue && (issue.title || issue.id)) || 'untitled').replace(/\s+/g, ' ').trim();
148
+ return `- ${title.length > CAPTURE_TITLE_CAP ? `${title.slice(0, CAPTURE_TITLE_CAP)}…` : title}`;
149
+ });
150
+ const more = issues.length > capped.length ? `\n- …and ${issues.length - capped.length} more` : '';
151
+ const body = lines.length
152
+ ? `Session boundary (${trigger}) — in-progress:\n${lines.join('\n')}${more}`
153
+ : `Session boundary (${trigger}) — no in-progress issues.`;
154
+ // Reserve one char for the appended ellipsis so the FINAL note (incl. '…') is ≤ the cap,
155
+ // never CAPTURE_NOTE_CAP + 1.
156
+ return body.length > CAPTURE_NOTE_CAP ? `${body.slice(0, CAPTURE_NOTE_CAP - 1)}…` : body;
157
+ }
158
+
49
159
  /** Wrap a digest into a harness-native SessionStart payload, or '' when unsupported. */
50
160
  function formatSessionStart(harness, text) {
51
161
  if (harness === 'claude') {
@@ -70,17 +180,35 @@ function formatSessionStart(harness, text) {
70
180
  * @returns {Promise<{ success: boolean, output: string }>}
71
181
  */
72
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 = '';
73
203
  try {
74
- const harness = parseHarness(rest);
75
- if (!sessionStartCapability(harness).rendered) return { success: true, output: '' };
76
204
  const data = await collectDigestData(projectRoot, opts);
77
205
  const digest = buildMemoryDigest(data, opts);
78
- if (digest.empty) return { success: true, output: '' };
79
- return { success: true, output: formatSessionStart(harness, digest.text) };
80
- } catch {
81
- // Fail-open: a context hook must never break a session.
82
- return { success: true, output: '' };
83
- }
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) };
84
212
  }
85
213
 
86
214
  /** Wrap a digest into a harness-native UserPromptSubmit payload, or '' when unsupported. */
@@ -126,6 +254,157 @@ async function handleInboxPickup(rest, projectRoot, opts = {}) {
126
254
  }
127
255
  }
128
256
 
257
+ /**
258
+ * `forge hooks shepherd-events --harness <h>` — the PR-shepherd CONTEXT hook. On each
259
+ * prompt it emits harness-native UserPromptSubmit JSON carrying a COMPACT, capped digest
260
+ * of NEW PR-monitor events (verdict changes, failed checks, new threads, merged/closed)
261
+ * since the last read across all open-PR journals, then advances the per-PR consumer
262
+ * cursor so nothing re-surfaces. This is the CONSUMER side of the constant watcher: the
263
+ * watch loop writes the journal, this pushes the deltas to the working agent. It reads the
264
+ * user's OWN local journal via a supported hook — it NEVER injects into stdin and NEVER
265
+ * drives the agent (Anthropic Usage Policy). FAIL-OPEN: any failure, an unsupported
266
+ * harness, or no new events yields '' (the harness injects nothing). NEVER throws.
267
+ *
268
+ * @param {string[]} rest - args after the `shepherd-events` action.
269
+ * @param {string} projectRoot
270
+ * @param {object} [opts] - injectable digest collector ({ collectDigest }).
271
+ * @returns {{ success: boolean, output: string }}
272
+ */
273
+ function handleShepherdEvents(rest, projectRoot, opts = {}) {
274
+ try {
275
+ const harness = parseHarness(rest);
276
+ const capability = userPromptSubmitCapability(harness);
277
+ // Honest capability matrix: a non-Claude harness gets an explicit skip
278
+ // reason as surface-only result metadata (for callers/telemetry) but NEVER
279
+ // any injected output — `reason` must not drive the agent.
280
+ if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
281
+ const collect = opts.collectDigest || collectDigest;
282
+ const { text } = collect({ root: projectRoot });
283
+ if (!text) return { success: true, output: '' };
284
+ return { success: true, output: formatUserPromptSubmit(harness, text) };
285
+ } catch {
286
+ // Fail-open: a context hook must never break a prompt.
287
+ return { success: true, output: '' };
288
+ }
289
+ }
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
+
354
+ /**
355
+ * `forge hooks capture --harness <h> --trigger <precompact|stop>` — the CAPTURE-on-exit hook.
356
+ * PreCompact (before context compaction) and Stop (turn end) fire it; it snapshots a bounded
357
+ * session-summary note into the memory store BEFORE learnings are lost. This is the WRITE half
358
+ * of Forge memory (SessionStart only INJECTS). It PERSISTS to the store and emits NO stdout — a
359
+ * Stop hook that printed text would inject into the turn, and it never drives the agent
360
+ * (Anthropic Usage Policy). FAIL-OPEN: any failure, an unsupported harness, or nothing worth
361
+ * capturing yields '' and no write. NEVER throws.
362
+ *
363
+ * Flooding guard: a plain Stop with nothing in progress is skipped (Stop fires every turn), and
364
+ * a byte-identical repeat of the newest auto-capture note is skipped — so only meaningful,
365
+ * changed session state is written. PreCompact records a boundary even when nothing is in
366
+ * progress (unlike Stop), but it still goes through the same dedupe — a byte-identical
367
+ * PreCompact repeat is skipped too.
368
+ *
369
+ * @param {string[]} rest - args after the `capture` action.
370
+ * @param {string} projectRoot
371
+ * @param {object} [opts] - injectable { fetchIssues, fetchNotes, append } for tests.
372
+ * @returns {Promise<{ success: boolean, output: string }>}
373
+ */
374
+ async function handleCapture(rest, projectRoot, opts = {}) {
375
+ try {
376
+ const harness = parseHarness(rest);
377
+ if (!sessionEndCapability(harness).rendered) return { success: true, output: '' };
378
+ const trigger = parseTrigger(rest);
379
+
380
+ const fetchIssues = opts.fetchIssues || defaultFetchIssues;
381
+ const claimed = await fetchIssues(projectRoot, 'in_progress', opts);
382
+ const issues = Array.isArray(claimed) ? claimed : [];
383
+
384
+ // Stop fires every turn; a plain Stop with nothing in progress is not worth a note.
385
+ // PreCompact is rare and precedes real context loss, so it records a boundary even with
386
+ // nothing in progress — but it is NOT exempt from the byte-identical dedupe below.
387
+ if (trigger !== 'precompact' && issues.length === 0) return { success: true, output: '' };
388
+
389
+ const body = buildCaptureNote(trigger, issues);
390
+
391
+ // Content dedupe: if the newest auto-capture note is byte-identical, this is a repeat of
392
+ // an unchanged session — skip the write so the store never floods with duplicates.
393
+ const fetchNotes = opts.fetchNotes || defaultFetchNotes;
394
+ const recent = await fetchNotes(projectRoot, { ...opts, noteLimit: 10 });
395
+ const lastCapture = (Array.isArray(recent) ? recent : [])
396
+ .find(note => Array.isArray(note && note.tags) && note.tags.includes(CAPTURE_AUTO_TAG));
397
+ if (lastCapture && lastCapture.note === body) return { success: true, output: '' };
398
+
399
+ const append = opts.append || require('../memory/router').append;
400
+ append(projectRoot, body, { tags: CAPTURE_TAGS });
401
+ return { success: true, output: '' };
402
+ } catch {
403
+ // Fail-open: a capture hook must never break a session.
404
+ return { success: true, output: '' };
405
+ }
406
+ }
407
+
129
408
  function parseInstallArgs(rest) {
130
409
  const parsed = { global: false, dryRun: false, harness: 'all', unknown: [] };
131
410
  for (let i = 0; i < rest.length; i += 1) {
@@ -222,10 +501,13 @@ async function handler(args, flags = {}, projectRoot, opts = {}) {
222
501
  const action = args[0];
223
502
  if (action === 'session-start') return handleSessionStart(args.slice(1), projectRoot, opts);
224
503
  if (action === 'inbox-pickup') return handleInboxPickup(args.slice(1), projectRoot, opts);
504
+ if (action === 'shepherd-events') return handleShepherdEvents(args.slice(1), projectRoot, opts);
505
+ if (action === 'memory-recall') return handleMemoryRecall(args.slice(1), projectRoot, opts);
506
+ if (action === 'capture') return handleCapture(args.slice(1), projectRoot, opts);
225
507
  if (action === 'install') return handleInstall(args, flags, opts);
226
508
  return {
227
509
  success: false,
228
- error: `forge hooks supports: install, session-start, inbox-pickup.\n${usage()}`,
510
+ error: `forge hooks supports: install, session-start, inbox-pickup, shepherd-events, capture.\n${usage()}`,
229
511
  };
230
512
  }
231
513