forge-workflow 0.1.0-beta.2 → 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/.forge/hooks/check-tdd.js +79 -5
- package/.forge/hooks/forge-native-hook.js +194 -8
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +6 -2
- package/README.md +3 -1
- package/bin/forge-cmd.js +1 -1
- package/bin/forge.js +95 -19
- package/docs/guides/SETUP.md +4 -1
- package/docs/guides/SUPPORT.md +5 -0
- package/docs/reference/COMMANDS.md +12 -1
- package/docs/reference/shepherd.md +114 -3
- package/lib/activation/ensure-forge-home.js +135 -0
- package/lib/adapters/beads-kernel-compat.js +67 -0
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +103 -8
- package/lib/adoption-profiles.js +17 -4
- package/lib/agents-config.js +5 -0
- package/lib/beads-detect.js +60 -0
- package/lib/commands/_aliases.js +248 -0
- package/lib/commands/_issue.js +65 -41
- package/lib/commands/_manifest.js +3 -1
- package/lib/commands/_registry.js +14 -0
- package/lib/commands/_resolve-command-opts.js +36 -60
- package/lib/commands/claim.js +2 -4
- package/lib/commands/gate.js +19 -2
- package/lib/commands/hooks.js +294 -12
- package/lib/commands/init.js +26 -20
- package/lib/commands/memory.js +81 -0
- package/lib/commands/migrate.js +0 -161
- package/lib/commands/plan.js +71 -123
- package/lib/commands/pr.js +88 -0
- package/lib/commands/prime.js +8 -1
- package/lib/commands/push.js +66 -0
- package/lib/commands/recall.js +67 -12
- package/lib/commands/recap.js +18 -4
- package/lib/commands/release.js +15 -3
- package/lib/commands/remember.js +86 -20
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +135 -73
- package/lib/commands/shepherd.js +102 -4
- package/lib/commands/ship.js +40 -4
- 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 +87 -5
- package/lib/core/runtime-graph.js +122 -17
- package/lib/forge-issues.js +13 -464
- package/lib/gate-events.js +54 -55
- package/lib/global-flags.js +30 -0
- package/lib/grounding/context-events.js +230 -0
- package/lib/grounding/read-first.js +112 -0
- package/lib/harness-capability-matrix.js +2 -2
- package/lib/hook-renderer.js +144 -12
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +7 -1
- package/lib/kernel/broker.js +28 -0
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/owned-kernel.js +43 -0
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +187 -1
- package/lib/memory-recall.js +151 -0
- package/lib/orientation.js +303 -6
- package/lib/pr-bundle.js +6 -2
- package/lib/pr-monitor/auto-actions.js +175 -0
- package/lib/pr-monitor/digest.js +206 -0
- 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 +57 -8
- package/lib/pr-monitor/shepherd-lease.js +243 -0
- package/lib/pr-monitor/upsert-sticky.js +169 -0
- package/lib/pr-monitor/watch-lifecycle.js +1 -1
- package/lib/pr-pull.js +113 -17
- package/lib/project-memory.js +8 -0
- package/lib/release-readiness.js +17 -1
- 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 +52 -1
- package/lib/using-forge.js +315 -0
- package/lib/workflow/enforce-stage.js +64 -7
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +2 -2
- 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/pr-auto-actions.js +93 -0
- package/scripts/pr-verdict-label.js +50 -0
- 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/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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { secureExecFileSync } = require('../shell-utils.js');
|
|
4
|
+
|
|
5
|
+
// Developer identity for the status read model. Extracted from the retired
|
|
6
|
+
// lib/status/beads-snapshot.js: the identity lookup is backend-agnostic (it reads
|
|
7
|
+
// git config, never an issue store), so it outlived the Beads reader it shipped in.
|
|
8
|
+
// Kept in its own module so lib/status/snapshot.js stays read-model-only.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read a single git config value, or '' when git is unavailable or the key is unset.
|
|
12
|
+
* Never throws — status must render even in a non-git directory.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} projectRoot
|
|
15
|
+
* @param {string} key — git config key (e.g. 'user.email')
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function getGitConfig(projectRoot, key) {
|
|
19
|
+
try {
|
|
20
|
+
return secureExecFileSync('git', ['config', key], {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
cwd: projectRoot,
|
|
23
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
24
|
+
}).trim();
|
|
25
|
+
} catch (_error) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The current developer's git identity, used to match claims to "my" work.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} projectRoot
|
|
34
|
+
* @returns {{ email: string, name: string }}
|
|
35
|
+
*/
|
|
36
|
+
function getDeveloperIdentity(projectRoot) {
|
|
37
|
+
return {
|
|
38
|
+
email: getGitConfig(projectRoot, 'user.email'),
|
|
39
|
+
name: getGitConfig(projectRoot, 'user.name'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
getDeveloperIdentity,
|
|
45
|
+
getGitConfig,
|
|
46
|
+
};
|
package/lib/status/presenter.js
CHANGED
|
@@ -173,43 +173,8 @@ function buildPersonalStatusJson({ context, snapshot, workflowResult = null }) {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
function formatBoard({ context, snapshot }) {
|
|
177
|
-
return [
|
|
178
|
-
'',
|
|
179
|
-
'Team Runtime Board',
|
|
180
|
-
`Source: local Beads runtime state`,
|
|
181
|
-
`Branch: ${context.branch}`,
|
|
182
|
-
`Working tree: ${context.workingTree.summary}`,
|
|
183
|
-
'',
|
|
184
|
-
...buildSection('Active', (snapshot.active || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
|
|
185
|
-
...buildSection('Ready', (snapshot.ready || []).map(issue => formatIssue(issue)), { limit: DEFAULT_SECTION_LIMIT }),
|
|
186
|
-
...buildSection('Blocked', (snapshot.blocked || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
|
|
187
|
-
...buildSection('Stale', (snapshot.stale || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
|
|
188
|
-
...buildSection('Parked', (snapshot.parked || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
|
|
189
|
-
...buildSection('Recent Completions', (snapshot.recentCompleted || []).map(issue => formatIssue(issue)), { limit: DEFAULT_SECTION_LIMIT }),
|
|
190
|
-
...buildSection('Limits', snapshot.limits || []),
|
|
191
|
-
].join('\n');
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function buildBoardJson({ context, snapshot }) {
|
|
195
|
-
return {
|
|
196
|
-
context,
|
|
197
|
-
board: {
|
|
198
|
-
active: (snapshot.active || []).map(toIssueSummary),
|
|
199
|
-
ready: (snapshot.ready || []).map(toIssueSummary),
|
|
200
|
-
blocked: (snapshot.blocked || []).map(toIssueSummary),
|
|
201
|
-
stale: (snapshot.stale || []).map(toIssueSummary),
|
|
202
|
-
parked: (snapshot.parked || []).map(toIssueSummary),
|
|
203
|
-
recentCompleted: (snapshot.recentCompleted || []).map(toIssueSummary),
|
|
204
|
-
},
|
|
205
|
-
limits: snapshot.limits || [],
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
176
|
module.exports = {
|
|
210
|
-
buildBoardJson,
|
|
211
177
|
buildPersonalStatusJson,
|
|
212
|
-
formatBoard,
|
|
213
178
|
formatRunNextLines,
|
|
214
179
|
formatZeroArgStatus,
|
|
215
180
|
toIssueSummary,
|
package/lib/status/snapshot.js
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const { resolveIssueBackend } = require('../issue-backend.js');
|
|
4
4
|
const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues.js');
|
|
5
|
-
const {
|
|
5
|
+
const { getDeveloperIdentity } = require('./identity.js');
|
|
6
6
|
|
|
7
7
|
// Kernel status vocabulary (taxonomy-validator): 'open', 'in_progress', 'review',
|
|
8
8
|
// the parked 'backlog', and the terminal 'done' / 'cancelled'. `ready` / `blocked`
|
|
9
9
|
// are DERIVED read-model facts, never stored. An issue is treated as active here when
|
|
10
10
|
// it is OPEN and carries a live claim (claimed_by); parked (`backlog`) work is its own
|
|
11
11
|
// bucket so it stays visible instead of vanishing between ready and done. These buckets
|
|
12
|
-
//
|
|
13
|
-
// either backend's snapshot identically.
|
|
12
|
+
// are the contract lib/status/presenter.js renders against.
|
|
14
13
|
const KERNEL_LIMITS = Object.freeze([
|
|
15
14
|
'Reads Forge Kernel issue authority (ready/blocked/stale/active).',
|
|
16
15
|
'Does not read GitHub review, CI, project, or sync freshness state.',
|
|
@@ -102,7 +101,7 @@ function emptyKernelSnapshot(developer) {
|
|
|
102
101
|
* @param {object} [options]
|
|
103
102
|
* @param {function} [options.runIssueOperation] — injectable kernel read (tests)
|
|
104
103
|
* @param {object} [options.env]
|
|
105
|
-
* @returns {Promise<object>} snapshot
|
|
104
|
+
* @returns {Promise<object>} snapshot in the presenter's bucket contract
|
|
106
105
|
*/
|
|
107
106
|
async function readKernelSnapshot(projectRoot, options = {}) {
|
|
108
107
|
const runIssueOperation = options.runIssueOperation || defaultRunIssueOperation;
|
|
@@ -153,29 +152,25 @@ async function readKernelSnapshot(projectRoot, options = {}) {
|
|
|
153
152
|
}
|
|
154
153
|
|
|
155
154
|
/**
|
|
156
|
-
* Read the personal/board status snapshot from the
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
155
|
+
* Read the personal/board status snapshot from the Kernel — the only issue backend.
|
|
156
|
+
*
|
|
157
|
+
* A repo that still carries the retired `issueBackend: beads` signal alongside an
|
|
158
|
+
* unmigrated `.beads/*.jsonl` store now renders the (empty) kernel board rather than
|
|
159
|
+
* the legacy Beads one; `forge upgrade`'s advisory is what points those users at
|
|
160
|
+
* `forge migrate --from beads`.
|
|
161
161
|
*
|
|
162
162
|
* @param {string} projectRoot
|
|
163
|
-
* @param {object} [options] — forwarded to the backend reader; `
|
|
164
|
-
*
|
|
163
|
+
* @param {object} [options] — forwarded to the backend reader; `env` overrides
|
|
164
|
+
* process.env, `backend` short-circuits resolution entirely.
|
|
165
165
|
* @returns {Promise<object>} snapshot for lib/status/presenter.js
|
|
166
166
|
*/
|
|
167
167
|
async function readStatusSnapshot(projectRoot, options = {}) {
|
|
168
168
|
const backend = options.backend || resolveIssueBackend({
|
|
169
|
-
deps: options.issueBackend ? { issueBackend: options.issueBackend } : {},
|
|
170
169
|
env: options.env || process.env,
|
|
171
170
|
projectRoot,
|
|
172
171
|
warn: () => {},
|
|
173
172
|
});
|
|
174
173
|
|
|
175
|
-
if (backend === 'beads') {
|
|
176
|
-
return readBeadsSnapshot(projectRoot, options);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
174
|
return readKernelSnapshot(projectRoot, { ...options, backend });
|
|
180
175
|
}
|
|
181
176
|
|
package/lib/upgrade-safety.js
CHANGED
|
@@ -6,6 +6,8 @@ const path = require('node:path');
|
|
|
6
6
|
const { lintRuntimeGraphConfig } = require('./core/runtime-graph');
|
|
7
7
|
const { resolvePatchIntentRecords } = require('./patch-intent');
|
|
8
8
|
const { verifyForgeLock, readForgeLock } = require('./forge-lock');
|
|
9
|
+
const { readConfigBackend, resolveIssueBackend } = require('./issue-backend');
|
|
10
|
+
const { detectBeadsJsonlSource } = require('./beads-detect');
|
|
9
11
|
|
|
10
12
|
function checkStatus(ok) {
|
|
11
13
|
return ok ? 'pass' : 'fail';
|
|
@@ -56,7 +58,34 @@ function buildSelfHealCandidates(projectRoot) {
|
|
|
56
58
|
return candidates;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
function
|
|
61
|
+
function safeConfigBackend(projectRoot) {
|
|
62
|
+
try {
|
|
63
|
+
return readConfigBackend(projectRoot);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Detect the 0.0.10 -> current breaking boundary that hides a returning user's
|
|
70
|
+
// issues (kernel issue a5399f3d): a `.beads/*.jsonl` store still present now that
|
|
71
|
+
// the Kernel is the only backend. There is no longer any way to opt back into
|
|
72
|
+
// Beads, so a leftover `issueBackend: beads` in `.forge/config.yaml` (or
|
|
73
|
+
// FORGE_ISSUE_BACKEND) can no longer suppress this advisory — resolveIssueBackend
|
|
74
|
+
// answers 'kernel' regardless, which is precisely when the user needs to migrate.
|
|
75
|
+
// `configBackend` is still reported so the surface can name the stale setting.
|
|
76
|
+
// Uses the single shared detector so the two surfaces cannot drift.
|
|
77
|
+
function buildBeadsMigrationSummary(projectRoot, env = process.env) {
|
|
78
|
+
const jsonlPresent = detectBeadsJsonlSource(projectRoot) !== null;
|
|
79
|
+
const configBackend = safeConfigBackend(projectRoot);
|
|
80
|
+
const backend = resolveIssueBackend({ env, projectRoot, warn: () => {} });
|
|
81
|
+
return {
|
|
82
|
+
jsonlPresent,
|
|
83
|
+
configBackend,
|
|
84
|
+
needsMigration: jsonlPresent && backend === 'kernel',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildUpgradeDryRunReport(projectRoot = process.cwd(), env = process.env) {
|
|
60
89
|
const root = path.resolve(projectRoot);
|
|
61
90
|
const runtime = lintRuntimeGraphConfig({ projectRoot: root });
|
|
62
91
|
const patchIntent = buildPatchIntentSummary(root);
|
|
@@ -66,8 +95,12 @@ function buildUpgradeDryRunReport(projectRoot = process.cwd()) {
|
|
|
66
95
|
const failedLockEntries = lockReport.results.filter(result => result.status === 'fail');
|
|
67
96
|
const untrustedOptIns = countUntrustedOptIns(lock);
|
|
68
97
|
const lockTrustOk = lockReport.ok && untrustedOptIns === 0;
|
|
98
|
+
const beadsMigration = buildBeadsMigrationSummary(root, env);
|
|
69
99
|
|
|
70
100
|
return {
|
|
101
|
+
// A pending beads -> kernel migration is a guided ADVISORY, not an integrity
|
|
102
|
+
// failure — it never flips `ok` (scripts keying on it stay stable); it surfaces
|
|
103
|
+
// as its own prominent "action required" section in the rendered report.
|
|
71
104
|
ok: runtime.ok && patchIntent.ok && lockTrustOk,
|
|
72
105
|
projectRoot: root,
|
|
73
106
|
runtime,
|
|
@@ -77,6 +110,7 @@ function buildUpgradeDryRunReport(projectRoot = process.cwd()) {
|
|
|
77
110
|
lockTrustOk,
|
|
78
111
|
selfHealCandidates,
|
|
79
112
|
failedLockEntries,
|
|
113
|
+
beadsMigration,
|
|
80
114
|
};
|
|
81
115
|
}
|
|
82
116
|
|
|
@@ -139,6 +173,21 @@ function appendSelfHealResult(lines, selfHealResult) {
|
|
|
139
173
|
}
|
|
140
174
|
}
|
|
141
175
|
|
|
176
|
+
function appendBeadsMigration(lines, beadsMigration) {
|
|
177
|
+
if (!beadsMigration || !beadsMigration.needsMigration) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
lines.push(
|
|
181
|
+
'',
|
|
182
|
+
'Breaking change since 0.0.10 — action required',
|
|
183
|
+
'Detected a Beads issue store (.beads/*.jsonl). The Kernel is now the only',
|
|
184
|
+
'issue backend, so these issues will NOT appear until migrated (your data is safe',
|
|
185
|
+
'on disk in the meantime). To migrate:',
|
|
186
|
+
' forge migrate --from beads # import your Beads issues into the Kernel',
|
|
187
|
+
' forge setup # (re)wire hooks + provision the Kernel store',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
142
191
|
function renderUpgradeDryRunReport(report, selfHealResult = null) {
|
|
143
192
|
const lines = [
|
|
144
193
|
'Forge upgrade dry-run',
|
|
@@ -149,6 +198,7 @@ function renderUpgradeDryRunReport(report, selfHealResult = null) {
|
|
|
149
198
|
...readinessLines(report),
|
|
150
199
|
];
|
|
151
200
|
|
|
201
|
+
appendBeadsMigration(lines, report.beadsMigration);
|
|
152
202
|
appendPlannedSelfHeal(lines, report.selfHealCandidates);
|
|
153
203
|
appendSelfHealResult(lines, selfHealResult);
|
|
154
204
|
|
|
@@ -194,6 +244,7 @@ function applySelfHeal(projectRoot, report) {
|
|
|
194
244
|
module.exports = {
|
|
195
245
|
applySelfHeal,
|
|
196
246
|
buildUpgradeDryRunReport,
|
|
247
|
+
buildBeadsMigrationSummary,
|
|
197
248
|
buildSelfHealCandidates,
|
|
198
249
|
renderUpgradeDryRunReport,
|
|
199
250
|
};
|
|
@@ -0,0 +1,315 @@
|
|
|
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
|
+
} else if (line.trim() !== '') {
|
|
104
|
+
state.inDescription = false;
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
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
|
+
const descMatch = /^description:\s*(.*)$/.exec(line);
|
|
117
|
+
if (descMatch) {
|
|
118
|
+
state.inDescription = true;
|
|
119
|
+
const inline = descMatch[1].trim();
|
|
120
|
+
if (inline && !['>', '|', '>-', '|-'].includes(inline)) state.descParts.push(unquote(inline));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Parse the name and (flattened) description from a SKILL.md frontmatter block. */
|
|
125
|
+
function parseFrontmatter(raw) {
|
|
126
|
+
const block = frontmatterBlock(raw);
|
|
127
|
+
if (block === null) return { name: null, description: '' };
|
|
128
|
+
const state = { name: null, descParts: [], inDescription: false };
|
|
129
|
+
for (const line of block.split(/\r?\n/)) applyFrontmatterLine(state, line);
|
|
130
|
+
return { name: state.name, description: state.descParts.join(' ').replace(/\s+/g, ' ').trim() };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Read one skill dir into a `{ name, description }` catalog entry, or null when unreadable. */
|
|
134
|
+
function readCatalogEntry(root, name) {
|
|
135
|
+
const raw = readSkillFile(root, name);
|
|
136
|
+
if (!raw) return null;
|
|
137
|
+
const fm = parseFrontmatter(raw);
|
|
138
|
+
return { name: fm.name || name, description: fm.description || '' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Load the canonical Forge skill catalog: name + description for every skills/*\/SKILL.md under
|
|
143
|
+
* the resolved root. Sorted by name for determinism. Never throws -- an unresolved root or
|
|
144
|
+
* absent dir yields an empty array (the router then honestly returns no matches).
|
|
145
|
+
* @param {string} [override] - optional skills-root override (defaults to the package root).
|
|
146
|
+
* @returns {{name: string, description: string}[]}
|
|
147
|
+
*/
|
|
148
|
+
function loadSkillCatalog(override) {
|
|
149
|
+
const root = resolveSkillsRoot(override);
|
|
150
|
+
if (!root) return [];
|
|
151
|
+
let entries;
|
|
152
|
+
try {
|
|
153
|
+
entries = fs.readdirSync(path.join(root, 'skills'), { withFileTypes: true });
|
|
154
|
+
} catch {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
const catalog = [];
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (!entry.isDirectory()) continue;
|
|
160
|
+
const item = readCatalogEntry(root, entry.name);
|
|
161
|
+
if (item) catalog.push(item);
|
|
162
|
+
}
|
|
163
|
+
catalog.sort((a, b) => a.name.localeCompare(b.name));
|
|
164
|
+
return catalog;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Curated intent-to-skill rules: the deterministic backbone of the router. Each rule scores when
|
|
168
|
+
// a situation contains one of its keyword phrases; weight lets a strong signal outrank an
|
|
169
|
+
// incidental token. Keywords match as normalized substrings, so a multi-word phrase matches only
|
|
170
|
+
// when adjacent. This is the reasoning fallback for harnesses that cannot auto-load the skill.
|
|
171
|
+
const INTENT_RULES = Object.freeze([
|
|
172
|
+
{ 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'] },
|
|
173
|
+
{ 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'] },
|
|
174
|
+
{ skill: 'validate', weight: 3, keywords: ['run tests', 'run the tests', 'lint', 'type check', 'typecheck', 'type-check', 'validate', 'security scan', 'run checks', 'all checks'] },
|
|
175
|
+
{ 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'] },
|
|
176
|
+
{ skill: 'review', weight: 3, keywords: ['review feedback', 'address feedback', 'pr feedback', 'coderabbit', 'greptile', 'review comment', 'resolve threads', 'address the review'] },
|
|
177
|
+
{ skill: 'verify', weight: 3, keywords: ['post-merge', 'after merge', 'verify health', 'health check', 'ci on main', 'close the issue after merge'] },
|
|
178
|
+
{ 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'] },
|
|
179
|
+
{ skill: 'status', weight: 3, keywords: ['where am i', 'current stage', 'what stage', 'stale work', 'status', 'active work', 'whats going on'] },
|
|
180
|
+
{ 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'] },
|
|
181
|
+
{ skill: 'memory', weight: 2, keywords: ['remember', 'recall', 'remember that', 'recall the', 'save a note', 'persist a note', 'note the decision', 'jot down', 'remind me'] },
|
|
182
|
+
{ skill: 'claim-safety', weight: 2, keywords: ['claim an issue', 'claim the issue', 'prove ownership', 'own the lease', 'lease'] },
|
|
183
|
+
{ skill: 'smith', weight: 2, keywords: ['end to end', 'end-to-end', 'drive one issue', 'plan to merged', 'whole issue', 'from plan to pr'] },
|
|
184
|
+
{ 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'] },
|
|
185
|
+
{ 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'] },
|
|
186
|
+
{ 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'] },
|
|
187
|
+
{ 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'] },
|
|
188
|
+
{ 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'] },
|
|
189
|
+
{ skill: 'research', weight: 2, keywords: ['research', 'best practices', 'investigate the landscape', 'deep research', 'compare libraries'] },
|
|
190
|
+
{ skill: 'parallel-deep-research', weight: 2, keywords: ['market research', 'competitive research', 'competitor analysis', 'competitive analysis', 'competitive landscape', 'market landscape'] },
|
|
191
|
+
{ skill: 'sonarcloud', weight: 2, keywords: ['sonarcloud', 'sonar', 'quality gate', 'code smell', 'code smells', 'cognitive complexity'] },
|
|
192
|
+
{ skill: 'sonarcloud-analysis', weight: 1, keywords: ['sonarcloud analysis', 'sonar analysis', 'sonarcloud report', 'analyze with sonar'] },
|
|
193
|
+
{ skill: 'rollback', weight: 2, keywords: ['revert', 'roll back', 'rollback', 'undo the merge', 'undo a change'] },
|
|
194
|
+
{ skill: 'kernel', weight: 1, keywords: ['how does forge', 'which command', 'which skill', 'how is forge set up', 'new here', 'orient'] },
|
|
195
|
+
{ skill: 'hermes-forge', weight: 2, keywords: ['hermes', 'hermes session', 'hermes harness'] },
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
/** Normalize a situation string for matching: lowercase, collapse whitespace, strip punctuation. */
|
|
199
|
+
function normalizeSituation(situation) {
|
|
200
|
+
return String(situation || '')
|
|
201
|
+
.toLowerCase()
|
|
202
|
+
.replace(/[^a-z0-9\s-]/g, ' ')
|
|
203
|
+
.replace(/\s+/g, ' ')
|
|
204
|
+
.trim();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* True when `norm` contains `keyword` as a WHOLE token/phrase, not as a substring inside a larger
|
|
209
|
+
* word. Bare "lease" must not fire on "please review"; "plan" must not fire on "planning". `norm`
|
|
210
|
+
* is pre-normalized (lowercase; only [a-z0-9\s-]), so the boundary is start/end or a non-[a-z0-9]
|
|
211
|
+
* char. Works for single words and multi-word phrases alike.
|
|
212
|
+
*/
|
|
213
|
+
function matchesKeyword(norm, keyword) {
|
|
214
|
+
const needle = keyword.trim();
|
|
215
|
+
if (!needle) return false;
|
|
216
|
+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
217
|
+
return new RegExp('(?:^|[^a-z0-9])' + escaped + '(?:[^a-z0-9]|$)').test(norm);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Accumulate a weighted score + reason for a skill into the scores map. */
|
|
221
|
+
function bumpScore(scores, skill, amount, reason) {
|
|
222
|
+
if (!scores.has(skill)) scores.set(skill, { score: 0, reasons: new Set() });
|
|
223
|
+
const entry = scores.get(skill);
|
|
224
|
+
entry.score += amount;
|
|
225
|
+
if (reason) entry.reasons.add(reason);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Score curated intent rules. Only skills PRESENT in the catalog (`known`) are ever scored, so an
|
|
230
|
+
* empty/unavailable catalog produces no matches instead of fabricating nonexistent skill names.
|
|
231
|
+
*/
|
|
232
|
+
function scoreCuratedRules(norm, known, scores) {
|
|
233
|
+
for (const rule of INTENT_RULES) {
|
|
234
|
+
if (!known.has(rule.skill)) continue;
|
|
235
|
+
for (const kw of rule.keywords) {
|
|
236
|
+
if (matchesKeyword(norm, kw)) bumpScore(scores, rule.skill, rule.weight, 'matches "' + kw.trim() + '"');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Light token-overlap bonus against each skill's own description — a TIE-BREAKER ONLY. It bumps
|
|
243
|
+
* ONLY skills that already earned a curated INTENT_RULES hit (present in `scores`); it never
|
|
244
|
+
* introduces a new skill on description overlap alone. Otherwise a generic, non-Forge prompt
|
|
245
|
+
* ("please review this commit") would route into a workflow stage via incidental token overlap.
|
|
246
|
+
*/
|
|
247
|
+
function scoreDescriptionOverlap(norm, catalog, scores) {
|
|
248
|
+
const tokens = new Set(norm.split(' ').filter(t => t.length >= 4));
|
|
249
|
+
for (const skill of catalog) {
|
|
250
|
+
if (!scores.has(skill.name)) continue; // no curated hit -> not a routing candidate
|
|
251
|
+
const desc = normalizeSituation(skill.description);
|
|
252
|
+
let overlap = 0;
|
|
253
|
+
for (const token of tokens) {
|
|
254
|
+
if (desc.includes(token)) overlap += 1;
|
|
255
|
+
}
|
|
256
|
+
if (overlap > 0) bumpScore(scores, skill.name, Math.min(overlap * 0.25, 1), 'described intent');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Rank scored skills highest-first (name tiebreak) and cap to `limit`. */
|
|
261
|
+
function rankMatches(scores, limit) {
|
|
262
|
+
return [...scores.entries()]
|
|
263
|
+
.filter(([, v]) => v.score > 0)
|
|
264
|
+
.map(([name, v]) => ({ name, score: Math.round(v.score * 100) / 100, why: [...v.reasons].join('; ') }))
|
|
265
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
266
|
+
.slice(0, limit);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Deterministically route a natural-language situation to the best-fit Forge skill(s).
|
|
271
|
+
*
|
|
272
|
+
* Scoring: curated INTENT_RULES keyword hits (weighted, catalog-gated) PLUS a light token-overlap
|
|
273
|
+
* bonus against each skill's own description. Ties break by skill name for determinism. When the
|
|
274
|
+
* catalog is empty/unavailable, returns no matches (unknown:true) rather than nonexistent skills.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} situation
|
|
277
|
+
* @param {object} [options]
|
|
278
|
+
* @param {{name: string, description: string}[]} [options.catalog] - skill catalog (injectable).
|
|
279
|
+
* @param {number} [options.limit=3] - max matches to return.
|
|
280
|
+
* @returns {{ situation: string, matches: {name: string, score: number, why: string}[], best: string|null, unknown: boolean }}
|
|
281
|
+
*/
|
|
282
|
+
function routeSkill(situation, options = {}) {
|
|
283
|
+
const catalog = options.catalog || [];
|
|
284
|
+
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 3;
|
|
285
|
+
const norm = normalizeSituation(situation);
|
|
286
|
+
const known = new Set(catalog.map(s => s.name));
|
|
287
|
+
|
|
288
|
+
const scores = new Map();
|
|
289
|
+
if (norm) {
|
|
290
|
+
scoreCuratedRules(norm, known, scores);
|
|
291
|
+
// Description overlap is a tie-breaker among curated hits only. With NO curated hit the situation
|
|
292
|
+
// is not a confident Forge-skill match -> unknown (no overlap-only routing of generic prompts).
|
|
293
|
+
if (scores.size > 0) scoreDescriptionOverlap(norm, catalog, scores);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const matches = rankMatches(scores, limit);
|
|
297
|
+
return {
|
|
298
|
+
situation: String(situation || ''),
|
|
299
|
+
matches,
|
|
300
|
+
best: matches.length > 0 ? matches[0].name : null,
|
|
301
|
+
unknown: matches.length === 0,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
DISPATCH_SKILL,
|
|
307
|
+
INTENT_RULES,
|
|
308
|
+
resolveSkillsRoot,
|
|
309
|
+
loadDispatchText,
|
|
310
|
+
loadSkillCatalog,
|
|
311
|
+
parseFrontmatter,
|
|
312
|
+
stripFrontmatter,
|
|
313
|
+
normalizeSituation,
|
|
314
|
+
routeSkill,
|
|
315
|
+
};
|