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.
- package/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +16 -374
- 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 +8 -5
- 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 +54 -25
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +344 -142
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +223 -25
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +21 -8
- 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 +17 -2
- package/lib/commands/setup.js +191 -94
- package/lib/commands/shepherd.js +13 -1
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +119 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/test.js +24 -34
- package/lib/commands/worktree.js +220 -42
- package/lib/core/runtime-graph.js +1 -1
- 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 +56 -32
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +93 -4
- package/lib/insights.js +96 -80
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +16 -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/sqlite-driver.js +153 -29
- 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 +71 -10
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +68 -43
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +224 -41
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +10 -1
- package/lib/pr-monitor/watch-lifecycle.js +13 -1
- package/lib/pr-pull.js +33 -14
- 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 +33 -1
- 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/shell-utils.js +1 -1
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/package.json +6 -7
- package/scripts/doc-asserting-tests.js +158 -0
- 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 +176 -43
- package/skills/review/SKILL.md +4 -11
- package/skills/review/evals/scorecard.json +3 -3
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +20 -14
- package/skills/shepherd/evals/scorecard.json +2 -2
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- 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/lib/beads-migrate-to-dolt.mjs +0 -503
package/lib/forge-issues.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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('
|
|
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',
|
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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
|
};
|