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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -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,40 @@ 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({
29
+ actor,
30
+ surface,
31
+ path: filePath,
32
+ content,
33
+ operation = 'generate',
34
+ viaForgeApi = false,
35
+ sourceHead,
36
+ }) {
37
+ if (!actor || !surface || !filePath || content === undefined) {
38
+ throw new TypeError('Protected state audit record requires actor, surface, path, and content');
39
+ }
40
+
41
+ return {
42
+ kind: 'protected_state_write',
43
+ actor,
44
+ path: normalizeRepoPath(filePath),
45
+ decision: 'allowed',
46
+ requiredSurface: surface,
47
+ declaredSurface: surface,
48
+ operation,
49
+ viaForgeApi: viaForgeApi === true,
50
+ ...(sourceHead ? { sourceHead } : {}),
51
+ contentHash: hashProtectedContent(content),
52
+ reason: `Forge API generated content for protected surface: ${surface}.`,
53
+ repairHint: null,
54
+ };
55
+ }
56
+
13
57
  function startsWithAny(filePath, prefixes) {
14
58
  return prefixes.some(prefix => filePath === prefix || filePath.startsWith(`${prefix}/`));
15
59
  }
@@ -42,13 +86,13 @@ const PROTECTED_SURFACES = [
42
86
  '.forge/agent-log.ndjson',
43
87
  '.beads/interactions.jsonl',
44
88
  ].includes(filePath),
45
- repairHint: 'append-only logs must be written by the Forge or Beads audit writer; do not rewrite existing log content.',
89
+ repairHint: 'append-only logs must be written by the Forge audit writer; do not rewrite existing log content.',
46
90
  },
47
91
  {
48
92
  id: 'beads_state',
49
- label: 'Beads state',
93
+ label: 'Legacy Beads state',
50
94
  matches: filePath => startsWithAny(filePath, ['.beads']),
51
- repairHint: 'Use bd or Forge issue commands such as bd update, bd close, forge ready, or forge close.',
95
+ 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
96
  },
53
97
  {
54
98
  id: 'forge_config',
@@ -164,8 +208,12 @@ function nearestExistingPath(candidate) {
164
208
  return current;
165
209
  }
166
210
 
211
+ function realpathNearestExistingPath(candidate) {
212
+ return fs.realpathSync(nearestExistingPath(candidate));
213
+ }
214
+
167
215
  function assertNoAncestorSymlinkEscape(root, target) {
168
- const realRoot = fs.realpathSync(root);
216
+ const realRoot = realpathNearestExistingPath(root);
169
217
  const existing = nearestExistingPath(path.dirname(target));
170
218
  const existingStat = lstatIfPresent(existing);
171
219
  if (existing !== root && existingStat?.isSymbolicLink()) {
@@ -177,7 +225,7 @@ function assertNoAncestorSymlinkEscape(root, target) {
177
225
  };
178
226
  }
179
227
 
180
- const realExisting = fs.realpathSync(existing);
228
+ const realExisting = realpathNearestExistingPath(existing);
181
229
  if (!pathStaysInsideRoot(realRoot, realExisting)) {
182
230
  return {
183
231
  allowed: false,
@@ -191,8 +239,8 @@ function assertNoAncestorSymlinkEscape(root, target) {
191
239
 
192
240
  function assertNoSymlinkEscape(root, target) {
193
241
  const parent = path.dirname(target);
194
- const realRoot = fs.realpathSync(root);
195
- const existingParent = fs.existsSync(parent) ? fs.realpathSync(parent) : parent;
242
+ const realRoot = realpathNearestExistingPath(root);
243
+ const existingParent = realpathNearestExistingPath(parent);
196
244
  if (!pathStaysInsideRoot(realRoot, existingParent)) {
197
245
  return {
198
246
  allowed: false,
@@ -258,6 +306,89 @@ function assertProtectedWriteAllowed(filePath, options = {}) {
258
306
  };
259
307
  }
260
308
 
309
+ function fileIdentity(stat) {
310
+ return `${stat.dev}:${stat.ino}`;
311
+ }
312
+
313
+ function blockedAtomicDecision(decision, reason) {
314
+ return {
315
+ ...decision,
316
+ allowed: false,
317
+ decision: 'blocked',
318
+ reason,
319
+ repairHint: 'Retry through the owning Forge command after confirming the protected path is unchanged.',
320
+ };
321
+ }
322
+
323
+ function captureParentBoundary(root, target) {
324
+ const parent = path.dirname(target);
325
+ const stat = lstatIfPresent(parent);
326
+ if (!stat?.isDirectory() || stat.isSymbolicLink()) {
327
+ return { ok: false, reason: `Protected write parent boundary changed: ${parent}` };
328
+ }
329
+ const realRoot = fs.realpathSync(root);
330
+ const realParent = fs.realpathSync(parent);
331
+ if (!pathStaysInsideRoot(realRoot, realParent)) {
332
+ return { ok: false, reason: `Protected write parent boundary changed outside project root: ${parent}` };
333
+ }
334
+ return { ok: true, parent, realParent, identity: fileIdentity(stat) };
335
+ }
336
+
337
+ function captureTargetState(target) {
338
+ const before = lstatIfPresent(target);
339
+ if (!before) return { ok: true, exists: false };
340
+ if (!before.isFile() || before.isSymbolicLink()) {
341
+ return { ok: false, reason: `Protected write target boundary changed: ${target}` };
342
+ }
343
+ const content = fs.readFileSync(target);
344
+ const after = fs.lstatSync(target);
345
+ if (fileIdentity(before) !== fileIdentity(after) || after.isSymbolicLink()) {
346
+ return { ok: false, reason: `Protected write target boundary changed while reading: ${target}` };
347
+ }
348
+ return {
349
+ ok: true,
350
+ exists: true,
351
+ identity: fileIdentity(after),
352
+ content,
353
+ contentHash: hashProtectedContent(content),
354
+ };
355
+ }
356
+
357
+ function sameParentBoundary(left, right) {
358
+ return left.ok && right.ok && left.realParent === right.realParent && left.identity === right.identity;
359
+ }
360
+
361
+ function sameTargetState(left, right) {
362
+ if (!left.ok || !right.ok || left.exists !== right.exists) return false;
363
+ if (!left.exists) return true;
364
+ return left.identity === right.identity && left.contentHash === right.contentHash;
365
+ }
366
+
367
+ function expectedTargetMatches(state, expectedContent) {
368
+ if (expectedContent === null) return !state.exists;
369
+ if (!state.exists) return false;
370
+ const expected = Buffer.isBuffer(expectedContent)
371
+ ? expectedContent
372
+ : Buffer.from(String(expectedContent), 'utf8');
373
+ return state.content.equals(expected);
374
+ }
375
+
376
+ function expectedTargetChanged(state, options) {
377
+ return Object.prototype.hasOwnProperty.call(options, 'expectedContent')
378
+ && !expectedTargetMatches(state, options.expectedContent);
379
+ }
380
+
381
+ function cleanupOwnedTemp(tempPath, identity) {
382
+ try {
383
+ const current = fs.lstatSync(tempPath);
384
+ if (current.isFile() && !current.isSymbolicLink() && fileIdentity(current) === identity) {
385
+ fs.unlinkSync(tempPath);
386
+ }
387
+ } catch (error) {
388
+ if (error.code !== 'ENOENT') throw error;
389
+ }
390
+ }
391
+
261
392
  function writeProtectedFile(projectRoot, filePath, content, options = {}) {
262
393
  const resolved = resolveRepoRelativePath(projectRoot, filePath);
263
394
  if (!resolved.insideRoot) {
@@ -296,8 +427,88 @@ function writeProtectedFile(projectRoot, filePath, content, options = {}) {
296
427
  };
297
428
  }
298
429
 
299
- fs.writeFileSync(resolved.target, content, options.encoding || 'utf8');
300
- return { ...decision, fullPath: resolved.target };
430
+ const initialParent = captureParentBoundary(resolved.root, resolved.target);
431
+ if (!initialParent.ok) return blockedAtomicDecision(decision, initialParent.reason);
432
+ const initialTarget = captureTargetState(resolved.target);
433
+ if (!initialTarget.ok) return blockedAtomicDecision(decision, initialTarget.reason);
434
+ if (expectedTargetChanged(initialTarget, options)) {
435
+ return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to overwrite it.');
436
+ }
437
+
438
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(String(content), options.encoding || 'utf8');
439
+ const tempPath = path.join(
440
+ initialParent.parent,
441
+ `.${path.basename(resolved.target)}.forge-${process.pid}-${crypto.randomUUID()}.tmp`,
442
+ );
443
+ let tempIdentity;
444
+ try {
445
+ const flags = fs.constants.O_NOFOLLOW
446
+ ? fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
447
+ : 'wx';
448
+ const fd = fs.openSync(tempPath, flags, 0o600);
449
+ try {
450
+ fs.writeFileSync(fd, bytes);
451
+ fs.fsyncSync(fd);
452
+ tempIdentity = fileIdentity(fs.fstatSync(fd));
453
+ } finally {
454
+ fs.closeSync(fd);
455
+ }
456
+
457
+ if (typeof options.beforeAtomicCommit === 'function') {
458
+ options.beforeAtomicCommit({ parent: initialParent.parent, target: resolved.target, tempPath });
459
+ }
460
+
461
+ const finalParent = captureParentBoundary(resolved.root, resolved.target);
462
+ const finalTarget = captureTargetState(resolved.target);
463
+ if (!sameParentBoundary(initialParent, finalParent) || !sameTargetState(initialTarget, finalTarget)) {
464
+ return blockedAtomicDecision(decision, 'Protected write boundary changed before atomic replacement.');
465
+ }
466
+ if (expectedTargetChanged(finalTarget, options)) {
467
+ return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to overwrite it.');
468
+ }
469
+ const tempState = fs.lstatSync(tempPath);
470
+ if (!tempState.isFile() || tempState.isSymbolicLink() || fileIdentity(tempState) !== tempIdentity) {
471
+ return blockedAtomicDecision(decision, 'Protected write temporary-file identity changed before atomic replacement.');
472
+ }
473
+
474
+ fs.renameSync(tempPath, resolved.target);
475
+ tempIdentity = null;
476
+ return { ...decision, contentHash: hashProtectedContent(bytes), fullPath: resolved.target };
477
+ } catch (error) {
478
+ return blockedAtomicDecision(decision, `Protected atomic write failed: ${error.message}`);
479
+ } finally {
480
+ if (tempIdentity) cleanupOwnedTemp(tempPath, tempIdentity);
481
+ }
482
+ }
483
+
484
+ function removeProtectedFile(projectRoot, filePath, options = {}) {
485
+ const resolved = resolveRepoRelativePath(projectRoot, filePath);
486
+ if (!resolved.insideRoot) {
487
+ return blockedAtomicDecision({}, `Protected remove path escapes project root: ${filePath}`);
488
+ }
489
+ const decision = assertProtectedWriteAllowed(resolved.relativePath, {
490
+ ...options,
491
+ operation: options.operation || 'remove',
492
+ });
493
+ if (!decision.allowed) return decision;
494
+ const initialParent = captureParentBoundary(resolved.root, resolved.target);
495
+ const initialTarget = captureTargetState(resolved.target);
496
+ if (!initialParent.ok || !initialTarget.ok) {
497
+ return blockedAtomicDecision(decision, initialParent.reason || initialTarget.reason);
498
+ }
499
+ if (!Object.prototype.hasOwnProperty.call(options, 'expectedContent') || !expectedTargetMatches(initialTarget, options.expectedContent)) {
500
+ return blockedAtomicDecision(decision, 'Protected workflow has a concurrent content change; refusing to remove it.');
501
+ }
502
+ if (typeof options.beforeAtomicCommit === 'function') {
503
+ options.beforeAtomicCommit({ parent: initialParent.parent, target: resolved.target });
504
+ }
505
+ const finalParent = captureParentBoundary(resolved.root, resolved.target);
506
+ const finalTarget = captureTargetState(resolved.target);
507
+ if (!sameParentBoundary(initialParent, finalParent) || !sameTargetState(initialTarget, finalTarget)) {
508
+ return blockedAtomicDecision(decision, 'Protected remove boundary changed before exact-content removal.');
509
+ }
510
+ fs.unlinkSync(resolved.target);
511
+ return { ...decision, removed: true, fullPath: resolved.target };
301
512
  }
302
513
 
303
514
  function buildProtectedStateAuditEvent(decision) {
@@ -313,12 +524,18 @@ function buildProtectedStateAuditEvent(decision) {
313
524
  requiredSurface: decision.requiredSurface,
314
525
  declaredSurface: decision.declaredSurface || null,
315
526
  operation: decision.operation || 'write',
527
+ viaForgeApi: decision.viaForgeApi === true,
528
+ ...(decision.sourceHead ? { sourceHead: decision.sourceHead } : {}),
529
+ contentHash: decision.contentHash || null,
316
530
  reason: decision.reason,
317
531
  repairHint: decision.repairHint,
318
532
  metadata: {
319
533
  actor: decision.actor || 'unknown',
320
534
  path: decision.path,
321
535
  operation: decision.operation || 'write',
536
+ viaForgeApi: decision.viaForgeApi === true,
537
+ ...(decision.sourceHead ? { sourceHead: decision.sourceHead } : {}),
538
+ contentHash: decision.contentHash || null,
322
539
  requiredSurface: decision.requiredSurface,
323
540
  declaredSurface: decision.declaredSurface || null,
324
541
  decision: decision.decision,
@@ -330,51 +547,31 @@ function buildProtectedStateAuditEvent(decision) {
330
547
  return redact(event);
331
548
  }
332
549
 
550
+ /**
551
+ * Best-effort: a failed audit write is reported to the caller, never thrown, so
552
+ * the protected-state decision itself still stands.
553
+ */
333
554
  function recordProtectedStateAuditEvent(decision, options = {}) {
334
555
  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
- }
556
+ const logPath = path.resolve(options.cwd || process.cwd(), PROTECTED_STATE_AUDIT_LOG);
557
+ const appendRecord = options.appendRecord || appendCappedJsonlRecord;
362
558
 
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 };
559
+ try {
560
+ const record = { ...event, recordedAt: options.now || new Date().toISOString() };
561
+ appendRecord(logPath, record, options.maxRecords || PROTECTED_STATE_AUDIT_MAX_RECORDS);
562
+ return { success: true, event, logPath };
370
563
  } catch (error) {
371
- return { success: false, event, error: error.message };
564
+ return { success: false, event, logPath, error: error.message };
372
565
  }
373
566
  }
374
567
 
375
568
  module.exports = {
376
569
  PROTECTED_SURFACES,
570
+ PROTECTED_STATE_AUDIT_LOG,
571
+ PROTECTED_STATE_AUDIT_MAX_RECORDS,
377
572
  normalizeRepoPath,
573
+ hashProtectedContent,
574
+ createProtectedStateAuditRecord,
378
575
  resolveRepoRelativePath,
379
576
  lstatIfPresent,
380
577
  assertNoAncestorSymlinkEscape,
@@ -382,6 +579,7 @@ module.exports = {
382
579
  classifyProtectedPath,
383
580
  assertProtectedWriteAllowed,
384
581
  writeProtectedFile,
582
+ removeProtectedFile,
385
583
  buildProtectedStateAuditEvent,
386
584
  recordProtectedStateAuditEvent,
387
585
  };
@@ -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
 
@@ -496,9 +510,8 @@ function addCallSites(groups, group, relativePath, lineEntries) {
496
510
  function trackedRepoFiles(projectRoot) {
497
511
  let output;
498
512
  try {
499
- // NOSONAR S4036 — `git` is resolved from PATH by design (cross-platform CLI; the repo
500
- // spawns git this way throughout and cannot hardcode a portable absolute path).
501
- output = execFileSync('git', ['-C', projectRoot, 'ls-files', '-z'], {
513
+ // `git` is resolved from PATH by design; Forge cannot hardcode a portable absolute path.
514
+ output = execFileSync('git', ['-C', projectRoot, 'ls-files', '-z'], { // NOSONAR S4036 -- Fixed local Git executable with structured non-shell arguments is the trusted repository toolchain boundary.
502
515
  encoding: 'utf8',
503
516
  maxBuffer: 64 * 1024 * 1024,
504
517
  });
@@ -557,6 +570,38 @@ function auditBdCallSites(projectRoot, options = {}) {
557
570
  };
558
571
  }
559
572
 
573
+ // Would a change to this repo-relative path shift the bd call-site census?
574
+ //
575
+ // The pre-commit auto-heal (scripts/sync-d20-audit.js) asks this before doing any work,
576
+ // so it must answer exactly what auditBdCallSites would scan. It is therefore assembled
577
+ // from that walk's own pieces — getScanRoots, shouldSkipRelativePath, isTextFile,
578
+ // SKIP_DIR_NAMES — rather than a parallel list that could drift from the gate.
579
+ function isBdCensusPath(projectRoot, filePath, options = {}) {
580
+ const relativePath = safeNormalizeRepoPath(projectRoot, filePath);
581
+ if (!relativePath || shouldSkipRelativePath(relativePath) || !isTextFile(relativePath, projectRoot)) {
582
+ return false;
583
+ }
584
+
585
+ // getScanRoots already returns normalized roots — re-normalizing here would be a second
586
+ // copy of that rule, free to drift from the one auditBdCallSites walks.
587
+ return getScanRoots(projectRoot, options)
588
+ .some(root => {
589
+ // walkFiles applies no directory skips to a root that is itself a file.
590
+ if (relativePath === root) {
591
+ return true;
592
+ }
593
+ if (!relativePath.startsWith(`${root}/`)) {
594
+ return false;
595
+ }
596
+ // Below a directory root it refuses to descend into skipped directories.
597
+ return !relativePath
598
+ .slice(root.length + 1)
599
+ .split('/')
600
+ .slice(0, -1)
601
+ .some(directory => SKIP_DIR_NAMES.has(directory));
602
+ });
603
+ }
604
+
560
605
  function readRepoFile(projectRoot, relativePath) {
561
606
  const fullPath = absolutePath(projectRoot, relativePath);
562
607
  return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : '';
@@ -2096,6 +2141,7 @@ module.exports = {
2096
2141
  auditBdCallSites,
2097
2142
  buildReadinessReport,
2098
2143
  canonicalizeAuditArtifact,
2144
+ isBdCensusPath,
2099
2145
  renderBdCallSiteAuditMarkdown,
2100
2146
  renderReadinessReport,
2101
2147
  writeAuditArtifact,
@@ -8,6 +8,68 @@ const REQUIRED_REVIEW_ADAPTER_METHODS = [
8
8
  'score',
9
9
  ];
10
10
 
11
+ const REVIEW_EVIDENCE_LIMITS = Object.freeze({
12
+ maxTextChars: 256,
13
+ });
14
+
15
+ const SECRET_PATTERNS = [
16
+ /\bBearer\s+\S+/gi,
17
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
18
+ /\bgithub_pat_\w{20,}\b/gi,
19
+ /\b(?:sk_(?:live|test)_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9]{16,})\b/g,
20
+ /\bAKIA[0-9A-Z]{16}\b/gi,
21
+ /\b(?:api[_ -]?key|authorization|credential|password|private[_ -]?key|secret|token)\s*[:=]\s*\S{8,}/gi,
22
+ ];
23
+ const PRIVATE_PATH_PATTERNS = [
24
+ /[a-z]:\\Users\\[^\\\s]+(?:\\[^\s]*)?/gi,
25
+ /\/(?:Users|home)\/[^/\s]+(?:\/[^\s]*)?/g,
26
+ /\/root\/[^\s"'<>{}[\]]+/g,
27
+ ];
28
+
29
+ function stripControlCharacters(value) {
30
+ let output = '';
31
+ for (const character of value) {
32
+ const code = character.codePointAt(0);
33
+ const control = (code >= 0 && code <= 8)
34
+ || code === 11
35
+ || code === 12
36
+ || (code >= 14 && code <= 31)
37
+ || code === 127;
38
+ output += control ? ' ' : character;
39
+ }
40
+ return output;
41
+ }
42
+
43
+ function normalizeEvidenceText(value, options = {}) {
44
+ const requestedLimit = options.maxChars ?? REVIEW_EVIDENCE_LIMITS.maxTextChars;
45
+ const maxChars = Number.isInteger(requestedLimit) && requestedLimit > 0
46
+ ? requestedLimit
47
+ : REVIEW_EVIDENCE_LIMITS.maxTextChars;
48
+ let text = stripControlCharacters(String(value ?? ''))
49
+ .replace(/[\r\n\t]+/g, ' ');
50
+ for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, '[REDACTED]');
51
+ for (const pattern of PRIVATE_PATH_PATTERNS) text = text.replace(pattern, '[REDACTED_PATH]');
52
+ text = text.replace(/\s+/g, ' ').trim();
53
+ return text.length <= maxChars ? text : `${text.slice(0, Math.max(0, maxChars - 1))}…`;
54
+ }
55
+
56
+ function classifyReviewActor(value) {
57
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return 'unknown';
58
+ const typename = value.authorTypename
59
+ || value.authorType
60
+ || value.actorTypename
61
+ || value.actorType
62
+ || value.author?.__typename;
63
+ const actorKinds = {
64
+ Bot: 'bot',
65
+ User: 'user',
66
+ Organization: 'organization',
67
+ Mannequin: 'mannequin',
68
+ EnterpriseUserAccount: 'enterprise-user',
69
+ };
70
+ return actorKinds[typename] || 'unknown';
71
+ }
72
+
11
73
  class ReviewAdapter {
12
74
  constructor(options = {}) {
13
75
  this.id = options.id || 'review-adapter';
@@ -62,7 +124,10 @@ function validateReviewAdapter(adapter) {
62
124
  }
63
125
 
64
126
  module.exports = {
127
+ REVIEW_EVIDENCE_LIMITS,
65
128
  ReviewAdapter,
66
129
  REQUIRED_REVIEW_ADAPTER_METHODS,
130
+ classifyReviewActor,
131
+ normalizeEvidenceText,
67
132
  validateReviewAdapter,
68
133
  };
@@ -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
  }