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.
- package/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +95 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +12 -1
- package/docs/reference/shepherd.md +114 -3
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/adoption-profiles.js +17 -4
- package/lib/agents-config.js +5 -0
- package/lib/beads-detect.js +60 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +65 -41
- package/lib/commands/_manifest.js +3 -1
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +36 -60
- package/lib/commands/claim.js +2 -4
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +294 -12
- 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 +71 -123
- package/lib/commands/pr.js +88 -0
- package/lib/commands/prime.js +8 -1
- 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 +15 -3
- package/lib/commands/remember.js +86 -20
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +135 -73
- package/lib/commands/shepherd.js +102 -4
- package/lib/commands/ship.js +40 -4
- package/lib/commands/skill.js +275 -0
- package/lib/commands/status.js +37 -32
- package/lib/commands/test.js +32 -0
- package/lib/commands/worktree.js +87 -5
- package/lib/core/runtime-graph.js +122 -17
- package/lib/forge-issues.js +13 -464
- 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/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +144 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +187 -1
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- package/lib/pr-monitor/reconcile-executor.js +659 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-sticky.js +57 -8
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +113 -17
- package/lib/project-memory.js +8 -0
- package/lib/release-readiness.js +17 -1
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/skill-eval.js +750 -0
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/upgrade-safety.js +52 -1
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +64 -7
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +2 -2
- package/rules/using-forge.md +24 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
- package/scripts/test.js +8 -1
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +6 -0
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +1 -0
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +76 -36
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +6 -0
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +87 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/commands/board.js +0 -64
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
package/lib/global-flags.js
CHANGED
|
@@ -67,8 +67,38 @@ function stripGlobalFlags(args) {
|
|
|
67
67
|
return kept;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Index of the first positional token (a bare, non-flag argument), skipping any
|
|
72
|
+
* leading global flags and their consumed values. Returns -1 when no positional
|
|
73
|
+
* exists. Uses the SAME flag-consumption rules as {@link stripGlobalFlags} so the
|
|
74
|
+
* two agree on what counts as a positional. Unlike stripGlobalFlags this returns
|
|
75
|
+
* an index into the ORIGINAL array, so callers can splice out the positional
|
|
76
|
+
* while preserving the intervening flags.
|
|
77
|
+
*
|
|
78
|
+
* @param {string[]} args - Raw command arguments.
|
|
79
|
+
* @param {number} [start=0] - Index to begin scanning from.
|
|
80
|
+
* @returns {number} Index into `args` of the first positional token, or -1.
|
|
81
|
+
*/
|
|
82
|
+
function firstPositionalIndex(args, start = 0) {
|
|
83
|
+
for (let index = start; index < args.length; index += 1) {
|
|
84
|
+
const arg = args[index];
|
|
85
|
+
if (GLOBAL_BOOLEAN_FLAGS.has(arg)) continue;
|
|
86
|
+
if (GLOBAL_VALUE_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix))) continue;
|
|
87
|
+
if (GLOBAL_VALUE_FLAGS.has(arg)) {
|
|
88
|
+
const next = args[index + 1];
|
|
89
|
+
if (next !== undefined && !next.startsWith('-')) index += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Any other dash-prefixed token is a non-global flag, not the positional.
|
|
93
|
+
if (arg.startsWith('-')) continue;
|
|
94
|
+
return index;
|
|
95
|
+
}
|
|
96
|
+
return -1;
|
|
97
|
+
}
|
|
98
|
+
|
|
70
99
|
module.exports = {
|
|
71
100
|
GLOBAL_BOOLEAN_FLAGS,
|
|
72
101
|
GLOBAL_VALUE_FLAGS,
|
|
73
102
|
stripGlobalFlags,
|
|
103
|
+
firstPositionalIndex,
|
|
74
104
|
};
|
|
@@ -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
|
+
};
|
|
@@ -33,7 +33,7 @@ const SOURCE_ROWS = [
|
|
|
33
33
|
['S4', 'Cursor MCP', 'https://docs.cursor.com/context/model-context-protocol', 'Cursor connects external tools and data sources through MCP using stdio, SSE, and streamable HTTP transports.'],
|
|
34
34
|
['S5', 'Codex skills', 'https://developers.openai.com/codex/skills', 'Codex skills package instructions, resources, and scripts; Codex scans .agents/skills from cwd up to the repo root for repo-scope discovery. Forge installs the global $CODEX_HOME/skills from the canonical skills/ source AND commits the repo-local .agents/skills mirror (kept in sync with skills/ by a pre-commit hook + drift gate) for teammate-clone discovery.'],
|
|
35
35
|
['S6', 'Codex MCP', 'https://developers.openai.com/codex/mcp', 'Codex configures MCP servers in config.toml and supports plugin-provided MCP servers.'],
|
|
36
|
-
['S7', 'Codex hooks', 'https://developers.openai.com/codex/hooks', 'Codex hooks run deterministic scripts during lifecycle events.'],
|
|
36
|
+
['S7', 'Codex hooks', 'https://developers.openai.com/codex/hooks', 'Codex hooks run deterministic scripts during lifecycle events; they live in GLOBAL $CODEX_HOME/config.toml so project setup does not render them. Codex instead reads the committed project-local AGENTS.md every session, which is the always-on carrier for Forge instruction/policy including the using-forge skill-dispatch pointer (see S9).'],
|
|
37
37
|
['S8', 'Codex plugins and marketplaces', 'https://developers.openai.com/codex/plugins/build', 'Codex plugins can package skills, apps, MCP servers, hooks, and marketplace metadata.'],
|
|
38
38
|
['S9', 'AGENTS.md standard', 'https://agents.md/', 'AGENTS.md provides repository instructions for coding agents.'],
|
|
39
39
|
['S10', 'Cursor Agent Skills', 'https://docs.cursor.com/en/agent/skills', 'Cursor reads on-demand Agent Skills from .cursor/skills/<name>/SKILL.md; Forge populates them at setup from the canonical skills/ source.'],
|
|
@@ -41,7 +41,7 @@ const SOURCE_ROWS = [
|
|
|
41
41
|
['S12', 'Cursor ignore files', 'https://cursor.com/docs/reference/ignore-file', 'Cursor reads project-local .cursorignore (blocks AI access + indexing) and .cursorindexingignore (index-only), gitignore-style; .gitignore and common secret/lock files are ignored by default.'],
|
|
42
42
|
['S13', 'Codex sandbox and approvals config', 'https://developers.openai.com/codex/config-reference', 'Codex config.toml sets sandbox_mode (read-only/workspace-write/danger-full-access) and approval_policy (untrusted/on-request/never); a project-local .codex/config.toml is honored ONLY when the project is trusted in the GLOBAL $CODEX_HOME/config.toml (projects.<path>.trust_level).'],
|
|
43
43
|
['S14', 'Claude Code hooks', 'https://code.claude.com/docs/en/hooks', 'Claude Code hooks live in .claude/settings.json under a `hooks` block; a PreToolUse matcher group (e.g. "Write|Edit"/"Bash") runs a command that can deny the tool call via hookSpecificOutput.permissionDecision.'],
|
|
44
|
-
['S15', 'Cursor hooks', 'https://cursor.com/docs/hooks', 'Cursor 1.7+ reads project-local .cursor/hooks.json ({ version: 1, hooks }); only before* events (beforeShellExecution/beforeReadFile/beforeMCPExecution) can deny — there is no pre-edit deny event, so afterFileEdit is observational.'],
|
|
44
|
+
['S15', 'Cursor hooks', 'https://cursor.com/docs/hooks', 'Cursor 1.7+ reads project-local .cursor/hooks.json ({ version: 1, hooks }); only before* events (beforeShellExecution/beforeReadFile/beforeMCPExecution) can deny — there is no pre-edit deny event, so afterFileEdit is observational. Cursor hooks are therefore NOT the session-context path: Cursor auto-injects always-apply .cursor/rules/*.mdc (alwaysApply:true) every session (see S3), which is the always-on carrier for the using-forge skill-dispatch pointer.'],
|
|
45
45
|
['S16', 'Hermes Agent event hooks', 'https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/', 'Hermes (NousResearch) shell hooks are declared in a `hooks:` block in the GLOBAL ~/.hermes/config.yaml; each matcher/command entry runs as a subprocess over a JSON-stdin/stdout wire protocol, and a pre_tool_call hook CAN deny a tool call ({action:block, message} — it also accepts the Claude-Code {decision:block, reason} shape).'],
|
|
46
46
|
];
|
|
47
47
|
|
package/lib/hook-renderer.js
CHANGED
|
@@ -75,17 +75,52 @@ 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
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
//
|
|
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 memory-recall context hook (UserPromptSubmit tier). Its own idempotency marker so a
|
|
84
|
+
// re-render recognizes and REPLACES the Forge-owned entry in place rather than duplicating it.
|
|
85
|
+
const FORGE_MEMORY_RECALL_MARKER = 'hooks memory-recall';
|
|
86
|
+
// The capture-on-exit context hook (PreCompact + Stop tier). A THIRD context marker so a
|
|
87
|
+
// re-merge recognizes + replaces the Forge-owned PreCompact/Stop entries in place. Both
|
|
88
|
+
// events share this one marker (they differ only by a --trigger suffix on the command).
|
|
89
|
+
//
|
|
90
|
+
// It matches the FULL resolved Forge invocation (`node "<abs bin/forge.js>" hooks capture`),
|
|
91
|
+
// NOT the bare `hooks capture` verb: a bare-substring check would treat ANY user hook command
|
|
92
|
+
// that merely mentions "hooks capture" as Forge-owned and DELETE it on re-merge (data-integrity
|
|
93
|
+
// bug, CodeRabbit on #397). Only Forge's own rendered capture command contains this prefix, so
|
|
94
|
+
// the merge replaces exactly Forge's group and preserves the user's.
|
|
95
|
+
const FORGE_CAPTURE_CONTEXT_MARKER = `${FORGE_CLI} hooks capture`;
|
|
96
|
+
|
|
97
|
+
// Per-harness SessionStart HOOK-render capability. This constant governs ONE thing: whether
|
|
98
|
+
// Forge renders a harness-native SessionStart *hook* that injects additionalContext. Only Claude
|
|
99
|
+
// exposes that surface, so only Claude is rendered:true. Every other harness is rendered:false
|
|
100
|
+
// with an HONEST reason naming WHERE the same dispatch bootstrap is delivered instead — because
|
|
101
|
+
// the bootstrap is NOT Claude-only, it just travels a different always-on surface per harness
|
|
102
|
+
// (see BOOTSTRAP_DELIVERY below): Cursor auto-applies an always-on rule (.cursor/rules/*.mdc,
|
|
103
|
+
// alwaysApply:true); Codex reads the committed project-local AGENTS.md every session; Hermes
|
|
104
|
+
// consumes it through the forge CLI (forge prime/orient + forge skill for). We NEVER fake parity
|
|
105
|
+
// and NEVER branch on harness identity at runtime — delivery is keyed by SURFACE TYPE.
|
|
84
106
|
const SESSION_START_SUPPORT = Object.freeze({
|
|
85
107
|
claude: Object.freeze({ rendered: true }),
|
|
86
|
-
cursor: Object.freeze({ rendered: false, reason: '
|
|
87
|
-
codex: Object.freeze({ rendered: false, reason: '
|
|
88
|
-
hermes: Object.freeze({ rendered: false, reason: '
|
|
108
|
+
cursor: Object.freeze({ rendered: false, reason: 'delivered-via-rule-surface' }),
|
|
109
|
+
codex: Object.freeze({ rendered: false, reason: 'delivered-via-agents-md' }),
|
|
110
|
+
hermes: Object.freeze({ rendered: false, reason: 'delivered-via-cli-fallback' }),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Where the using-forge dispatch BOOTSTRAP reaches each harness EVERY session. Data, not
|
|
114
|
+
// identity-branching: the parity gate iterates this so no harness is silently left with no
|
|
115
|
+
// auto-surface. Only Claude's is a native SessionStart hook; the rest are equally always-on
|
|
116
|
+
// surfaces (a Cursor always-apply rule, the committed AGENTS.md for Codex, the forge CLI as an
|
|
117
|
+
// honest fallback for Hermes). All four ALSO carry the full using-forge SKILL.md via the synced
|
|
118
|
+
// skill mirrors. 'cli-fallback' is the explicit honest-fallback marker (no faked native surface).
|
|
119
|
+
const BOOTSTRAP_DELIVERY = Object.freeze({
|
|
120
|
+
claude: 'session-start-hook',
|
|
121
|
+
cursor: 'always-apply-rule',
|
|
122
|
+
codex: 'agents-md',
|
|
123
|
+
hermes: 'cli-fallback',
|
|
89
124
|
});
|
|
90
125
|
|
|
91
126
|
// Per-harness UserPromptSubmit context-injection capability (the near-real-time inbox
|
|
@@ -99,6 +134,18 @@ const USER_PROMPT_SUBMIT_SUPPORT = Object.freeze({
|
|
|
99
134
|
hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
|
|
100
135
|
});
|
|
101
136
|
|
|
137
|
+
// Per-harness session-END (capture-on-exit) capability. Only Claude exposes native
|
|
138
|
+
// PreCompact + Stop hook surfaces where Forge can snapshot session learnings BEFORE
|
|
139
|
+
// context is compacted or the session ends. Cursor 1.7 hooks are deny-oriented with no
|
|
140
|
+
// session-end surface; Codex and Hermes hooks live in GLOBAL home config project setup
|
|
141
|
+
// never writes. Same honesty rule as the other context tiers — no faked parity.
|
|
142
|
+
const SESSION_END_SUPPORT = Object.freeze({
|
|
143
|
+
claude: Object.freeze({ rendered: true }),
|
|
144
|
+
cursor: Object.freeze({ rendered: false, reason: 'no-session-end-surface' }),
|
|
145
|
+
codex: Object.freeze({ rendered: false, reason: 'global-config' }),
|
|
146
|
+
hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
|
|
147
|
+
});
|
|
148
|
+
|
|
102
149
|
// Claude exposes $CLAUDE_PROJECT_DIR (absolute project root) to hook commands and
|
|
103
150
|
// documents it as THE cwd-independent way to reference project-local hook scripts —
|
|
104
151
|
// a bare relative path breaks whenever Claude runs the hook from another cwd. Cursor
|
|
@@ -114,7 +161,7 @@ function adapterInvocation(harness) {
|
|
|
114
161
|
* (per-commit) so the rendered Claude PreToolUse groups read write-guard first.
|
|
115
162
|
*/
|
|
116
163
|
const FORGE_HOOK_CONTRACT = Object.freeze({
|
|
117
|
-
schemaVersion: '1.
|
|
164
|
+
schemaVersion: '1.2.0',
|
|
118
165
|
kind: 'forge.hookContract',
|
|
119
166
|
adapter: FORGE_HOOK_ADAPTER,
|
|
120
167
|
intents: Object.freeze([
|
|
@@ -152,6 +199,51 @@ const FORGE_HOOK_CONTRACT = Object.freeze({
|
|
|
152
199
|
lifecycle: 'user-prompt-submit',
|
|
153
200
|
command: `${FORGE_CLI} hooks inbox-pickup`,
|
|
154
201
|
}),
|
|
202
|
+
Object.freeze({
|
|
203
|
+
id: 'memory-recall',
|
|
204
|
+
kind: 'context',
|
|
205
|
+
cliAction: 'memory-recall',
|
|
206
|
+
// QUERY-RELEVANT MEMORY (tier-2): on each prompt, ranks stored memories by BM25
|
|
207
|
+
// relevance to the submitted prompt (read from the hook's own stdin) and injects the
|
|
208
|
+
// survivors, gated by a relevance floor + anaphora guard + cross-turn dedupe + a hard
|
|
209
|
+
// token budget. Complements the recency digest at SessionStart, which answers a
|
|
210
|
+
// different question (what's newest, not what's relevant to THIS turn). Reads the
|
|
211
|
+
// user's OWN kernel memory via a supported hook and its own stdin input — it NEVER
|
|
212
|
+
// injects into a running session's stdin and never drives the agent (Anthropic Usage
|
|
213
|
+
// Policy). Kill-switch: `forge gate disable rail.memory_recall`.
|
|
214
|
+
enforces: 'Memory recall: on each UserPromptSubmit, inject query-relevant stored memories ranked by BM25 against the submitted prompt, gated by a relevance floor, an anaphora guard, cross-turn dedupe, and a hard token budget (compact to avoid additionalContext accumulation). Additive and FAIL-OPEN — nothing relevant, a disabled rail, or any error injects nothing and never blocks a prompt.',
|
|
215
|
+
lifecycle: 'user-prompt-submit',
|
|
216
|
+
command: `${FORGE_CLI} hooks memory-recall`,
|
|
217
|
+
}),
|
|
218
|
+
Object.freeze({
|
|
219
|
+
id: 'shepherd-events',
|
|
220
|
+
kind: 'context',
|
|
221
|
+
cliAction: 'shepherd-events',
|
|
222
|
+
// PR-SHEPHERD DELTAS: surfaces a compact, capped digest of NEW PR-monitor events
|
|
223
|
+
// (verdict changes, failed checks, new threads, merged/closed) since the last read,
|
|
224
|
+
// then advances the per-PR consumer cursor. This is the CONSUMER side of the constant
|
|
225
|
+
// watcher — the watch loop writes the journal, this pushes the deltas each turn. Reads
|
|
226
|
+
// the user's OWN local journal via a supported hook — NEVER stdin injection, never
|
|
227
|
+
// drives the agent (Anthropic Usage Policy).
|
|
228
|
+
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.',
|
|
229
|
+
lifecycle: 'user-prompt-submit',
|
|
230
|
+
command: `${FORGE_CLI} hooks shepherd-events`,
|
|
231
|
+
}),
|
|
232
|
+
Object.freeze({
|
|
233
|
+
id: 'memory-capture',
|
|
234
|
+
kind: 'context',
|
|
235
|
+
cliAction: 'capture',
|
|
236
|
+
// CAPTURE-ON-EXIT: the write half of Forge memory. SessionStart INJECTS remembered
|
|
237
|
+
// notes but nothing ever CAPTURES on the way out, so long sessions lose learnings and
|
|
238
|
+
// memory stays orphaned (the eval scored memory pull-only). PreCompact + Stop fire this
|
|
239
|
+
// to snapshot a bounded session-summary note BEFORE context is compacted / the session
|
|
240
|
+
// ends. Persists to the memory store — it NEVER injects into the turn and never drives
|
|
241
|
+
// the agent (Anthropic Usage Policy). The trigger (precompact|stop) rides as a --trigger
|
|
242
|
+
// suffix stamped by the rendered hook, so the CLI never has to read hook stdin.
|
|
243
|
+
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.',
|
|
244
|
+
lifecycle: 'session-end',
|
|
245
|
+
command: `${FORGE_CLI} hooks capture`,
|
|
246
|
+
}),
|
|
155
247
|
]),
|
|
156
248
|
});
|
|
157
249
|
|
|
@@ -202,6 +294,16 @@ function userPromptSubmitCapability(harness) {
|
|
|
202
294
|
return USER_PROMPT_SUBMIT_SUPPORT[harness] || { rendered: false, reason: 'unknown-harness' };
|
|
203
295
|
}
|
|
204
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Report the per-harness session-END (capture-on-exit) capability (the honest matrix for
|
|
299
|
+
* the PreCompact + Stop capture tier).
|
|
300
|
+
* @param {string} harness
|
|
301
|
+
* @returns {{ rendered: boolean, reason?: string }}
|
|
302
|
+
*/
|
|
303
|
+
function sessionEndCapability(harness) {
|
|
304
|
+
return SESSION_END_SUPPORT[harness] || { rendered: false, reason: 'unknown-harness' };
|
|
305
|
+
}
|
|
306
|
+
|
|
205
307
|
/**
|
|
206
308
|
* Render the Claude `.claude/settings.json` `hooks` block (PreToolUse groups only).
|
|
207
309
|
* Write/Edit/MultiEdit/NotebookEdit → protected-path deny; Bash → TDD gate.
|
|
@@ -229,8 +331,30 @@ function renderClaudeHooks(contract) {
|
|
|
229
331
|
// Surfaces pending targeted dashboard instruction comments (fenced kernel DATA) on each
|
|
230
332
|
// prompt; the command emits { hookSpecificOutput.additionalContext }. Reads the user's
|
|
231
333
|
// own kernel data via a supported hook — NEVER stdin injection (Anthropic Usage Policy).
|
|
334
|
+
// The UserPromptSubmit context hooks share ONE Forge-owned group (inbox-pickup nudge,
|
|
335
|
+
// PR-shepherd deltas, and query-relevant memory recall). Claude runs every hook in the
|
|
336
|
+
// group and appends each hook's additionalContext; keeping them in one group means a
|
|
337
|
+
// re-merge replaces the set atomically (the group is Forge-owned via any of their
|
|
338
|
+
// markers). All three are compact + fail-open. (memory-recall reads its own hook stdin
|
|
339
|
+
// for the prompt — that is its supported input, not session-stdin injection.)
|
|
232
340
|
UserPromptSubmit: [
|
|
233
|
-
{
|
|
341
|
+
{
|
|
342
|
+
hooks: [
|
|
343
|
+
{ type: 'command', command: harnessCommand(contract, 'inbox-pickup', 'claude') },
|
|
344
|
+
{ type: 'command', command: harnessCommand(contract, 'shepherd-events', 'claude') },
|
|
345
|
+
{ type: 'command', command: harnessCommand(contract, 'memory-recall', 'claude') },
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
// Capture-on-exit (memory capture). PreCompact fires before context is compacted; Stop
|
|
350
|
+
// fires when the agent finishes. Both call the same capture command; the event stamps the
|
|
351
|
+
// --trigger so the CLI never reads hook stdin. The command persists a bounded session
|
|
352
|
+
// summary — it emits NO stdout (a Stop hook that printed text would inject into the turn).
|
|
353
|
+
PreCompact: [
|
|
354
|
+
{ hooks: [{ type: 'command', command: `${harnessCommand(contract, 'memory-capture', 'claude')} --trigger precompact` }] },
|
|
355
|
+
],
|
|
356
|
+
Stop: [
|
|
357
|
+
{ hooks: [{ type: 'command', command: `${harnessCommand(contract, 'memory-capture', 'claude')} --trigger stop` }] },
|
|
234
358
|
],
|
|
235
359
|
};
|
|
236
360
|
}
|
|
@@ -311,7 +435,10 @@ function isForgeCommand(command) {
|
|
|
311
435
|
return typeof command === 'string'
|
|
312
436
|
&& (command.includes(FORGE_HOOK_MARKER)
|
|
313
437
|
|| command.includes(FORGE_CONTEXT_MARKER)
|
|
314
|
-
|| command.includes(FORGE_INBOX_CONTEXT_MARKER)
|
|
438
|
+
|| command.includes(FORGE_INBOX_CONTEXT_MARKER)
|
|
439
|
+
|| command.includes(FORGE_SHEPHERD_EVENTS_MARKER)
|
|
440
|
+
|| command.includes(FORGE_MEMORY_RECALL_MARKER)
|
|
441
|
+
|| command.includes(FORGE_CAPTURE_CONTEXT_MARKER));
|
|
315
442
|
}
|
|
316
443
|
|
|
317
444
|
/** True when a hook group/entry is Forge-owned (any inner command is Forge-owned). */
|
|
@@ -436,10 +563,15 @@ module.exports = {
|
|
|
436
563
|
FORGE_HOOK_MARKER,
|
|
437
564
|
FORGE_CONTEXT_MARKER,
|
|
438
565
|
FORGE_INBOX_CONTEXT_MARKER,
|
|
566
|
+
FORGE_MEMORY_RECALL_MARKER,
|
|
567
|
+
FORGE_CAPTURE_CONTEXT_MARKER,
|
|
439
568
|
SESSION_START_SUPPORT,
|
|
569
|
+
BOOTSTRAP_DELIVERY,
|
|
440
570
|
USER_PROMPT_SUBMIT_SUPPORT,
|
|
571
|
+
SESSION_END_SUPPORT,
|
|
441
572
|
sessionStartCapability,
|
|
442
573
|
userPromptSubmitCapability,
|
|
574
|
+
sessionEndCapability,
|
|
443
575
|
HookConfigParseError,
|
|
444
576
|
renderClaudeHooks,
|
|
445
577
|
renderCursorHooks,
|