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.
- package/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -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/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- 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 +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- 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 +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- 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 +92 -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/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- 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
|
|
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
|
|
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: '
|
|
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 =
|
|
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 =
|
|
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 =
|
|
195
|
-
const existingParent =
|
|
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
|
|
336
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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,
|
package/lib/release-readiness.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/lib/runtime-health.js
CHANGED
|
@@ -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'
|
|
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"
|
|
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'
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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) {
|