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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.forge/hooks/check-tdd.js +79 -5
  2. package/.forge/hooks/forge-native-hook.js +194 -8
  3. package/AGENTS.md +2 -1
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge-cmd.js +1 -1
  8. package/bin/forge.js +95 -19
  9. package/docs/guides/SETUP.md +4 -1
  10. package/docs/guides/SUPPORT.md +5 -0
  11. package/docs/reference/COMMANDS.md +12 -1
  12. package/docs/reference/shepherd.md +114 -3
  13. package/lib/activation/ensure-forge-home.js +135 -0
  14. package/lib/adapters/beads-kernel-compat.js +67 -0
  15. package/lib/adapters/greptile-review-adapter.js +1 -1
  16. package/lib/adapters/pr-state-adapter.js +103 -8
  17. package/lib/adoption-profiles.js +17 -4
  18. package/lib/agents-config.js +5 -0
  19. package/lib/beads-detect.js +60 -0
  20. package/lib/commands/_aliases.js +248 -0
  21. package/lib/commands/_issue.js +65 -41
  22. package/lib/commands/_manifest.js +3 -1
  23. package/lib/commands/_registry.js +14 -0
  24. package/lib/commands/_resolve-command-opts.js +36 -60
  25. package/lib/commands/claim.js +2 -4
  26. package/lib/commands/gate.js +19 -2
  27. package/lib/commands/hooks.js +294 -12
  28. package/lib/commands/init.js +26 -20
  29. package/lib/commands/memory.js +81 -0
  30. package/lib/commands/migrate.js +0 -161
  31. package/lib/commands/plan.js +71 -123
  32. package/lib/commands/pr.js +88 -0
  33. package/lib/commands/prime.js +8 -1
  34. package/lib/commands/push.js +66 -0
  35. package/lib/commands/recall.js +67 -12
  36. package/lib/commands/recap.js +18 -4
  37. package/lib/commands/release.js +15 -3
  38. package/lib/commands/remember.js +86 -20
  39. package/lib/commands/serve.js +5 -2
  40. package/lib/commands/setup.js +135 -73
  41. package/lib/commands/shepherd.js +102 -4
  42. package/lib/commands/ship.js +40 -4
  43. package/lib/commands/skill.js +275 -0
  44. package/lib/commands/status.js +37 -32
  45. package/lib/commands/test.js +32 -0
  46. package/lib/commands/worktree.js +87 -5
  47. package/lib/core/runtime-graph.js +122 -17
  48. package/lib/forge-issues.js +13 -464
  49. package/lib/gate-events.js +54 -55
  50. package/lib/global-flags.js +30 -0
  51. package/lib/grounding/context-events.js +230 -0
  52. package/lib/grounding/read-first.js +112 -0
  53. package/lib/harness-capability-matrix.js +2 -2
  54. package/lib/hook-renderer.js +144 -12
  55. package/lib/issue-backend.js +42 -3
  56. package/lib/kernel/backing-issue.js +7 -1
  57. package/lib/kernel/broker.js +28 -0
  58. package/lib/kernel/migrations.js +30 -2
  59. package/lib/kernel/owned-kernel.js +43 -0
  60. package/lib/kernel/schema.js +35 -0
  61. package/lib/kernel/sqlite-driver.js +187 -1
  62. package/lib/memory-recall.js +151 -0
  63. package/lib/orientation.js +303 -6
  64. package/lib/pr-bundle.js +6 -2
  65. package/lib/pr-monitor/auto-actions.js +175 -0
  66. package/lib/pr-monitor/digest.js +206 -0
  67. package/lib/pr-monitor/reconcile-executor.js +659 -0
  68. package/lib/pr-monitor/reconcile-tick.js +138 -0
  69. package/lib/pr-monitor/reconcile.js +0 -0
  70. package/lib/pr-monitor/render-sticky.js +57 -8
  71. package/lib/pr-monitor/shepherd-lease.js +243 -0
  72. package/lib/pr-monitor/upsert-sticky.js +169 -0
  73. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  74. package/lib/pr-pull.js +113 -17
  75. package/lib/project-memory.js +8 -0
  76. package/lib/release-readiness.js +17 -1
  77. package/lib/rules-sync.js +4 -0
  78. package/lib/runtime-health.js +15 -46
  79. package/lib/skill-eval.js +750 -0
  80. package/lib/status/identity.js +46 -0
  81. package/lib/status/presenter.js +0 -35
  82. package/lib/status/snapshot.js +11 -16
  83. package/lib/upgrade-safety.js +52 -1
  84. package/lib/using-forge.js +315 -0
  85. package/lib/workflow/enforce-stage.js +64 -7
  86. package/lib/workflow/state-manager.js +23 -23
  87. package/package.json +2 -2
  88. package/rules/using-forge.md +24 -0
  89. package/scripts/forge-team/index.sh +0 -5
  90. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  91. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  92. package/scripts/pr-auto-actions.js +93 -0
  93. package/scripts/pr-verdict-label.js +50 -0
  94. package/scripts/test.js +8 -1
  95. package/skills/claim-safety/SKILL.md +4 -0
  96. package/skills/claim-safety/evals/scorecard.json +41 -0
  97. package/skills/coverage.json +83 -0
  98. package/skills/dev/SKILL.md +4 -0
  99. package/skills/dev/evals/scorecard.json +41 -0
  100. package/skills/gates/SKILL.md +80 -0
  101. package/skills/gates/evals/evals.json +38 -0
  102. package/skills/gates/evals/scorecard.json +41 -0
  103. package/skills/hermes-forge/SKILL.md +1 -0
  104. package/skills/hermes-forge/evals/scorecard.json +41 -0
  105. package/skills/issue-basics/SKILL.md +1 -0
  106. package/skills/issue-basics/evals/scorecard.json +41 -0
  107. package/skills/kernel/SKILL.md +38 -0
  108. package/skills/kernel/evals/scorecard.json +41 -0
  109. package/skills/memory/SKILL.md +16 -1
  110. package/skills/memory/evals/scorecard.json +41 -0
  111. package/skills/parallel-deep-research/SKILL.md +1 -0
  112. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  113. package/skills/plan/SKILL.md +6 -0
  114. package/skills/plan/evals/scorecard.json +41 -0
  115. package/skills/portability/SKILL.md +47 -0
  116. package/skills/portability/evals/evals.json +34 -0
  117. package/skills/portability/evals/scorecard.json +41 -0
  118. package/skills/research/SKILL.md +1 -0
  119. package/skills/research/evals/scorecard.json +41 -0
  120. package/skills/review/SKILL.md +6 -0
  121. package/skills/review/evals/scorecard.json +41 -0
  122. package/skills/rollback/SKILL.md +1 -0
  123. package/skills/rollback/evals/scorecard.json +41 -0
  124. package/skills/setup/SKILL.md +91 -0
  125. package/skills/setup/evals/evals.json +42 -0
  126. package/skills/setup/evals/scorecard.json +41 -0
  127. package/skills/shepherd/SKILL.md +76 -36
  128. package/skills/shepherd/evals/evals.json +21 -9
  129. package/skills/shepherd/evals/scorecard.json +41 -0
  130. package/skills/ship/SKILL.md +6 -0
  131. package/skills/ship/evals/scorecard.json +41 -0
  132. package/skills/smith/SKILL.md +8 -0
  133. package/skills/smith/evals/scorecard.json +41 -0
  134. package/skills/sonarcloud/SKILL.md +1 -0
  135. package/skills/sonarcloud/evals/scorecard.json +41 -0
  136. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  137. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  138. package/skills/status/SKILL.md +3 -0
  139. package/skills/status/evals/scorecard.json +41 -0
  140. package/skills/triage-ready/SKILL.md +2 -0
  141. package/skills/triage-ready/evals/scorecard.json +41 -0
  142. package/skills/using-forge/SKILL.md +104 -0
  143. package/skills/using-forge/evals/scorecard.json +41 -0
  144. package/skills/validate/SKILL.md +4 -0
  145. package/skills/validate/evals/scorecard.json +41 -0
  146. package/skills/verify/SKILL.md +4 -0
  147. package/skills/verify/evals/scorecard.json +41 -0
  148. package/skills/worktree/SKILL.md +87 -0
  149. package/skills/worktree/evals/evals.json +38 -0
  150. package/skills/worktree/evals/scorecard.json +41 -0
  151. package/lib/adapters/beads-issue-adapter.js +0 -127
  152. package/lib/commands/board.js +0 -64
  153. package/lib/status/beads-snapshot.js +0 -145
  154. package/scripts/forge-team/lib/dashboard.sh +0 -316
  155. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
@@ -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
 
@@ -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
- // Per-harness SessionStart context-injection capability. Honest capability matrix
80
- // only Claude exposes a native session-start surface that can inject additionalContext.
81
- // Cursor's 1.7 hooks are deny-oriented (no session-start context surface); Codex and
82
- // Hermes hooks live in GLOBAL home config that project setup never writes. We NEVER
83
- // fake parity: each non-Claude harness carries an explicit, tested skip reason.
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: 'no-session-start-surface' }),
87
- codex: Object.freeze({ rendered: false, reason: 'global-config' }),
88
- hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
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.1.0',
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
- { hooks: [{ type: 'command', command: harnessCommand(contract, 'inbox-pickup', 'claude') }] },
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,