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

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 (57) 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 +1 -0
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge.js +90 -19
  8. package/docs/guides/SETUP.md +4 -1
  9. package/docs/guides/SUPPORT.md +5 -0
  10. package/docs/reference/COMMANDS.md +9 -0
  11. package/docs/reference/shepherd.md +42 -2
  12. package/lib/activation/ensure-forge-home.js +135 -0
  13. package/lib/adapters/beads-kernel-compat.js +67 -0
  14. package/lib/adoption-profiles.js +17 -4
  15. package/lib/beads-detect.js +60 -0
  16. package/lib/beads-nudge.js +91 -0
  17. package/lib/commands/_aliases.js +248 -0
  18. package/lib/commands/_issue.js +39 -0
  19. package/lib/commands/_manifest.js +2 -0
  20. package/lib/commands/_registry.js +14 -0
  21. package/lib/commands/_resolve-command-opts.js +0 -31
  22. package/lib/commands/gate.js +19 -2
  23. package/lib/commands/hooks.js +139 -4
  24. package/lib/commands/init.js +26 -20
  25. package/lib/commands/memory.js +81 -0
  26. package/lib/commands/migrate.js +0 -161
  27. package/lib/commands/plan.js +48 -8
  28. package/lib/commands/pr.js +88 -0
  29. package/lib/commands/push.js +66 -0
  30. package/lib/commands/recall.js +67 -12
  31. package/lib/commands/recap.js +18 -4
  32. package/lib/commands/release.js +14 -1
  33. package/lib/commands/remember.js +86 -20
  34. package/lib/commands/setup.js +135 -72
  35. package/lib/commands/shepherd.js +67 -2
  36. package/lib/commands/ship.js +40 -4
  37. package/lib/commands/worktree.js +60 -4
  38. package/lib/core/runtime-graph.js +34 -3
  39. package/lib/gate-events.js +54 -55
  40. package/lib/global-flags.js +30 -0
  41. package/lib/grounding/context-events.js +230 -0
  42. package/lib/grounding/read-first.js +112 -0
  43. package/lib/hook-renderer.js +93 -3
  44. package/lib/kernel/backing-issue.js +7 -1
  45. package/lib/kernel/owned-kernel.js +43 -0
  46. package/lib/kernel/sqlite-driver.js +37 -1
  47. package/lib/pr-monitor/auto-actions.js +175 -0
  48. package/lib/pr-monitor/digest.js +206 -0
  49. package/lib/pr-monitor/render-sticky.js +43 -8
  50. package/lib/pr-monitor/upsert-sticky.js +169 -0
  51. package/lib/pr-pull.js +43 -2
  52. package/lib/release-readiness.js +17 -1
  53. package/lib/upgrade-safety.js +53 -1
  54. package/lib/workflow/enforce-stage.js +59 -2
  55. package/package.json +2 -2
  56. package/scripts/pr-auto-actions.js +93 -0
  57. package/scripts/pr-verdict-label.js +50 -0
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module grounding/context-events
5
+ *
6
+ * The state primitive behind gate.read_first: "this issue's context was loaded"
7
+ * recorded as a durable kernel EVENT, structurally identical to gate.approved
8
+ * (lib/gate-events.js). `forge recap`/`forge show` append a `context.loaded`
9
+ * event to the issue's stream on a successful render; `forge claim` refuses to
10
+ * proceed until such an event exists for the issue this session/window.
11
+ *
12
+ * Mechanism (mirrors gate-events, deliberately): a PURE APPEND via the driver
13
+ * primitives (`insertKernelEvent` + `listKernelEvents`), NOT the guarded
14
+ * issue-mutation pipeline — context.loaded does not mutate the issue, so it must
15
+ * not participate in the issue-revision CAS. Idempotency is enforced by a
16
+ * window-bucketed key (so a re-read inside the freshness window mints no
17
+ * duplicate, but a re-read in the NEXT window mints a fresh event that clears a
18
+ * re-block) plus the unique idempotency index catching a concurrent race.
19
+ *
20
+ * Config-surface note: whether the gate is enabled lives in the runtime graph +
21
+ * `.forge/config.yaml`; this module only records/reads the events.
22
+ */
23
+
24
+ const { resolveOwnedKernel, closeIfOwned } = require('../kernel/owned-kernel');
25
+ const { resolveIssueActor } = require('../forge-issues');
26
+
27
+ const CONTEXT_LOADED_EVENT = 'context.loaded';
28
+ const ISSUE_ENTITY_TYPE = 'issue';
29
+ const CONTEXT_EVENT_ORIGIN = 'cli';
30
+ // Tier-2 agnostic freshness floor: a stale-but-loaded issue re-blocks after this
31
+ // window; one `forge recap` clears it. Overridable via
32
+ // `workflow.gates.gate.read_first.window` at the call site (P2).
33
+ const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000;
34
+
35
+ // Kernel lifecycle (resolve + close-what-you-built) is shared with gate-events
36
+ // via lib/kernel/owned-kernel; `resolveOwnedKernel`/`closeIfOwned` are imported.
37
+
38
+ /** Coarse window bucket for a timestamp — the roll-over that makes re-reads fresh. */
39
+ function windowBucket(nowMs, windowMs) {
40
+ return Math.floor(nowMs / windowMs);
41
+ }
42
+
43
+ /**
44
+ * Idempotency key for a context.loaded event. Scoped to issue + scope
45
+ * (session id when present, else actor) + window bucket so a re-read inside the
46
+ * window is idempotent, while the next window mints a fresh event.
47
+ */
48
+ function contextLoadedIdempotencyKey(issueId, scope, bucket) {
49
+ return `${CONTEXT_LOADED_EVENT}:${issueId}:${scope}:${bucket}`;
50
+ }
51
+
52
+ /** Shape a stored kernel_events row into the context-event view callers consume. */
53
+ function parseContextEvent(row) {
54
+ let payload;
55
+ try {
56
+ payload = row.payload_json ? JSON.parse(row.payload_json) : {};
57
+ } catch {
58
+ payload = {};
59
+ }
60
+ const view = {
61
+ event_type: row.event_type,
62
+ actor: row.actor,
63
+ created_at: row.created_at,
64
+ };
65
+ if (payload.session !== undefined) view.session = payload.session;
66
+ if (payload.cmd !== undefined) view.cmd = payload.cmd;
67
+ if (payload.budget !== undefined) view.budget = payload.budget;
68
+ return view;
69
+ }
70
+
71
+ function isIdempotencyRace(error) {
72
+ const message = error && error.message ? String(error.message) : '';
73
+ return /UNIQUE constraint failed/i.test(message) && /idempotency_key/i.test(message);
74
+ }
75
+
76
+ /**
77
+ * Record that an issue's context was loaded. Idempotent per issue+scope+window.
78
+ * Validates the issue exists (no orphan events).
79
+ *
80
+ * @param {string} projectRoot
81
+ * @param {Object} params
82
+ * @param {string} params.issueId
83
+ * @param {string} [params.cmd] - the command that loaded the context (recap|show).
84
+ * @param {string} [params.session] - harness session id (Tier-1 scoping) when known.
85
+ * @param {number|string} [params.budget]
86
+ * @param {Object} [params.env] - env source for actor resolution.
87
+ * @param {Object} [params.deps] - injected { kernelBroker, kernelDriver }.
88
+ * @param {string} [params.now] - ISO timestamp (defaults to now).
89
+ * @param {number} [params.windowMs] - freshness window (defaults to 24h).
90
+ * @returns {Promise<{ ok: boolean, duplicate?: boolean, issueMissing?: boolean, event?: Object, actor?: string }>}
91
+ */
92
+ async function recordContextLoaded(projectRoot, params = {}) {
93
+ const { issueId, cmd, session, budget, env, deps, now, windowMs } = params;
94
+ const actor = resolveIssueActor(env || process.env) || 'forge';
95
+ const kernel = await resolveOwnedKernel(projectRoot, deps);
96
+ const { driver, config } = kernel;
97
+
98
+ try {
99
+ const entity = await driver.loadKernelEntity(ISSUE_ENTITY_TYPE, issueId, {}, config);
100
+ if (!entity) {
101
+ return { ok: false, issueMissing: true, actor };
102
+ }
103
+
104
+ const nowIso = now || new Date().toISOString();
105
+ const win = Number.isFinite(windowMs) ? windowMs : DEFAULT_WINDOW_MS;
106
+ const bucket = windowBucket(Date.parse(nowIso), win);
107
+ const scope = session || actor;
108
+ const idempotencyKey = contextLoadedIdempotencyKey(issueId, scope, bucket);
109
+
110
+ const existing = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
111
+ if (existing) {
112
+ return { ok: true, duplicate: true, event: parseContextEvent(existing), actor };
113
+ }
114
+
115
+ const payload = { actor };
116
+ if (typeof session === 'string' && session.length > 0) payload.session = session;
117
+ if (typeof cmd === 'string' && cmd.length > 0) payload.cmd = cmd;
118
+ if (budget !== undefined) payload.budget = budget;
119
+
120
+ const event = {
121
+ entity_type: ISSUE_ENTITY_TYPE,
122
+ entity_id: issueId,
123
+ event_type: CONTEXT_LOADED_EVENT,
124
+ idempotency_key: idempotencyKey,
125
+ expected_revision: 0,
126
+ actor,
127
+ origin: CONTEXT_EVENT_ORIGIN,
128
+ payload,
129
+ created_at: nowIso,
130
+ };
131
+
132
+ try {
133
+ const inserted = await driver.insertKernelEvent(event, {}, config);
134
+ return { ok: true, duplicate: false, event: parseContextEvent(inserted), actor };
135
+ } catch (error) {
136
+ if (isIdempotencyRace(error)) {
137
+ const winner = await driver.loadKernelEventByIdempotencyKey(idempotencyKey, {}, config);
138
+ return { ok: true, duplicate: true, event: winner ? parseContextEvent(winner) : parseContextEvent(event), actor };
139
+ }
140
+ throw error;
141
+ }
142
+ } finally {
143
+ closeIfOwned(kernel);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * List every context.loaded event on an issue, oldest first.
149
+ *
150
+ * @returns {Promise<Array<{ event_type: string, actor: string, created_at: string, session?: string, cmd?: string }>>}
151
+ */
152
+ async function listContextLoadedEvents(projectRoot, issueId, options = {}) {
153
+ const kernel = await resolveOwnedKernel(projectRoot, options.deps);
154
+ try {
155
+ const rows = await kernel.driver.listKernelEvents(ISSUE_ENTITY_TYPE, issueId, {}, kernel.config);
156
+ return (rows || [])
157
+ .filter(row => row.event_type === CONTEXT_LOADED_EVENT)
158
+ .map(parseContextEvent);
159
+ } finally {
160
+ closeIfOwned(kernel);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * True iff the issue has a context.loaded event that satisfies scoping:
166
+ * - Tier-1 (session given): an event stamped with the SAME session.
167
+ * - Tier-2 (no session): an event newer than the freshness window.
168
+ *
169
+ * @param {string} projectRoot
170
+ * @param {string} issueId
171
+ * @param {{ session?: string, windowMs?: number, now?: string, deps?: Object }} [options]
172
+ * @returns {Promise<boolean>}
173
+ */
174
+ async function hasFreshContextLoaded(projectRoot, issueId, options = {}) {
175
+ const { session, windowMs, now, deps } = options;
176
+ const events = await listContextLoadedEvents(projectRoot, issueId, { deps });
177
+ return eventsSatisfyFreshness(events, { session, windowMs, now });
178
+ }
179
+
180
+ /** Shared freshness predicate over an already-listed event set. */
181
+ function eventsSatisfyFreshness(events, { session, windowMs, now } = {}) {
182
+ if (!events || events.length === 0) return false;
183
+ if (typeof session === 'string' && session.length > 0) {
184
+ return events.some(event => event.session === session);
185
+ }
186
+ const nowMs = now ? Date.parse(now) : Date.now();
187
+ const win = Number.isFinite(windowMs) ? windowMs : DEFAULT_WINDOW_MS;
188
+ return events.some(event => {
189
+ const ts = Date.parse(event.created_at);
190
+ return Number.isFinite(ts) && (nowMs - ts) <= win;
191
+ });
192
+ }
193
+
194
+ /**
195
+ * The gate.read_first verdict for an issue, resolving the kernel ONCE:
196
+ * - 'missing': the issue does not exist in the consulted kernel. The gate is
197
+ * INERT — grounding a phantom issue is meaningless and the real claim will
198
+ * fail on its own; this is also what keeps unit doubles (fake runner, no real
199
+ * store) from being false-blocked. No bypass for a REAL issue: a claim on a
200
+ * non-existent issue fails regardless.
201
+ * - 'loaded': a context.loaded event satisfies session/window scoping -> allow.
202
+ * - 'unread': the issue exists but has no fresh context.loaded event -> BLOCK.
203
+ *
204
+ * @returns {Promise<'missing'|'loaded'|'unread'>}
205
+ */
206
+ async function readFirstVerdict(projectRoot, issueId, options = {}) {
207
+ const { session, windowMs, now, deps } = options;
208
+ const kernel = await resolveOwnedKernel(projectRoot, deps);
209
+ const { driver, config } = kernel;
210
+ try {
211
+ const entity = await driver.loadKernelEntity(ISSUE_ENTITY_TYPE, issueId, {}, config);
212
+ if (!entity) return 'missing';
213
+ const rows = await driver.listKernelEvents(ISSUE_ENTITY_TYPE, issueId, {}, config);
214
+ const events = (rows || []).filter(row => row.event_type === CONTEXT_LOADED_EVENT).map(parseContextEvent);
215
+ return eventsSatisfyFreshness(events, { session, windowMs, now }) ? 'loaded' : 'unread';
216
+ } finally {
217
+ closeIfOwned(kernel);
218
+ }
219
+ }
220
+
221
+ module.exports = {
222
+ CONTEXT_LOADED_EVENT,
223
+ DEFAULT_WINDOW_MS,
224
+ contextLoadedIdempotencyKey,
225
+ parseContextEvent,
226
+ recordContextLoaded,
227
+ listContextLoadedEvents,
228
+ hasFreshContextLoaded,
229
+ readFirstVerdict,
230
+ };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module grounding/read-first
5
+ *
6
+ * gate.read_first — the first gate that DENIES (fd4c03b3's first real payment):
7
+ * acting on an issue requires having read it. Consulted at the `forge claim`
8
+ * chokepoint (P1) exactly like gate.issue_verify is consulted at the _issue.js
9
+ * boundary (isIssueVerifyEnabled). Fail-closed: no context.loaded event for the
10
+ * issue this session/window -> a block result whose remedy IS the load action
11
+ * (`forge recap <id>`), so the cheapest path through the gate is the correct
12
+ * behavior. Disabling rail.grounding (master) or gate.read_first allows the
13
+ * action (logged), same toggle surface as rail.kernel_tracking.
14
+ */
15
+
16
+ const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
17
+ const { readFirstVerdict } = require('./context-events');
18
+
19
+ const GROUNDING_RAIL_ID = 'rail.grounding';
20
+ const READ_FIRST_GATE_ID = 'gate.read_first';
21
+
22
+ /**
23
+ * The block message. Contract: exit != 0, remedy is one copy-pastable command,
24
+ * and running that command both satisfies the gate AND injects the context.
25
+ */
26
+ function buildReadFirstBlockMessage(issueId) {
27
+ return [
28
+ `✗ ${READ_FIRST_GATE_ID}: issue ${issueId} has not been read this session.`,
29
+ ` Run: forge recap ${issueId} first`,
30
+ ` Then retry. (Override: forge gate disable ${READ_FIRST_GATE_ID} — logged.)`,
31
+ ].join('\n');
32
+ }
33
+
34
+ /**
35
+ * Resolve whether grounding + read_first are BOTH enabled. Mirrors
36
+ * isIssueVerifyEnabled: an injected opts.resolveRuntimeGraph wins (tests); an
37
+ * unresolvable config (lint errors) yields `unknown` so the caller can fail-open
38
+ * on a broken config rather than bricking claim.
39
+ *
40
+ * @returns {{ enabled: boolean, disabledPrimitive?: string, unknown?: boolean }}
41
+ */
42
+ function resolveReadFirstEnabled(projectRoot, opts = {}) {
43
+ const resolveGraph = opts.resolveRuntimeGraph || getResolvedRuntimeGraph;
44
+ let graph;
45
+ try {
46
+ graph = resolveGraph({ projectRoot });
47
+ } catch {
48
+ return { enabled: false, unknown: true };
49
+ }
50
+ const rail = (graph.rails || []).find(candidate => candidate.id === GROUNDING_RAIL_ID);
51
+ const gate = (graph.gates || []).find(candidate => candidate.id === READ_FIRST_GATE_ID);
52
+ const railOn = rail ? rail.enabled !== false : true;
53
+ const gateOn = gate ? gate.enabled !== false : true;
54
+ if (!railOn) return { enabled: false, disabledPrimitive: GROUNDING_RAIL_ID };
55
+ if (!gateOn) return { enabled: false, disabledPrimitive: READ_FIRST_GATE_ID };
56
+ return { enabled: true };
57
+ }
58
+
59
+ /**
60
+ * Consult gate.read_first for an issue. Returns `null` when the action is
61
+ * allowed (gate off, or a fresh context.loaded event exists), or a block result
62
+ * `{ success:false, error, exitCode }` when the issue has not been read.
63
+ *
64
+ * @param {string} projectRoot
65
+ * @param {string} issueId
66
+ * @param {Object} [opts] - { resolveRuntimeGraph, kernelBroker, kernelDriver, session, windowMs, now }
67
+ * @returns {Promise<null | { success: false, error: string, exitCode: number }>}
68
+ */
69
+ async function checkReadFirst(projectRoot, issueId, opts = {}) {
70
+ const state = resolveReadFirstEnabled(projectRoot, opts);
71
+ if (!state.enabled) {
72
+ // Disabled (or unresolvable config) -> allow. Log the deliberate skip.
73
+ if (state.disabledPrimitive) {
74
+ console.error(`forge: ${state.disabledPrimitive} disabled — claim on ${issueId} allowed without grounding check.`);
75
+ }
76
+ return null;
77
+ }
78
+
79
+ const deps = (opts.kernelBroker && opts.kernelDriver)
80
+ ? { kernelBroker: opts.kernelBroker, kernelDriver: opts.kernelDriver }
81
+ : undefined;
82
+
83
+ let verdict;
84
+ try {
85
+ verdict = await readFirstVerdict(projectRoot, issueId, {
86
+ session: opts.session,
87
+ windowMs: opts.windowMs,
88
+ now: opts.now,
89
+ deps,
90
+ });
91
+ } catch {
92
+ // Kernel unavailable (broken env) -> fail-open, like issue_verify on a
93
+ // config error. In production the project kernel resolves; the enforced
94
+ // path is the normal one.
95
+ return null;
96
+ }
97
+
98
+ // 'missing' -> the issue is not in the consulted kernel; the gate is inert (a
99
+ // real claim on a non-existent issue fails on its own — no grounding bypass).
100
+ // 'loaded' -> a fresh context.loaded event exists. Both allow.
101
+ if (verdict !== 'unread') return null;
102
+
103
+ return { success: false, error: buildReadFirstBlockMessage(issueId), exitCode: 6 };
104
+ }
105
+
106
+ module.exports = {
107
+ GROUNDING_RAIL_ID,
108
+ READ_FIRST_GATE_ID,
109
+ buildReadFirstBlockMessage,
110
+ resolveReadFirstEnabled,
111
+ checkReadFirst,
112
+ };
@@ -75,6 +75,21 @@ const FORGE_CONTEXT_MARKER = 'hooks session-start';
75
75
  // re-merge recognizes + replaces the Forge-owned UserPromptSubmit entry in place, exactly
76
76
  // as FORGE_CONTEXT_MARKER does for the SessionStart entry.
77
77
  const FORGE_INBOX_CONTEXT_MARKER = 'hooks inbox-pickup';
78
+ // The PR-shepherd events context hook (a SECOND UserPromptSubmit-tier hook). Its own
79
+ // marker so a re-merge recognizes + replaces the Forge-owned entry in place. The whole
80
+ // Forge UserPromptSubmit group is already recognized via the inbox marker, but this keeps
81
+ // the shepherd-events command independently identifiable (symmetry with the other tiers).
82
+ const FORGE_SHEPHERD_EVENTS_MARKER = 'hooks shepherd-events';
83
+ // The capture-on-exit context hook (PreCompact + Stop tier). A THIRD context marker so a
84
+ // re-merge recognizes + replaces the Forge-owned PreCompact/Stop entries in place. Both
85
+ // events share this one marker (they differ only by a --trigger suffix on the command).
86
+ //
87
+ // It matches the FULL resolved Forge invocation (`node "<abs bin/forge.js>" hooks capture`),
88
+ // NOT the bare `hooks capture` verb: a bare-substring check would treat ANY user hook command
89
+ // that merely mentions "hooks capture" as Forge-owned and DELETE it on re-merge (data-integrity
90
+ // bug, CodeRabbit on #397). Only Forge's own rendered capture command contains this prefix, so
91
+ // the merge replaces exactly Forge's group and preserves the user's.
92
+ const FORGE_CAPTURE_CONTEXT_MARKER = `${FORGE_CLI} hooks capture`;
78
93
 
79
94
  // Per-harness SessionStart context-injection capability. Honest capability matrix —
80
95
  // only Claude exposes a native session-start surface that can inject additionalContext.
@@ -99,6 +114,18 @@ const USER_PROMPT_SUBMIT_SUPPORT = Object.freeze({
99
114
  hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
100
115
  });
101
116
 
117
+ // Per-harness session-END (capture-on-exit) capability. Only Claude exposes native
118
+ // PreCompact + Stop hook surfaces where Forge can snapshot session learnings BEFORE
119
+ // context is compacted or the session ends. Cursor 1.7 hooks are deny-oriented with no
120
+ // session-end surface; Codex and Hermes hooks live in GLOBAL home config project setup
121
+ // never writes. Same honesty rule as the other context tiers — no faked parity.
122
+ const SESSION_END_SUPPORT = Object.freeze({
123
+ claude: Object.freeze({ rendered: true }),
124
+ cursor: Object.freeze({ rendered: false, reason: 'no-session-end-surface' }),
125
+ codex: Object.freeze({ rendered: false, reason: 'global-config' }),
126
+ hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
127
+ });
128
+
102
129
  // Claude exposes $CLAUDE_PROJECT_DIR (absolute project root) to hook commands and
103
130
  // documents it as THE cwd-independent way to reference project-local hook scripts —
104
131
  // a bare relative path breaks whenever Claude runs the hook from another cwd. Cursor
@@ -114,7 +141,7 @@ function adapterInvocation(harness) {
114
141
  * (per-commit) so the rendered Claude PreToolUse groups read write-guard first.
115
142
  */
116
143
  const FORGE_HOOK_CONTRACT = Object.freeze({
117
- schemaVersion: '1.1.0',
144
+ schemaVersion: '1.2.0',
118
145
  kind: 'forge.hookContract',
119
146
  adapter: FORGE_HOOK_ADAPTER,
120
147
  intents: Object.freeze([
@@ -152,6 +179,35 @@ const FORGE_HOOK_CONTRACT = Object.freeze({
152
179
  lifecycle: 'user-prompt-submit',
153
180
  command: `${FORGE_CLI} hooks inbox-pickup`,
154
181
  }),
182
+ Object.freeze({
183
+ id: 'shepherd-events',
184
+ kind: 'context',
185
+ cliAction: 'shepherd-events',
186
+ // PR-SHEPHERD DELTAS: surfaces a compact, capped digest of NEW PR-monitor events
187
+ // (verdict changes, failed checks, new threads, merged/closed) since the last read,
188
+ // then advances the per-PR consumer cursor. This is the CONSUMER side of the constant
189
+ // watcher — the watch loop writes the journal, this pushes the deltas each turn. Reads
190
+ // the user's OWN local journal via a supported hook — NEVER stdin injection, never
191
+ // drives the agent (Anthropic Usage Policy).
192
+ enforces: 'PR shepherd events: on each UserPromptSubmit, surface a compact, capped digest of NEW PR-monitor events (verdict changes, failed checks, new threads, merged/closed) since the last read across open-PR journals, then advance the cursor. Compact to avoid additionalContext accumulation. Additive and FAIL-OPEN — a missing digest never blocks a prompt.',
193
+ lifecycle: 'user-prompt-submit',
194
+ command: `${FORGE_CLI} hooks shepherd-events`,
195
+ }),
196
+ Object.freeze({
197
+ id: 'memory-capture',
198
+ kind: 'context',
199
+ cliAction: 'capture',
200
+ // CAPTURE-ON-EXIT: the write half of Forge memory. SessionStart INJECTS remembered
201
+ // notes but nothing ever CAPTURES on the way out, so long sessions lose learnings and
202
+ // memory stays orphaned (the eval scored memory pull-only). PreCompact + Stop fire this
203
+ // to snapshot a bounded session-summary note BEFORE context is compacted / the session
204
+ // ends. Persists to the memory store — it NEVER injects into the turn and never drives
205
+ // the agent (Anthropic Usage Policy). The trigger (precompact|stop) rides as a --trigger
206
+ // suffix stamped by the rendered hook, so the CLI never has to read hook stdin.
207
+ enforces: 'Capture-on-exit: snapshot a bounded session-summary note (trigger + in-progress issues) into the memory store on PreCompact and Stop, BEFORE context is compacted or the session ends. Additive and FAIL-OPEN — a capture failure never blocks a session.',
208
+ lifecycle: 'session-end',
209
+ command: `${FORGE_CLI} hooks capture`,
210
+ }),
155
211
  ]),
156
212
  });
157
213
 
@@ -202,6 +258,16 @@ function userPromptSubmitCapability(harness) {
202
258
  return USER_PROMPT_SUBMIT_SUPPORT[harness] || { rendered: false, reason: 'unknown-harness' };
203
259
  }
204
260
 
261
+ /**
262
+ * Report the per-harness session-END (capture-on-exit) capability (the honest matrix for
263
+ * the PreCompact + Stop capture tier).
264
+ * @param {string} harness
265
+ * @returns {{ rendered: boolean, reason?: string }}
266
+ */
267
+ function sessionEndCapability(harness) {
268
+ return SESSION_END_SUPPORT[harness] || { rendered: false, reason: 'unknown-harness' };
269
+ }
270
+
205
271
  /**
206
272
  * Render the Claude `.claude/settings.json` `hooks` block (PreToolUse groups only).
207
273
  * Write/Edit/MultiEdit/NotebookEdit → protected-path deny; Bash → TDD gate.
@@ -229,8 +295,27 @@ function renderClaudeHooks(contract) {
229
295
  // Surfaces pending targeted dashboard instruction comments (fenced kernel DATA) on each
230
296
  // prompt; the command emits { hookSpecificOutput.additionalContext }. Reads the user's
231
297
  // own kernel data via a supported hook — NEVER stdin injection (Anthropic Usage Policy).
298
+ // Both UserPromptSubmit context hooks share ONE Forge-owned group (inbox-pickup +
299
+ // PR-shepherd deltas). Claude runs every hook in the group and appends each hook's
300
+ // additionalContext; keeping them in one group means a re-merge replaces the pair
301
+ // atomically (the group is Forge-owned via either marker). Both are compact + fail-open.
232
302
  UserPromptSubmit: [
233
- { hooks: [{ type: 'command', command: harnessCommand(contract, 'inbox-pickup', 'claude') }] },
303
+ {
304
+ hooks: [
305
+ { type: 'command', command: harnessCommand(contract, 'inbox-pickup', 'claude') },
306
+ { type: 'command', command: harnessCommand(contract, 'shepherd-events', 'claude') },
307
+ ],
308
+ },
309
+ ],
310
+ // Capture-on-exit (memory capture). PreCompact fires before context is compacted; Stop
311
+ // fires when the agent finishes. Both call the same capture command; the event stamps the
312
+ // --trigger so the CLI never reads hook stdin. The command persists a bounded session
313
+ // summary — it emits NO stdout (a Stop hook that printed text would inject into the turn).
314
+ PreCompact: [
315
+ { hooks: [{ type: 'command', command: `${harnessCommand(contract, 'memory-capture', 'claude')} --trigger precompact` }] },
316
+ ],
317
+ Stop: [
318
+ { hooks: [{ type: 'command', command: `${harnessCommand(contract, 'memory-capture', 'claude')} --trigger stop` }] },
234
319
  ],
235
320
  };
236
321
  }
@@ -311,7 +396,9 @@ function isForgeCommand(command) {
311
396
  return typeof command === 'string'
312
397
  && (command.includes(FORGE_HOOK_MARKER)
313
398
  || command.includes(FORGE_CONTEXT_MARKER)
314
- || command.includes(FORGE_INBOX_CONTEXT_MARKER));
399
+ || command.includes(FORGE_INBOX_CONTEXT_MARKER)
400
+ || command.includes(FORGE_SHEPHERD_EVENTS_MARKER)
401
+ || command.includes(FORGE_CAPTURE_CONTEXT_MARKER));
315
402
  }
316
403
 
317
404
  /** True when a hook group/entry is Forge-owned (any inner command is Forge-owned). */
@@ -436,10 +523,13 @@ module.exports = {
436
523
  FORGE_HOOK_MARKER,
437
524
  FORGE_CONTEXT_MARKER,
438
525
  FORGE_INBOX_CONTEXT_MARKER,
526
+ FORGE_CAPTURE_CONTEXT_MARKER,
439
527
  SESSION_START_SUPPORT,
440
528
  USER_PROMPT_SUBMIT_SUPPORT,
529
+ SESSION_END_SUPPORT,
441
530
  sessionStartCapability,
442
531
  userPromptSubmitCapability,
532
+ sessionEndCapability,
443
533
  HookConfigParseError,
444
534
  renderClaudeHooks,
445
535
  renderCursorHooks,
@@ -171,7 +171,12 @@ function findExistingLink(driver, { worktreePath, branch }) {
171
171
  }
172
172
  if (typeof driver.listWorktrees === 'function') {
173
173
  const rows = driver.listWorktrees() || [];
174
- const match = rows.find(row => row && row.branch === branch && row.issue_id);
174
+ // Match ACTIVE (live) rows only: a superseded/stale registration for a
175
+ // reused branch name must not be treated as the existing link (be18881c —
176
+ // the third resolver, kept consistent with resolveActiveIssueId and
177
+ // currentBranchIssueFromDriver). Tolerate a null state for legacy rows.
178
+ const match = rows.find(row => row && row.branch === branch && row.issue_id
179
+ && (row.state === 'active' || row.state == null));
175
180
  if (match) return match;
176
181
  }
177
182
  } catch {
@@ -298,6 +303,7 @@ module.exports = {
298
303
  classifyBranch,
299
304
  deriveTitle,
300
305
  extractEncodedIssueId,
306
+ findExistingLink,
301
307
  matchesIgnoreGlob,
302
308
  DEFAULT_PROTECTED_BRANCHES,
303
309
  DEFAULT_IGNORE_GLOBS,
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module kernel/owned-kernel
5
+ *
6
+ * Shared kernel-lifecycle helper for the pure-append event modules
7
+ * (grounding/context-events, gate-events). Both resolve a kernel driver the same
8
+ * way and carry the same close-what-you-built invariant, so it lives here once
9
+ * instead of being copied per module.
10
+ *
11
+ * The invariant: an INJECTED (shared) kernel is caller-owned and must NEVER be
12
+ * closed here — closing it would break the next operation that reuses it. A
13
+ * kernel this module BUILDS for a single short-lived read/append it MUST close —
14
+ * an unclosed SQLite handle leaks and, on Windows, locks the DB directory
15
+ * (`EBUSY` on `rmSync`, kernel issue e62e4bde).
16
+ */
17
+
18
+ const { buildMigratedKernelIssueDeps } = require('./cli-broker-factory');
19
+
20
+ /**
21
+ * Resolve the kernel driver + config. An injected (shared) kernel is returned
22
+ * untouched with `ownsKernel:false` — the caller owns its lifecycle. Otherwise a
23
+ * fresh one is built (via `deps.kernelBuilder`, a test seam over
24
+ * `buildMigratedKernelIssueDeps`, or the real builder) and tagged
25
+ * `ownsKernel:true` so {@link closeIfOwned} closes it.
26
+ */
27
+ async function resolveOwnedKernel(projectRoot, deps = {}) {
28
+ if (deps.kernelBroker && deps.kernelDriver) {
29
+ return { broker: deps.kernelBroker, driver: deps.kernelDriver, config: deps.kernelBroker.config, ownsKernel: false };
30
+ }
31
+ const build = deps.kernelBuilder || buildMigratedKernelIssueDeps;
32
+ const built = await build({ projectRoot });
33
+ return { broker: built.kernelBroker, driver: built.kernelDriver, config: built.kernelBroker.config, ownsKernel: true };
34
+ }
35
+
36
+ /** Close a kernel driver only when this module built it (never an injected one). */
37
+ function closeIfOwned(kernel) {
38
+ if (kernel && kernel.ownsKernel && kernel.driver && typeof kernel.driver.close === 'function') {
39
+ try { kernel.driver.close(); } catch { /* best-effort: closing is cleanup, never fatal */ }
40
+ }
41
+ }
42
+
43
+ module.exports = { resolveOwnedKernel, closeIfOwned };
@@ -1113,8 +1113,41 @@ function loadWorktreeRowByPath(runtime, db, worktreePath) {
1113
1113
  return rows[0] || null;
1114
1114
  }
1115
1115
 
1116
+ // The idempotent upsert key. `forge plan` registers MULTIPLE branches from ONE
1117
+ // checkout (same absolute path), so keying by path ALONE made a second plan-first
1118
+ // feature UPDATE-in-place over the first branch's row and dead-end its ship (R1).
1119
+ // Key by (path, branch) so each branch keeps its own row; fall back to path-only
1120
+ // when no branch is supplied (worktree flows use distinct paths, so behavior there
1121
+ // is unchanged).
1122
+ function loadWorktreeRowByPathAndBranch(runtime, db, worktreePath, branch) {
1123
+ if (!worktreePath) return null;
1124
+ if (!branch) return loadWorktreeRowByPath(runtime, db, worktreePath);
1125
+ const rows = safeAll(
1126
+ runtime,
1127
+ db,
1128
+ 'SELECT * FROM kernel_worktrees WHERE path = ? AND branch = ? ORDER BY registered_at DESC LIMIT 1',
1129
+ [worktreePath, branch],
1130
+ );
1131
+ return rows[0] || null;
1132
+ }
1133
+
1134
+ // A git branch is checked out in exactly ONE worktree, so a NEW active registration
1135
+ // for a branch supersedes any prior ACTIVE row carrying that same branch under a
1136
+ // different id (be18881c): a reused/deleted-and-recreated branch must not keep a
1137
+ // stale binding to the OLD issue. Marking those rows state='superseded' lets the
1138
+ // active-only branch resolver skip them regardless of their timestamp.
1139
+ function supersedePriorBranchRegistrations(runtime, db, branch, keepId) {
1140
+ if (!branch) return;
1141
+ runParams(
1142
+ runtime,
1143
+ db,
1144
+ "UPDATE kernel_worktrees SET state = 'superseded' WHERE branch = ? AND state = 'active' AND id != ?",
1145
+ [branch, keepId || ''],
1146
+ );
1147
+ }
1148
+
1116
1149
  function upsertWorktreeRow(runtime, db, input) {
1117
- const existing = loadWorktreeRowByPath(runtime, db, input.path);
1150
+ const existing = loadWorktreeRowByPathAndBranch(runtime, db, input.path, input.branch);
1118
1151
  const row = {
1119
1152
  id: input.id || existing?.id || randomUUID(),
1120
1153
  git_common_dir: input.git_common_dir,
@@ -1142,6 +1175,9 @@ function upsertWorktreeRow(runtime, db, input) {
1142
1175
  KERNEL_WORKTREE_COLUMNS.map(column => row[column]),
1143
1176
  );
1144
1177
  }
1178
+ if (row.state === 'active') {
1179
+ supersedePriorBranchRegistrations(runtime, db, row.branch, row.id);
1180
+ }
1145
1181
  return row;
1146
1182
  }
1147
1183