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.
Files changed (155) hide show
  1. package/.forge/hooks/check-tdd.js +79 -5
  2. package/.forge/hooks/forge-native-hook.js +194 -8
  3. package/AGENTS.md +2 -1
  4. package/CHANGELOG.md +28 -0
  5. package/QUICKSTART.md +6 -2
  6. package/README.md +3 -1
  7. package/bin/forge-cmd.js +1 -1
  8. package/bin/forge.js +95 -19
  9. package/docs/guides/SETUP.md +4 -1
  10. package/docs/guides/SUPPORT.md +5 -0
  11. package/docs/reference/COMMANDS.md +12 -1
  12. package/docs/reference/shepherd.md +114 -3
  13. package/lib/activation/ensure-forge-home.js +135 -0
  14. package/lib/adapters/beads-kernel-compat.js +67 -0
  15. package/lib/adapters/greptile-review-adapter.js +1 -1
  16. package/lib/adapters/pr-state-adapter.js +103 -8
  17. package/lib/adoption-profiles.js +17 -4
  18. package/lib/agents-config.js +5 -0
  19. package/lib/beads-detect.js +60 -0
  20. package/lib/commands/_aliases.js +248 -0
  21. package/lib/commands/_issue.js +65 -41
  22. package/lib/commands/_manifest.js +3 -1
  23. package/lib/commands/_registry.js +14 -0
  24. package/lib/commands/_resolve-command-opts.js +36 -60
  25. package/lib/commands/claim.js +2 -4
  26. package/lib/commands/gate.js +19 -2
  27. package/lib/commands/hooks.js +294 -12
  28. package/lib/commands/init.js +26 -20
  29. package/lib/commands/memory.js +81 -0
  30. package/lib/commands/migrate.js +0 -161
  31. package/lib/commands/plan.js +71 -123
  32. package/lib/commands/pr.js +88 -0
  33. package/lib/commands/prime.js +8 -1
  34. package/lib/commands/push.js +66 -0
  35. package/lib/commands/recall.js +67 -12
  36. package/lib/commands/recap.js +18 -4
  37. package/lib/commands/release.js +15 -3
  38. package/lib/commands/remember.js +86 -20
  39. package/lib/commands/serve.js +5 -2
  40. package/lib/commands/setup.js +135 -73
  41. package/lib/commands/shepherd.js +102 -4
  42. package/lib/commands/ship.js +40 -4
  43. package/lib/commands/skill.js +275 -0
  44. package/lib/commands/status.js +37 -32
  45. package/lib/commands/test.js +32 -0
  46. package/lib/commands/worktree.js +87 -5
  47. package/lib/core/runtime-graph.js +122 -17
  48. package/lib/forge-issues.js +13 -464
  49. package/lib/gate-events.js +54 -55
  50. package/lib/global-flags.js +30 -0
  51. package/lib/grounding/context-events.js +230 -0
  52. package/lib/grounding/read-first.js +112 -0
  53. package/lib/harness-capability-matrix.js +2 -2
  54. package/lib/hook-renderer.js +144 -12
  55. package/lib/issue-backend.js +42 -3
  56. package/lib/kernel/backing-issue.js +7 -1
  57. package/lib/kernel/broker.js +28 -0
  58. package/lib/kernel/migrations.js +30 -2
  59. package/lib/kernel/owned-kernel.js +43 -0
  60. package/lib/kernel/schema.js +35 -0
  61. package/lib/kernel/sqlite-driver.js +187 -1
  62. package/lib/memory-recall.js +151 -0
  63. package/lib/orientation.js +303 -6
  64. package/lib/pr-bundle.js +6 -2
  65. package/lib/pr-monitor/auto-actions.js +175 -0
  66. package/lib/pr-monitor/digest.js +206 -0
  67. package/lib/pr-monitor/reconcile-executor.js +659 -0
  68. package/lib/pr-monitor/reconcile-tick.js +138 -0
  69. package/lib/pr-monitor/reconcile.js +0 -0
  70. package/lib/pr-monitor/render-sticky.js +57 -8
  71. package/lib/pr-monitor/shepherd-lease.js +243 -0
  72. package/lib/pr-monitor/upsert-sticky.js +169 -0
  73. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  74. package/lib/pr-pull.js +113 -17
  75. package/lib/project-memory.js +8 -0
  76. package/lib/release-readiness.js +17 -1
  77. package/lib/rules-sync.js +4 -0
  78. package/lib/runtime-health.js +15 -46
  79. package/lib/skill-eval.js +750 -0
  80. package/lib/status/identity.js +46 -0
  81. package/lib/status/presenter.js +0 -35
  82. package/lib/status/snapshot.js +11 -16
  83. package/lib/upgrade-safety.js +52 -1
  84. package/lib/using-forge.js +315 -0
  85. package/lib/workflow/enforce-stage.js +64 -7
  86. package/lib/workflow/state-manager.js +23 -23
  87. package/package.json +2 -2
  88. package/rules/using-forge.md +24 -0
  89. package/scripts/forge-team/index.sh +0 -5
  90. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  91. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  92. package/scripts/pr-auto-actions.js +93 -0
  93. package/scripts/pr-verdict-label.js +50 -0
  94. package/scripts/test.js +8 -1
  95. package/skills/claim-safety/SKILL.md +4 -0
  96. package/skills/claim-safety/evals/scorecard.json +41 -0
  97. package/skills/coverage.json +83 -0
  98. package/skills/dev/SKILL.md +4 -0
  99. package/skills/dev/evals/scorecard.json +41 -0
  100. package/skills/gates/SKILL.md +80 -0
  101. package/skills/gates/evals/evals.json +38 -0
  102. package/skills/gates/evals/scorecard.json +41 -0
  103. package/skills/hermes-forge/SKILL.md +1 -0
  104. package/skills/hermes-forge/evals/scorecard.json +41 -0
  105. package/skills/issue-basics/SKILL.md +1 -0
  106. package/skills/issue-basics/evals/scorecard.json +41 -0
  107. package/skills/kernel/SKILL.md +38 -0
  108. package/skills/kernel/evals/scorecard.json +41 -0
  109. package/skills/memory/SKILL.md +16 -1
  110. package/skills/memory/evals/scorecard.json +41 -0
  111. package/skills/parallel-deep-research/SKILL.md +1 -0
  112. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  113. package/skills/plan/SKILL.md +6 -0
  114. package/skills/plan/evals/scorecard.json +41 -0
  115. package/skills/portability/SKILL.md +47 -0
  116. package/skills/portability/evals/evals.json +34 -0
  117. package/skills/portability/evals/scorecard.json +41 -0
  118. package/skills/research/SKILL.md +1 -0
  119. package/skills/research/evals/scorecard.json +41 -0
  120. package/skills/review/SKILL.md +6 -0
  121. package/skills/review/evals/scorecard.json +41 -0
  122. package/skills/rollback/SKILL.md +1 -0
  123. package/skills/rollback/evals/scorecard.json +41 -0
  124. package/skills/setup/SKILL.md +91 -0
  125. package/skills/setup/evals/evals.json +42 -0
  126. package/skills/setup/evals/scorecard.json +41 -0
  127. package/skills/shepherd/SKILL.md +76 -36
  128. package/skills/shepherd/evals/evals.json +21 -9
  129. package/skills/shepherd/evals/scorecard.json +41 -0
  130. package/skills/ship/SKILL.md +6 -0
  131. package/skills/ship/evals/scorecard.json +41 -0
  132. package/skills/smith/SKILL.md +8 -0
  133. package/skills/smith/evals/scorecard.json +41 -0
  134. package/skills/sonarcloud/SKILL.md +1 -0
  135. package/skills/sonarcloud/evals/scorecard.json +41 -0
  136. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  137. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  138. package/skills/status/SKILL.md +3 -0
  139. package/skills/status/evals/scorecard.json +41 -0
  140. package/skills/triage-ready/SKILL.md +2 -0
  141. package/skills/triage-ready/evals/scorecard.json +41 -0
  142. package/skills/using-forge/SKILL.md +104 -0
  143. package/skills/using-forge/evals/scorecard.json +41 -0
  144. package/skills/validate/SKILL.md +4 -0
  145. package/skills/validate/evals/scorecard.json +41 -0
  146. package/skills/verify/SKILL.md +4 -0
  147. package/skills/verify/evals/scorecard.json +41 -0
  148. package/skills/worktree/SKILL.md +87 -0
  149. package/skills/worktree/evals/evals.json +38 -0
  150. package/skills/worktree/evals/scorecard.json +41 -0
  151. package/lib/adapters/beads-issue-adapter.js +0 -127
  152. package/lib/commands/board.js +0 -64
  153. package/lib/status/beads-snapshot.js +0 -145
  154. package/scripts/forge-team/lib/dashboard.sh +0 -316
  155. 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
+ };
@@ -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,
@@ -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 { readBeadsSnapshot, getDeveloperIdentity } = require('./beads-snapshot.js');
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
- // mirror the shape readBeadsSnapshot produces so lib/status/presenter.js consumes
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 shaped like readBeadsSnapshot's output
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 active issue backend. Reads the
157
- * Kernel by default (the flagship `forge status` view); reads Beads only when Beads is
158
- * explicitly selected (--issue-backend beads / FORGE_ISSUE_BACKEND=beads /
159
- * issueBackend: beads in .forge/config.yaml). Resolution reuses lib/issue-backend.js
160
- * so the snapshot never drifts from the issue commands' backend authority.
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; `issueBackend` selects
164
- * an explicit backend, `env` overrides process.env, `backend` short-circuits resolution.
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
 
@@ -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 buildUpgradeDryRunReport(projectRoot = process.cwd()) {
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
+ };