forge-workflow 0.1.0-beta.4 → 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 +20 -0
- package/bin/forge.js +16 -374
- 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 +8 -5
- 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 +54 -25
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +344 -142
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +223 -25
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +21 -8
- 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 +17 -2
- package/lib/commands/setup.js +191 -94
- package/lib/commands/shepherd.js +13 -1
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +119 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/test.js +24 -34
- package/lib/commands/worktree.js +220 -42
- package/lib/core/runtime-graph.js +1 -1
- 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 +56 -32
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +93 -4
- package/lib/insights.js +96 -80
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +16 -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/sqlite-driver.js +153 -29
- 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 +71 -10
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +68 -43
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +224 -41
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +10 -1
- package/lib/pr-monitor/watch-lifecycle.js +13 -1
- package/lib/pr-pull.js +33 -14
- 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 +33 -1
- 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/shell-utils.js +1 -1
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/package.json +6 -7
- package/scripts/doc-asserting-tests.js +158 -0
- 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 +176 -43
- package/skills/review/SKILL.md +4 -11
- package/skills/review/evals/scorecard.json +3 -3
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +20 -14
- package/skills/shepherd/evals/scorecard.json +2 -2
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- 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/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/shell-utils.js
CHANGED
|
@@ -105,7 +105,7 @@ function secureExecFileSync(command, args = [], options = {}) {
|
|
|
105
105
|
// metacharacters, so fold them into a single command line and pass no args array —
|
|
106
106
|
// identical execution through cmd.exe (npm.cmd/npx.cmd shims), no deprecation warning.
|
|
107
107
|
const commandLine = [command, ...args].join(' ');
|
|
108
|
-
return _execFileSync(commandLine, [], { ...execOptions, shell: true });
|
|
108
|
+
return _execFileSync(commandLine, [], { env: process.env, ...execOptions, shell: true });
|
|
109
109
|
}
|
|
110
110
|
return _execFileSync(spec.file, args, execOptions);
|
|
111
111
|
}
|
package/lib/skills-sync.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
const fs = require('node:fs');
|
|
36
36
|
const path = require('node:path');
|
|
37
|
+
const { parseFrontmatter } = require('./using-forge');
|
|
37
38
|
|
|
38
39
|
/** Canonical skills live under this directory at the repo/package root. */
|
|
39
40
|
const CANONICAL_SKILLS_DIR = 'skills';
|
|
@@ -71,7 +72,7 @@ function isValidSkillName(name) {
|
|
|
71
72
|
* @param {string} sourceRoot - Directory containing the canonical `skills/` dir.
|
|
72
73
|
* @param {object} [options]
|
|
73
74
|
* @param {Set<string>|string[]} [options.only] - Restrict to these skill names.
|
|
74
|
-
* @returns {{name: string, sourcePath: string}[]} Sorted list of skills.
|
|
75
|
+
* @returns {{name: string, sourcePath: string, invocation: string}[]} Sorted list of skills.
|
|
75
76
|
*/
|
|
76
77
|
function listCanonicalSkills(sourceRoot, options = {}) {
|
|
77
78
|
const skillsDir = path.join(sourceRoot, CANONICAL_SKILLS_DIR);
|
|
@@ -88,9 +89,11 @@ function listCanonicalSkills(sourceRoot, options = {}) {
|
|
|
88
89
|
if (only && !only.has(entry.name)) continue;
|
|
89
90
|
|
|
90
91
|
const sourcePath = path.join(skillsDir, entry.name);
|
|
91
|
-
|
|
92
|
+
const skillFile = path.join(sourcePath, 'SKILL.md');
|
|
93
|
+
if (!fs.existsSync(skillFile)) continue;
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
const { invocation } = parseFrontmatter(fs.readFileSync(skillFile, 'utf8'));
|
|
96
|
+
skills.push({ name: entry.name, sourcePath, invocation });
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
skills.sort((a, b) => a.name.localeCompare(b.name));
|
package/lib/smart-merge.js
CHANGED
|
@@ -1,13 +1,32 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const IMPROVEMENT_FOOTER = `---\n\n## Improving This Workflow\n\nEvery time you give the same instruction twice, add it to this file:\n1. User-specific rules: Add to USER:START section above\n2. Forge workflow improvements: Suggest to forge maintainers\n\n**Keep this file updated as you learn about the project.**\n\n---\n\nSee \`AGENTS.md\` for complete workflow guide.\nSee \`docs/TOOLCHAIN.md\` for comprehensive tool reference.\n`;
|
|
4
|
+
|
|
5
|
+
function extractUnmanagedContent(existingContent) {
|
|
6
|
+
let unmanagedContent = existingContent
|
|
7
|
+
.replace(/\r\n?/g, '\n')
|
|
8
|
+
.replace(/<!-- FORGE:SETUP-INSTRUCTIONS[\s\S]*?-->/g, '')
|
|
9
|
+
.replace(/<!-- FORGE:START.*?-->[\s\S]*?<!-- FORGE:END -->/g, '')
|
|
10
|
+
.trim();
|
|
11
|
+
|
|
12
|
+
unmanagedContent = unmanagedContent.replace(/^# AGENTS\.md\s*/, '').trim();
|
|
13
|
+
|
|
14
|
+
const footer = IMPROVEMENT_FOOTER.trim();
|
|
15
|
+
// The generated footer may precede user-appended text; remove every exact managed copy.
|
|
16
|
+
unmanagedContent = unmanagedContent.split(footer).join('').trim();
|
|
17
|
+
|
|
18
|
+
return unmanagedContent;
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
/**
|
|
4
22
|
* Smart merge for AGENTS.md - preserves USER sections, updates FORGE sections.
|
|
5
23
|
*
|
|
6
|
-
* Handles
|
|
24
|
+
* Handles five cases:
|
|
7
25
|
* 1. No markers at all: Wrap existing content in USER markers, append FORGE section
|
|
8
26
|
* 2. USER markers but no FORGE markers: Keep USER section, insert FORGE section
|
|
9
27
|
* 3. Both markers present: Preserve USER section, update FORGE section (existing behavior)
|
|
10
|
-
* 4.
|
|
28
|
+
* 4. Forge markers without USER markers: Preserve surrounding unmanaged content as USER content
|
|
29
|
+
* 5. Empty existing content: Return only FORGE section (no empty USER block)
|
|
11
30
|
*
|
|
12
31
|
* @param {string} existingContent - The current AGENTS.md content
|
|
13
32
|
* @param {string} newContent - The new template content containing FORGE section
|
|
@@ -20,6 +39,7 @@ function smartMergeAgentsMd(existingContent, newContent) {
|
|
|
20
39
|
|
|
21
40
|
// Check if existing content has markers
|
|
22
41
|
const hasUserMarkers = existingContent.includes('<!-- USER:START') && existingContent.includes('<!-- USER:END');
|
|
42
|
+
const hasForgeMarkers = existingContent.includes('<!-- FORGE:START') && existingContent.includes('<!-- FORGE:END');
|
|
23
43
|
|
|
24
44
|
let userSection;
|
|
25
45
|
|
|
@@ -28,8 +48,12 @@ function smartMergeAgentsMd(existingContent, newContent) {
|
|
|
28
48
|
const userMatch = (/(<!-- USER:START.*?-->[\s\S]*?<!-- USER:END -->)/).exec(existingContent);
|
|
29
49
|
userSection = userMatch ? userMatch[0] : '';
|
|
30
50
|
} else if (existingContent.trim() === '') {
|
|
31
|
-
// Empty existing content: no USER block at all
|
|
32
51
|
userSection = null;
|
|
52
|
+
} else if (hasForgeMarkers) {
|
|
53
|
+
const unmanagedContent = extractUnmanagedContent(existingContent);
|
|
54
|
+
userSection = unmanagedContent
|
|
55
|
+
? `<!-- USER:START -->\n${unmanagedContent}\n<!-- USER:END -->`
|
|
56
|
+
: null;
|
|
33
57
|
} else {
|
|
34
58
|
// No markers: wrap entire existing content in USER markers
|
|
35
59
|
userSection = `<!-- USER:START -->\n${existingContent.trim()}\n<!-- USER:END -->`;
|
|
@@ -56,7 +80,7 @@ function smartMergeAgentsMd(existingContent, newContent) {
|
|
|
56
80
|
merged += forgeSection + '\n\n';
|
|
57
81
|
|
|
58
82
|
// Add footer
|
|
59
|
-
merged +=
|
|
83
|
+
merged += IMPROVEMENT_FOOTER;
|
|
60
84
|
|
|
61
85
|
return merged;
|
|
62
86
|
}
|