forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) 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 +21 -1
  5. package/bin/forge.js +16 -369
  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 +9 -4
  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 +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -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',
@@ -33,7 +33,7 @@ const SOURCE_ROWS = [
33
33
  ['S4', 'Cursor MCP', 'https://docs.cursor.com/context/model-context-protocol', 'Cursor connects external tools and data sources through MCP using stdio, SSE, and streamable HTTP transports.'],
34
34
  ['S5', 'Codex skills', 'https://developers.openai.com/codex/skills', 'Codex skills package instructions, resources, and scripts; Codex scans .agents/skills from cwd up to the repo root for repo-scope discovery. Forge installs the global $CODEX_HOME/skills from the canonical skills/ source AND commits the repo-local .agents/skills mirror (kept in sync with skills/ by a pre-commit hook + drift gate) for teammate-clone discovery.'],
35
35
  ['S6', 'Codex MCP', 'https://developers.openai.com/codex/mcp', 'Codex configures MCP servers in config.toml and supports plugin-provided MCP servers.'],
36
- ['S7', 'Codex hooks', 'https://developers.openai.com/codex/hooks', 'Codex hooks run deterministic scripts during lifecycle events.'],
36
+ ['S7', 'Codex hooks', 'https://developers.openai.com/codex/hooks', 'Codex hooks run deterministic scripts during lifecycle events; they live in GLOBAL $CODEX_HOME/config.toml so project setup does not render them. Codex instead reads the committed project-local AGENTS.md every session, which is the always-on carrier for Forge instruction/policy including the using-forge skill-dispatch pointer (see S9).'],
37
37
  ['S8', 'Codex plugins and marketplaces', 'https://developers.openai.com/codex/plugins/build', 'Codex plugins can package skills, apps, MCP servers, hooks, and marketplace metadata.'],
38
38
  ['S9', 'AGENTS.md standard', 'https://agents.md/', 'AGENTS.md provides repository instructions for coding agents.'],
39
39
  ['S10', 'Cursor Agent Skills', 'https://docs.cursor.com/en/agent/skills', 'Cursor reads on-demand Agent Skills from .cursor/skills/<name>/SKILL.md; Forge populates them at setup from the canonical skills/ source.'],
@@ -41,7 +41,7 @@ const SOURCE_ROWS = [
41
41
  ['S12', 'Cursor ignore files', 'https://cursor.com/docs/reference/ignore-file', 'Cursor reads project-local .cursorignore (blocks AI access + indexing) and .cursorindexingignore (index-only), gitignore-style; .gitignore and common secret/lock files are ignored by default.'],
42
42
  ['S13', 'Codex sandbox and approvals config', 'https://developers.openai.com/codex/config-reference', 'Codex config.toml sets sandbox_mode (read-only/workspace-write/danger-full-access) and approval_policy (untrusted/on-request/never); a project-local .codex/config.toml is honored ONLY when the project is trusted in the GLOBAL $CODEX_HOME/config.toml (projects.<path>.trust_level).'],
43
43
  ['S14', 'Claude Code hooks', 'https://code.claude.com/docs/en/hooks', 'Claude Code hooks live in .claude/settings.json under a `hooks` block; a PreToolUse matcher group (e.g. "Write|Edit"/"Bash") runs a command that can deny the tool call via hookSpecificOutput.permissionDecision.'],
44
- ['S15', 'Cursor hooks', 'https://cursor.com/docs/hooks', 'Cursor 1.7+ reads project-local .cursor/hooks.json ({ version: 1, hooks }); only before* events (beforeShellExecution/beforeReadFile/beforeMCPExecution) can deny — there is no pre-edit deny event, so afterFileEdit is observational.'],
44
+ ['S15', 'Cursor hooks', 'https://cursor.com/docs/hooks', 'Cursor 1.7+ reads project-local .cursor/hooks.json ({ version: 1, hooks }); only before* events (beforeShellExecution/beforeReadFile/beforeMCPExecution) can deny — there is no pre-edit deny event, so afterFileEdit is observational. Cursor hooks are therefore NOT the session-context path: Cursor auto-injects always-apply .cursor/rules/*.mdc (alwaysApply:true) every session (see S3), which is the always-on carrier for the using-forge skill-dispatch pointer.'],
45
45
  ['S16', 'Hermes Agent event hooks', 'https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks/', 'Hermes (NousResearch) shell hooks are declared in a `hooks:` block in the GLOBAL ~/.hermes/config.yaml; each matcher/command entry runs as a subprocess over a JSON-stdin/stdout wire protocol, and a pre_tool_call hook CAN deny a tool call ({action:block, message} — it also accepts the Claude-Code {decision:block, reason} shape).'],
46
46
  ];
47
47
 
@@ -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',
@@ -80,6 +80,9 @@ const FORGE_INBOX_CONTEXT_MARKER = 'hooks inbox-pickup';
80
80
  // Forge UserPromptSubmit group is already recognized via the inbox marker, but this keeps
81
81
  // the shepherd-events command independently identifiable (symmetry with the other tiers).
82
82
  const FORGE_SHEPHERD_EVENTS_MARKER = 'hooks shepherd-events';
83
+ // The memory-recall context hook (UserPromptSubmit tier). Its own idempotency marker so a
84
+ // re-render recognizes and REPLACES the Forge-owned entry in place rather than duplicating it.
85
+ const FORGE_MEMORY_RECALL_MARKER = 'hooks memory-recall';
83
86
  // The capture-on-exit context hook (PreCompact + Stop tier). A THIRD context marker so a
84
87
  // re-merge recognizes + replaces the Forge-owned PreCompact/Stop entries in place. Both
85
88
  // events share this one marker (they differ only by a --trigger suffix on the command).
@@ -91,16 +94,33 @@ const FORGE_SHEPHERD_EVENTS_MARKER = 'hooks shepherd-events';
91
94
  // the merge replaces exactly Forge's group and preserves the user's.
92
95
  const FORGE_CAPTURE_CONTEXT_MARKER = `${FORGE_CLI} hooks capture`;
93
96
 
94
- // Per-harness SessionStart context-injection capability. Honest capability matrix —
95
- // only Claude exposes a native session-start surface that can inject additionalContext.
96
- // Cursor's 1.7 hooks are deny-oriented (no session-start context surface); Codex and
97
- // Hermes hooks live in GLOBAL home config that project setup never writes. We NEVER
98
- // fake parity: each non-Claude harness carries an explicit, tested skip reason.
97
+ // Per-harness SessionStart HOOK-render capability. This constant governs ONE thing: whether
98
+ // Forge renders a harness-native SessionStart *hook* that injects additionalContext. Only Claude
99
+ // exposes that surface, so only Claude is rendered:true. Every other harness is rendered:false
100
+ // with an HONEST reason naming WHERE the same dispatch bootstrap is delivered instead — because
101
+ // the bootstrap is NOT Claude-only, it just travels a different always-on surface per harness
102
+ // (see BOOTSTRAP_DELIVERY below): Cursor auto-applies an always-on rule (.cursor/rules/*.mdc,
103
+ // alwaysApply:true); Codex reads the committed project-local AGENTS.md every session; Hermes
104
+ // consumes it through the forge CLI (forge prime/orient + forge skill for). We NEVER fake parity
105
+ // and NEVER branch on harness identity at runtime — delivery is keyed by SURFACE TYPE.
99
106
  const SESSION_START_SUPPORT = Object.freeze({
100
107
  claude: Object.freeze({ rendered: true }),
101
- cursor: Object.freeze({ rendered: false, reason: 'no-session-start-surface' }),
102
- codex: Object.freeze({ rendered: false, reason: 'global-config' }),
103
- hermes: Object.freeze({ rendered: false, reason: 'global-config' }),
108
+ cursor: Object.freeze({ rendered: false, reason: 'delivered-via-rule-surface' }),
109
+ codex: Object.freeze({ rendered: false, reason: 'delivered-via-agents-md' }),
110
+ hermes: Object.freeze({ rendered: false, reason: 'delivered-via-cli-fallback' }),
111
+ });
112
+
113
+ // Where the using-forge dispatch BOOTSTRAP reaches each harness EVERY session. Data, not
114
+ // identity-branching: the parity gate iterates this so no harness is silently left with no
115
+ // auto-surface. Only Claude's is a native SessionStart hook; the rest are equally always-on
116
+ // surfaces (a Cursor always-apply rule, the committed AGENTS.md for Codex, the forge CLI as an
117
+ // honest fallback for Hermes). All four ALSO carry the full using-forge SKILL.md via the synced
118
+ // skill mirrors. 'cli-fallback' is the explicit honest-fallback marker (no faked native surface).
119
+ const BOOTSTRAP_DELIVERY = Object.freeze({
120
+ claude: 'session-start-hook',
121
+ cursor: 'always-apply-rule',
122
+ codex: 'agents-md',
123
+ hermes: 'cli-fallback',
104
124
  });
105
125
 
106
126
  // Per-harness UserPromptSubmit context-injection capability (the near-real-time inbox
@@ -179,6 +199,22 @@ const FORGE_HOOK_CONTRACT = Object.freeze({
179
199
  lifecycle: 'user-prompt-submit',
180
200
  command: `${FORGE_CLI} hooks inbox-pickup`,
181
201
  }),
202
+ Object.freeze({
203
+ id: 'memory-recall',
204
+ kind: 'context',
205
+ cliAction: 'memory-recall',
206
+ // QUERY-RELEVANT MEMORY (tier-2): on each prompt, ranks stored memories by BM25
207
+ // relevance to the submitted prompt (read from the hook's own stdin) and injects the
208
+ // survivors, gated by a relevance floor + anaphora guard + cross-turn dedupe + a hard
209
+ // token budget. Complements the recency digest at SessionStart, which answers a
210
+ // different question (what's newest, not what's relevant to THIS turn). Reads the
211
+ // user's OWN kernel memory via a supported hook and its own stdin input — it NEVER
212
+ // injects into a running session's stdin and never drives the agent (Anthropic Usage
213
+ // Policy). Kill-switch: `forge gate disable rail.memory_recall`.
214
+ enforces: 'Memory recall: on each UserPromptSubmit, inject query-relevant stored memories ranked by BM25 against the submitted prompt, gated by a relevance floor, an anaphora guard, cross-turn dedupe, and a hard token budget (compact to avoid additionalContext accumulation). Additive and FAIL-OPEN — nothing relevant, a disabled rail, or any error injects nothing and never blocks a prompt.',
215
+ lifecycle: 'user-prompt-submit',
216
+ command: `${FORGE_CLI} hooks memory-recall`,
217
+ }),
182
218
  Object.freeze({
183
219
  id: 'shepherd-events',
184
220
  kind: 'context',
@@ -295,15 +331,18 @@ function renderClaudeHooks(contract) {
295
331
  // Surfaces pending targeted dashboard instruction comments (fenced kernel DATA) on each
296
332
  // prompt; the command emits { hookSpecificOutput.additionalContext }. Reads the user's
297
333
  // own kernel data via a supported hook — NEVER stdin injection (Anthropic Usage Policy).
298
- // Both UserPromptSubmit context hooks share ONE Forge-owned group (inbox-pickup +
299
- // PR-shepherd deltas). Claude runs every hook in the group and appends each hook's
300
- // additionalContext; keeping them in one group means a re-merge replaces the pair
301
- // atomically (the group is Forge-owned via either marker). Both are compact + fail-open.
334
+ // The UserPromptSubmit context hooks share ONE Forge-owned group (inbox-pickup nudge,
335
+ // PR-shepherd deltas, and query-relevant memory recall). Claude runs every hook in the
336
+ // group and appends each hook's additionalContext; keeping them in one group means a
337
+ // re-merge replaces the set atomically (the group is Forge-owned via any of their
338
+ // markers). All three are compact + fail-open. (memory-recall reads its own hook stdin
339
+ // for the prompt — that is its supported input, not session-stdin injection.)
302
340
  UserPromptSubmit: [
303
341
  {
304
342
  hooks: [
305
343
  { type: 'command', command: harnessCommand(contract, 'inbox-pickup', 'claude') },
306
344
  { type: 'command', command: harnessCommand(contract, 'shepherd-events', 'claude') },
345
+ { type: 'command', command: harnessCommand(contract, 'memory-recall', 'claude') },
307
346
  ],
308
347
  },
309
348
  ],
@@ -398,6 +437,7 @@ function isForgeCommand(command) {
398
437
  || command.includes(FORGE_CONTEXT_MARKER)
399
438
  || command.includes(FORGE_INBOX_CONTEXT_MARKER)
400
439
  || command.includes(FORGE_SHEPHERD_EVENTS_MARKER)
440
+ || command.includes(FORGE_MEMORY_RECALL_MARKER)
401
441
  || command.includes(FORGE_CAPTURE_CONTEXT_MARKER));
402
442
  }
403
443
 
@@ -407,10 +447,30 @@ function isForgeClaudeGroup(group) {
407
447
  return hooks.some(h => isForgeCommand(h?.command));
408
448
  }
409
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
+
410
458
  function isForgeCursorEntry(entry) {
411
459
  return isForgeCommand(entry?.command);
412
460
  }
413
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
+
414
474
  function parseJsonConfig(existingText) {
415
475
  if (!existingText || !existingText.trim()) return {};
416
476
  let obj;
@@ -439,12 +499,55 @@ function mergeClaudeSettings(existingText, contract) {
439
499
  const rendered = renderClaudeHooks(contract);
440
500
  for (const [event, forgeGroups] of Object.entries(rendered)) {
441
501
  const existingGroups = Array.isArray(obj.hooks[event]) ? obj.hooks[event] : [];
442
- const userGroups = existingGroups.filter(group => !isForgeClaudeGroup(group));
502
+ const userGroups = existingGroups.map(withoutForgeCommands).filter(Boolean);
443
503
  obj.hooks[event] = [...userGroups, ...forgeGroups];
444
504
  }
445
505
  return JSON.stringify(obj, null, 2) + '\n';
446
506
  }
447
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
+
448
551
  /**
449
552
  * Merge Forge's hooks into an existing `.cursor/hooks.json` string.
450
553
  * Forces `version: 1`, preserves all non-Forge events and the user's own entries;
@@ -471,6 +574,26 @@ const MERGERS = {
471
574
  cursor: mergeCursorHooks,
472
575
  };
473
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
+
474
597
  /**
475
598
  * Render (merge) Forge's native hooks into one harness's native config on disk.
476
599
  * Read → merge → write. Unparseable existing file → BACKED UP + left untouched
@@ -497,7 +620,9 @@ function renderHookConfig({ harness, targetRoot, contract = FORGE_HOOK_CONTRACT
497
620
  if (!merge || !rel) throw new Error(`Unknown hook harness: ${harness}`);
498
621
 
499
622
  const filePath = path.join(targetRoot, rel);
623
+ assertSafeHookConfigPath(targetRoot, filePath);
500
624
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
625
+ assertSafeHookConfigPath(targetRoot, filePath);
501
626
  const existed = fs.existsSync(filePath);
502
627
  const existing = existed ? fs.readFileSync(filePath, 'utf-8') : '';
503
628
 
@@ -506,12 +631,14 @@ function renderHookConfig({ harness, targetRoot, contract = FORGE_HOOK_CONTRACT
506
631
  merged = merge(existing, contract);
507
632
  } catch (err) {
508
633
  if (err instanceof HookConfigParseError && existed) {
509
- const backup = backupFile(filePath);
634
+ assertSafeHookConfigPath(targetRoot, filePath);
635
+ const backup = backupHookConfig(filePath);
510
636
  return { file: filePath, existed, skipped: true, wrote: false, backup };
511
637
  }
512
638
  throw err;
513
639
  }
514
640
 
641
+ assertSafeHookConfigPath(targetRoot, filePath);
515
642
  fs.writeFileSync(filePath, merged, 'utf-8');
516
643
  return { file: filePath, existed, skipped: false, wrote: true };
517
644
  }
@@ -523,8 +650,10 @@ module.exports = {
523
650
  FORGE_HOOK_MARKER,
524
651
  FORGE_CONTEXT_MARKER,
525
652
  FORGE_INBOX_CONTEXT_MARKER,
653
+ FORGE_MEMORY_RECALL_MARKER,
526
654
  FORGE_CAPTURE_CONTEXT_MARKER,
527
655
  SESSION_START_SUPPORT,
656
+ BOOTSTRAP_DELIVERY,
528
657
  USER_PROMPT_SUBMIT_SUPPORT,
529
658
  SESSION_END_SUPPORT,
530
659
  sessionStartCapability,
@@ -536,6 +665,8 @@ module.exports = {
536
665
  renderCodexHooksToml,
537
666
  renderHermesHooksYaml,
538
667
  mergeClaudeSettings,
668
+ hasForgeClaudeHooks,
669
+ hasMultipleHardLinks,
539
670
  mergeCursorHooks,
540
671
  renderHookConfig,
541
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
  };