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
package/lib/commands/hooks.js
CHANGED
|
@@ -27,16 +27,151 @@ const {
|
|
|
27
27
|
renderGlobalHookBlock,
|
|
28
28
|
installGlobalHooks,
|
|
29
29
|
} = require('../hook-global-installer');
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
30
32
|
const { sessionStartCapability, userPromptSubmitCapability, sessionEndCapability } = require('../hook-renderer');
|
|
31
|
-
const {
|
|
33
|
+
const { buildMemoryDigest, defaultFetchIssues, defaultFetchNotes } = require('../memory-digest');
|
|
32
34
|
const { collectInbox, buildInboxNudge } = require('../inbox');
|
|
33
35
|
const { collectDigest } = require('../pr-monitor/digest');
|
|
36
|
+
const { loadDispatchText } = require('../using-forge');
|
|
37
|
+
const projectMemory = require('../project-memory');
|
|
38
|
+
const { parseHookInput, selectInjection, meaningfulTokens, DEFAULT_TOKEN_BUDGET, DEFAULT_SCORE_FLOOR } = require('../memory-recall');
|
|
39
|
+
const { launchMemoryRecallEvent } = require('../memory-recall-events');
|
|
40
|
+
const { getResolvedRuntimeGraph } = require('../core/runtime-graph');
|
|
41
|
+
const { fireAndForget } = require('../pr-monitor/reconcile-executor');
|
|
42
|
+
|
|
43
|
+
// Default-ON rail; `forge gate disable rail.memory_recall` turns tier-2 off. Mirrors
|
|
44
|
+
// autoShepherdRailEnabled (lib/commands/ship.js): absent id = enabled, fail-open to true.
|
|
45
|
+
const MEMORY_RECALL_RAIL = 'rail.memory_recall';
|
|
46
|
+
// bm25 candidate pool to rank/floor down from. Wider than what we inject so the floor +
|
|
47
|
+
// dedupe have room (mirrors the research "widen before ranking" guidance).
|
|
48
|
+
const MEMORY_RECALL_CANDIDATES = 25;
|
|
49
|
+
// Cross-turn dedupe memory: how many recently-injected keys to remember per session.
|
|
50
|
+
const SEEN_KEYS_CAP = 40;
|
|
51
|
+
const SESSION_START_DEADLINE_MS = 9_000;
|
|
52
|
+
const PROMPT_RECALL_DEADLINE_MS = 4_500;
|
|
53
|
+
const PROMPT_RECALL_SQLITE_BUSY_MS = 1_000;
|
|
54
|
+
|
|
55
|
+
function withinDeadline(work, fallback, deadlineMs, onTimeout) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
if (onTimeout) onTimeout();
|
|
59
|
+
resolve(fallback);
|
|
60
|
+
}, deadlineMs);
|
|
61
|
+
Promise.resolve().then(work).then(
|
|
62
|
+
value => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
resolve(value);
|
|
65
|
+
},
|
|
66
|
+
() => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
resolve(fallback);
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function collectSessionStartData(projectRoot, opts, deadlineMs) {
|
|
75
|
+
const fetchNotes = opts.fetchNotes || defaultFetchNotes;
|
|
76
|
+
const fetchIssues = opts.fetchIssues || defaultFetchIssues;
|
|
77
|
+
const fetchInbox = opts.fetchInbox || collectInbox;
|
|
78
|
+
const bounded = work => withinDeadline(work, [], deadlineMs);
|
|
79
|
+
const [notes, ready, claimed, inbox] = await Promise.all([
|
|
80
|
+
bounded(() => fetchNotes(projectRoot, opts)),
|
|
81
|
+
bounded(() => fetchIssues(projectRoot, 'ready', opts)),
|
|
82
|
+
bounded(() => fetchIssues(projectRoot, 'in_progress', opts)),
|
|
83
|
+
bounded(() => fetchInbox(projectRoot, opts)),
|
|
84
|
+
]);
|
|
85
|
+
return { notes, ready, claimed, inbox };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function memoryRecallRailEnabled(projectRoot, resolveGraph = getResolvedRuntimeGraph) {
|
|
89
|
+
try {
|
|
90
|
+
const graph = resolveGraph({ projectRoot });
|
|
91
|
+
const rail = [...(graph.rails || []), ...(graph.gates || [])].find(entry => entry.id === MEMORY_RECALL_RAIL);
|
|
92
|
+
return !(rail?.enabled === false);
|
|
93
|
+
} catch {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Read the hook's stdin payload (Claude delivers UserPromptSubmit JSON on fd 0). Never
|
|
99
|
+
// throws — no stdin / a closed fd yields '' so the hook fails open.
|
|
100
|
+
function readHookStdin() {
|
|
101
|
+
try {
|
|
102
|
+
return fs.readFileSync(0, 'utf8');
|
|
103
|
+
} catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function seenPath(projectRoot, sessionId) {
|
|
109
|
+
const safe = String(sessionId || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
110
|
+
return path.join(projectRoot, '.forge', 'memory-recall', `${safe}.json`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Keys injected on recent turns of THIS session (cross-turn dedupe). Best-effort: any read
|
|
114
|
+
// error yields [] so a first turn or a corrupt file simply injects without exclusion.
|
|
115
|
+
function loadSeenKeys(projectRoot, sessionId) {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(fs.readFileSync(seenPath(projectRoot, sessionId), 'utf8'));
|
|
118
|
+
return Array.isArray(parsed) ? parsed.filter(k => typeof k === 'string') : [];
|
|
119
|
+
} catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Append the just-injected keys to the session's seen-list (newest last, capped). Best-effort.
|
|
125
|
+
function saveSeenKeys(projectRoot, sessionId, keys) {
|
|
126
|
+
try {
|
|
127
|
+
const merged = [...loadSeenKeys(projectRoot, sessionId), ...keys].slice(-SEEN_KEYS_CAP);
|
|
128
|
+
const file = seenPath(projectRoot, sessionId);
|
|
129
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
130
|
+
fs.writeFileSync(file, JSON.stringify(merged), 'utf8');
|
|
131
|
+
} catch {
|
|
132
|
+
// Dedupe is a nicety, not a correctness gate — a failed write never breaks the prompt.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Shadow-log the tier-2 recall decision (kernel issue f71784d3 step-0 instrument): one JSON
|
|
137
|
+
// line per run records what was retrieved and what cleared the floor, so the corpus-dependent
|
|
138
|
+
// scoreFloor can be tuned from real data instead of guessed. Capped so it never grows unbounded.
|
|
139
|
+
const SHADOW_LOG_MAX_BYTES = 512 * 1024;
|
|
140
|
+
// A shadow record is a tuning sample, not an archive. It contains only aggregate counts/scores.
|
|
141
|
+
|
|
142
|
+
function shadowLogPath(projectRoot) {
|
|
143
|
+
return path.join(projectRoot, '.forge', 'memory-recall', 'shadow.jsonl');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Append one JSON line, then evict whole records oldest-first until the file fits the byte cap.
|
|
147
|
+
// Trimming by bytes (not line count) is what keeps this hook's synchronous read/write bounded: a
|
|
148
|
+
// record that exceeds the cap alone leaves the file empty rather than permanently oversized.
|
|
149
|
+
// Best-effort — any failure is swallowed by the caller so logging never affects the hook result.
|
|
150
|
+
function appendShadowLog(projectRoot, record) {
|
|
151
|
+
const file = shadowLogPath(projectRoot);
|
|
152
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
153
|
+
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
|
|
154
|
+
let size;
|
|
155
|
+
try { size = fs.statSync(file).size; } catch { size = 0; }
|
|
156
|
+
if (size <= SHADOW_LOG_MAX_BYTES) return;
|
|
157
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
158
|
+
const keep = [];
|
|
159
|
+
let bytes = 0;
|
|
160
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
161
|
+
const cost = Buffer.byteLength(`${lines[i]}\n`, 'utf8');
|
|
162
|
+
if (bytes + cost > SHADOW_LOG_MAX_BYTES) break;
|
|
163
|
+
bytes += cost;
|
|
164
|
+
keep.unshift(lines[i]);
|
|
165
|
+
}
|
|
166
|
+
fs.writeFileSync(file, keep.length ? `${keep.join('\n')}\n` : '', 'utf8');
|
|
167
|
+
}
|
|
34
168
|
|
|
35
169
|
function usage() {
|
|
36
170
|
return 'Usage: forge hooks install --global [--harness codex|hermes|all] [--dry-run]\n'
|
|
37
171
|
+ ' forge hooks session-start --harness <claude> (machine-facing; emits SessionStart context)\n'
|
|
38
172
|
+ ' forge hooks inbox-pickup --harness <claude> (machine-facing; emits UserPromptSubmit context)\n'
|
|
39
173
|
+ ' forge hooks shepherd-events --harness <claude> (machine-facing; emits UserPromptSubmit PR-monitor deltas)\n'
|
|
174
|
+
+ ' forge hooks memory-recall --harness <claude> (machine-facing; emits UserPromptSubmit query-relevant memory)\n'
|
|
40
175
|
+ ' forge hooks capture --harness <claude> --trigger <precompact|stop> (machine-facing; captures a session summary on exit)';
|
|
41
176
|
}
|
|
42
177
|
|
|
@@ -111,21 +246,46 @@ function formatSessionStart(harness, text) {
|
|
|
111
246
|
*
|
|
112
247
|
* @param {string[]} rest - args after the `session-start` action.
|
|
113
248
|
* @param {string} projectRoot
|
|
114
|
-
* @param {object} [opts] - injectable digest fetchers ({ fetchNotes, fetchIssues })
|
|
249
|
+
* @param {object} [opts] - injectable digest fetchers ({ fetchNotes, fetchIssues }) and an
|
|
250
|
+
* optional host `harness` capability ({ hasBgShell: true, runBgShell(argv, { cwd }) }).
|
|
115
251
|
* @returns {Promise<{ success: boolean, output: string }>}
|
|
116
252
|
*/
|
|
117
253
|
async function handleSessionStart(rest, projectRoot, opts = {}) {
|
|
254
|
+
const harness = parseHarness(rest);
|
|
255
|
+
const capability = sessionStartCapability(harness);
|
|
256
|
+
if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
|
|
118
257
|
try {
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
|
|
258
|
+
const triggerContext = { projectRoot };
|
|
259
|
+
if (opts.harness !== undefined) triggerContext.harness = opts.harness;
|
|
260
|
+
(opts.fireAndForget || fireAndForget)(triggerContext);
|
|
261
|
+
} catch { /* the automatic trigger must never break session start */ }
|
|
262
|
+
|
|
263
|
+
// The using-forge dispatch bootstrap is injected FIRST so Forge skills auto-trigger from turn
|
|
264
|
+
// one (the Superpowers mechanism): a reasoning-driven system, not just harness description
|
|
265
|
+
// matching. It is a deterministic file read that survives a kernel outage. The memory digest
|
|
266
|
+
// (remembered notes + top open issues) is appended when present. Either alone is enough to
|
|
267
|
+
// inject; a total blank yields '' (the harness injects nothing). FAIL-OPEN throughout.
|
|
268
|
+
let dispatch = '';
|
|
269
|
+
try {
|
|
270
|
+
// No projectRoot: the dispatch skill is read from the Forge PACKAGE's canonical skills/
|
|
271
|
+
// (a set-up consumer project has no root skills/, only generated mirrors).
|
|
272
|
+
dispatch = (opts.loadDispatchText || loadDispatchText)() || '';
|
|
273
|
+
} catch { /* keep '' — a missing dispatch skill must not break session start */ }
|
|
274
|
+
|
|
275
|
+
let digestText = '';
|
|
276
|
+
try {
|
|
277
|
+
const data = await collectSessionStartData(
|
|
278
|
+
projectRoot,
|
|
279
|
+
opts,
|
|
280
|
+
opts.sessionStartDeadlineMs || SESSION_START_DEADLINE_MS,
|
|
281
|
+
);
|
|
122
282
|
const digest = buildMemoryDigest(data, opts);
|
|
123
|
-
if (digest.empty)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
283
|
+
if (!digest.empty) digestText = digest.text;
|
|
284
|
+
} catch { /* fail-open: a kernel outage must not suppress the dispatch bootstrap */ }
|
|
285
|
+
|
|
286
|
+
const combined = [dispatch, digestText].filter(Boolean).join('\n\n');
|
|
287
|
+
if (!combined) return { success: true, output: '' };
|
|
288
|
+
return { success: true, output: formatSessionStart(harness, combined) };
|
|
129
289
|
}
|
|
130
290
|
|
|
131
291
|
/** Wrap a digest into a harness-native UserPromptSubmit payload, or '' when unsupported. */
|
|
@@ -160,7 +320,8 @@ function formatUserPromptSubmit(harness, text) {
|
|
|
160
320
|
async function handleInboxPickup(rest, projectRoot, opts = {}) {
|
|
161
321
|
try {
|
|
162
322
|
const harness = parseHarness(rest);
|
|
163
|
-
|
|
323
|
+
const capability = userPromptSubmitCapability(harness);
|
|
324
|
+
if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
|
|
164
325
|
const pending = await collectInbox(projectRoot, opts);
|
|
165
326
|
const nudge = buildInboxNudge(pending);
|
|
166
327
|
if (nudge.empty) return { success: true, output: '' };
|
|
@@ -205,6 +366,187 @@ function handleShepherdEvents(rest, projectRoot, opts = {}) {
|
|
|
205
366
|
}
|
|
206
367
|
}
|
|
207
368
|
|
|
369
|
+
/**
|
|
370
|
+
* `forge hooks memory-recall --harness <h>` — the QUERY-RELEVANT memory tier (tier-2). On each
|
|
371
|
+
* prompt it reads the submitted prompt from the hook's stdin, ranks stored memories by BM25
|
|
372
|
+
* relevance to it (NOT recency — that is the SessionStart digest's job), applies a relevance
|
|
373
|
+
* floor + anaphora guard + cross-turn dedupe + a hard token budget, and emits the survivors as
|
|
374
|
+
* harness-native UserPromptSubmit context. Complements the always-on recency digest: this one
|
|
375
|
+
* answers "what memory is relevant to THIS turn".
|
|
376
|
+
*
|
|
377
|
+
* Compliance + safety (verified against the Claude Code hooks contract, kernel issue 781f6f65):
|
|
378
|
+
* - It READS its own hook stdin (the supported input channel) — it does NOT inject into a
|
|
379
|
+
* running session's stdin and never drives the agent (Anthropic Usage Policy).
|
|
380
|
+
* - additionalContext APPENDS to history every prompt, so the injection is tiny and gated:
|
|
381
|
+
* below the relevance floor, or on a low-signal (anaphora) prompt, it injects NOTHING.
|
|
382
|
+
* - Injected memory bodies are UNTRUSTED (a planted note could carry directives), so each is
|
|
383
|
+
* provenance-fenced.
|
|
384
|
+
* - UserPromptSubmit has a 30s timeout and blocks the prompt; the read is local BM25 over
|
|
385
|
+
* SQLite (sub-ms) and FAIL-OPEN — any error, a disabled rail, an unsupported harness, or
|
|
386
|
+
* nothing relevant yields '' and the prompt proceeds untouched. NEVER throws.
|
|
387
|
+
* - Kill-switch: `forge gate disable rail.memory_recall`.
|
|
388
|
+
*
|
|
389
|
+
* @param {string[]} rest - args after the `memory-recall` action.
|
|
390
|
+
* @param {string} projectRoot
|
|
391
|
+
* @param {object} [opts] - injectable seams for tests: { railEnabled, readInput, search,
|
|
392
|
+
* loadSeen, saveSeen, scoreFloor, tokenBudget }.
|
|
393
|
+
* @returns {{ success: boolean, output: string }}
|
|
394
|
+
*/
|
|
395
|
+
async function runMemoryRecall(rest, projectRoot, opts = {}) {
|
|
396
|
+
const observation = opts.observation || {};
|
|
397
|
+
try {
|
|
398
|
+
const harness = parseHarness(rest);
|
|
399
|
+
observation.harness = harness;
|
|
400
|
+
const capability = userPromptSubmitCapability(harness);
|
|
401
|
+
if (!capability.rendered) {
|
|
402
|
+
observation.outcome = 'unsupported';
|
|
403
|
+
return { success: true, output: '', reason: capability.reason };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const railEnabled = opts.railEnabled || memoryRecallRailEnabled;
|
|
407
|
+
if (!railEnabled(projectRoot)) {
|
|
408
|
+
observation.outcome = 'filtered';
|
|
409
|
+
return { success: true, output: '' };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const readInput = opts.readInput || readHookStdin;
|
|
413
|
+
const { prompt, sessionId } = parseHookInput(readInput());
|
|
414
|
+
if (!prompt) {
|
|
415
|
+
observation.outcome = 'filtered';
|
|
416
|
+
return { success: true, output: '' };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const search = opts.search
|
|
420
|
+
|| ((root, query, limit, options) => projectMemory.searchRankedScored(root, query, limit, options));
|
|
421
|
+
const loadSeen = opts.loadSeen || loadSeenKeys;
|
|
422
|
+
const saveSeen = opts.saveSeen || saveSeenKeys;
|
|
423
|
+
const appendShadow = opts.appendShadow || appendShadowLog;
|
|
424
|
+
|
|
425
|
+
// Stopword-aware, unicode-safe tokenization lives HERE (single source); the driver's
|
|
426
|
+
// buildMemoryFtsMatchOr only quotes+ORs whatever tokens it receives. Passing the raw prompt
|
|
427
|
+
// to a token-AND match was the 0-recall bug (kernel issue f71784d3).
|
|
428
|
+
const tokens = meaningfulTokens(prompt);
|
|
429
|
+
const excludeKeys = await loadSeen(projectRoot, sessionId) || [];
|
|
430
|
+
const hits = await search(
|
|
431
|
+
projectRoot,
|
|
432
|
+
tokens.join(' '),
|
|
433
|
+
MEMORY_RECALL_CANDIDATES,
|
|
434
|
+
{
|
|
435
|
+
excludeKeys: excludeKeys.slice(0, 256),
|
|
436
|
+
busyTimeoutMs: Math.max(0, Math.min(
|
|
437
|
+
opts.promptRecallSqliteBusyMs ?? PROMPT_RECALL_SQLITE_BUSY_MS,
|
|
438
|
+
(opts.promptRecallDeadlineMs || PROMPT_RECALL_DEADLINE_MS) - 100,
|
|
439
|
+
)),
|
|
440
|
+
},
|
|
441
|
+
) || [];
|
|
442
|
+
observation.candidateCount = hits.length;
|
|
443
|
+
observation.eligibleCount = hits.length;
|
|
444
|
+
if (opts.deadline?.expired) {
|
|
445
|
+
observation.outcome = 'timeout';
|
|
446
|
+
return { success: true, output: '', reason: 'timeout' };
|
|
447
|
+
}
|
|
448
|
+
const scoreFloor = typeof opts.scoreFloor === 'number' ? opts.scoreFloor : DEFAULT_SCORE_FLOOR;
|
|
449
|
+
const { lines, entries, injectedKeys } = selectInjection({
|
|
450
|
+
query: prompt,
|
|
451
|
+
hits,
|
|
452
|
+
scoreFloor,
|
|
453
|
+
tokenBudget: opts.tokenBudget || DEFAULT_TOKEN_BUDGET,
|
|
454
|
+
excludeKeys,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Step-0 instrument: record every real query (even ones that inject nothing) so the floor is
|
|
458
|
+
// tuned from data. Own try/catch — a logging failure must NEVER fall through to the fail-open
|
|
459
|
+
// outer catch (which would suppress a legitimate injection).
|
|
460
|
+
if (tokens.length) {
|
|
461
|
+
try {
|
|
462
|
+
appendShadow(projectRoot, {
|
|
463
|
+
candidateCount: hits.length,
|
|
464
|
+
injectedCount: injectedKeys.length,
|
|
465
|
+
candidateScoreMin: hits.length ? Math.min(...hits.map(hit => Number(hit?.score) || 0)) : null,
|
|
466
|
+
candidateScoreMax: hits.length ? Math.max(...hits.map(hit => Number(hit?.score) || 0)) : null,
|
|
467
|
+
scoreFloor,
|
|
468
|
+
});
|
|
469
|
+
} catch { /* best-effort: shadow logging never affects the hook result */ }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!lines.length) {
|
|
473
|
+
observation.outcome = hits.length ? 'filtered' : 'empty';
|
|
474
|
+
return { success: true, output: '' };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (opts.deadline?.expired) return { success: true, output: '', reason: 'timeout' };
|
|
478
|
+
try {
|
|
479
|
+
const pending = saveSeen(projectRoot, sessionId, injectedKeys);
|
|
480
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {});
|
|
481
|
+
} catch { /* dedupe persistence never suppresses an injection */ }
|
|
482
|
+
const sections = [
|
|
483
|
+
['confirmed', 'Confirmed memory (project-local; provenance shown)'],
|
|
484
|
+
['suggested', 'Suggested memory — verify before relying'],
|
|
485
|
+
].map(([trust, heading]) => {
|
|
486
|
+
const selectedLines = entries.filter(entry => entry.trust === trust).map(entry => entry.line);
|
|
487
|
+
return selectedLines.length ? `${heading}\n${selectedLines.join('\n')}` : '';
|
|
488
|
+
}).filter(Boolean);
|
|
489
|
+
const fenced = sections.join('\n');
|
|
490
|
+
const selected = hits.filter(hit => injectedKeys.includes(hit.memory_id || hit.key));
|
|
491
|
+
observation.outcome = 'selected';
|
|
492
|
+
observation.selectedIds = injectedKeys;
|
|
493
|
+
observation.sourceMix = selected.reduce((mix, hit) => {
|
|
494
|
+
const sourceAgent = hit.provenance?.source_agent || hit.sourceAgent || 'unknown';
|
|
495
|
+
mix[sourceAgent] = (mix[sourceAgent] || 0) + 1;
|
|
496
|
+
return mix;
|
|
497
|
+
}, {});
|
|
498
|
+
observation.trustMix = selected.reduce((mix, hit) => {
|
|
499
|
+
const trust = hit.trust_status || 'unknown';
|
|
500
|
+
mix[trust] = (mix[trust] || 0) + 1;
|
|
501
|
+
return mix;
|
|
502
|
+
}, {});
|
|
503
|
+
observation.tokenEstimate = Math.ceil(fenced.length / 4);
|
|
504
|
+
return { success: true, output: formatUserPromptSubmit(harness, fenced) };
|
|
505
|
+
} catch {
|
|
506
|
+
// Fail-open: a context hook must never break a prompt.
|
|
507
|
+
observation.outcome = 'error';
|
|
508
|
+
return { success: true, output: '' };
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function handleMemoryRecall(rest, projectRoot, opts = {}) {
|
|
513
|
+
const harness = parseHarness(rest);
|
|
514
|
+
const startedAt = Date.now();
|
|
515
|
+
const observation = { harness };
|
|
516
|
+
const record = result => {
|
|
517
|
+
const outcome = result.reason === 'timeout'
|
|
518
|
+
? 'timeout'
|
|
519
|
+
: (observation.outcome || (result.output ? 'selected' : 'empty'));
|
|
520
|
+
const eventObservation = {
|
|
521
|
+
...observation,
|
|
522
|
+
outcome,
|
|
523
|
+
harness,
|
|
524
|
+
elapsedMs: Date.now() - startedAt,
|
|
525
|
+
};
|
|
526
|
+
try {
|
|
527
|
+
const pending = (opts.recordRecallEvent || opts.launchRecallEvent || launchMemoryRecallEvent)(
|
|
528
|
+
projectRoot,
|
|
529
|
+
eventObservation,
|
|
530
|
+
);
|
|
531
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {});
|
|
532
|
+
} catch { /* Operational evidence is best-effort and must never delay or suppress a prompt. */ }
|
|
533
|
+
return result;
|
|
534
|
+
};
|
|
535
|
+
const capability = userPromptSubmitCapability(harness);
|
|
536
|
+
if (!capability.rendered) {
|
|
537
|
+
observation.outcome = 'unsupported';
|
|
538
|
+
return record({ success: true, output: '', reason: capability.reason });
|
|
539
|
+
}
|
|
540
|
+
const deadline = { expired: false };
|
|
541
|
+
const result = await withinDeadline(
|
|
542
|
+
() => runMemoryRecall(rest, projectRoot, { ...opts, deadline, observation }),
|
|
543
|
+
{ success: true, output: '', reason: 'timeout' },
|
|
544
|
+
opts.promptRecallDeadlineMs || PROMPT_RECALL_DEADLINE_MS,
|
|
545
|
+
() => { deadline.expired = true; },
|
|
546
|
+
);
|
|
547
|
+
return record(result);
|
|
548
|
+
}
|
|
549
|
+
|
|
208
550
|
/**
|
|
209
551
|
* `forge hooks capture --harness <h> --trigger <precompact|stop>` — the CAPTURE-on-exit hook.
|
|
210
552
|
* PreCompact (before context compaction) and Stop (turn end) fire it; it snapshots a bounded
|
|
@@ -228,7 +570,8 @@ function handleShepherdEvents(rest, projectRoot, opts = {}) {
|
|
|
228
570
|
async function handleCapture(rest, projectRoot, opts = {}) {
|
|
229
571
|
try {
|
|
230
572
|
const harness = parseHarness(rest);
|
|
231
|
-
|
|
573
|
+
const capability = sessionEndCapability(harness);
|
|
574
|
+
if (!capability.rendered) return { success: true, output: '', reason: capability.reason };
|
|
232
575
|
const trigger = parseTrigger(rest);
|
|
233
576
|
|
|
234
577
|
const fetchIssues = opts.fetchIssues || defaultFetchIssues;
|
|
@@ -356,6 +699,7 @@ async function handler(args, flags = {}, projectRoot, opts = {}) {
|
|
|
356
699
|
if (action === 'session-start') return handleSessionStart(args.slice(1), projectRoot, opts);
|
|
357
700
|
if (action === 'inbox-pickup') return handleInboxPickup(args.slice(1), projectRoot, opts);
|
|
358
701
|
if (action === 'shepherd-events') return handleShepherdEvents(args.slice(1), projectRoot, opts);
|
|
702
|
+
if (action === 'memory-recall') return handleMemoryRecall(args.slice(1), projectRoot, opts);
|
|
359
703
|
if (action === 'capture') return handleCapture(args.slice(1), projectRoot, opts);
|
|
360
704
|
if (action === 'install') return handleInstall(args, flags, opts);
|
|
361
705
|
return {
|
|
@@ -374,4 +718,5 @@ module.exports = {
|
|
|
374
718
|
'--dry-run': 'Preview the merge without writing anything',
|
|
375
719
|
},
|
|
376
720
|
handler,
|
|
721
|
+
_internal: { appendShadowLog, SHADOW_LOG_MAX_BYTES },
|
|
377
722
|
};
|
package/lib/commands/insights.js
CHANGED
|
@@ -36,7 +36,7 @@ function positionals(args) {
|
|
|
36
36
|
return values;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
async function handler(args, flags, projectRoot) {
|
|
39
|
+
async function handler(args, flags, projectRoot, opts = {}) {
|
|
40
40
|
const commandFlags = flags ?? {};
|
|
41
41
|
const [subcommand, candidateId] = positionals(args);
|
|
42
42
|
if (subcommand === 'accept' || subcommand === 'reject') {
|
|
@@ -54,14 +54,19 @@ async function handler(args, flags, projectRoot) {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
const result = analyzeInsights(projectRoot, {
|
|
57
|
+
const result = await analyzeInsights(projectRoot, {
|
|
58
58
|
limit: readOption(args, '--limit', undefined),
|
|
59
59
|
minCount: readOption(args, '--min-count', undefined),
|
|
60
60
|
since: readOption(args, '--since', undefined),
|
|
61
|
+
// Injectable kernel-read seams (Slice C2); undefined falls back to the real
|
|
62
|
+
// cli-broker-factory-backed reads inside analyzeInsights.
|
|
63
|
+
runIssueOperation: opts.runIssueOperation,
|
|
64
|
+
listRecentEvents: opts.listRecentEvents,
|
|
65
|
+
env: opts.env,
|
|
61
66
|
});
|
|
62
67
|
if (args.includes('--review-feedback')) {
|
|
63
68
|
result.limitations = [
|
|
64
|
-
'Compatibility note: --review-feedback now reads
|
|
69
|
+
'Compatibility note: --review-feedback now reads kernel events and issue evidence; external review-provider comments are not inferred.',
|
|
65
70
|
...result.limitations,
|
|
66
71
|
];
|
|
67
72
|
}
|