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.
Files changed (196) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +21 -1
  5. package/bin/forge.js +16 -369
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +9 -4
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. 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/beads state instead of crashing a stage command.
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/beads state.
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') so
390
- // the runtime gate only treats bd as a hard prerequisite for the beads backend. The
391
- // kernel default needs no bd, so stages must run without it.
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 readBeadsIssue(issueId, options = {}) {
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 readWorkflowStateFromBeads(issueId, options = {}) {
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 = readBeadsIssue(issueId, { projectRoot: options.projectRoot });
153
+ const issue = readIssueWorkflowState(issueId, { projectRoot: options.projectRoot });
154
154
  return extractWorkflowStateFromIssue(issue);
155
155
  }
156
156
 
157
- function loadStateFromBeads(options, projectRoot) {
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: 'beads' };
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: 'beads' };
168
+ return { state, source: 'issue' };
169
169
  }
170
170
  }
171
171
 
172
172
  if (options.issueId) {
173
- const state = readWorkflowStateFromBeads(options.issueId, {
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: 'beads' };
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 beadsResult = loadStateFromBeads(options, null);
189
- return beadsResult || { state: null, source: null };
188
+ const issueResult = loadStateFromIssue(options, null);
189
+ return issueResult || { state: null, source: null };
190
190
  }
191
191
 
192
- if (options.preferBeads && (options.comments || options.issue || options.issueId)) {
192
+ if (options.preferIssueLookup && (options.comments || options.issue || options.issueId)) {
193
193
  try {
194
- const beadsResult = loadStateFromBeads(options, projectRoot);
195
- if (beadsResult) {
196
- return beadsResult;
194
+ const issueResult = loadStateFromIssue(options, projectRoot);
195
+ if (issueResult) {
196
+ return issueResult;
197
197
  }
198
198
  } catch (error) {
199
- if (typeof options.onBeadsError === 'function') {
200
- options.onBeadsError(error);
199
+ if (typeof options.onIssueLookupError === 'function') {
200
+ options.onIssueLookupError(error);
201
201
  }
202
- // Fall through to the on-disk state file when Beads cannot be read.
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 Beads fallback
212
+ // File is malformed — fall through to the issue-lookup fallback
213
213
  }
214
214
  }
215
215
 
216
- const beadsResult = loadStateFromBeads(options, projectRoot);
217
- if (beadsResult) {
218
- return beadsResult;
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
- readBeadsIssue,
335
- readWorkflowStateFromBeads,
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",
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": "^11.0.0",
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.1.0",
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.2",
123
+ "fast-uri": "^3.1.4",
126
124
  "flatted": "3.4.2",
127
125
  "minimatch": "10.2.4",
128
- "brace-expansion": "5.0.6",
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.