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.
- package/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge.js +90 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +9 -0
- package/docs/reference/shepherd.md +42 -2
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adoption-profiles.js +17 -4
- package/lib/beads-detect.js +60 -0
- package/lib/beads-nudge.js +91 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +39 -0
- package/lib/commands/_manifest.js +2 -0
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +0 -31
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +139 -4
- package/lib/commands/init.js +26 -20
- package/lib/commands/memory.js +81 -0
- package/lib/commands/migrate.js +0 -161
- package/lib/commands/plan.js +48 -8
- package/lib/commands/pr.js +88 -0
- package/lib/commands/push.js +66 -0
- package/lib/commands/recall.js +67 -12
- package/lib/commands/recap.js +18 -4
- package/lib/commands/release.js +14 -1
- package/lib/commands/remember.js +86 -20
- package/lib/commands/setup.js +135 -72
- package/lib/commands/shepherd.js +67 -2
- package/lib/commands/ship.js +40 -4
- package/lib/commands/worktree.js +60 -4
- package/lib/core/runtime-graph.js +34 -3
- package/lib/gate-events.js +54 -55
- package/lib/global-flags.js +30 -0
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/hook-renderer.js +93 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/sqlite-driver.js +37 -1
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/render-sticky.js +43 -8
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-pull.js +43 -2
- package/lib/release-readiness.js +17 -1
- package/lib/upgrade-safety.js +53 -1
- package/lib/workflow/enforce-stage.js +59 -2
- package/package.json +2 -2
- package/scripts/pr-auto-actions.js +93 -0
- 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
|
+
};
|
package/lib/hook-renderer.js
CHANGED
|
@@ -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.
|
|
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
|
-
{
|
|
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
|
-
|
|
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 =
|
|
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
|
|