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

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 (196) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +21 -1
  5. package/bin/forge.js +16 -369
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +9 -4
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+
3
+ // The ignored JSONL audit log is visibility-only. Workflow write authority is
4
+ // an append-only Kernel capability scoped to one worktree and consumed once.
5
+
6
+ const { randomUUID } = require('node:crypto');
7
+ const fs = require('node:fs');
8
+ const path = require('node:path');
9
+ const { resolveOwnedKernel, closeIfOwned } = require('./kernel/owned-kernel');
10
+ const { hashProtectedContent, normalizeRepoPath } = require('./protected-state-surfaces');
11
+
12
+ const PROTECTED_STATE_ENTITY_TYPE = 'protected_state';
13
+ const PROTECTED_STATE_AUTHORIZATION_ISSUED = 'protected_state.authorization.issued';
14
+ const PROTECTED_STATE_AUTHORIZATION_CONSUMED = 'protected_state.authorization.consumed';
15
+ const PROTECTED_STATE_AUTHORIZATION_VERSION = 1;
16
+ const PROTECTED_STATE_AUTHORIZATION_ORIGIN = 'cli';
17
+ const NPM_WORKFLOW_SOURCE_COMMAND = 'forge release generate-npm-workflow';
18
+
19
+ function resolveWorktreeScope(projectRoot, deps = {}) {
20
+ const resolved = path.resolve(projectRoot);
21
+ const realpath = deps.realpathSync || fs.realpathSync.native;
22
+ let canonical;
23
+ try {
24
+ canonical = realpath(resolved);
25
+ } catch {
26
+ canonical = resolved;
27
+ }
28
+ if (process.platform === 'win32') canonical = canonical.toLowerCase();
29
+ return hashProtectedContent(canonical);
30
+ }
31
+
32
+ function authorizationEntityId(worktreeScope, filePath) {
33
+ return `${worktreeScope}:${normalizeRepoPath(filePath)}`;
34
+ }
35
+
36
+ function parsePayload(row) {
37
+ try {
38
+ return row?.payload_json ? JSON.parse(row.payload_json) : (row?.payload || {});
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ function parseAuthorizationEvent(row) {
45
+ const payload = parsePayload(row);
46
+ return {
47
+ eventType: row?.event_type,
48
+ actor: row?.actor,
49
+ payloadActor: payload.actor,
50
+ origin: row?.origin,
51
+ entityId: row?.entity_id,
52
+ createdAt: row?.created_at,
53
+ version: payload.version,
54
+ capabilityId: payload.capabilityId,
55
+ path: normalizeRepoPath(payload.path),
56
+ surface: payload.surface,
57
+ contentHash: payload.contentHash,
58
+ operation: payload.operation,
59
+ sourceCommand: payload.sourceCommand,
60
+ worktreeScope: payload.worktreeScope,
61
+ };
62
+ }
63
+
64
+ function blockedDecision(request, reason) {
65
+ return {
66
+ allowed: false,
67
+ decision: 'blocked',
68
+ actor: request.actor,
69
+ path: normalizeRepoPath(request.path),
70
+ operation: request.operation || 'staged_edit',
71
+ requiredSurface: request.surface,
72
+ declaredSurface: request.surface,
73
+ contentHash: hashProtectedContent(request.content),
74
+ reason,
75
+ repairHint: 'Regenerate the protected file through its owning Forge command, then stage that exact output.',
76
+ };
77
+ }
78
+
79
+ function evaluateAuthorization(request, rows = []) {
80
+ const expected = {
81
+ actor: request.actor,
82
+ path: normalizeRepoPath(request.path),
83
+ surface: request.surface,
84
+ contentHash: hashProtectedContent(request.content),
85
+ worktreeScope: request.worktreeScope,
86
+ };
87
+ const issued = rows
88
+ .filter(row => row?.event_type === PROTECTED_STATE_AUTHORIZATION_ISSUED)
89
+ .map(parseAuthorizationEvent);
90
+ const consumedCapabilities = new Set(rows
91
+ .filter(row => row?.event_type === PROTECTED_STATE_AUTHORIZATION_CONSUMED)
92
+ .map(row => parseAuthorizationEvent(row).capabilityId)
93
+ .filter(Boolean));
94
+ const active = issued.filter(event => !consumedCapabilities.has(event.capabilityId));
95
+
96
+ if (issued.length === 0) {
97
+ return blockedDecision(request, 'No Forge-owned authorization exists for this protected path and content-bound write.');
98
+ }
99
+ if (active.length === 0) {
100
+ return blockedDecision(request, 'The latest Forge-owned authorization was already consumed; stale and same-content replays are denied.');
101
+ }
102
+ if (active.length !== 1) {
103
+ return blockedDecision(request, 'Protected-state authority is ambiguous because multiple unconsumed authorizations exist; failing closed.');
104
+ }
105
+ const latest = active[0];
106
+
107
+ const structurallyValid =
108
+ latest.version === PROTECTED_STATE_AUTHORIZATION_VERSION &&
109
+ typeof latest.capabilityId === 'string' && latest.capabilityId.length > 0 &&
110
+ latest.origin === PROTECTED_STATE_AUTHORIZATION_ORIGIN &&
111
+ latest.actor === latest.payloadActor &&
112
+ latest.worktreeScope === expected.worktreeScope &&
113
+ latest.entityId === authorizationEntityId(expected.worktreeScope, latest.path) &&
114
+ latest.sourceCommand === NPM_WORKFLOW_SOURCE_COMMAND;
115
+ if (!structurallyValid) {
116
+ return blockedDecision(request, 'The latest Forge-owned authorization is malformed or was not issued by the owning command.');
117
+ }
118
+
119
+ if (
120
+ latest.actor !== expected.actor ||
121
+ latest.path !== expected.path ||
122
+ latest.surface !== expected.surface ||
123
+ latest.contentHash !== expected.contentHash
124
+ ) {
125
+ return blockedDecision(request, 'The latest Forge-owned content-bound authorization does not match this actor, surface, path, and content hash.');
126
+ }
127
+
128
+ return {
129
+ allowed: true,
130
+ decision: 'allowed',
131
+ actor: expected.actor,
132
+ path: expected.path,
133
+ operation: request.operation || 'staged_edit',
134
+ requiredSurface: expected.surface,
135
+ declaredSurface: expected.surface,
136
+ contentHash: expected.contentHash,
137
+ capabilityId: latest.capabilityId,
138
+ worktreeScope: latest.worktreeScope,
139
+ reason: 'Staged content matches the latest unconsumed Forge-owned authorization.',
140
+ repairHint: null,
141
+ };
142
+ }
143
+
144
+ async function issueProtectedStateAuthorization(projectRoot, request = {}, options = {}) {
145
+ const actor = request.actor;
146
+ const normalizedPath = normalizeRepoPath(request.path);
147
+ if (!actor || !request.surface || !normalizedPath || request.content === undefined) {
148
+ throw new TypeError('Protected state authorization requires actor, surface, path, and content');
149
+ }
150
+
151
+ const capabilityId = options.capabilityId || randomUUID();
152
+ const createdAt = options.now || new Date().toISOString();
153
+ const worktreeScope = options.worktreeScope || resolveWorktreeScope(projectRoot, options.deps);
154
+ const entityId = authorizationEntityId(worktreeScope, normalizedPath);
155
+ const event = {
156
+ entity_type: PROTECTED_STATE_ENTITY_TYPE,
157
+ entity_id: entityId,
158
+ event_type: PROTECTED_STATE_AUTHORIZATION_ISSUED,
159
+ idempotency_key: `${PROTECTED_STATE_AUTHORIZATION_ISSUED}:${capabilityId}`,
160
+ expected_revision: 0,
161
+ actor,
162
+ origin: PROTECTED_STATE_AUTHORIZATION_ORIGIN,
163
+ payload: {
164
+ version: PROTECTED_STATE_AUTHORIZATION_VERSION,
165
+ capabilityId,
166
+ actor,
167
+ path: normalizedPath,
168
+ surface: request.surface,
169
+ contentHash: hashProtectedContent(request.content),
170
+ worktreeScope,
171
+ operation: request.operation || 'generate',
172
+ sourceCommand: request.sourceCommand || NPM_WORKFLOW_SOURCE_COMMAND,
173
+ },
174
+ created_at: createdAt,
175
+ };
176
+
177
+ const kernel = await resolveOwnedKernel(projectRoot, options.deps);
178
+ try {
179
+ const existingRows = await kernel.driver.listKernelEvents(
180
+ PROTECTED_STATE_ENTITY_TYPE,
181
+ entityId,
182
+ {},
183
+ kernel.config,
184
+ );
185
+ const consumedCapabilities = new Set((existingRows || [])
186
+ .filter(row => row?.event_type === PROTECTED_STATE_AUTHORIZATION_CONSUMED)
187
+ .map(row => parseAuthorizationEvent(row).capabilityId)
188
+ .filter(Boolean));
189
+ const activeCapabilities = (existingRows || [])
190
+ .filter(row => row?.event_type === PROTECTED_STATE_AUTHORIZATION_ISSUED)
191
+ .map(parseAuthorizationEvent)
192
+ .filter(existing => existing.capabilityId && !consumedCapabilities.has(existing.capabilityId));
193
+ for (const existing of activeCapabilities) {
194
+ await kernel.driver.insertKernelEvent({
195
+ entity_type: PROTECTED_STATE_ENTITY_TYPE,
196
+ entity_id: entityId,
197
+ event_type: PROTECTED_STATE_AUTHORIZATION_CONSUMED,
198
+ idempotency_key: `${PROTECTED_STATE_AUTHORIZATION_CONSUMED}:${existing.capabilityId}`,
199
+ expected_revision: 0,
200
+ actor,
201
+ origin: PROTECTED_STATE_AUTHORIZATION_ORIGIN,
202
+ payload: {
203
+ version: PROTECTED_STATE_AUTHORIZATION_VERSION,
204
+ capabilityId: existing.capabilityId,
205
+ actor,
206
+ path: normalizedPath,
207
+ surface: existing.surface,
208
+ contentHash: existing.contentHash,
209
+ worktreeScope,
210
+ operation: 'superseded',
211
+ sourceCommand: request.sourceCommand || NPM_WORKFLOW_SOURCE_COMMAND,
212
+ },
213
+ created_at: createdAt,
214
+ }, {}, kernel.config);
215
+ }
216
+ const inserted = await kernel.driver.insertKernelEvent(event, {}, kernel.config);
217
+ return { success: true, capabilityId, event: parseAuthorizationEvent(inserted) };
218
+ } finally {
219
+ closeIfOwned(kernel);
220
+ }
221
+ }
222
+
223
+ async function issueNpmPublishWorkflowAuthorization(projectRoot, params = {}, options = {}) {
224
+ const {
225
+ NPM_PUBLISH_WORKFLOW_PATH,
226
+ renderNpmPublishWorkflow,
227
+ } = require('./npm-publish-workflow');
228
+ return issueProtectedStateAuthorization(projectRoot, {
229
+ actor: params.actor,
230
+ surface: 'workflows',
231
+ path: NPM_PUBLISH_WORKFLOW_PATH,
232
+ content: renderNpmPublishWorkflow(),
233
+ operation: 'generate_npm_workflow',
234
+ sourceCommand: NPM_WORKFLOW_SOURCE_COMMAND,
235
+ }, options);
236
+ }
237
+
238
+ async function authorizeAndConsumeProtectedStateWrites(projectRoot, requests = [], options = {}) {
239
+ if (requests.length === 0) return { success: true, decisions: [] };
240
+ const worktreeScope = options.worktreeScope || resolveWorktreeScope(projectRoot, options.deps);
241
+ const kernel = await resolveOwnedKernel(projectRoot, options.deps);
242
+ try {
243
+ const decisions = [];
244
+ for (const request of requests) {
245
+ const normalizedPath = normalizeRepoPath(request.path);
246
+ const entityId = authorizationEntityId(worktreeScope, normalizedPath);
247
+ const rows = await kernel.driver.listKernelEvents(
248
+ PROTECTED_STATE_ENTITY_TYPE,
249
+ entityId,
250
+ {},
251
+ kernel.config,
252
+ );
253
+ decisions.push(evaluateAuthorization({
254
+ ...request,
255
+ path: normalizedPath,
256
+ worktreeScope,
257
+ }, rows || []));
258
+ }
259
+
260
+ if (decisions.some(decision => !decision.allowed)) {
261
+ return { success: false, decisions };
262
+ }
263
+
264
+ for (const decision of decisions) {
265
+ const createdAt = options.now || new Date().toISOString();
266
+ await kernel.driver.insertKernelEvent({
267
+ entity_type: PROTECTED_STATE_ENTITY_TYPE,
268
+ entity_id: authorizationEntityId(decision.worktreeScope, decision.path),
269
+ event_type: PROTECTED_STATE_AUTHORIZATION_CONSUMED,
270
+ idempotency_key: `${PROTECTED_STATE_AUTHORIZATION_CONSUMED}:${decision.capabilityId}`,
271
+ expected_revision: 0,
272
+ actor: decision.actor,
273
+ origin: PROTECTED_STATE_AUTHORIZATION_ORIGIN,
274
+ payload: {
275
+ version: PROTECTED_STATE_AUTHORIZATION_VERSION,
276
+ capabilityId: decision.capabilityId,
277
+ actor: decision.actor,
278
+ path: decision.path,
279
+ surface: decision.requiredSurface,
280
+ contentHash: decision.contentHash,
281
+ worktreeScope: decision.worktreeScope,
282
+ operation: 'staged_edit',
283
+ sourceCommand: 'scripts/protected-state-check.js',
284
+ },
285
+ created_at: createdAt,
286
+ }, {}, kernel.config);
287
+ }
288
+
289
+ return { success: true, decisions };
290
+ } finally {
291
+ closeIfOwned(kernel);
292
+ }
293
+ }
294
+
295
+ module.exports = {
296
+ PROTECTED_STATE_ENTITY_TYPE,
297
+ PROTECTED_STATE_AUTHORIZATION_ISSUED,
298
+ PROTECTED_STATE_AUTHORIZATION_CONSUMED,
299
+ resolveWorktreeScope,
300
+ authorizationEntityId,
301
+ parseAuthorizationEvent,
302
+ evaluateAuthorization,
303
+ issueNpmPublishWorkflowAuthorization,
304
+ authorizeAndConsumeProtectedStateWrites,
305
+ };
@@ -1,7 +1,17 @@
1
1
  const fs = require('node:fs');
2
2
  const path = require('node:path');
3
- const { execFileSync } = require('node:child_process');
3
+ const crypto = require('node:crypto');
4
4
  const { redact } = require('./audit-evidence');
5
+ const { appendCappedJsonlRecord } = require('./capped-jsonl-log');
6
+
7
+ /**
8
+ * Protected-state decisions are logged locally rather than to the kernel event
9
+ * stream: kernel events are issue-scoped and async, and this runs inside a
10
+ * synchronous pre-commit hook that has no issue id and must not open the kernel
11
+ * SQLite handle. Capped so a repeated blocked commit cannot grow it unbounded.
12
+ */
13
+ const PROTECTED_STATE_AUDIT_LOG = '.forge/protected-state-audit.jsonl';
14
+ const PROTECTED_STATE_AUDIT_MAX_RECORDS = 500;
5
15
 
6
16
  function normalizeRepoPath(filePath) {
7
17
  return String(filePath || '')
@@ -10,6 +20,30 @@ function normalizeRepoPath(filePath) {
10
20
  .replace(/\/+/g, '/');
11
21
  }
12
22
 
23
+ function hashProtectedContent(content) {
24
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
25
+ return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
26
+ }
27
+
28
+ function createProtectedStateAuditRecord({ actor, surface, path: filePath, content, operation = 'generate' }) {
29
+ if (!actor || !surface || !filePath || content === undefined) {
30
+ throw new TypeError('Protected state audit record requires actor, surface, path, and content');
31
+ }
32
+
33
+ return {
34
+ kind: 'protected_state_write',
35
+ actor,
36
+ path: normalizeRepoPath(filePath),
37
+ decision: 'allowed',
38
+ requiredSurface: surface,
39
+ declaredSurface: surface,
40
+ operation,
41
+ contentHash: hashProtectedContent(content),
42
+ reason: `Forge API generated content for protected surface: ${surface}.`,
43
+ repairHint: null,
44
+ };
45
+ }
46
+
13
47
  function startsWithAny(filePath, prefixes) {
14
48
  return prefixes.some(prefix => filePath === prefix || filePath.startsWith(`${prefix}/`));
15
49
  }
@@ -42,13 +76,13 @@ const PROTECTED_SURFACES = [
42
76
  '.forge/agent-log.ndjson',
43
77
  '.beads/interactions.jsonl',
44
78
  ].includes(filePath),
45
- repairHint: 'append-only logs must be written by the Forge or Beads audit writer; do not rewrite existing log content.',
79
+ repairHint: 'append-only logs must be written by the Forge audit writer; do not rewrite existing log content.',
46
80
  },
47
81
  {
48
82
  id: 'beads_state',
49
- label: 'Beads state',
83
+ label: 'Legacy Beads state',
50
84
  matches: filePath => startsWithAny(filePath, ['.beads']),
51
- repairHint: 'Use bd or Forge issue commands such as bd update, bd close, forge ready, or forge close.',
85
+ repairHint: 'A legacy .beads directory is import-only state. Import it with `forge migrate --from beads`, then use Forge issue commands such as forge ready or forge close.',
52
86
  },
53
87
  {
54
88
  id: 'forge_config',
@@ -164,8 +198,12 @@ function nearestExistingPath(candidate) {
164
198
  return current;
165
199
  }
166
200
 
201
+ function realpathNearestExistingPath(candidate) {
202
+ return fs.realpathSync(nearestExistingPath(candidate));
203
+ }
204
+
167
205
  function assertNoAncestorSymlinkEscape(root, target) {
168
- const realRoot = fs.realpathSync(root);
206
+ const realRoot = realpathNearestExistingPath(root);
169
207
  const existing = nearestExistingPath(path.dirname(target));
170
208
  const existingStat = lstatIfPresent(existing);
171
209
  if (existing !== root && existingStat?.isSymbolicLink()) {
@@ -177,7 +215,7 @@ function assertNoAncestorSymlinkEscape(root, target) {
177
215
  };
178
216
  }
179
217
 
180
- const realExisting = fs.realpathSync(existing);
218
+ const realExisting = realpathNearestExistingPath(existing);
181
219
  if (!pathStaysInsideRoot(realRoot, realExisting)) {
182
220
  return {
183
221
  allowed: false,
@@ -191,8 +229,8 @@ function assertNoAncestorSymlinkEscape(root, target) {
191
229
 
192
230
  function assertNoSymlinkEscape(root, target) {
193
231
  const parent = path.dirname(target);
194
- const realRoot = fs.realpathSync(root);
195
- const existingParent = fs.existsSync(parent) ? fs.realpathSync(parent) : parent;
232
+ const realRoot = realpathNearestExistingPath(root);
233
+ const existingParent = realpathNearestExistingPath(parent);
196
234
  if (!pathStaysInsideRoot(realRoot, existingParent)) {
197
235
  return {
198
236
  allowed: false,
@@ -297,7 +335,7 @@ function writeProtectedFile(projectRoot, filePath, content, options = {}) {
297
335
  }
298
336
 
299
337
  fs.writeFileSync(resolved.target, content, options.encoding || 'utf8');
300
- return { ...decision, fullPath: resolved.target };
338
+ return { ...decision, contentHash: hashProtectedContent(content), fullPath: resolved.target };
301
339
  }
302
340
 
303
341
  function buildProtectedStateAuditEvent(decision) {
@@ -313,12 +351,14 @@ function buildProtectedStateAuditEvent(decision) {
313
351
  requiredSurface: decision.requiredSurface,
314
352
  declaredSurface: decision.declaredSurface || null,
315
353
  operation: decision.operation || 'write',
354
+ contentHash: decision.contentHash || null,
316
355
  reason: decision.reason,
317
356
  repairHint: decision.repairHint,
318
357
  metadata: {
319
358
  actor: decision.actor || 'unknown',
320
359
  path: decision.path,
321
360
  operation: decision.operation || 'write',
361
+ contentHash: decision.contentHash || null,
322
362
  requiredSurface: decision.requiredSurface,
323
363
  declaredSurface: decision.declaredSurface || null,
324
364
  decision: decision.decision,
@@ -330,51 +370,31 @@ function buildProtectedStateAuditEvent(decision) {
330
370
  return redact(event);
331
371
  }
332
372
 
373
+ /**
374
+ * Best-effort: a failed audit write is reported to the caller, never thrown, so
375
+ * the protected-state decision itself still stands.
376
+ */
333
377
  function recordProtectedStateAuditEvent(decision, options = {}) {
334
378
  const event = buildProtectedStateAuditEvent(decision);
335
- const runCommand = options.runCommand || execFileSync;
336
- try {
337
- const args = [
338
- 'audit',
339
- 'record',
340
- '--json',
341
- '--kind',
342
- 'protected_state_write',
343
- '--model',
344
- 'forge-protected-state',
345
- '--prompt',
346
- JSON.stringify({
347
- actor: event.actor,
348
- path: event.path,
349
- operation: event.operation,
350
- }),
351
- '--response',
352
- JSON.stringify({
353
- decision: event.decision,
354
- requiredSurface: event.requiredSurface,
355
- repairHint: event.repairHint,
356
- }),
357
- ];
358
-
359
- if (event.metadata) {
360
- args.push('--meta-json', JSON.stringify(event.metadata));
361
- }
379
+ const logPath = path.resolve(options.cwd || process.cwd(), PROTECTED_STATE_AUDIT_LOG);
380
+ const appendRecord = options.appendRecord || appendCappedJsonlRecord;
362
381
 
363
- const output = runCommand('bd', args, {
364
- cwd: options.cwd || process.cwd(),
365
- encoding: 'utf8',
366
- stdio: ['ignore', 'pipe', 'pipe'],
367
- timeout: options.timeoutMs || 5000,
368
- });
369
- return { success: true, event, output };
382
+ try {
383
+ const record = { ...event, recordedAt: options.now || new Date().toISOString() };
384
+ appendRecord(logPath, record, options.maxRecords || PROTECTED_STATE_AUDIT_MAX_RECORDS);
385
+ return { success: true, event, logPath };
370
386
  } catch (error) {
371
- return { success: false, event, error: error.message };
387
+ return { success: false, event, logPath, error: error.message };
372
388
  }
373
389
  }
374
390
 
375
391
  module.exports = {
376
392
  PROTECTED_SURFACES,
393
+ PROTECTED_STATE_AUDIT_LOG,
394
+ PROTECTED_STATE_AUDIT_MAX_RECORDS,
377
395
  normalizeRepoPath,
396
+ hashProtectedContent,
397
+ createProtectedStateAuditRecord,
378
398
  resolveRepoRelativePath,
379
399
  lstatIfPresent,
380
400
  assertNoAncestorSymlinkEscape,
@@ -413,13 +413,27 @@ function readSyncManifestScanRoots(projectRoot) {
413
413
  }
414
414
  }
415
415
 
416
+ // Scan roots reach here in whatever spelling their source used — a plugin manifest
417
+ // directory entry, a sync-manifest file, an explicit scanRoots option — so they are
418
+ // normalized once, at the single place they are resolved. walkFiles would join a trailing
419
+ // separator away and scan the tree regardless, while isBdCensusPath would build `root//`
420
+ // and match nothing under it; normalizing here is what keeps the census and the
421
+ // pre-commit predicate from disagreeing about which files count.
422
+ function normalizeScanRoot(root) {
423
+ if (typeof root !== 'string' || root.length === 0) {
424
+ return '';
425
+ }
426
+ return normalizeRepoPath(root).replace(/[/\\]+$/, '');
427
+ }
428
+
416
429
  function getScanRoots(projectRoot, options = {}) {
417
430
  const roots = options.scanRoots || getDefaultScanRoots(projectRoot);
418
431
  return [
419
- ...new Set([
420
- ...roots,
421
- ...readSyncManifestScanRoots(projectRoot),
422
- ]),
432
+ ...new Set(
433
+ [...roots, ...readSyncManifestScanRoots(projectRoot)]
434
+ .map(normalizeScanRoot)
435
+ .filter(Boolean),
436
+ ),
423
437
  ];
424
438
  }
425
439
 
@@ -557,6 +571,38 @@ function auditBdCallSites(projectRoot, options = {}) {
557
571
  };
558
572
  }
559
573
 
574
+ // Would a change to this repo-relative path shift the bd call-site census?
575
+ //
576
+ // The pre-commit auto-heal (scripts/sync-d20-audit.js) asks this before doing any work,
577
+ // so it must answer exactly what auditBdCallSites would scan. It is therefore assembled
578
+ // from that walk's own pieces — getScanRoots, shouldSkipRelativePath, isTextFile,
579
+ // SKIP_DIR_NAMES — rather than a parallel list that could drift from the gate.
580
+ function isBdCensusPath(projectRoot, filePath, options = {}) {
581
+ const relativePath = safeNormalizeRepoPath(projectRoot, filePath);
582
+ if (!relativePath || shouldSkipRelativePath(relativePath) || !isTextFile(relativePath, projectRoot)) {
583
+ return false;
584
+ }
585
+
586
+ // getScanRoots already returns normalized roots — re-normalizing here would be a second
587
+ // copy of that rule, free to drift from the one auditBdCallSites walks.
588
+ return getScanRoots(projectRoot, options)
589
+ .some(root => {
590
+ // walkFiles applies no directory skips to a root that is itself a file.
591
+ if (relativePath === root) {
592
+ return true;
593
+ }
594
+ if (!relativePath.startsWith(`${root}/`)) {
595
+ return false;
596
+ }
597
+ // Below a directory root it refuses to descend into skipped directories.
598
+ return !relativePath
599
+ .slice(root.length + 1)
600
+ .split('/')
601
+ .slice(0, -1)
602
+ .some(directory => SKIP_DIR_NAMES.has(directory));
603
+ });
604
+ }
605
+
560
606
  function readRepoFile(projectRoot, relativePath) {
561
607
  const fullPath = absolutePath(projectRoot, relativePath);
562
608
  return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : '';
@@ -2096,6 +2142,7 @@ module.exports = {
2096
2142
  auditBdCallSites,
2097
2143
  buildReadinessReport,
2098
2144
  canonicalizeAuditArtifact,
2145
+ isBdCensusPath,
2099
2146
  renderBdCallSiteAuditMarkdown,
2100
2147
  renderReadinessReport,
2101
2148
  writeAuditArtifact,
package/lib/rules-sync.js CHANGED
@@ -48,6 +48,10 @@ const CURSOR_RULE_FILES = {
48
48
  security: 'security-scanning.mdc',
49
49
  documentation: 'documentation.mdc',
50
50
  'kernel-tracking': 'kernel-tracking.mdc',
51
+ // The using-forge dispatch pointer as an always-apply Cursor rule — Cursor's native
52
+ // always-on carrier for the skill auto-trigger bootstrap (parallels the Claude SessionStart
53
+ // hook and the committed AGENTS.md for Codex). Thin pointer; full policy stays in the skill.
54
+ 'using-forge': 'using-forge.mdc',
51
55
  };
52
56
 
53
57
  const CANONICAL_RULE_NAMES = Object.keys(CURSOR_RULE_FILES);
@@ -172,17 +172,6 @@ function invokesForgeHook(line) {
172
172
  });
173
173
  }
174
174
 
175
- function invokesBeadsHook(line, hookName) {
176
- const tokens = commandTokens(line);
177
- return tokens.some((token, index) => (
178
- commandName(token) === 'bd'
179
- && toText(tokens[index + 1]).toLowerCase() === 'hooks'
180
- && toText(tokens[index + 2]).toLowerCase() === 'run'
181
- && toText(tokens[index + 3]).toLowerCase() === hookName
182
- && isExecutingTokenPosition(tokens, index)
183
- ));
184
- }
185
-
186
175
  function createDiagnostic(code, subject, message, repair, severity = 'hard-stop') {
187
176
  return {
188
177
  code,
@@ -216,7 +205,7 @@ function isUsableWindowsShellCandidate(candidate, options = {}) {
216
205
  function checkHookInstallation(projectRoot, options = {}) {
217
206
  const exec = options._exec || defaultExecFileSync;
218
207
  const platform = options.platform || process.platform;
219
- const expectedRelativeHooksPaths = ['.lefthook/hooks', '.beads/hooks'];
208
+ const expectedRelativeHooksPaths = ['.lefthook/hooks'];
220
209
  const requiredHooks = ['pre-commit', 'pre-push'];
221
210
 
222
211
  function resolveGitRoot() {
@@ -269,23 +258,10 @@ function checkHookInstallation(projectRoot, options = {}) {
269
258
  const content = fs.readFileSync(filePath, 'utf8');
270
259
  const runsLefthook = hasExecutableHookCommand(content, invokesLefthook);
271
260
  const runsForgeHook = hasExecutableHookCommand(content, invokesForgeHook);
272
- const runsBeadsHook = hasExecutableHookCommand(content, (line) => invokesBeadsHook(line, hookName));
273
261
 
274
262
  if (runsLefthook) return { active: true, provider: 'lefthook' };
275
263
  if (runsForgeHook) return { active: true, provider: 'forge' };
276
264
 
277
- if (runsBeadsHook) {
278
- const chainedHookPath = path.join(gitRoot, '.beads', 'hooks', hookName);
279
- if (path.resolve(chainedHookPath) !== normalizedFilePath && fs.existsSync(chainedHookPath)) {
280
- const chained = classifyHookFile(chainedHookPath, hookName, visited);
281
- if (chained.active) {
282
- return { active: true, provider: `beads->${chained.provider}` };
283
- }
284
- }
285
-
286
- return { active: false, provider: 'beads-unverified' };
287
- }
288
-
289
265
  return { active: false, provider: 'unknown' };
290
266
  } catch {
291
267
  return { active: false, provider: 'missing' };
@@ -333,7 +309,7 @@ function checkHookInstallation(projectRoot, options = {}) {
333
309
  state: 'inactive',
334
310
  verification: 'core.hooksPath',
335
311
  hooksPath,
336
- message: `Git core.hooksPath is set to "${hooksPath}", not ".lefthook/hooks" or ".beads/hooks".`
312
+ message: `Git core.hooksPath is set to "${hooksPath}", not ".lefthook/hooks".`
337
313
  };
338
314
  }
339
315
  } catch {
@@ -488,9 +464,11 @@ function normalizeProjectRoot(projectRoot) {
488
464
  // shared resolver (env > .forge/config.yaml > default 'kernel'). Never throws and
489
465
  // never warns from the health path.
490
466
  function resolveHealthIssueBackend(options, projectRoot) {
491
- if (options.issueBackend === 'kernel' || options.issueBackend === 'beads') {
467
+ if (options.issueBackend === 'kernel') {
492
468
  return options.issueBackend;
493
469
  }
470
+ // Anything else — including the retired `beads` value — falls through to the
471
+ // shared resolver, which answers 'kernel' for every unrecognized selector.
494
472
  return resolveIssueBackend({
495
473
  deps: options.backendDeps || {},
496
474
  env: options.env || process.env,
@@ -540,25 +518,16 @@ function checkRuntimeHealth(projectRoot, options = {}) {
540
518
  }
541
519
 
542
520
  if (!bd.available) {
543
- if (issueBackend === 'beads') {
544
- // Beads is the active issue backend, so bd is a genuine hard prerequisite.
545
- diagnostics.push(createDiagnostic(
546
- 'BD_MISSING',
547
- 'bd',
548
- 'bd is required for stage-entry workflow checks when the beads issue backend is selected.'
549
- ));
550
- } else {
551
- // Kernel is the default issue backend and needs no bd binary, so a missing bd
552
- // must NOT hard-stop stage entry (the "no Beads install required" contract).
553
- // Surface it as a non-blocking advisory instead.
554
- advisories.push(createDiagnostic(
555
- 'BD_MISSING',
556
- 'bd',
557
- 'bd is not installed. The kernel issue backend does not require it; install bd only if you switch to the beads backend.',
558
- undefined,
559
- 'advisory'
560
- ));
561
- }
521
+ // The kernel is the only issue backend and needs no bd binary, so bd is never a
522
+ // prerequisite and a missing bd must NOT hard-stop stage entry (the "no Beads
523
+ // install required" contract). It stays a non-blocking advisory.
524
+ advisories.push(createDiagnostic(
525
+ 'BD_MISSING',
526
+ 'bd',
527
+ 'bd is not installed. Forge does not require it — the kernel issue backend is the only backend.',
528
+ undefined,
529
+ 'advisory'
530
+ ));
562
531
  }
563
532
 
564
533
  if (!gh.available) {