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
@@ -25,6 +25,7 @@ function isHelpInvocation(args = []) {
25
25
  // on a missing `exec` method before ever reaching the "no tables" case.
26
26
  function createDefaultKernelBroker(brokerContext, deps) {
27
27
  let driver = deps.kernelDriver;
28
+ const ownsDriver = !driver;
28
29
  if (!driver) {
29
30
  const config = buildLocalBrokerConfig({
30
31
  projectRoot: brokerContext.projectRoot,
@@ -35,7 +36,7 @@ function createDefaultKernelBroker(brokerContext, deps) {
35
36
  driver = createBuiltinSQLiteDriver({ databasePath: config.databasePath });
36
37
  }
37
38
 
38
- return createLocalBroker({
39
+ const broker = createLocalBroker({
39
40
  projectRoot: brokerContext.projectRoot,
40
41
  gitCommonDir: deps.gitCommonDir,
41
42
  databasePath: deps.kernelDatabasePath,
@@ -46,6 +47,10 @@ function createDefaultKernelBroker(brokerContext, deps) {
46
47
  // classifier instead of paying the real Windows drive probe per per-op broker.
47
48
  classifyFilesystem: deps.classifyFilesystem,
48
49
  });
50
+ if (ownsDriver && typeof driver.close === 'function') {
51
+ broker.close = () => driver.close();
52
+ }
53
+ return broker;
49
54
  }
50
55
 
51
56
  // Symbol marker for the memoized lazy-init promise. Stored ON the broker object
@@ -82,6 +87,11 @@ function withLazyKernelInit(broker) {
82
87
  async initialize() {
83
88
  return ensureInitialized();
84
89
  },
90
+ async close() {
91
+ if (typeof broker.close === 'function') {
92
+ await broker.close();
93
+ }
94
+ },
85
95
  async runIssueOperation(operation, args = [], context = {}) {
86
96
  await ensureInitialized();
87
97
  return broker.runIssueOperation(operation, args, context);
@@ -94,11 +104,14 @@ function createKernelIssueBackend(context = {}) {
94
104
  const createBroker = deps.createKernelBroker
95
105
  || ((brokerContext) => createDefaultKernelBroker(brokerContext, deps));
96
106
 
107
+ const ownsBroker = !deps.kernelBroker;
97
108
  const broker = deps.kernelBroker || createBroker(context);
98
-
99
- return new KernelIssueAdapter({
100
- broker: withLazyKernelInit(broker),
101
- });
109
+ const managedBroker = withLazyKernelInit(broker);
110
+ const backend = new KernelIssueAdapter({ broker: managedBroker });
111
+ if (ownsBroker && typeof managedBroker?.close === 'function') {
112
+ backend.dispose = () => managedBroker.close();
113
+ }
114
+ return backend;
102
115
  }
103
116
 
104
117
  function createIssueService({ backend } = {}) {
@@ -108,6 +121,11 @@ function createIssueService({ backend } = {}) {
108
121
  const resolvedBackend = backend || createKernelIssueBackend();
109
122
 
110
123
  return {
124
+ async dispose() {
125
+ if (typeof resolvedBackend?.dispose === 'function') {
126
+ await resolvedBackend.dispose();
127
+ }
128
+ },
111
129
  async run(operation, args = [], context = {}) {
112
130
  const methodName = operation === 'show' && typeof resolvedBackend?.show !== 'function'
113
131
  ? 'read'
@@ -205,36 +223,42 @@ async function runIssueOperation(operation, rawArgs, projectRoot, deps = {}) {
205
223
  const isClaim = operation === 'claim';
206
224
  const worktreeId = isClaim ? resolveWorktreeId(projectRoot, actorEnv, deps) : undefined;
207
225
  const leaseTtlMs = isClaim ? resolveLeaseTtlMs(actorEnv) : undefined;
208
- const result = await service.run(operation, rawArgs, {
209
- projectRoot,
210
- deps,
211
- ...(actor ? { actor } : {}),
212
- ...(sessionId ? { sessionId } : {}),
213
- ...(worktreeId ? { worktreeId } : {}),
214
- ...(leaseTtlMs ? { leaseTtlMs } : {}),
215
- // Kernel mutations enqueue a projection-outbox "dirty marker" that `forge export`
216
- // (the D16 git-JSONL portability projection) drains under target 'jsonl'. The
217
- // broker's legacy primitive default is still 'beads', so without steering the target
218
- // here every Kernel-created issue would be enqueued as 'beads' and `forge export` —
219
- // which drains 'jsonl' — would find nothing, silently never emitting git-tracked
220
- // JSONL. Unconditional now that the Kernel is the only backend.
221
- projectionTarget: 'jsonl',
222
- });
226
+ try {
227
+ const result = await service.run(operation, rawArgs, {
228
+ projectRoot,
229
+ deps,
230
+ ...(actor ? { actor } : {}),
231
+ ...(sessionId ? { sessionId } : {}),
232
+ ...(worktreeId ? { worktreeId } : {}),
233
+ ...(leaseTtlMs ? { leaseTtlMs } : {}),
234
+ // Kernel mutations enqueue a projection-outbox "dirty marker" that `forge export`
235
+ // (the D16 git-JSONL portability projection) drains under target 'jsonl'. The
236
+ // broker's legacy primitive default is still 'beads', so without steering the target
237
+ // here every Kernel-created issue would be enqueued as 'beads' and `forge export` —
238
+ // which drains 'jsonl' — would find nothing, silently never emitting git-tracked
239
+ // JSONL. Unconditional now that the Kernel is the only backend.
240
+ projectionTarget: 'jsonl',
241
+ });
223
242
 
224
- const queueGitHubProjection = deps.enqueueGitHubProjection || deps.queueGitHubProjection;
225
- if (result?.success && typeof queueGitHubProjection === 'function') {
226
- const projectionPlan = createGitHubProjectionPlan(operation, rawArgs);
227
- if (projectionPlan) {
228
- await queueGitHubProjection(projectionPlan, {
229
- operation,
230
- args: rawArgs,
231
- projectRoot,
232
- result,
233
- });
243
+ const queueGitHubProjection = deps.enqueueGitHubProjection || deps.queueGitHubProjection;
244
+ if (result?.success && typeof queueGitHubProjection === 'function') {
245
+ const projectionPlan = createGitHubProjectionPlan(operation, rawArgs);
246
+ if (projectionPlan) {
247
+ await queueGitHubProjection(projectionPlan, {
248
+ operation,
249
+ args: rawArgs,
250
+ projectRoot,
251
+ result,
252
+ });
253
+ }
234
254
  }
235
- }
236
255
 
237
- return result;
256
+ return result;
257
+ } finally {
258
+ if (!injectedService && typeof service.dispose === 'function') {
259
+ try { await service.dispose(); } catch { /* cleanup must not mask the operation outcome */ }
260
+ }
261
+ }
238
262
  }
239
263
 
240
264
  module.exports = {
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync } = require('node:child_process');
4
+
5
+ /**
6
+ * Detect the default branch of the repository.
7
+ *
8
+ * Strategy (in order):
9
+ * 1. `git symbolic-ref refs/remotes/origin/HEAD` -> parse branch name
10
+ * 2. `git remote show origin` -> parse "HEAD branch:" line
11
+ * 3. Fall back to `'main'`
12
+ *
13
+ * @param {string} projectRoot - Absolute path to the project root.
14
+ * @param {object} [options] - Options object.
15
+ * @param {Function} [options._exec] - Injected execFileSync for testing.
16
+ * @returns {string} The default branch name.
17
+ */
18
+ function detectDefaultBranch(projectRoot, options = {}) {
19
+ const exec = options._exec || execFileSync;
20
+
21
+ // Strategy 1: symbolic-ref
22
+ try {
23
+ const out = exec('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], {
24
+ cwd: projectRoot,
25
+ stdio: ['pipe', 'pipe', 'pipe'],
26
+ });
27
+ const ref = out.toString().trim();
28
+ // refs/remotes/origin/release/2026 -> release/2026 (branch names may contain '/')
29
+ const prefix = 'refs/remotes/origin/';
30
+ if (ref.startsWith(prefix)) {
31
+ return ref.slice(prefix.length);
32
+ }
33
+ } catch (_e) { // NOSONAR S2486 — symbolic-ref fails when origin/HEAD is unset; fall through to strategy 2
34
+ }
35
+
36
+ // Strategy 2: remote show origin
37
+ try {
38
+ const out = exec('git', ['remote', 'show', 'origin'], {
39
+ cwd: projectRoot,
40
+ stdio: ['pipe', 'pipe', 'pipe'],
41
+ });
42
+ const text = out.toString();
43
+ const match = text.match(/HEAD branch:\s*(.+)/);
44
+ if (match) {
45
+ return match[1].trim();
46
+ }
47
+ } catch (_e) { // NOSONAR S2486 — 'git remote show origin' fails with no remote configured; fall through to the default
48
+ }
49
+
50
+ // Strategy 3: fallback
51
+ return 'main';
52
+ }
53
+
54
+ module.exports = {
55
+ detectDefaultBranch,
56
+ };
@@ -13,7 +13,7 @@ const CAPABILITY_IDS = [
13
13
  'commands',
14
14
  'agents',
15
15
  'stages',
16
- 'beads',
16
+ 'issueState',
17
17
  'typedMemory',
18
18
  'patchOverrides',
19
19
  'marketplaceTrust',
@@ -157,7 +157,7 @@ const CAPABILITY_ROWS = [
157
157
  ['codex', target('skill-first', '$CODEX_HOME/skills/<stage>/SKILL.md', { role: 'generated at setup from canonical skills/', evidence: ['S5'], knownIssue: 'Repo-local Codex discovery uses .agents/skills/<stage>/SKILL.md, the committed mirror (generated from skills/, kept in sync by a pre-commit hook + drift gate) (kernel issue 55dfeccf).' })],
158
158
  ['hermes', target('forge-owned', '.hermes/skills/<stage>/SKILL.md', { role: 'Forge-owned stage-skill projection consumed via forge orient/recap' })],
159
159
  ]),
160
- row('beads', 'state-and-memory', 'issue and audit state authority', HARNESS_IDS.map(id => [id, target('forge-owned', 'forge CLI + bd adapter')])),
160
+ row('issueState', 'state-and-memory', 'issue and audit state authority', HARNESS_IDS.map(id => [id, target('forge-owned', 'forge CLI + Forge Kernel store')])),
161
161
  row('typedMemory', 'state-and-memory', 'typed memory projection', [
162
162
  ['claude', target('not-delivered', null, { knownIssue: 'Typed memory is WRITE-ONLY (insights.js); no generator projects a memory section into any Claude instruction/rule file. Tracked as kernel issue dce9da46 (epic 90f2f631).' })],
163
163
  ['cursor', target('not-delivered', null, { knownIssue: 'Typed memory is write-only; no memory-projection renderer emits a Cursor memory section. Tracked as kernel issue dce9da46 (epic 90f2f631).' })],
@@ -312,7 +312,7 @@ const RENDERER_FAMILIES = [
312
312
  ['commands', 'Forge command shim manifest', ['shim points to canonical skill', 'no duplicated stage body']],
313
313
  ['agents', 'Forge agent role spec', ['role mapping', 'parallelism or fallback policy']],
314
314
  ['stage-graph', 'Forge skills-first stage graph', ['super skill target', 'subskill target list', 'gate mapping']],
315
- ['state-and-memory', 'Beads, typed memory, and patch override manifests', ['state authority', 'projection provenance', 'protected path policy']],
315
+ ['state-and-memory', 'Kernel issue state, typed memory, and patch override manifests', ['state authority', 'projection provenance', 'protected path policy']],
316
316
  ['distribution', 'Forge extension manifest and lock metadata', ['lock hash', 'trusted source', 'generated target inventory']],
317
317
  ['safety', 'Forge safety-surface manifest (permissions, ignore, sandbox)', ['permission scope (allow/deny/ask)', 'read/index ignore boundary', 'sandbox/approval policy or global-scope deferral note']],
318
318
  ];
@@ -33,14 +33,14 @@
33
33
  * the protected-path set, and delegates the TDD gate to the real `check-tdd.js`.
34
34
  *
35
35
  * Dependency-free (JSON only; no TOML lib) so it runs under `bun test` and the
36
- * release gates. Reuses the MCP renderer's `backupFile` for the data-loss guard.
36
+ * release gates.
37
37
  *
38
38
  * @module hook-renderer
39
39
  */
40
40
 
41
41
  const fs = require('node:fs');
42
42
  const path = require('node:path');
43
- const { backupFile } = require('./mcp-config-renderer');
43
+ const { assertNoAncestorSymlinkEscape, assertNoSymlinkEscape } = require('./protected-state-surfaces');
44
44
 
45
45
  const HARNESS_HOOK_FILES = {
46
46
  claude: '.claude/settings.json',
@@ -447,10 +447,30 @@ function isForgeClaudeGroup(group) {
447
447
  return hooks.some(h => isForgeCommand(h?.command));
448
448
  }
449
449
 
450
+ function withoutForgeCommands(group) {
451
+ const hooks = Array.isArray(group?.hooks) ? group.hooks : [];
452
+ const userHooks = hooks.filter(hook => !isForgeCommand(hook?.command));
453
+ if (userHooks.length === hooks.length) return group;
454
+ if (userHooks.length === 0) return null;
455
+ return { ...group, hooks: userHooks };
456
+ }
457
+
450
458
  function isForgeCursorEntry(entry) {
451
459
  return isForgeCommand(entry?.command);
452
460
  }
453
461
 
462
+ function groupContains(actual, expected) {
463
+ if ((actual?.matcher || '') !== (expected?.matcher || '')) return false;
464
+ const actualHooks = Array.isArray(actual?.hooks) ? actual.hooks : [];
465
+ const expectedHooks = Array.isArray(expected?.hooks) ? expected.hooks : [];
466
+ const forgeHooks = actualHooks.filter(hook => isForgeCommand(hook?.command));
467
+ return forgeHooks.length === expectedHooks.length
468
+ && expectedHooks.every(expectedHook =>
469
+ forgeHooks.filter(actualHook =>
470
+ actualHook?.type === expectedHook?.type
471
+ && actualHook?.command === expectedHook?.command).length === 1);
472
+ }
473
+
454
474
  function parseJsonConfig(existingText) {
455
475
  if (!existingText || !existingText.trim()) return {};
456
476
  let obj;
@@ -479,12 +499,55 @@ function mergeClaudeSettings(existingText, contract) {
479
499
  const rendered = renderClaudeHooks(contract);
480
500
  for (const [event, forgeGroups] of Object.entries(rendered)) {
481
501
  const existingGroups = Array.isArray(obj.hooks[event]) ? obj.hooks[event] : [];
482
- const userGroups = existingGroups.filter(group => !isForgeClaudeGroup(group));
502
+ const userGroups = existingGroups.map(withoutForgeCommands).filter(Boolean);
483
503
  obj.hooks[event] = [...userGroups, ...forgeGroups];
484
504
  }
485
505
  return JSON.stringify(obj, null, 2) + '\n';
486
506
  }
487
507
 
508
+ function hasForgeClaudeHooks(existingText, contract) {
509
+ const obj = parseJsonConfig(existingText);
510
+ const hooks = obj.hooks && typeof obj.hooks === 'object' && !Array.isArray(obj.hooks)
511
+ ? obj.hooks
512
+ : {};
513
+ return Object.entries(renderClaudeHooks(contract)).every(([event, expectedGroups]) => {
514
+ const actualGroups = Array.isArray(hooks[event]) ? hooks[event] : [];
515
+ const forgeGroups = actualGroups.filter(isForgeClaudeGroup);
516
+ return forgeGroups.length === expectedGroups.length
517
+ && expectedGroups.every(expected =>
518
+ forgeGroups.filter(actual => groupContains(actual, expected)).length === 1);
519
+ });
520
+ }
521
+
522
+ function backupHookConfig(filePath) {
523
+ const source = fs.readFileSync(filePath);
524
+ const sourceStat = fs.statSync(filePath);
525
+ for (let suffix = 0; ; suffix += 1) {
526
+ const candidate = `${filePath}.bak${suffix ? `.${suffix}` : ''}`;
527
+ let candidateStat;
528
+ try {
529
+ candidateStat = fs.lstatSync(candidate);
530
+ } catch (error) {
531
+ if (error.code !== 'ENOENT') throw error;
532
+ try {
533
+ fs.copyFileSync(filePath, candidate, fs.constants.COPYFILE_EXCL);
534
+ return candidate;
535
+ } catch (copyError) {
536
+ if (copyError.code === 'EEXIST') continue;
537
+ throw copyError;
538
+ }
539
+ }
540
+ if (candidateStat.isSymbolicLink()) continue;
541
+ if (sourceStat.dev != null && sourceStat.ino != null
542
+ && sourceStat.dev === candidateStat.dev && sourceStat.ino === candidateStat.ino) continue;
543
+ try {
544
+ if (source.equals(fs.readFileSync(candidate))) return candidate;
545
+ } catch {
546
+ // An unreadable backup cannot prove the source is already safe.
547
+ }
548
+ }
549
+ }
550
+
488
551
  /**
489
552
  * Merge Forge's hooks into an existing `.cursor/hooks.json` string.
490
553
  * Forces `version: 1`, preserves all non-Forge events and the user's own entries;
@@ -511,6 +574,26 @@ const MERGERS = {
511
574
  cursor: mergeCursorHooks,
512
575
  };
513
576
 
577
+ function assertSafeHookConfigPath(targetRoot, filePath) {
578
+ const escape =
579
+ assertNoAncestorSymlinkEscape(targetRoot, filePath) ||
580
+ assertNoSymlinkEscape(targetRoot, filePath);
581
+ if (escape) throw new Error(`Refusing to render hook config: ${escape.reason}`);
582
+ if (hasMultipleHardLinks(filePath)) {
583
+ throw new Error('Refusing to render hardlinked hook config');
584
+ }
585
+ }
586
+
587
+ function hasMultipleHardLinks(filePath) {
588
+ try {
589
+ const stat = fs.lstatSync(filePath);
590
+ return stat.isFile() && stat.nlink > 1;
591
+ } catch (error) {
592
+ if (error.code === 'ENOENT') return false;
593
+ throw error;
594
+ }
595
+ }
596
+
514
597
  /**
515
598
  * Render (merge) Forge's native hooks into one harness's native config on disk.
516
599
  * Read → merge → write. Unparseable existing file → BACKED UP + left untouched
@@ -537,7 +620,9 @@ function renderHookConfig({ harness, targetRoot, contract = FORGE_HOOK_CONTRACT
537
620
  if (!merge || !rel) throw new Error(`Unknown hook harness: ${harness}`);
538
621
 
539
622
  const filePath = path.join(targetRoot, rel);
623
+ assertSafeHookConfigPath(targetRoot, filePath);
540
624
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
625
+ assertSafeHookConfigPath(targetRoot, filePath);
541
626
  const existed = fs.existsSync(filePath);
542
627
  const existing = existed ? fs.readFileSync(filePath, 'utf-8') : '';
543
628
 
@@ -546,12 +631,14 @@ function renderHookConfig({ harness, targetRoot, contract = FORGE_HOOK_CONTRACT
546
631
  merged = merge(existing, contract);
547
632
  } catch (err) {
548
633
  if (err instanceof HookConfigParseError && existed) {
549
- const backup = backupFile(filePath);
634
+ assertSafeHookConfigPath(targetRoot, filePath);
635
+ const backup = backupHookConfig(filePath);
550
636
  return { file: filePath, existed, skipped: true, wrote: false, backup };
551
637
  }
552
638
  throw err;
553
639
  }
554
640
 
641
+ assertSafeHookConfigPath(targetRoot, filePath);
555
642
  fs.writeFileSync(filePath, merged, 'utf-8');
556
643
  return { file: filePath, existed, skipped: false, wrote: true };
557
644
  }
@@ -578,6 +665,8 @@ module.exports = {
578
665
  renderCodexHooksToml,
579
666
  renderHermesHooksYaml,
580
667
  mergeClaudeSettings,
668
+ hasForgeClaudeHooks,
669
+ hasMultipleHardLinks,
581
670
  mergeCursorHooks,
582
671
  renderHookConfig,
583
672
  };
package/lib/insights.js CHANGED
@@ -3,10 +3,76 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const typedMemory = require('./memory/typed-api');
6
+ const { runIssueOperation: defaultRunIssueOperation } = require('./forge-issues');
7
+ const { buildMigratedKernelIssueDeps } = require('./kernel/cli-broker-factory');
6
8
 
7
9
  const ISSUE_REFS = ['forge-besw.12', 'forge-1gry', 'forge-5q7s'];
8
10
  const DEFAULT_MIN_COUNT = 5;
9
11
  const DEFAULT_LIMIT = 10;
12
+ // Upper bound on kernel_events scanned for interaction patterns — a spot-read, not a
13
+ // full history walk. Newest-first, so recent activity dominates the signal.
14
+ const EVENT_READ_LIMIT = 2000;
15
+
16
+ // Default kernel activity read for `forge insights` (Slice C2). Builds a short-lived
17
+ // migrated broker, reads recent kernel_events, and ALWAYS closes the driver (Windows
18
+ // CI fails on a leaked handle). Injectable via analyzeInsights options for tests.
19
+ async function defaultListRecentEvents(projectRoot, { since = null, limit = null } = {}) {
20
+ let deps;
21
+ try {
22
+ deps = await buildMigratedKernelIssueDeps({ projectRoot });
23
+ } catch {
24
+ return [];
25
+ }
26
+ try {
27
+ return await deps.kernelBroker.listRecentEvents({ since, limit });
28
+ } catch {
29
+ return [];
30
+ } finally {
31
+ if (deps.kernelDriver && typeof deps.kernelDriver.close === 'function') {
32
+ deps.kernelDriver.close();
33
+ }
34
+ }
35
+ }
36
+
37
+ // Map a kernel_events row back to the interaction shape insights consumes. Imported beads
38
+ // interactions carry event_type `beads.interaction.<kind>` and payload_json `{ kind, ...extra }`
39
+ // where field/new_value/reason live at the top level alongside kind (see beads-kernel-compat
40
+ // mapBeadsInteractionToKernel). Native kernel events fall back to their event_type as the kind.
41
+ function eventToInteraction(row) {
42
+ if (!row || typeof row !== 'object') return null;
43
+ let parsed;
44
+ try {
45
+ parsed = row.payload_json ? JSON.parse(row.payload_json) : row.payload;
46
+ } catch {
47
+ parsed = null;
48
+ }
49
+ // A scalar or array payload carries no interaction fields — treat it as empty.
50
+ const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
51
+ const kind = payload.kind
52
+ || String(row.event_type || '').replace(/^beads\.interaction\./, '')
53
+ || 'interaction';
54
+ return {
55
+ kind,
56
+ id: row.id,
57
+ issue_id: row.entity_id || null,
58
+ created_at: row.created_at || null,
59
+ extra: payload,
60
+ };
61
+ }
62
+
63
+ // Read all issues from the Kernel (the sole issue-state authority) for theme mining.
64
+ // Resilient: any read failure degrades to empty issue evidence rather than throwing.
65
+ async function listKernelIssues(projectRoot, runIssueOperation, env) {
66
+ try {
67
+ const result = await runIssueOperation('list', [], projectRoot, { issueBackend: 'kernel', env });
68
+ if (result && result.ok && result.data && Array.isArray(result.data.issues)) {
69
+ return result.data.issues;
70
+ }
71
+ } catch {
72
+ // best-effort: insights never crashes on a kernel read failure
73
+ }
74
+ return [];
75
+ }
10
76
  const STOP_WORDS = new Set([
11
77
  'after',
12
78
  'against',
@@ -108,10 +174,15 @@ function addPattern(map, key, patch) {
108
174
  map.set(key, existing);
109
175
  }
110
176
 
111
- function interactionPatterns(projectRoot, options, map) {
112
- const rows = readJsonl(path.join(projectRoot, '.beads', 'interactions.jsonl'));
113
- for (const row of rows) {
114
- if (!row || typeof row !== 'object' || row._parseError || !isSince(row.created_at, options.since)) continue;
177
+ async function interactionPatterns(projectRoot, options, map) {
178
+ const listRecentEvents = options.listRecentEvents || defaultListRecentEvents;
179
+ const since = options.since ? options.since.toISOString() : null;
180
+ const rawEvents = await listRecentEvents(projectRoot, { since, limit: EVENT_READ_LIMIT, env: options.env });
181
+ const interactions = (Array.isArray(rawEvents) ? rawEvents : [])
182
+ .map(eventToInteraction)
183
+ .filter(Boolean);
184
+ for (const row of interactions) {
185
+ if (!isSince(row.created_at, options.since)) continue;
115
186
  const extra = row.extra && typeof row.extra === 'object' ? row.extra : {};
116
187
  if (row.kind === 'field_change' && extra.field) {
117
188
  const family = reasonFamily(extra.reason);
@@ -120,7 +191,7 @@ function interactionPatterns(projectRoot, options, map) {
120
191
  kind: 'interaction',
121
192
  title: `${extra.field} changed to ${extra.new_value || 'changed'} (${family})`,
122
193
  evidence: row.issue_id || row.id,
123
- source: '.beads/interactions.jsonl',
194
+ source: 'kernel_events',
124
195
  lastSeen: row.created_at,
125
196
  });
126
197
  } else if (row.kind) {
@@ -128,12 +199,12 @@ function interactionPatterns(projectRoot, options, map) {
128
199
  kind: 'interaction',
129
200
  title: `Interaction event: ${row.kind}`,
130
201
  evidence: row.issue_id || row.id,
131
- source: '.beads/interactions.jsonl',
202
+ source: 'kernel_events',
132
203
  lastSeen: row.created_at,
133
204
  });
134
205
  }
135
206
  }
136
- return rows;
207
+ return interactions;
137
208
  }
138
209
 
139
210
  function words(value) {
@@ -153,9 +224,9 @@ function words(value) {
153
224
  return tokens;
154
225
  }
155
226
 
156
- function issuePatterns(projectRoot, options, map) {
157
- const rows = readJsonl(path.join(projectRoot, '.beads', 'issues.jsonl'))
158
- .filter(row => row && !row._parseError && row._type === 'issue');
227
+ async function issuePatterns(projectRoot, options, map) {
228
+ const runIssueOperation = options.runIssueOperation || defaultRunIssueOperation;
229
+ const rows = await listKernelIssues(projectRoot, runIssueOperation, options.env);
159
230
  const perWord = new Map();
160
231
  for (const issue of rows) {
161
232
  if (!isSince(issue.updated_at || issue.closed_at || issue.created_at, options.since)) continue;
@@ -173,7 +244,7 @@ function issuePatterns(projectRoot, options, map) {
173
244
  title: `Recurring issue theme: ${word}`,
174
245
  count: issues.length,
175
246
  evidence: issues.slice(0, 5).map(issue => issue.id).join(', '),
176
- source: '.beads/issues.jsonl',
247
+ source: 'kernel',
177
248
  lastSeen: issues.map(issue => asDate(issue.updated_at || issue.closed_at || issue.created_at))
178
249
  .filter(Boolean)
179
250
  .sort((a, b) => b - a)[0],
@@ -244,11 +315,19 @@ function candidateFromPattern(pattern) {
244
315
  };
245
316
  }
246
317
 
247
- function analyzeInsights(projectRoot, options = {}) {
318
+ async function analyzeInsights(projectRoot, options = {}) {
248
319
  const normalized = normalizeOptions(options);
320
+ // Read options carry the normalized thresholds PLUS the injectable kernel seams
321
+ // (defaulted to the real cli-broker-factory-backed reads) so tests can supply fakes.
322
+ const readOptions = {
323
+ ...normalized,
324
+ runIssueOperation: options.runIssueOperation,
325
+ listRecentEvents: options.listRecentEvents,
326
+ env: options.env,
327
+ };
249
328
  const map = new Map();
250
- const interactions = interactionPatterns(projectRoot, normalized, map);
251
- const issues = issuePatterns(projectRoot, normalized, map);
329
+ const interactions = await interactionPatterns(projectRoot, readOptions, map);
330
+ const issues = await issuePatterns(projectRoot, readOptions, map);
252
331
  const audit = auditPatterns(projectRoot, normalized, map);
253
332
  const patterns = toPatternList(map, normalized);
254
333
  const candidates = patterns.map(candidateFromPattern);
@@ -267,7 +346,7 @@ function analyzeInsights(projectRoot, options = {}) {
267
346
  candidates,
268
347
  limitations: [
269
348
  'Insights are local workflow signals, not proof of correctness.',
270
- 'Sparse Beads interactions or missing audit logs reduce confidence.',
349
+ 'Sparse kernel events or missing audit logs reduce confidence.',
271
350
  'Accepting a suggestion records a decision; it does not install trusted executable code.',
272
351
  ],
273
352
  };
@@ -314,6 +393,8 @@ function recordInsightDecision(projectRoot, candidateId, status, options = {}) {
314
393
  }, {
315
394
  memory: options.memory,
316
395
  tags: ['insights', status],
396
+ // `beadsRefs` is a persisted typed-memory field name kept for data-shape compat
397
+ // (imported/legacy memories carry it); the values are historical issue references.
317
398
  beadsRefs: ISSUE_REFS,
318
399
  provenance: {
319
400
  actor: 'forge insights',
@@ -323,75 +404,10 @@ function recordInsightDecision(projectRoot, candidateId, status, options = {}) {
323
404
  });
324
405
  }
325
406
 
326
- function issueSummary(issues) {
327
- return issues.reduce((summary, issue) => {
328
- summary.total += 1;
329
- if (issue.status === 'closed') summary.closed += 1;
330
- else summary.open += 1;
331
- return summary;
332
- }, { total: 0, open: 0, closed: 0 });
333
- }
334
-
335
- function buildRecap(projectRoot, options = {}) {
336
- const normalized = normalizeOptions(options);
337
- const insights = analyzeInsights(projectRoot, options);
338
- const issues = readJsonl(path.join(projectRoot, '.beads', 'issues.jsonl'))
339
- .filter(row => row && !row._parseError && row._type === 'issue')
340
- .filter(issue => isSince(issue.updated_at || issue.closed_at || issue.created_at, normalized.since));
341
- const interactions = readJsonl(path.join(projectRoot, '.beads', 'interactions.jsonl'))
342
- .filter(row => row && typeof row === 'object' && !row._parseError)
343
- .filter(row => isSince(row.created_at, normalized.since));
344
- const reviewOutcomes = interactions.filter(row => {
345
- const reason = row.extra?.reason || '';
346
- return reasonFamily(reason) === 'merged-and-verified' || String(reason).toLowerCase().includes('review');
347
- }).length;
348
- const recentIssues = [...issues]
349
- .sort((a, b) => String(b.updated_at || b.closed_at || b.created_at || '').localeCompare(String(a.updated_at || a.closed_at || a.created_at || '')))
350
- .slice(0, normalized.limit)
351
- .map(issue => ({
352
- id: issue.id,
353
- title: issue.title,
354
- status: issue.status,
355
- }));
356
-
357
- return {
358
- generatedAt: new Date().toISOString(),
359
- issueSummary: issueSummary(issues),
360
- reviewOutcomes,
361
- recentIssues,
362
- insights,
363
- };
364
- }
365
-
366
- function formatRecapText(recap) {
367
- const lines = [
368
- 'Forge recap',
369
- `Issues: ${recap.issueSummary.total} total, ${recap.issueSummary.open} open, ${recap.issueSummary.closed} closed`,
370
- `Review outcomes found: ${recap.reviewOutcomes}`,
371
- 'Recent work:',
372
- ];
373
- for (const issue of recap.recentIssues) {
374
- lines.push(`- ${issue.id}: ${issue.title} [${issue.status || 'unknown'}]`);
375
- }
376
- lines.push('Insight candidates:');
377
- if (recap.insights.candidates.length === 0) {
378
- lines.push('- No strong recurring patterns found.');
379
- } else {
380
- for (const candidate of recap.insights.candidates) {
381
- lines.push(`- ${candidate.id}: ${candidate.title}`);
382
- }
383
- }
384
- lines.push('Limitations:');
385
- for (const limitation of recap.insights.limitations) lines.push(`- ${limitation}`);
386
- return `${lines.join('\n')}\n`;
387
- }
388
-
389
407
  module.exports = {
390
408
  ISSUE_REFS,
391
409
  analyzeInsights,
392
- buildRecap,
393
410
  formatInsightsText,
394
- formatRecapText,
395
411
  readJsonl,
396
412
  recordInsightDecision,
397
413
  };