forge-workflow 0.1.0-beta.3 → 0.1.0-beta.4
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 +1 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +5 -0
- package/docs/reference/COMMANDS.md +3 -1
- package/docs/reference/shepherd.md +72 -1
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/agents-config.js +5 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/hooks.js +155 -8
- package/lib/commands/plan.js +23 -115
- package/lib/commands/prime.js +8 -1
- package/lib/commands/release.js +1 -2
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +0 -1
- package/lib/commands/shepherd.js +36 -3
- package/lib/commands/skill.js +275 -0
- package/lib/commands/status.js +37 -32
- package/lib/commands/test.js +32 -0
- package/lib/commands/worktree.js +27 -1
- package/lib/core/runtime-graph.js +88 -14
- package/lib/forge-issues.js +13 -464
- package/lib/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +54 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +150 -0
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/reconcile-executor.js +659 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-sticky.js +14 -0
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +1 -1
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +70 -15
- package/lib/project-memory.js +8 -0
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/skill-eval.js +750 -0
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/upgrade-safety.js +8 -9
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +1 -1
- package/rules/using-forge.md +24 -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/test.js +8 -1
- 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 +6 -0
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +1 -0
- 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 +76 -36
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +6 -0
- 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 +87 -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/commands/board.js +0 -64
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
|
@@ -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
|
|
package/lib/hook-renderer.js
CHANGED
|
@@ -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
|
|
|
@@ -523,8 +563,10 @@ module.exports = {
|
|
|
523
563
|
FORGE_HOOK_MARKER,
|
|
524
564
|
FORGE_CONTEXT_MARKER,
|
|
525
565
|
FORGE_INBOX_CONTEXT_MARKER,
|
|
566
|
+
FORGE_MEMORY_RECALL_MARKER,
|
|
526
567
|
FORGE_CAPTURE_CONTEXT_MARKER,
|
|
527
568
|
SESSION_START_SUPPORT,
|
|
569
|
+
BOOTSTRAP_DELIVERY,
|
|
528
570
|
USER_PROMPT_SUBMIT_SUPPORT,
|
|
529
571
|
SESSION_END_SUPPORT,
|
|
530
572
|
sessionStartCapability,
|
package/lib/issue-backend.js
CHANGED
|
@@ -3,10 +3,34 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
|
-
const VALID_BACKENDS = new Set(['kernel'
|
|
6
|
+
const VALID_BACKENDS = new Set(['kernel']);
|
|
7
7
|
const DEFAULT_BACKEND = 'kernel';
|
|
8
8
|
const ENV_VAR = 'FORGE_ISSUE_BACKEND';
|
|
9
9
|
|
|
10
|
+
// Backends that Forge used to accept and has since retired. Kept as an explicit set
|
|
11
|
+
// (rather than folding them into the generic "unknown backend" path) so a user who
|
|
12
|
+
// still carries `issueBackend: beads` in config — or `FORGE_ISSUE_BACKEND=beads` in a
|
|
13
|
+
// shell profile — gets the ONE actionable instruction instead of a bare valid-values
|
|
14
|
+
// list: import the Beads store into the kernel.
|
|
15
|
+
const REMOVED_BACKENDS = new Set(['beads']);
|
|
16
|
+
|
|
17
|
+
// The single migrate pointer shared by every removed-backend surface (the resolver's
|
|
18
|
+
// warning and the CLI flag's hard error) so the two can never drift.
|
|
19
|
+
const BEADS_REMOVED_HINT =
|
|
20
|
+
'the beads backend was removed; run `forge migrate --from beads` to import a Beads store into the kernel';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The migrate-pointer hint for a retired backend value, or null when the value is
|
|
24
|
+
* not a retired backend (callers then use the generic unknown-backend wording).
|
|
25
|
+
*
|
|
26
|
+
* @param {string} value
|
|
27
|
+
* @returns {string|null}
|
|
28
|
+
*/
|
|
29
|
+
function removedBackendHint(value) {
|
|
30
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
31
|
+
return REMOVED_BACKENDS.has(normalized) ? BEADS_REMOVED_HINT : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
/**
|
|
11
35
|
* Read the `issueBackend` key from `<projectRoot>/.forge/config.yaml`, if the
|
|
12
36
|
* file exists and is parseable. Returns `null` when the file is missing, the
|
|
@@ -72,14 +96,17 @@ function collectBackendSignal({ deps = {}, env = process.env, projectRoot } = {}
|
|
|
72
96
|
* explicit deps.issueBackend > FORGE_ISSUE_BACKEND env > .forge/config.yaml > 'kernel'.
|
|
73
97
|
*
|
|
74
98
|
* An unknown value (from any source) falls back to the default backend and emits
|
|
75
|
-
* a warning via the injected `warn` callback (defaults to console.warn).
|
|
99
|
+
* a warning via the injected `warn` callback (defaults to console.warn). A RETIRED
|
|
100
|
+
* value (`beads`) takes the same fallback path but warns with the migrate pointer,
|
|
101
|
+
* because "unknown backend, valid backends: kernel" would not tell a user carrying
|
|
102
|
+
* `issueBackend: beads` in config what to actually do about it.
|
|
76
103
|
*
|
|
77
104
|
* @param {object} [options]
|
|
78
105
|
* @param {object} [options.deps]
|
|
79
106
|
* @param {object} [options.env]
|
|
80
107
|
* @param {string} [options.projectRoot]
|
|
81
108
|
* @param {function(string): void} [options.warn]
|
|
82
|
-
* @returns {'kernel'
|
|
109
|
+
* @returns {'kernel'}
|
|
83
110
|
*/
|
|
84
111
|
function resolveIssueBackend({
|
|
85
112
|
deps = {},
|
|
@@ -98,6 +125,15 @@ function resolveIssueBackend({
|
|
|
98
125
|
return normalized;
|
|
99
126
|
}
|
|
100
127
|
|
|
128
|
+
const removedHint = removedBackendHint(normalized);
|
|
129
|
+
if (removedHint) {
|
|
130
|
+
warn(
|
|
131
|
+
`Issue backend "${value}" from ${source} is no longer available: ${removedHint}. `
|
|
132
|
+
+ `Falling back to "${DEFAULT_BACKEND}".`,
|
|
133
|
+
);
|
|
134
|
+
return DEFAULT_BACKEND;
|
|
135
|
+
}
|
|
136
|
+
|
|
101
137
|
warn(
|
|
102
138
|
`Unknown issue backend "${value}" from ${source}; `
|
|
103
139
|
+ `falling back to "${DEFAULT_BACKEND}". Valid backends: ${[...VALID_BACKENDS].join(', ')}.`,
|
|
@@ -139,7 +175,10 @@ module.exports = {
|
|
|
139
175
|
hasExplicitBackendSignal,
|
|
140
176
|
shouldUseKernelBroker,
|
|
141
177
|
readConfigBackend,
|
|
178
|
+
removedBackendHint,
|
|
142
179
|
VALID_BACKENDS,
|
|
180
|
+
REMOVED_BACKENDS,
|
|
181
|
+
BEADS_REMOVED_HINT,
|
|
143
182
|
DEFAULT_BACKEND,
|
|
144
183
|
ENV_VAR,
|
|
145
184
|
};
|
package/lib/kernel/broker.js
CHANGED
|
@@ -1177,6 +1177,34 @@ function createLocalBroker(options = {}) {
|
|
|
1177
1177
|
return driver.importIssues(kernel, options, {}, config);
|
|
1178
1178
|
},
|
|
1179
1179
|
|
|
1180
|
+
// PR reconcile-ledger read (autonomous-shepherd design §3.4). Read-only SELECT of
|
|
1181
|
+
// the open `pr` rows for a repo (keyed by git_common_dir); creates/migrates nothing.
|
|
1182
|
+
// Consumed later by prime and the reconciler to enumerate PRs under shepherd.
|
|
1183
|
+
async listOpenPrs(gitCommonDir, context = {}) {
|
|
1184
|
+
requireDriverMethod(driver, 'listOpenPrs');
|
|
1185
|
+
return driver.listOpenPrs(gitCommonDir, context, getConfig());
|
|
1186
|
+
},
|
|
1187
|
+
|
|
1188
|
+
// PR reconcile-ledger WRITE path (autonomous-shepherd design §5a). Mirror of the
|
|
1189
|
+
// listOpenPrs wrapper: guard the driver method, delegate with getConfig(). pr rows
|
|
1190
|
+
// are derived reconcile state (a direct idempotent upsert), not the guarded-event
|
|
1191
|
+
// issue path. upsertPr = register/refresh; updatePrVerdict = the one verdict authority
|
|
1192
|
+
// with freshest-head precedence enforced at the write; retirePr = merged/closed.
|
|
1193
|
+
async upsertPr(row, context = {}) {
|
|
1194
|
+
requireDriverMethod(driver, 'upsertPr');
|
|
1195
|
+
return driver.upsertPr(row, context, getConfig());
|
|
1196
|
+
},
|
|
1197
|
+
|
|
1198
|
+
async updatePrVerdict(key, patch = {}, context = {}) {
|
|
1199
|
+
requireDriverMethod(driver, 'updatePrVerdict');
|
|
1200
|
+
return driver.updatePrVerdict(key, patch, context, getConfig());
|
|
1201
|
+
},
|
|
1202
|
+
|
|
1203
|
+
async retirePr(key, patch = {}, context = {}) {
|
|
1204
|
+
requireDriverMethod(driver, 'retirePr');
|
|
1205
|
+
return driver.retirePr(key, patch, context, getConfig());
|
|
1206
|
+
},
|
|
1207
|
+
|
|
1180
1208
|
// --- Projection-outbox read/update surface (D16) -----------------------
|
|
1181
1209
|
// Additive read/update methods for projection consumers. These never touch
|
|
1182
1210
|
// the append/CAS path above (runGuardedEvent / enqueueKernelProjection);
|
package/lib/kernel/migrations.js
CHANGED
|
@@ -57,8 +57,8 @@ function renderDropTable(table) {
|
|
|
57
57
|
// stays the full current schema; the named tables are filtered out of 001 so they are
|
|
58
58
|
// created exactly once by their dedicated migration (both on a fresh DB and, via the
|
|
59
59
|
// ledger, on an existing DB). KEEP IN SYNC with every new table-creating migration.
|
|
60
|
-
// memories → 005
|
|
61
|
-
const MIGRATION_ADDED_TABLES = ['memories'];
|
|
60
|
+
// memories → 005 ; pr → 009
|
|
61
|
+
const MIGRATION_ADDED_TABLES = ['memories', 'pr'];
|
|
62
62
|
|
|
63
63
|
function getInitialKernelSchema() {
|
|
64
64
|
const schema = getKernelSchema();
|
|
@@ -278,6 +278,32 @@ function buildMemoryFtsMigration() {
|
|
|
278
278
|
};
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// 009: the PR reconcile ledger + verdict store (kernel_pr). Rendered from the schema.js
|
|
282
|
+
// table definition so the DDL never drifts from the registry — mirroring migration 005.
|
|
283
|
+
// A NEW authority table (NOT columns on kernel_worktrees) because a PR can outlive its
|
|
284
|
+
// worktree or have none at all (autonomous-shepherd design §3.1). Excluded from the 001
|
|
285
|
+
// initial schema (MIGRATION_ADDED_TABLES), so a fresh DB creates it exactly once here and
|
|
286
|
+
// an existing DB picks it up through the broker's per-migration ledger. CREATE … IF NOT
|
|
287
|
+
// EXISTS keeps a re-run idempotent; it is a create-table with no data backfill, so no
|
|
288
|
+
// BEGIN IMMEDIATE is needed (broker.js apply-loop caveat does not bite).
|
|
289
|
+
function buildPrLinkageMigration() {
|
|
290
|
+
const pr = getKernelSchema().tables.find(table => table.name === 'pr');
|
|
291
|
+
if (!pr) {
|
|
292
|
+
throw new Error('Kernel schema is missing the pr authority table');
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
id: '009_kernel_pr_linkage',
|
|
296
|
+
apply: [
|
|
297
|
+
renderCreateTable(pr),
|
|
298
|
+
...pr.indexes.map(prIndex => renderCreateIndex(pr, prIndex)),
|
|
299
|
+
],
|
|
300
|
+
rollback: [
|
|
301
|
+
...[...pr.indexes].reverse().map(prIndex => renderDropIndex(prIndex)),
|
|
302
|
+
renderDropTable(pr),
|
|
303
|
+
],
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
281
307
|
function validateKernelMigrations(migrations) {
|
|
282
308
|
const ids = new Set();
|
|
283
309
|
for (const migration of migrations) {
|
|
@@ -308,6 +334,7 @@ function buildKernelMigrationPlan(migrations = [
|
|
|
308
334
|
buildIssueFidelityColumnsMigration(),
|
|
309
335
|
buildWorktreeLinkageColumnsMigration(),
|
|
310
336
|
buildMemoryFtsMigration(),
|
|
337
|
+
buildPrLinkageMigration(),
|
|
311
338
|
]) {
|
|
312
339
|
validateKernelMigrations(migrations);
|
|
313
340
|
|
|
@@ -326,6 +353,7 @@ module.exports = {
|
|
|
326
353
|
buildKernelMigrationPlan,
|
|
327
354
|
buildMemoryFtsMigration,
|
|
328
355
|
buildMemoryProjectionMigration,
|
|
356
|
+
buildPrLinkageMigration,
|
|
329
357
|
buildSchemaMigration,
|
|
330
358
|
buildWorktreeLinkageColumnsMigration,
|
|
331
359
|
memoryFtsDdl,
|
package/lib/kernel/schema.js
CHANGED
|
@@ -278,6 +278,41 @@ const TABLE_LIST = deepFreeze([
|
|
|
278
278
|
], [
|
|
279
279
|
index('idx_kernel_memories_source_agent', ['source_agent']),
|
|
280
280
|
]),
|
|
281
|
+
// The PR reconcile ledger + verdict store (autonomous-shepherd design §3): a
|
|
282
|
+
// first-class `pr` authority row links a pull request to its issue, worktree and
|
|
283
|
+
// journal, and records the winning verdict with its freshness discriminators
|
|
284
|
+
// (head_sha, verdict_source, verdict_at). A PR is the unit of ownership and can
|
|
285
|
+
// outlive its worktree (or have none — a hand-opened/other-harness PR), so it is a
|
|
286
|
+
// separate table, NOT columns on kernel_worktrees. git_common_dir keys every open PR
|
|
287
|
+
// to its repo so all worktrees share one reconcile view. issue_id/worktree_id are
|
|
288
|
+
// soft nullable links. Created by migration 009 (excluded from the 001 initial schema
|
|
289
|
+
// via MIGRATION_ADDED_TABLES), so a fresh DB creates it exactly once and an existing
|
|
290
|
+
// DB picks it up through the broker's per-migration ledger.
|
|
291
|
+
table('pr', 'authority', [
|
|
292
|
+
field('id', 'TEXT', { primaryKey: true }),
|
|
293
|
+
field('git_common_dir', 'TEXT', { notNull: true }),
|
|
294
|
+
field('repo', 'TEXT', { notNull: true }),
|
|
295
|
+
field('number', 'INTEGER', { notNull: true }),
|
|
296
|
+
field('issue_id', 'TEXT'),
|
|
297
|
+
field('worktree_id', 'TEXT'),
|
|
298
|
+
field('branch', 'TEXT'),
|
|
299
|
+
field('head_sha', 'TEXT'),
|
|
300
|
+
field('verdict', 'TEXT'),
|
|
301
|
+
field('verdict_source', 'TEXT'),
|
|
302
|
+
field('verdict_at', 'TEXT'),
|
|
303
|
+
field('journal_ptr', 'TEXT'),
|
|
304
|
+
field('state', 'TEXT', { notNull: true, default: "'open'" }),
|
|
305
|
+
field('registered_at', 'TEXT', { notNull: true }),
|
|
306
|
+
field('retired_at', 'TEXT'),
|
|
307
|
+
], [
|
|
308
|
+
// Covering index for the reconciler's hot read `listOpenPrs` (WHERE
|
|
309
|
+
// git_common_dir=? AND state='open' ORDER BY repo, number): the trailing
|
|
310
|
+
// repo/number let SQLite satisfy both the state filter AND the ordering from
|
|
311
|
+
// this one index, instead of scanning every PR for the common-dir once the
|
|
312
|
+
// ledger retains merged/closed history. (Codex review, PR #424.)
|
|
313
|
+
index('idx_pr_common_dir_state_repo_number', ['git_common_dir', 'state', 'repo', 'number']),
|
|
314
|
+
index('idx_pr_common_dir_repo_number', ['git_common_dir', 'repo', 'number'], { unique: true }),
|
|
315
|
+
]),
|
|
281
316
|
]);
|
|
282
317
|
|
|
283
318
|
const KERNEL_TABLES = deepFreeze(Object.fromEntries(TABLE_LIST.map(candidate => [candidate.name, candidate])));
|
|
@@ -2070,6 +2070,30 @@ function searchMemoryRowsRanked(runtime, db, query, limit) {
|
|
|
2070
2070
|
).map(memoryRowToEntry);
|
|
2071
2071
|
}
|
|
2072
2072
|
|
|
2073
|
+
// Relevance-ONLY BM25 recall that exposes the raw bm25 score on each entry. The
|
|
2074
|
+
// per-turn auto-recall hook needs the score to apply a relevance FLOOR (inject nothing
|
|
2075
|
+
// when nothing clears the bar) — ordinal rank can't express "nothing was relevant".
|
|
2076
|
+
// Unlike searchMemoryRowsRanked, a no-match (or empty) query returns [] with NO recency
|
|
2077
|
+
// fallback: the whole point is to avoid surfacing recent-but-irrelevant notes. bm25()
|
|
2078
|
+
// returns more-negative for stronger matches, so rows come back best (lowest) first.
|
|
2079
|
+
function searchMemoryRowsRankedScored(runtime, db, query, limit) {
|
|
2080
|
+
const capped = Number.isInteger(limit) && limit > 0 ? limit : 20;
|
|
2081
|
+
const match = buildMemoryFtsMatch(query);
|
|
2082
|
+
if (!match) {
|
|
2083
|
+
return [];
|
|
2084
|
+
}
|
|
2085
|
+
return allParams(
|
|
2086
|
+
runtime,
|
|
2087
|
+
db,
|
|
2088
|
+
`SELECT m.*, bm25(kernel_memories_fts) AS __score FROM kernel_memories m
|
|
2089
|
+
JOIN kernel_memories_fts ON kernel_memories_fts.rowid = m.rowid
|
|
2090
|
+
WHERE kernel_memories_fts MATCH ?
|
|
2091
|
+
ORDER BY bm25(kernel_memories_fts)
|
|
2092
|
+
LIMIT ?`,
|
|
2093
|
+
[match, capped],
|
|
2094
|
+
).map(row => ({ ...memoryRowToEntry(row), score: row.__score }));
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2073
2097
|
function closeDatabase(db) {
|
|
2074
2098
|
if (db && typeof db.close === 'function') {
|
|
2075
2099
|
db.close();
|
|
@@ -2181,6 +2205,124 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2181
2205
|
[`${escaped}%`, limit],
|
|
2182
2206
|
);
|
|
2183
2207
|
},
|
|
2208
|
+
// Open PRs under shepherd for one repo (autonomous-shepherd design §3.4): the
|
|
2209
|
+
// reconciler's "open PRs in this repo" read, keyed by git_common_dir so every
|
|
2210
|
+
// worktree shares one view. Parameterized (git_common_dir is a filesystem path —
|
|
2211
|
+
// never interpolate it), covered by idx_pr_common_dir_state_repo_number. Ordered so
|
|
2212
|
+
// the result is deterministic. `context` is part of the broker contract but unused
|
|
2213
|
+
// by this direct SELECT (prefixed `_` for eslint no-unused-vars).
|
|
2214
|
+
async listOpenPrs(gitCommonDir, _context = {}, config = {}) {
|
|
2215
|
+
return allParams(
|
|
2216
|
+
runtime, getDatabase(config),
|
|
2217
|
+
"SELECT * FROM kernel_pr WHERE git_common_dir = ? AND state = 'open' ORDER BY repo ASC, number ASC",
|
|
2218
|
+
[gitCommonDir],
|
|
2219
|
+
);
|
|
2220
|
+
},
|
|
2221
|
+
// --- kernel_pr WRITE path (autonomous-shepherd design §5a). pr rows are DERIVED
|
|
2222
|
+
// reconcile state (reconstructable from GitHub), not audit-critical issue authority,
|
|
2223
|
+
// so they take a DIRECT idempotent upsert — NOT the event-sourced guarded path
|
|
2224
|
+
// (applyAcceptedIssueMutation). All target the physical `kernel_pr` table (matching
|
|
2225
|
+
// the listOpenPrs read) and are parameterized (git_common_dir/branch/head_sha are
|
|
2226
|
+
// externally-influenced values — never interpolate). `context` is part of the broker
|
|
2227
|
+
// contract but unused by these direct writes (prefixed `_` for eslint no-unused-vars).
|
|
2228
|
+
//
|
|
2229
|
+
// Register/refresh a PR row keyed by (git_common_dir, repo, number). Idempotent via
|
|
2230
|
+
// ON CONFLICT on the unique idx_pr_common_dir_repo_number: a re-upsert updates the
|
|
2231
|
+
// mutable columns and coalesces soft links (a later null never clobbers an existing
|
|
2232
|
+
// issue_id/worktree_id). registered_at is set on INSERT only; state defaults 'open'.
|
|
2233
|
+
async upsertPr(row, _context = {}, config = {}) {
|
|
2234
|
+
const id = row.id || randomUUID();
|
|
2235
|
+
const registeredAt = row.registered_at || new Date().toISOString();
|
|
2236
|
+
const state = row.state || 'open';
|
|
2237
|
+
runParams(
|
|
2238
|
+
runtime, getDatabase(config),
|
|
2239
|
+
`INSERT INTO kernel_pr
|
|
2240
|
+
(id, git_common_dir, repo, number, issue_id, worktree_id, branch, head_sha, journal_ptr, state, registered_at)
|
|
2241
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2242
|
+
ON CONFLICT(git_common_dir, repo, number) DO UPDATE SET
|
|
2243
|
+
head_sha = excluded.head_sha,
|
|
2244
|
+
branch = excluded.branch,
|
|
2245
|
+
issue_id = coalesce(excluded.issue_id, kernel_pr.issue_id),
|
|
2246
|
+
worktree_id = coalesce(excluded.worktree_id, kernel_pr.worktree_id),
|
|
2247
|
+
-- journal_ptr is a soft link: coalesce it (like issue_id/worktree_id) so a
|
|
2248
|
+
-- head-only refresh that omits journalPtr never severs the ledger↔journal
|
|
2249
|
+
-- link with NULL. (Codex review, PR #426.)
|
|
2250
|
+
journal_ptr = coalesce(excluded.journal_ptr, kernel_pr.journal_ptr),
|
|
2251
|
+
-- REOPEN semantics: upsertPr is only ever called for PRs GitHub reports as
|
|
2252
|
+
-- OPEN, so re-registering a previously retired row (a reopened PR) must flip
|
|
2253
|
+
-- it back to open and clear retired_at — else listOpenPrs (state='open') would
|
|
2254
|
+
-- keep the reopened PR invisible forever. (Codex review, PR #426.)
|
|
2255
|
+
state = 'open',
|
|
2256
|
+
retired_at = NULL,
|
|
2257
|
+
-- A new commit INVALIDATES the prior verdict: when the head advances to a
|
|
2258
|
+
-- different non-null sha, clear verdict/source/at so a verdict computed
|
|
2259
|
+
-- against the OLD head is never presented as fresh for the new head (and the
|
|
2260
|
+
-- freshest-head guard in updatePrVerdict keeps intact evidence). IS NOT is the
|
|
2261
|
+
-- null-safe distinctness test; a headless refresh (excluded.head_sha NULL)
|
|
2262
|
+
-- never clears. (Codex review, PR #426.)
|
|
2263
|
+
verdict = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict END,
|
|
2264
|
+
verdict_source = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict_source END,
|
|
2265
|
+
verdict_at = CASE WHEN excluded.head_sha IS NOT NULL AND excluded.head_sha IS NOT kernel_pr.head_sha THEN NULL ELSE kernel_pr.verdict_at END`,
|
|
2266
|
+
[
|
|
2267
|
+
id,
|
|
2268
|
+
row.git_common_dir,
|
|
2269
|
+
row.repo,
|
|
2270
|
+
row.number,
|
|
2271
|
+
row.issue_id ?? null,
|
|
2272
|
+
row.worktree_id ?? null,
|
|
2273
|
+
row.branch ?? null,
|
|
2274
|
+
row.head_sha ?? null,
|
|
2275
|
+
row.journal_ptr ?? null,
|
|
2276
|
+
state,
|
|
2277
|
+
registeredAt,
|
|
2278
|
+
],
|
|
2279
|
+
);
|
|
2280
|
+
return { ok: true, id };
|
|
2281
|
+
},
|
|
2282
|
+
// The ONE verdict authority WRITE (design §1.2 rule 2) — FRESHEST-HEAD-SHA
|
|
2283
|
+
// PRECEDENCE enforced in the WHERE so a verdict computed against a SUPERSEDED head is
|
|
2284
|
+
// DISCARDED, not written (kills stale 9d35c14b at the write). A non-local (Actions
|
|
2285
|
+
// backstop) write lands only when its head_sha matches the row's current head (or the
|
|
2286
|
+
// row has none yet); a `local` verdict is computed live against the current head and
|
|
2287
|
+
// is always authoritative, so it bypasses the head match.
|
|
2288
|
+
async updatePrVerdict(key, patch = {}, _context = {}, config = {}) {
|
|
2289
|
+
const headSha = patch.head_sha ?? null;
|
|
2290
|
+
const source = patch.verdict_source ?? null;
|
|
2291
|
+
runParams(
|
|
2292
|
+
runtime, getDatabase(config),
|
|
2293
|
+
`UPDATE kernel_pr SET verdict = ?, verdict_source = ?, verdict_at = ?, head_sha = ?
|
|
2294
|
+
WHERE git_common_dir = ? AND repo = ? AND number = ?
|
|
2295
|
+
AND (head_sha IS NULL OR head_sha = ? OR ? = 'local')`,
|
|
2296
|
+
[
|
|
2297
|
+
patch.verdict ?? null,
|
|
2298
|
+
source,
|
|
2299
|
+
patch.verdict_at ?? null,
|
|
2300
|
+
headSha,
|
|
2301
|
+
key.git_common_dir,
|
|
2302
|
+
key.repo,
|
|
2303
|
+
key.number,
|
|
2304
|
+
headSha,
|
|
2305
|
+
source,
|
|
2306
|
+
],
|
|
2307
|
+
);
|
|
2308
|
+
return { ok: true };
|
|
2309
|
+
},
|
|
2310
|
+
// Retire a PR row (merged/closed): flip state + stamp retired_at so it drops out of
|
|
2311
|
+
// the open-PR read while the reconcile history is retained.
|
|
2312
|
+
async retirePr(key, patch = {}, _context = {}, config = {}) {
|
|
2313
|
+
runParams(
|
|
2314
|
+
runtime, getDatabase(config),
|
|
2315
|
+
'UPDATE kernel_pr SET state = ?, retired_at = ? WHERE git_common_dir = ? AND repo = ? AND number = ?',
|
|
2316
|
+
[
|
|
2317
|
+
patch.state ?? 'closed',
|
|
2318
|
+
patch.retired_at ?? new Date().toISOString(),
|
|
2319
|
+
key.git_common_dir,
|
|
2320
|
+
key.repo,
|
|
2321
|
+
key.number,
|
|
2322
|
+
],
|
|
2323
|
+
);
|
|
2324
|
+
return { ok: true };
|
|
2325
|
+
},
|
|
2184
2326
|
// --- Event-store primitives (Wave 2) — composed by broker.runGuardedEvent.
|
|
2185
2327
|
// `context` is part of the broker contract but unused by these direct SQL
|
|
2186
2328
|
// reads/writes (prefixed `_` for eslint no-unused-vars).
|
|
@@ -2313,6 +2455,14 @@ function createDriver(runtime, configuredDatabasePath) {
|
|
|
2313
2455
|
ensureMemorySchema(database);
|
|
2314
2456
|
return searchMemoryRowsRanked(runtime, database, query, limit);
|
|
2315
2457
|
},
|
|
2458
|
+
// Relevance-only BM25 recall that also returns the raw bm25 `score` per entry, so a
|
|
2459
|
+
// caller can apply a relevance floor. A no-match/empty query returns [] (no recency
|
|
2460
|
+
// fallback). Used by the per-turn memory-recall hook.
|
|
2461
|
+
searchMemoriesRankedScored(query, limit, config = {}) {
|
|
2462
|
+
const database = getDatabase(config);
|
|
2463
|
+
ensureMemorySchema(database);
|
|
2464
|
+
return searchMemoryRowsRankedScored(runtime, database, query, limit);
|
|
2465
|
+
},
|
|
2316
2466
|
// The newest `limit` entries (default recall with no query). `options.agents` scopes
|
|
2317
2467
|
// the read to a source_agent allow-list (e.g. human `remember` notes only).
|
|
2318
2468
|
recentMemories(limit, options = {}, config = {}) {
|