forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +92 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* using-forge dispatch: shared helpers for Forge's reasoning-driven skill auto-trigger.
|
|
5
|
+
*
|
|
6
|
+
* Two consumers share this module:
|
|
7
|
+
* - the SessionStart context hook (lib/commands/hooks.js), which injects the using-forge
|
|
8
|
+
* dispatch bootstrap text so an agent auto-triggers skills from turn one (the Superpowers
|
|
9
|
+
* mechanism), and
|
|
10
|
+
* - the "forge skill for" router (lib/commands/skill.js), the DETERMINISTIC intent-to-skill
|
|
11
|
+
* fallback for harnesses without a SessionStart hook.
|
|
12
|
+
*
|
|
13
|
+
* Skills are read from the Forge PACKAGE's canonical skills/ dir (resolved via getPackageRoot:
|
|
14
|
+
* the on-disk npm/dev package, or the compiled binary's extracted embedded assets) -- NEVER from
|
|
15
|
+
* the consumer's projectRoot, which after `forge setup` has only generated mirrors and no root
|
|
16
|
+
* skills/. Everything here is deterministic and NEVER throws -- an unresolved asset root or a
|
|
17
|
+
* missing skills dir degrades to empty results.
|
|
18
|
+
*
|
|
19
|
+
* @module using-forge
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const { getPackageRoot } = require('./package-root');
|
|
25
|
+
|
|
26
|
+
const DISPATCH_SKILL = 'using-forge';
|
|
27
|
+
|
|
28
|
+
/** Strip a leading UTF-8 BOM (U+FEFF) without embedding the char literally in source. */
|
|
29
|
+
function stripBom(value) {
|
|
30
|
+
const s = String(value);
|
|
31
|
+
return s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the root that carries the canonical `skills/` dir. Default: the Forge PACKAGE root
|
|
36
|
+
* (getPackageRoot) so the dispatch skill/catalog work in a consumer project and a compiled
|
|
37
|
+
* binary, not only in the Forge source checkout. An explicit `override` is honored where a
|
|
38
|
+
* caller intentionally wants a specific root (tests / repo-local use). Never throws -> null.
|
|
39
|
+
* @param {string} [override]
|
|
40
|
+
* @returns {string|null}
|
|
41
|
+
*/
|
|
42
|
+
function resolveSkillsRoot(override) {
|
|
43
|
+
if (override) return override;
|
|
44
|
+
try {
|
|
45
|
+
return getPackageRoot();
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the using-forge SKILL.md body (everything AFTER the frontmatter) -- the dispatch
|
|
53
|
+
* bootstrap text the SessionStart hook injects. Empty string when unresolved/absent (fail-open).
|
|
54
|
+
* @param {string} [override] - optional skills-root override (defaults to the package root).
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function loadDispatchText(override) {
|
|
58
|
+
const root = resolveSkillsRoot(override);
|
|
59
|
+
if (!root) return '';
|
|
60
|
+
const raw = readSkillFile(root, DISPATCH_SKILL);
|
|
61
|
+
return raw ? stripFrontmatter(raw).trim() : '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read a canonical skill's SKILL.md text under `<root>/skills/<name>`, or null when absent. */
|
|
65
|
+
function readSkillFile(root, name) {
|
|
66
|
+
try {
|
|
67
|
+
const filePath = path.join(root, 'skills', name, 'SKILL.md');
|
|
68
|
+
if (!fs.existsSync(filePath)) return null;
|
|
69
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Remove a leading YAML frontmatter block (--- ... ---), returning the body. */
|
|
76
|
+
function stripFrontmatter(raw) {
|
|
77
|
+
const text = stripBom(String(raw));
|
|
78
|
+
if (!text.startsWith('---')) return text;
|
|
79
|
+
const end = text.indexOf('\n---', 3);
|
|
80
|
+
if (end === -1) return text;
|
|
81
|
+
const afterClose = text.indexOf('\n', end + 1);
|
|
82
|
+
return afterClose === -1 ? '' : text.slice(afterClose + 1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Return the raw frontmatter block (between the leading `---` fences), or null when absent. */
|
|
86
|
+
function frontmatterBlock(raw) {
|
|
87
|
+
const text = stripBom(String(raw));
|
|
88
|
+
if (!text.startsWith('---')) return null;
|
|
89
|
+
const end = text.indexOf('\n---', 3);
|
|
90
|
+
return end === -1 ? text.slice(3) : text.slice(3, end);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Strip a single pair of leading/trailing quotes from a scalar value. */
|
|
94
|
+
function unquote(value) {
|
|
95
|
+
return value.replace(/^['"]|['"]$/g, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Apply one frontmatter line to the running parse state (name / folded description block). */
|
|
99
|
+
function applyFrontmatterLine(state, line) {
|
|
100
|
+
if (state.inDescription) {
|
|
101
|
+
if (/^\s+\S/.test(line)) {
|
|
102
|
+
state.descParts.push(line.trim());
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (line.trim() === '') return;
|
|
106
|
+
state.inDescription = false;
|
|
107
|
+
}
|
|
108
|
+
// Detect the `name:` key without a regex: SonarCloud flags every /^name:.../ variant for
|
|
109
|
+
// super-linear backtracking. startsWith + slice is behavior-identical — the remainder is
|
|
110
|
+
// trimmed and unquoted, and an empty value leaves state.name unset just as `.+` required a value.
|
|
111
|
+
if (line.startsWith('name:')) {
|
|
112
|
+
const value = line.slice('name:'.length).trim();
|
|
113
|
+
if (value) state.name = unquote(value);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (line.startsWith('invocation:')) {
|
|
117
|
+
const value = line.slice('invocation:'.length).trim();
|
|
118
|
+
const quote = value[0];
|
|
119
|
+
const hasMatchingQuotes = value.length >= 2
|
|
120
|
+
&& (quote === '"' || quote === "'")
|
|
121
|
+
&& value.endsWith(quote);
|
|
122
|
+
state.invocation = hasMatchingQuotes ? value.slice(1, -1) : value;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const descMatch = /^description:\s*(.*)$/.exec(line);
|
|
126
|
+
if (descMatch) {
|
|
127
|
+
state.inDescription = true;
|
|
128
|
+
const inline = descMatch[1].trim();
|
|
129
|
+
if (inline && !['>', '|', '>-', '|-'].includes(inline)) state.descParts.push(unquote(inline));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parse the name and (flattened) description from a SKILL.md frontmatter block. */
|
|
134
|
+
function parseFrontmatter(raw) {
|
|
135
|
+
const block = frontmatterBlock(raw);
|
|
136
|
+
if (block === null) return { name: null, description: '', invocation: 'model' };
|
|
137
|
+
const state = { name: null, descParts: [], inDescription: false, invocation: 'model' };
|
|
138
|
+
for (const line of block.split(/\r?\n/)) applyFrontmatterLine(state, line);
|
|
139
|
+
return {
|
|
140
|
+
name: state.name,
|
|
141
|
+
description: state.descParts.join(' ').replace(/\s+/g, ' ').trim(),
|
|
142
|
+
invocation: state.invocation,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Read one skill dir into a `{ name, description }` catalog entry, or null when unreadable. */
|
|
147
|
+
function readCatalogEntry(root, name) {
|
|
148
|
+
const raw = readSkillFile(root, name);
|
|
149
|
+
if (!raw) return null;
|
|
150
|
+
const fm = parseFrontmatter(raw);
|
|
151
|
+
return { name: fm.name || name, description: fm.description || '' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Load the canonical Forge skill catalog: name + description for every skills/*\/SKILL.md under
|
|
156
|
+
* the resolved root. Sorted by name for determinism. Never throws -- an unresolved root or
|
|
157
|
+
* absent dir yields an empty array (the router then honestly returns no matches).
|
|
158
|
+
* @param {string} [override] - optional skills-root override (defaults to the package root).
|
|
159
|
+
* @returns {{name: string, description: string}[]}
|
|
160
|
+
*/
|
|
161
|
+
function loadSkillCatalog(override) {
|
|
162
|
+
const root = resolveSkillsRoot(override);
|
|
163
|
+
if (!root) return [];
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = fs.readdirSync(path.join(root, 'skills'), { withFileTypes: true });
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const catalog = [];
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (!entry.isDirectory()) continue;
|
|
173
|
+
const item = readCatalogEntry(root, entry.name);
|
|
174
|
+
if (item) catalog.push(item);
|
|
175
|
+
}
|
|
176
|
+
catalog.sort((a, b) => a.name.localeCompare(b.name));
|
|
177
|
+
return catalog;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Curated intent-to-skill rules: the deterministic backbone of the router. Each rule scores when
|
|
181
|
+
// a situation contains one of its keyword phrases; weight lets a strong signal outrank an
|
|
182
|
+
// incidental token. Keywords match as normalized substrings, so a multi-word phrase matches only
|
|
183
|
+
// when adjacent. This is the reasoning fallback for harnesses that cannot auto-load the skill.
|
|
184
|
+
const INTENT_RULES = Object.freeze([
|
|
185
|
+
{ skill: 'plan', weight: 3, keywords: ['add a feature', 'add feature', 'new feature', 'build a', 'build the', 'scope', 'design a', 'design intent', 'brainstorm', 'plan ', 'break this into tasks', 'task list'] },
|
|
186
|
+
{ skill: 'dev', weight: 3, keywords: ['fix a failing test', 'failing test', 'fix a bug', 'fix the bug', 'fix this bug', 'debug', 'implement', 'write the code', 'red-green', 'tdd', 'unexpected behavior', 'broken'] },
|
|
187
|
+
{ skill: 'validate', weight: 3, keywords: ['run tests', 'run the tests', 'lint', 'type check', 'typecheck', 'type-check', 'validate', 'security scan', 'run checks', 'all checks'] },
|
|
188
|
+
{ skill: 'ship', weight: 3, keywords: ['open a pr', 'open pr', 'create a pr', 'create pr', 'raise a pr', 'push the branch', 'push branch', 'ship it', 'make a pull request', 'pull request'] },
|
|
189
|
+
{ skill: 'review', weight: 3, keywords: ['review feedback', 'address feedback', 'pr feedback', 'coderabbit', 'greptile', 'review comment', 'resolve threads', 'address the review'] },
|
|
190
|
+
{ skill: 'verify', weight: 3, keywords: ['post-merge', 'after merge', 'verify health', 'health check', 'ci on main', 'close the issue after merge'] },
|
|
191
|
+
{ skill: 'triage-ready', weight: 3, keywords: ['what should i work on', 'what to work on', 'next ready', 'ready queue', 'pick the next', 'what is ready', 'rank the'] },
|
|
192
|
+
{ skill: 'status', weight: 3, keywords: ['where am i', 'current stage', 'what stage', 'stale work', 'status', 'active work', 'whats going on'] },
|
|
193
|
+
{ skill: 'issue-basics', weight: 2, keywords: ['create an issue', 'close an issue', 'update an issue', 'search issues', 'comment on an issue', 'file an issue', 'new issue'] },
|
|
194
|
+
{ skill: 'memory', weight: 2, keywords: ['remember', 'recall', 'remember that', 'recall the', 'save a note', 'persist a note', 'note the decision', 'jot down', 'remind me'] },
|
|
195
|
+
{ skill: 'claim-safety', weight: 2, keywords: ['claim an issue', 'claim the issue', 'prove ownership', 'own the lease', 'lease'] },
|
|
196
|
+
{ skill: 'smith', weight: 2, keywords: ['end to end', 'end-to-end', 'drive one issue', 'plan to merged', 'whole issue', 'from plan to pr'] },
|
|
197
|
+
{ skill: 'shepherd', weight: 3, keywords: ['monitor a pr', 'shepherd', 'watch the pr', 'watch my pr', 'ci status', 'poll the pr', 'pr checks', 'pr blocked', 'blocking my pr', 'blocking the pr', 'pr merging', 'pr not merging', 'ready to merge', 'merge ready', 'pr check failed', 'pr check went red', 'keep an eye on my pr', 'keep an eye on the pr', 'keep an eye on my prs', 'babysit my pr', 'babysit the pr', 'keep watching my pr', 'keep watching the pr', 'pr verdict', 'shepherd daemon', 'watch the pull request'] },
|
|
198
|
+
{ skill: 'worktree', weight: 3, keywords: ['forge worktree', 'create a worktree', 'worktree list', 'isolated branch', 'isolated worktree', 'isolated checkout', 'spin up a worktree', 'work on another pr', 'merged worktrees', 'clean up merged', 'clean merged', 'orphaned worktree', 'remove the worktree', 'worktree miss', 'worktree dependencies', 'forge clean'] },
|
|
199
|
+
{ skill: 'gates', weight: 3, keywords: ['disable the gate', 'disable a gate', 'enable a gate', 'enable the gate', 'toggle a gate', 'toggle the gate', 'tdd gate', 'disable the tdd', 'turn off tdd', 'tdd enforcement', 'tdd intent', 'kernel tracking rail', 'auto shepherd rail', 'loosen enforcement', 'approve the human gate', 'approve a human gate', 'human gate', 'forge control', 'forge gate', 'forge gate check', 'forge gate status', 'doc-gate', 'forge doc-gate'] },
|
|
200
|
+
{ skill: 'setup', weight: 3, keywords: ['forge setup', 'install forge', 'set up forge', 'forge init', 'initialize forge', 'adoption profile', 'forge doctor', 'forge upgrade', 'upgrade forge', 'forge hooks', 'hooks globally', 'native hooks', 'forge reset', 'reset the forge', 'forge install', 'forge reinstall', 'reinstall forge', 'forge recommend', 'scaffold forge'] },
|
|
201
|
+
{ skill: 'portability', weight: 3, keywords: ['forge export', 'export the backlog', 'export the kernel', 'kernel backlog', 'backlog jsonl', 'back up the backlog', 'snapshot the backlog', 'hydrate the backlog', 'forge migrate', 'migrate from beads', 'beads migration', 'migrate the beads', 'beads issue', 'import the beads', 'import a beads store', 'v2 to v3 migration'] },
|
|
202
|
+
{ skill: 'research', weight: 2, keywords: ['research', 'best practices', 'investigate the landscape', 'deep research', 'compare libraries'] },
|
|
203
|
+
{ skill: 'parallel-deep-research', weight: 2, keywords: ['market research', 'competitive research', 'competitor analysis', 'competitive analysis', 'competitive landscape', 'market landscape'] },
|
|
204
|
+
{ skill: 'sonarcloud', weight: 2, keywords: ['sonarcloud', 'sonar', 'quality gate', 'code smell', 'code smells', 'cognitive complexity'] },
|
|
205
|
+
{ skill: 'sonarcloud-analysis', weight: 1, keywords: ['sonarcloud analysis', 'sonar analysis', 'sonarcloud report', 'analyze with sonar'] },
|
|
206
|
+
{ skill: 'rollback', weight: 2, keywords: ['revert', 'roll back', 'rollback', 'undo the merge', 'undo a change'] },
|
|
207
|
+
{ skill: 'kernel', weight: 1, keywords: ['how does forge', 'which command', 'which skill', 'how is forge set up', 'new here', 'orient'] },
|
|
208
|
+
{ skill: 'hermes-forge', weight: 2, keywords: ['hermes', 'hermes session', 'hermes harness'] },
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
/** Normalize a situation string for matching: lowercase, collapse whitespace, strip punctuation. */
|
|
212
|
+
function normalizeSituation(situation) {
|
|
213
|
+
return String(situation || '')
|
|
214
|
+
.toLowerCase()
|
|
215
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
216
|
+
.replace(/\s+/g, ' ')
|
|
217
|
+
.trim();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* True when `norm` contains `keyword` as a WHOLE token/phrase, not as a substring inside a larger
|
|
222
|
+
* word. Bare "lease" must not fire on "please review"; "plan" must not fire on "planning". `norm`
|
|
223
|
+
* is pre-normalized (lowercase; only [a-z0-9\s-]), so the boundary is start/end or a non-[a-z0-9]
|
|
224
|
+
* char. Works for single words and multi-word phrases alike.
|
|
225
|
+
*/
|
|
226
|
+
function matchesKeyword(norm, keyword) {
|
|
227
|
+
const needle = keyword.trim();
|
|
228
|
+
if (!needle) return false;
|
|
229
|
+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
230
|
+
return new RegExp('(?:^|[^a-z0-9])' + escaped + '(?:[^a-z0-9]|$)').test(norm);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Accumulate a weighted score + reason for a skill into the scores map. */
|
|
234
|
+
function bumpScore(scores, skill, amount, reason) {
|
|
235
|
+
if (!scores.has(skill)) scores.set(skill, { score: 0, reasons: new Set() });
|
|
236
|
+
const entry = scores.get(skill);
|
|
237
|
+
entry.score += amount;
|
|
238
|
+
if (reason) entry.reasons.add(reason);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Score curated intent rules. Only skills PRESENT in the catalog (`known`) are ever scored, so an
|
|
243
|
+
* empty/unavailable catalog produces no matches instead of fabricating nonexistent skill names.
|
|
244
|
+
*/
|
|
245
|
+
function scoreCuratedRules(norm, known, scores) {
|
|
246
|
+
for (const rule of INTENT_RULES) {
|
|
247
|
+
if (!known.has(rule.skill)) continue;
|
|
248
|
+
for (const kw of rule.keywords) {
|
|
249
|
+
if (matchesKeyword(norm, kw)) bumpScore(scores, rule.skill, rule.weight, 'matches "' + kw.trim() + '"');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Light token-overlap bonus against each skill's own description — a TIE-BREAKER ONLY. It bumps
|
|
256
|
+
* ONLY skills that already earned a curated INTENT_RULES hit (present in `scores`); it never
|
|
257
|
+
* introduces a new skill on description overlap alone. Otherwise a generic, non-Forge prompt
|
|
258
|
+
* ("please review this commit") would route into a workflow stage via incidental token overlap.
|
|
259
|
+
*/
|
|
260
|
+
function scoreDescriptionOverlap(norm, catalog, scores) {
|
|
261
|
+
const tokens = new Set(norm.split(' ').filter(t => t.length >= 4));
|
|
262
|
+
for (const skill of catalog) {
|
|
263
|
+
if (!scores.has(skill.name)) continue; // no curated hit -> not a routing candidate
|
|
264
|
+
const desc = normalizeSituation(skill.description);
|
|
265
|
+
let overlap = 0;
|
|
266
|
+
for (const token of tokens) {
|
|
267
|
+
if (desc.includes(token)) overlap += 1;
|
|
268
|
+
}
|
|
269
|
+
if (overlap > 0) bumpScore(scores, skill.name, Math.min(overlap * 0.25, 1), 'described intent');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Rank scored skills highest-first (name tiebreak) and cap to `limit`. */
|
|
274
|
+
function rankMatches(scores, limit) {
|
|
275
|
+
return [...scores.entries()]
|
|
276
|
+
.filter(([, v]) => v.score > 0)
|
|
277
|
+
.map(([name, v]) => ({ name, score: Math.round(v.score * 100) / 100, why: [...v.reasons].join('; ') }))
|
|
278
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
279
|
+
.slice(0, limit);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Deterministically route a natural-language situation to the best-fit Forge skill(s).
|
|
284
|
+
*
|
|
285
|
+
* Scoring: curated INTENT_RULES keyword hits (weighted, catalog-gated) PLUS a light token-overlap
|
|
286
|
+
* bonus against each skill's own description. Ties break by skill name for determinism. When the
|
|
287
|
+
* catalog is empty/unavailable, returns no matches (unknown:true) rather than nonexistent skills.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} situation
|
|
290
|
+
* @param {object} [options]
|
|
291
|
+
* @param {{name: string, description: string}[]} [options.catalog] - skill catalog (injectable).
|
|
292
|
+
* @param {number} [options.limit=3] - max matches to return.
|
|
293
|
+
* @returns {{ situation: string, matches: {name: string, score: number, why: string}[], best: string|null, unknown: boolean }}
|
|
294
|
+
*/
|
|
295
|
+
function routeSkill(situation, options = {}) {
|
|
296
|
+
const catalog = options.catalog || [];
|
|
297
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 3;
|
|
298
|
+
const norm = normalizeSituation(situation);
|
|
299
|
+
const known = new Set(catalog.map(s => s.name));
|
|
300
|
+
|
|
301
|
+
const scores = new Map();
|
|
302
|
+
if (norm) {
|
|
303
|
+
scoreCuratedRules(norm, known, scores);
|
|
304
|
+
// Description overlap is a tie-breaker among curated hits only. With NO curated hit the situation
|
|
305
|
+
// is not a confident Forge-skill match -> unknown (no overlap-only routing of generic prompts).
|
|
306
|
+
if (scores.size > 0) scoreDescriptionOverlap(norm, catalog, scores);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const matches = rankMatches(scores, limit);
|
|
310
|
+
return {
|
|
311
|
+
situation: String(situation || ''),
|
|
312
|
+
matches,
|
|
313
|
+
best: matches.length > 0 ? matches[0].name : null,
|
|
314
|
+
unknown: matches.length === 0,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
module.exports = {
|
|
319
|
+
DISPATCH_SKILL,
|
|
320
|
+
INTENT_RULES,
|
|
321
|
+
resolveSkillsRoot,
|
|
322
|
+
loadDispatchText,
|
|
323
|
+
loadSkillCatalog,
|
|
324
|
+
parseFrontmatter,
|
|
325
|
+
stripFrontmatter,
|
|
326
|
+
normalizeSituation,
|
|
327
|
+
routeSkill,
|
|
328
|
+
};
|
|
@@ -167,7 +167,7 @@ async function resolveActiveIssueId(driver, branch) {
|
|
|
167
167
|
|
|
168
168
|
// Lazily build a kernel driver from the project root (the real CLI path).
|
|
169
169
|
// Best-effort: returns null when the kernel is unavailable so the caller
|
|
170
|
-
// degrades to legacy file
|
|
170
|
+
// degrades to the legacy file state instead of crashing a stage command.
|
|
171
171
|
async function buildKernelDriver(projectRoot) {
|
|
172
172
|
if (!projectRoot) {
|
|
173
173
|
return null;
|
|
@@ -370,7 +370,7 @@ async function enforceStageEntry({
|
|
|
370
370
|
// B1 — kernel stage-state authority. Injectable for tests; the real CLI sets
|
|
371
371
|
// autoResolveKernel:true so the driver + active issue are resolved from the
|
|
372
372
|
// worktree. When neither a driver nor autoResolveKernel is provided, the
|
|
373
|
-
// kernel path is inert and behavior matches the legacy file
|
|
373
|
+
// kernel path is inert and behavior matches the legacy file state.
|
|
374
374
|
kernelDriver,
|
|
375
375
|
activeIssueId,
|
|
376
376
|
branch,
|
|
@@ -386,9 +386,9 @@ async function enforceStageEntry({
|
|
|
386
386
|
repairWorkflowRuntimeAssets(projectRoot);
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
-
// Resolve the active issue backend (env > .forge/config.yaml > default 'kernel')
|
|
390
|
-
//
|
|
391
|
-
//
|
|
389
|
+
// Resolve the active issue backend (env > .forge/config.yaml > default 'kernel').
|
|
390
|
+
// The kernel is the only backend and needs no bd, so bd is never a hard
|
|
391
|
+
// prerequisite and stages must run without it.
|
|
392
392
|
const issueBackend = resolveIssueBackend({ deps: {}, env: process.env, projectRoot, warn: () => {} });
|
|
393
393
|
const runtimeHealth = await resolveStageRuntimeHealth({
|
|
394
394
|
health, checkHealth, projectRoot, issueBackend, commandName, flags, workflowState, repairRuntime,
|
|
@@ -98,7 +98,7 @@ function extractWorkflowStateFromIssue(issue = {}) {
|
|
|
98
98
|
return extractWorkflowStateFromComments(mergedComments);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
function
|
|
101
|
+
function readIssueWorkflowState(issueId, options = {}) {
|
|
102
102
|
if (!issueId) {
|
|
103
103
|
return null;
|
|
104
104
|
}
|
|
@@ -127,7 +127,7 @@ function readBeadsIssue(issueId, options = {}) {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
function
|
|
130
|
+
function readWorkflowStateFromIssue(issueId, options = {}) {
|
|
131
131
|
if (!issueId) {
|
|
132
132
|
return null;
|
|
133
133
|
}
|
|
@@ -150,33 +150,33 @@ function readWorkflowStateFromBeads(issueId, options = {}) {
|
|
|
150
150
|
// history, so a single read covers both the structured issue and the raw
|
|
151
151
|
// comment text carrying the serialized WorkflowState marker. No separate
|
|
152
152
|
// comment-list read is required.
|
|
153
|
-
const issue =
|
|
153
|
+
const issue = readIssueWorkflowState(issueId, { projectRoot: options.projectRoot });
|
|
154
154
|
return extractWorkflowStateFromIssue(issue);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
function
|
|
157
|
+
function loadStateFromIssue(options, projectRoot) {
|
|
158
158
|
if (options.comments) {
|
|
159
159
|
const state = extractWorkflowStateFromComments(options.comments);
|
|
160
160
|
if (state) {
|
|
161
|
-
return { state, source: '
|
|
161
|
+
return { state, source: 'issue' };
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
if (options.issue) {
|
|
166
166
|
const state = extractWorkflowStateFromIssue(options.issue);
|
|
167
167
|
if (state) {
|
|
168
|
-
return { state, source: '
|
|
168
|
+
return { state, source: 'issue' };
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
if (options.issueId) {
|
|
173
|
-
const state =
|
|
173
|
+
const state = readWorkflowStateFromIssue(options.issueId, {
|
|
174
174
|
comments: options.comments,
|
|
175
175
|
issue: options.issue,
|
|
176
176
|
projectRoot,
|
|
177
177
|
});
|
|
178
178
|
if (state) {
|
|
179
|
-
return { state, source: '
|
|
179
|
+
return { state, source: 'issue' };
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
@@ -185,21 +185,21 @@ function loadStateFromBeads(options, projectRoot) {
|
|
|
185
185
|
|
|
186
186
|
function loadState(projectRoot, options = {}) {
|
|
187
187
|
if (!projectRoot) {
|
|
188
|
-
const
|
|
189
|
-
return
|
|
188
|
+
const issueResult = loadStateFromIssue(options, null);
|
|
189
|
+
return issueResult || { state: null, source: null };
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
if (options.
|
|
192
|
+
if (options.preferIssueLookup && (options.comments || options.issue || options.issueId)) {
|
|
193
193
|
try {
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
return
|
|
194
|
+
const issueResult = loadStateFromIssue(options, projectRoot);
|
|
195
|
+
if (issueResult) {
|
|
196
|
+
return issueResult;
|
|
197
197
|
}
|
|
198
198
|
} catch (error) {
|
|
199
|
-
if (typeof options.
|
|
200
|
-
options.
|
|
199
|
+
if (typeof options.onIssueLookupError === 'function') {
|
|
200
|
+
options.onIssueLookupError(error);
|
|
201
201
|
}
|
|
202
|
-
// Fall through to the on-disk state file when
|
|
202
|
+
// Fall through to the on-disk state file when the issue cannot be read.
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
@@ -209,13 +209,13 @@ function loadState(projectRoot, options = {}) {
|
|
|
209
209
|
const raw = fs.readFileSync(statePath, 'utf8');
|
|
210
210
|
return { state: readWorkflowState(raw), source: 'file' };
|
|
211
211
|
} catch (_parseError) {
|
|
212
|
-
// File is malformed — fall through to
|
|
212
|
+
// File is malformed — fall through to the issue-lookup fallback
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
const
|
|
217
|
-
if (
|
|
218
|
-
return
|
|
216
|
+
const issueResult = loadStateFromIssue(options, projectRoot);
|
|
217
|
+
if (issueResult) {
|
|
218
|
+
return issueResult;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
return { state: null, source: null };
|
|
@@ -331,8 +331,8 @@ module.exports = {
|
|
|
331
331
|
extractWorkflowStateFromIssue,
|
|
332
332
|
initializeState,
|
|
333
333
|
loadState,
|
|
334
|
-
|
|
335
|
-
|
|
334
|
+
readIssueWorkflowState,
|
|
335
|
+
readWorkflowStateFromIssue,
|
|
336
336
|
saveState,
|
|
337
337
|
transitionStage,
|
|
338
338
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-workflow",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.5",
|
|
4
4
|
"description": "Local runtime control plane for AI-assisted engineering workflows, gates, evidence, and all AI agents",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forge": "bin/forge.js",
|
|
@@ -53,11 +53,11 @@
|
|
|
53
53
|
"@eslint/js": "^10.0.1",
|
|
54
54
|
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
|
55
55
|
"@stryker-mutator/core": "^9.6.0",
|
|
56
|
-
"c8": "^
|
|
56
|
+
"c8": "^12.0.0",
|
|
57
57
|
"eslint": "^10.0.2",
|
|
58
58
|
"eslint-plugin-sonarjs": "^4.1.0",
|
|
59
59
|
"globals": "^17.3.0",
|
|
60
|
-
"js-yaml": "^5.
|
|
60
|
+
"js-yaml": "^5.2.2",
|
|
61
61
|
"lefthook": "^2.1.4",
|
|
62
62
|
"typescript": "7.0.2"
|
|
63
63
|
},
|
|
@@ -96,8 +96,6 @@
|
|
|
96
96
|
".cursor/",
|
|
97
97
|
"!.cursor/node_modules/",
|
|
98
98
|
"scripts/",
|
|
99
|
-
"!scripts/github-beads-sync/",
|
|
100
|
-
"!scripts/github-beads-sync.config.json",
|
|
101
99
|
"!scripts/**/*.test.js",
|
|
102
100
|
".github/PLUGIN_TEMPLATE.json",
|
|
103
101
|
".forge/hooks/",
|
|
@@ -122,10 +120,11 @@
|
|
|
122
120
|
},
|
|
123
121
|
"overrides": {
|
|
124
122
|
"eslint": "^10.0.2",
|
|
125
|
-
"fast-uri": "^3.1.
|
|
123
|
+
"fast-uri": "^3.1.4",
|
|
126
124
|
"flatted": "3.4.2",
|
|
127
125
|
"minimatch": "10.2.4",
|
|
128
|
-
"brace-expansion": "5.0.
|
|
126
|
+
"brace-expansion": "5.0.9",
|
|
127
|
+
"linkify-it": "^5.0.2",
|
|
129
128
|
"lodash": "4.18.1"
|
|
130
129
|
},
|
|
131
130
|
"c8": {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Always: before any response, if a Forge skill could apply, invoke it (or run forge skill for)"
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
globs: []
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Using Forge (skill dispatch — auto-trigger)
|
|
8
|
+
|
|
9
|
+
**Before ANY response or action** — including clarifying questions, exploring the
|
|
10
|
+
codebase, or checking files — if there is even a **1% chance** a Forge skill
|
|
11
|
+
applies to what you are doing, invoke that skill first. Then announce
|
|
12
|
+
`Using [skill] to [purpose]` and follow it.
|
|
13
|
+
|
|
14
|
+
- Not sure which skill fits? Run `forge skill for "<what you are about to do>"`
|
|
15
|
+
for the deterministic best-fit, or read the routing table in the dispatch skill.
|
|
16
|
+
- Common routes: build/scope a feature -> plan; implement a task -> dev; fix a
|
|
17
|
+
failing test -> dev (debug first); run checks/lint/tests -> validate; open a PR
|
|
18
|
+
-> ship; address review feedback -> review; where am I -> status.
|
|
19
|
+
|
|
20
|
+
This rule is a **thin pointer**. The full 1%-rule, red-flags table, subagent
|
|
21
|
+
escape hatch, and routing table live in the **`using-forge` dispatch skill**,
|
|
22
|
+
installed into your agent's own skill surface by `forge setup` (for Cursor:
|
|
23
|
+
`.cursor/skills/using-forge/SKILL.md`) — invoke it by name, or run
|
|
24
|
+
`forge skill for "<situation>"`. Do not duplicate that policy here.
|