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.
- package/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +92 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- 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
|
-
'
|
|
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('
|
|
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', '
|
|
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
|
];
|
package/lib/hook-renderer.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
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: '
|
|
102
|
-
codex: Object.freeze({ rendered: false, reason: '
|
|
103
|
-
hermes: Object.freeze({ rendered: false, reason: '
|
|
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
|
-
//
|
|
299
|
-
// PR-shepherd deltas). Claude runs every hook in the
|
|
300
|
-
// additionalContext; keeping them in one group means a
|
|
301
|
-
// atomically (the group is Forge-owned via
|
|
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(
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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: '
|
|
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: '
|
|
202
|
+
source: 'kernel_events',
|
|
132
203
|
lastSeen: row.created_at,
|
|
133
204
|
});
|
|
134
205
|
}
|
|
135
206
|
}
|
|
136
|
-
return
|
|
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
|
|
158
|
-
|
|
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: '
|
|
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,
|
|
251
|
-
const issues = issuePatterns(projectRoot,
|
|
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
|
|
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
|
};
|