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.
Files changed (119) 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 +20 -0
  5. package/bin/forge.js +16 -374
  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 +8 -5
  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 +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. 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,
@@ -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
  }
@@ -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
- if (!fs.existsSync(path.join(sourcePath, 'SKILL.md'))) continue;
92
+ const skillFile = path.join(sourcePath, 'SKILL.md');
93
+ if (!fs.existsSync(skillFile)) continue;
92
94
 
93
- skills.push({ name: entry.name, sourcePath });
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));
@@ -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 four cases:
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. Empty existing content: Return only FORGE section (no empty USER block)
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 += `---\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`;
83
+ merged += IMPROVEMENT_FOOTER;
60
84
 
61
85
  return merged;
62
86
  }