forge-workflow 0.1.0-beta.3 → 0.1.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/AGENTS.md +1 -1
  2. package/bin/forge-cmd.js +1 -1
  3. package/bin/forge.js +5 -0
  4. package/docs/reference/COMMANDS.md +3 -1
  5. package/docs/reference/shepherd.md +72 -1
  6. package/lib/adapters/greptile-review-adapter.js +1 -1
  7. package/lib/adapters/pr-state-adapter.js +103 -8
  8. package/lib/agents-config.js +5 -0
  9. package/lib/commands/_issue.js +31 -46
  10. package/lib/commands/_manifest.js +1 -1
  11. package/lib/commands/_resolve-command-opts.js +36 -29
  12. package/lib/commands/claim.js +2 -4
  13. package/lib/commands/hooks.js +155 -8
  14. package/lib/commands/plan.js +23 -115
  15. package/lib/commands/prime.js +8 -1
  16. package/lib/commands/release.js +1 -2
  17. package/lib/commands/serve.js +5 -2
  18. package/lib/commands/setup.js +0 -1
  19. package/lib/commands/shepherd.js +36 -3
  20. package/lib/commands/skill.js +275 -0
  21. package/lib/commands/status.js +37 -32
  22. package/lib/commands/test.js +32 -0
  23. package/lib/commands/worktree.js +27 -1
  24. package/lib/core/runtime-graph.js +88 -14
  25. package/lib/forge-issues.js +13 -464
  26. package/lib/harness-capability-matrix.js +2 -2
  27. package/lib/hook-renderer.js +54 -12
  28. package/lib/issue-backend.js +42 -3
  29. package/lib/kernel/broker.js +28 -0
  30. package/lib/kernel/migrations.js +30 -2
  31. package/lib/kernel/schema.js +35 -0
  32. package/lib/kernel/sqlite-driver.js +150 -0
  33. package/lib/memory-recall.js +151 -0
  34. package/lib/orientation.js +303 -6
  35. package/lib/pr-bundle.js +6 -2
  36. package/lib/pr-monitor/reconcile-executor.js +659 -0
  37. package/lib/pr-monitor/reconcile-tick.js +138 -0
  38. package/lib/pr-monitor/reconcile.js +0 -0
  39. package/lib/pr-monitor/render-sticky.js +14 -0
  40. package/lib/pr-monitor/shepherd-lease.js +243 -0
  41. package/lib/pr-monitor/upsert-sticky.js +1 -1
  42. package/lib/pr-monitor/watch-lifecycle.js +1 -1
  43. package/lib/pr-pull.js +70 -15
  44. package/lib/project-memory.js +8 -0
  45. package/lib/rules-sync.js +4 -0
  46. package/lib/runtime-health.js +15 -46
  47. package/lib/skill-eval.js +750 -0
  48. package/lib/status/identity.js +46 -0
  49. package/lib/status/presenter.js +0 -35
  50. package/lib/status/snapshot.js +11 -16
  51. package/lib/upgrade-safety.js +8 -9
  52. package/lib/using-forge.js +315 -0
  53. package/lib/workflow/enforce-stage.js +5 -5
  54. package/lib/workflow/state-manager.js +23 -23
  55. package/package.json +1 -1
  56. package/rules/using-forge.md +24 -0
  57. package/scripts/forge-team/index.sh +0 -5
  58. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  59. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  60. package/scripts/test.js +8 -1
  61. package/skills/claim-safety/SKILL.md +4 -0
  62. package/skills/claim-safety/evals/scorecard.json +41 -0
  63. package/skills/coverage.json +83 -0
  64. package/skills/dev/SKILL.md +4 -0
  65. package/skills/dev/evals/scorecard.json +41 -0
  66. package/skills/gates/SKILL.md +80 -0
  67. package/skills/gates/evals/evals.json +38 -0
  68. package/skills/gates/evals/scorecard.json +41 -0
  69. package/skills/hermes-forge/SKILL.md +1 -0
  70. package/skills/hermes-forge/evals/scorecard.json +41 -0
  71. package/skills/issue-basics/SKILL.md +1 -0
  72. package/skills/issue-basics/evals/scorecard.json +41 -0
  73. package/skills/kernel/SKILL.md +38 -0
  74. package/skills/kernel/evals/scorecard.json +41 -0
  75. package/skills/memory/SKILL.md +16 -1
  76. package/skills/memory/evals/scorecard.json +41 -0
  77. package/skills/parallel-deep-research/SKILL.md +1 -0
  78. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  79. package/skills/plan/SKILL.md +6 -0
  80. package/skills/plan/evals/scorecard.json +41 -0
  81. package/skills/portability/SKILL.md +47 -0
  82. package/skills/portability/evals/evals.json +34 -0
  83. package/skills/portability/evals/scorecard.json +41 -0
  84. package/skills/research/SKILL.md +1 -0
  85. package/skills/research/evals/scorecard.json +41 -0
  86. package/skills/review/SKILL.md +6 -0
  87. package/skills/review/evals/scorecard.json +41 -0
  88. package/skills/rollback/SKILL.md +1 -0
  89. package/skills/rollback/evals/scorecard.json +41 -0
  90. package/skills/setup/SKILL.md +91 -0
  91. package/skills/setup/evals/evals.json +42 -0
  92. package/skills/setup/evals/scorecard.json +41 -0
  93. package/skills/shepherd/SKILL.md +76 -36
  94. package/skills/shepherd/evals/evals.json +21 -9
  95. package/skills/shepherd/evals/scorecard.json +41 -0
  96. package/skills/ship/SKILL.md +6 -0
  97. package/skills/ship/evals/scorecard.json +41 -0
  98. package/skills/smith/SKILL.md +8 -0
  99. package/skills/smith/evals/scorecard.json +41 -0
  100. package/skills/sonarcloud/SKILL.md +1 -0
  101. package/skills/sonarcloud/evals/scorecard.json +41 -0
  102. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  103. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  104. package/skills/status/SKILL.md +3 -0
  105. package/skills/status/evals/scorecard.json +41 -0
  106. package/skills/triage-ready/SKILL.md +2 -0
  107. package/skills/triage-ready/evals/scorecard.json +41 -0
  108. package/skills/using-forge/SKILL.md +104 -0
  109. package/skills/using-forge/evals/scorecard.json +41 -0
  110. package/skills/validate/SKILL.md +4 -0
  111. package/skills/validate/evals/scorecard.json +41 -0
  112. package/skills/verify/SKILL.md +4 -0
  113. package/skills/verify/evals/scorecard.json +41 -0
  114. package/skills/worktree/SKILL.md +87 -0
  115. package/skills/worktree/evals/evals.json +38 -0
  116. package/skills/worktree/evals/scorecard.json +41 -0
  117. package/lib/adapters/beads-issue-adapter.js +0 -127
  118. package/lib/beads-nudge.js +0 -91
  119. package/lib/commands/board.js +0 -64
  120. package/lib/status/beads-snapshot.js +0 -145
  121. package/scripts/forge-team/lib/dashboard.sh +0 -316
  122. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
@@ -37,13 +37,21 @@ const { gatherMonitorSnapshot } = require('../pr-monitor/gather');
37
37
  const { pollEvents } = require('../pr-monitor/monitor');
38
38
  const { watchLoop } = require('../pr-monitor/watch');
39
39
  const { startPrWatcherDetached } = require('../pr-monitor/watch-lifecycle');
40
+ const reconcileExecutor = require('../pr-monitor/reconcile-executor');
40
41
  const monitorJournal = require('../pr-monitor/journal');
41
42
  const { EVENT_TYPES: T } = require('../pr-monitor/events');
42
43
  const { autoShepherdRailEnabled } = require('./ship');
43
44
 
44
45
  const DEFAULT_RERUN_BUDGET = 3;
45
46
 
46
- const defaultGhRunner = (cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', timeout: 30000 });
47
+ // windowsHide: true on EVERY spawn here is load-bearing, not cosmetic. The
48
+ // shepherd watcher runs detached in the background and re-polls every ~60s; on
49
+ // Windows a child process spawned WITHOUT windowsHide flashes a visible console
50
+ // window each time (Node's default is windowsHide:false). Background work's
51
+ // preferred home is the harness's managed shell (hidden + reaped); a
52
+ // Forge-spawned detached watcher is the no-session fallback and must be
53
+ // COMPLETELY silent — no console window, ever. See kernel issue 931e7924.
54
+ const defaultGhRunner = (cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', timeout: 30000, windowsHide: true });
47
55
 
48
56
  /**
49
57
  * Resolve owner/repo and base branch for the shepherd pass.
@@ -282,7 +290,7 @@ function wireSignals() {
282
290
  function defaultListOpenPrs(exec = execFileSync) {
283
291
  try {
284
292
  const out = exec('gh', ['pr', 'list', '--state', 'open', '--json', 'number', '-q', '.[].number'], {
285
- encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'],
293
+ encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
286
294
  });
287
295
  return String(out)
288
296
  .split(/\r?\n/)
@@ -380,6 +388,28 @@ async function handleWatch(args, projectRoot, deps = {}) {
380
388
  };
381
389
  }
382
390
 
391
+ /**
392
+ * `forge shepherd daemon` — the SINGLETON reconcile daemon (W-S4b). It acquires
393
+ * the machine-wide shepherd lease for this repo (exiting immediately if a live
394
+ * daemon already owns it), heartbeats, and converges the PR world every ~60s:
395
+ * self-registering hand-opened PRs, restarting killed watchers, reaping verified
396
+ * orphans, retiring merged/closed PRs. It self-retires (releases the lease, kills
397
+ * its verified children, exits) once no PRs remain open. Launched detached by the
398
+ * per-command `fireAndForget` trigger — not meant to be run by hand.
399
+ *
400
+ * @param {string} projectRoot
401
+ * @param {object} [deps] - injected for tests (acquire/heartbeat/gather/etc.).
402
+ * @returns {Promise<object>} result envelope.
403
+ */
404
+ async function handleDaemon(projectRoot, deps = {}) {
405
+ const res = await reconcileExecutor.runDaemon(projectRoot, { ...deps });
406
+ if (!res.ok) {
407
+ // A live foreign daemon owns the lease — this invocation is a clean no-op.
408
+ return { success: true, started: false, reason: res.reason || 'foreign-lease' };
409
+ }
410
+ return { success: true, started: true };
411
+ }
412
+
383
413
  /**
384
414
  * Command handler.
385
415
  *
@@ -401,6 +431,9 @@ async function handler(args, _flags, projectRoot, deps = {}) {
401
431
  if (positional[0] === 'watch') {
402
432
  return handleWatch(args, projectRoot, deps);
403
433
  }
434
+ if (positional[0] === 'daemon') {
435
+ return handleDaemon(projectRoot, deps);
436
+ }
404
437
 
405
438
  const pr = positional[0];
406
439
 
@@ -408,7 +441,7 @@ async function handler(args, _flags, projectRoot, deps = {}) {
408
441
  return { success: false, error: 'Usage: forge shepherd <pr> [--auto-rebase] [--bundle --json] [--pull --json]' };
409
442
  }
410
443
 
411
- const gh = deps.gh || ((cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', timeout: 30000 }));
444
+ const gh = deps.gh || ((cmd, a) => execFileSync(cmd, a, { encoding: 'utf8', timeout: 30000, windowsHide: true }));
412
445
  const git = deps.git || gh;
413
446
  const buildContext = deps.buildContext || defaultBuildContext;
414
447
  const runPass = deps.runPass || runShepherdPass;
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Forge Skill Command -- the unified "forge skill <verb>" noun.
5
+ *
6
+ * "forge skill for <situation>" is a DETERMINISTIC intent-to-skill router: it reads the canonical
7
+ * skill catalog (skills/*\/SKILL.md frontmatter) and prints the best-fit Forge skill(s) plus WHY,
8
+ * as the reasoning fallback for harnesses without a SessionStart hook that can auto-inject the
9
+ * using-forge dispatch skill. It NEVER opens the kernel and NEVER throws.
10
+ *
11
+ * The noun is structured so later waves can add sibling verbs (forge skill eval, forge skill
12
+ * scores) without a new top-level command -- the owner's command-surface rule: unify related
13
+ * commands under one self-explanatory noun instead of scattering verbs.
14
+ *
15
+ * @module commands/skill
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const { routeSkill, loadSkillCatalog } = require('../using-forge');
21
+ const skillEval = require('../skill-eval');
22
+
23
+ const USAGE = 'Usage: forge skill for "<situation>" [--json]\n' +
24
+ ' forge skill eval [name] --static [--json]\n' +
25
+ ' forge skill scores [--json]\n' +
26
+ ' forge skill coverage [--json]';
27
+
28
+ /** Extract the situation text (all non-flag args after the verb) and json flag. */
29
+ function parseForArgs(rest, flags) {
30
+ const positional = rest.filter(a => typeof a === 'string' && !a.startsWith('--'));
31
+ const json = flags.json === true || flags['--json'] === true || rest.includes('--json');
32
+ return { situation: positional.join(' ').trim(), json };
33
+ }
34
+
35
+ /** Render the human-readable routing answer. */
36
+ function formatRouting(result) {
37
+ const lines = ['Best skill for: "' + result.situation + '"', ''];
38
+ if (result.unknown) {
39
+ lines.push(
40
+ 'No confident match. This may not need a Forge skill -- or describe it more concretely.',
41
+ 'Fallbacks: `forge ready` for what to work on, or the kernel skill to see the whole surface.',
42
+ );
43
+ return lines.join('\n');
44
+ }
45
+ const [top, ...rest] = result.matches;
46
+ lines.push(
47
+ '-> ' + top.name + ' (' + top.why + ')',
48
+ ' Announce: "Using ' + top.name + ' to ..." then follow the skill.',
49
+ );
50
+ if (rest.length > 0) {
51
+ lines.push('', 'Also consider:');
52
+ for (const m of rest) lines.push(' - ' + m.name + ' (' + m.why + ')');
53
+ }
54
+ return lines.join('\n');
55
+ }
56
+
57
+ /** "forge skill for <situation>" -- deterministic router. */
58
+ function handleFor(rest, flags) {
59
+ const { situation, json } = parseForArgs(rest, flags);
60
+ if (!situation) {
61
+ return { success: false, error: 'Missing situation.\n' + USAGE };
62
+ }
63
+ // Read the canonical catalog from the Forge PACKAGE (not projectRoot): a set-up consumer
64
+ // project has no root skills/, so the routable skills live in the package assets.
65
+ const catalog = loadSkillCatalog();
66
+ const result = routeSkill(situation, { catalog });
67
+ if (json) {
68
+ return { success: true, result, output: JSON.stringify(result, null, 2) + '\n' };
69
+ }
70
+ return { success: true, result, output: formatRouting(result) };
71
+ }
72
+
73
+ /** Write a scorecard to skills/<name>/evals/scorecard.json (stable 2-space JSON + trailing NL). */
74
+ function writeScorecard(skillsDir, name, card) {
75
+ const dir = path.join(skillsDir, name, 'evals');
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ fs.writeFileSync(path.join(dir, 'scorecard.json'), JSON.stringify(card, null, 2) + '\n');
78
+ }
79
+
80
+ /**
81
+ * "forge skill eval [name] --static [--json]" -- compute + persist the DETERMINISTIC scorecard(s).
82
+ * All skills when no name. --static is the only tier today (behavioral is W5); it is accepted (and
83
+ * implied) so the flag reads honestly and future tiers can branch here.
84
+ */
85
+ function handleEval(rest, flags, projectRoot) {
86
+ const json = flags.json === true || flags['--json'] === true || rest.includes('--json');
87
+ const positional = rest.filter(a => typeof a === 'string' && !a.startsWith('--'));
88
+ const name = positional[0];
89
+ const ctx = skillEval.resolveSkillsContext(projectRoot);
90
+ if (!ctx) {
91
+ return { success: false, error: 'No canonical skills/ directory found (looked in the project and the packaged Forge root).' };
92
+ }
93
+ const { skillsDir, catalog } = ctx;
94
+ const targets = name
95
+ ? [name]
96
+ : fs.readdirSync(skillsDir, { withFileTypes: true })
97
+ .filter(e => e.isDirectory() && fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md')))
98
+ .map(e => e.name)
99
+ .sort();
100
+
101
+ const written = [];
102
+ const cards = {};
103
+ for (const target of targets) {
104
+ const card = skillEval.buildScorecard({ skillsDir, name: target, catalog });
105
+ if (!card) {
106
+ if (name) return { success: false, error: `Skill '${name}' not found under ${skillsDir}.` };
107
+ continue;
108
+ }
109
+ writeScorecard(skillsDir, target, card);
110
+ written.push(target);
111
+ cards[target] = card;
112
+ }
113
+
114
+ if (json) {
115
+ return { success: true, cards, output: JSON.stringify(name ? cards[name] : cards, null, 2) + '\n' };
116
+ }
117
+ const lines = ['Static scorecards written (deterministic tier):'];
118
+ for (const t of written) lines.push(' ' + t + ' composite=' + cards[t].composite);
119
+ lines.push('', 'Behavioral tier (recall/precision/chains/outcome/variance) is W5.');
120
+ return { success: true, cards, output: lines.join('\n') };
121
+ }
122
+
123
+ /** Render the worst-first league table from a scorecards map. */
124
+ function formatScores(scorecards, gate) {
125
+ const rows = Object.values(scorecards)
126
+ .map(c => ({
127
+ skill: c.skill,
128
+ composite: c.composite,
129
+ dq: c.static.description_quality.score,
130
+ tok: c.static.token_cost.score,
131
+ caps: c.static.caps.score,
132
+ fixtures: c.fixtures,
133
+ }))
134
+ .sort((a, b) => a.composite - b.composite || a.skill.localeCompare(b.skill));
135
+
136
+ const lines = ['Skill scores (static tier — worst first). Composite = 0.5*desc-quality + 0.3*token-cost + 0.2*caps.', ''];
137
+ lines.push(' COMPOSITE DESC-Q TOKEN CAPS FIXTURES SKILL');
138
+ for (const r of rows) {
139
+ lines.push(
140
+ ' ' + String(r.composite).padStart(9) +
141
+ ' ' + String(r.dq).padStart(6) +
142
+ ' ' + String(r.tok).padStart(5) +
143
+ ' ' + String(r.caps).padStart(4) +
144
+ ' ' + r.fixtures.padEnd(11) +
145
+ ' ' + r.skill,
146
+ );
147
+ }
148
+ if (gate.warnings.length > 0) {
149
+ lines.push('', 'Router-reachability warnings (paraphrase gap — W5 judge is the fix, not blocking):');
150
+ for (const w of gate.warnings) lines.push(' - ' + w.skill + ': ' + w.detail);
151
+ }
152
+ lines.push('', gate.passed ? 'CI gate: PASS' : 'CI gate: FAIL (' + gate.failures.length + ')');
153
+ for (const f of gate.failures) lines.push(' x ' + f.skill + ': ' + f.kind + ' — ' + f.detail);
154
+ return lines.join('\n');
155
+ }
156
+
157
+ /** Render the command-coverage section (a summary line + any failures/warnings). */
158
+ function formatCoverage(coverage) {
159
+ const lines = [
160
+ 'Skill coverage (every registered command must own a skill or be exempt):',
161
+ ' commands=' + coverage.total + ' mapped=' + coverage.mapped + ' exempt=' + coverage.exempt +
162
+ ' gaps=' + coverage.failures.length,
163
+ ];
164
+ if (coverage.warnings.length > 0) {
165
+ lines.push('', 'Stale coverage.json entries (non-blocking — remove them):');
166
+ for (const w of coverage.warnings) lines.push(' - ' + w.command + ': ' + w.kind);
167
+ }
168
+ lines.push('', coverage.passed ? 'Coverage gate: PASS' : 'Coverage gate: FAIL (' + coverage.failures.length + ')');
169
+ for (const f of coverage.failures) lines.push(' x ' + f.command + ': ' + f.kind + ' — ' + f.detail);
170
+ return lines.join('\n');
171
+ }
172
+
173
+ /** Build the combined gate error string (static scorecard gate + command-coverage gate). */
174
+ function buildScoresError(gate, coverage) {
175
+ const parts = [];
176
+ if (!gate.passed) {
177
+ parts.push('static gate (' + gate.failures.length + '): ' + gate.failures.map(f => f.skill + ' — ' + f.kind).join('; '));
178
+ }
179
+ if (coverage && !coverage.passed) {
180
+ parts.push('coverage gate (' + coverage.failures.length + '): ' + coverage.failures.map(f => f.command + ' — ' + f.kind).join('; '));
181
+ }
182
+ return 'Skill CI gate FAILED — ' + parts.join(' | ');
183
+ }
184
+
185
+ /** "forge skill scores [--json]" -- the league table. Gate state is drift-aware so it agrees with CI. */
186
+ function handleScores(rest, flags, projectRoot) {
187
+ const json = flags.json === true || flags['--json'] === true || rest.includes('--json');
188
+ const ctx = skillEval.resolveSkillsContext(projectRoot);
189
+ if (!ctx) {
190
+ return { success: false, error: 'No canonical skills/ directory found (looked in the project and the packaged Forge root).' };
191
+ }
192
+ const { skillsDir, catalog, source } = ctx;
193
+ const scorecards = skillEval.buildAllScorecards(skillsDir, catalog);
194
+ // Compare the recomputed cards against the COMMITTED artifacts (canonical skills/ AND the
195
+ // .agents/skills mirror). A stale/missing committed scorecard is drift, so the gate reported here
196
+ // is FAIL — matching the CI drift test — instead of a hollow PASS over freshly-rebuilt cards.
197
+ // Gate the mirror by CONTEXT, not existence: a SOURCE checkout (source==='project') is EXPECTED
198
+ // to ship the committed .agents/skills mirror, so pass mirrorDir UNCONDITIONALLY — a deleted or
199
+ // never-checked-out mirror then REPORTS drift instead of silently passing. From the PACKAGED root
200
+ // (source==='package', a consumer install) no mirror ships, so omit the check to avoid false drift.
201
+ const mirrorDir = source === 'project' ? path.join(path.dirname(skillsDir), '.agents', 'skills') : null;
202
+ const drift = skillEval.detectScorecardDrift({ skillsDir, freshCards: scorecards, mirrorDir });
203
+ const gate = skillEval.evaluateGate(scorecards, { drift });
204
+ // Command-coverage gate (§3.3): a registered command with no owning skill (and not exempt) must
205
+ // FAIL scores too, so CI running `forge skill scores` catches a new unrouted command — not only
206
+ // the dedicated `forge skill coverage`.
207
+ const coverage = skillEval.buildCoverageReport(projectRoot);
208
+ // The gate verdict MUST drive the command's exit status: a failing gate (scorecard drift, caps
209
+ // violation, invalid fixtures, OR a coverage gap) returns success:false so the registry runner
210
+ // exits non-zero and a CI job running `forge skill scores` actually FAILS — instead of exiting 0
211
+ // while the output says "gate: FAIL". The full league table + gate detail still ride along.
212
+ const coveragePassed = !coverage || coverage.passed === true;
213
+ const passed = gate.passed === true && coveragePassed;
214
+ const gateError = passed ? undefined : buildScoresError(gate, coverage);
215
+ if (json) {
216
+ return { success: passed, error: gateError, scorecards, gate, coverage, drift, output: JSON.stringify({ scorecards, gate, coverage, drift }, null, 2) + '\n' };
217
+ }
218
+ const text = coverage ? formatScores(scorecards, gate) + '\n\n' + formatCoverage(coverage) : formatScores(scorecards, gate);
219
+ return { success: passed, error: gateError, scorecards, gate, coverage, drift, output: text };
220
+ }
221
+
222
+ /** "forge skill coverage [--json]" -- the dedicated command→skill coverage gate. */
223
+ function handleCoverage(rest, flags, projectRoot) {
224
+ const json = flags.json === true || flags['--json'] === true || rest.includes('--json');
225
+ const report = skillEval.buildCoverageReport(projectRoot);
226
+ if (!report) {
227
+ return { success: false, error: 'No canonical skills/ directory found (looked in the project and the packaged Forge root).' };
228
+ }
229
+ const passed = report.passed === true;
230
+ const gateError = passed
231
+ ? undefined
232
+ : 'Skill coverage gate FAILED (' + report.failures.length + '): ' +
233
+ report.failures.map(f => f.command + ' — ' + f.kind).join('; ');
234
+ if (json) {
235
+ return { success: passed, error: gateError, report, output: JSON.stringify(report, null, 2) + '\n' };
236
+ }
237
+ return { success: passed, error: gateError, report, output: formatCoverage(report) };
238
+ }
239
+
240
+ module.exports = {
241
+ name: 'skill',
242
+ description: 'Route, evaluate, score, and coverage-check Forge skills (forge skill for | eval | scores | coverage)',
243
+ usage: USAGE,
244
+ flags: {
245
+ '--json': 'Emit the machine-readable result',
246
+ '--static': 'Score only the deterministic static tier (the only tier today; behavioral is W5)',
247
+ },
248
+ // flags is the LAST declared param so its `= {}` default is trailing (SonarCloud S1788). The
249
+ // registry still passes (args, flags, projectRoot, opts) — the router reads the canonical catalog
250
+ // from the package root; eval/scores read the canonical skills/ source dir.
251
+ handler: (args, flags = {}, projectRoot) => {
252
+ const verb = args[0];
253
+ if (verb === 'for') {
254
+ return handleFor(args.slice(1), flags);
255
+ }
256
+ if (verb === 'eval') {
257
+ return handleEval(args.slice(1), flags, projectRoot);
258
+ }
259
+ if (verb === 'scores') {
260
+ return handleScores(args.slice(1), flags, projectRoot);
261
+ }
262
+ if (verb === 'coverage') {
263
+ return handleCoverage(args.slice(1), flags, projectRoot);
264
+ }
265
+ if (!verb) {
266
+ return { success: false, error: 'Missing verb.\n' + USAGE };
267
+ }
268
+ return {
269
+ success: false,
270
+ error: "Unknown verb '" + verb + "'. Supported: for, eval, scores, coverage.\n" + USAGE,
271
+ };
272
+ },
273
+ // Exposed for unit tests; not part of the CLI surface.
274
+ _internal: { parseForArgs, formatRouting, handleFor, handleEval, handleScores, handleCoverage, formatScores, formatCoverage },
275
+ };
@@ -18,7 +18,7 @@ const {
18
18
  const {
19
19
  loadState,
20
20
  extractWorkflowStateFromComments,
21
- readWorkflowStateFromBeads,
21
+ readWorkflowStateFromIssue,
22
22
  } = require('../workflow/state-manager');
23
23
  const { secureExecFileSync } = require('../shell-utils.js');
24
24
 
@@ -140,12 +140,12 @@ function analyzeChecks(context) {
140
140
  }
141
141
 
142
142
  /**
143
- * Analyze Beads issue state
144
- * @param {object} beadsIssue - Beads issue object
145
- * @returns {object} Beads analysis
143
+ * Analyze kernel issue state
144
+ * @param {object} issueRecord - kernel issue record
145
+ * @returns {object} issue analysis
146
146
  */
147
- function analyzeBeads(beadsIssue) {
148
- if (!beadsIssue) {
147
+ function analyzeIssue(issueRecord) {
148
+ if (!issueRecord) {
149
149
  return {
150
150
  hasActiveIssue: false,
151
151
  issueStatus: null,
@@ -155,10 +155,10 @@ function analyzeBeads(beadsIssue) {
155
155
 
156
156
  return {
157
157
  hasActiveIssue: true,
158
- issueStatus: beadsIssue.status,
159
- issueType: beadsIssue.type,
160
- isInProgress: beadsIssue.status === 'in_progress',
161
- isClosed: beadsIssue.status === 'closed',
158
+ issueStatus: issueRecord.status,
159
+ issueType: issueRecord.type,
160
+ isInProgress: issueRecord.status === 'in_progress',
161
+ isClosed: issueRecord.status === 'closed',
162
162
  };
163
163
  }
164
164
 
@@ -178,7 +178,7 @@ function calculateConfidence(factors, stage) {
178
178
  files: 30,
179
179
  pr: 25,
180
180
  checks: 15,
181
- beads: 10,
181
+ issue: 10,
182
182
  };
183
183
 
184
184
  // Branch state
@@ -235,16 +235,16 @@ function calculateConfidence(factors, stage) {
235
235
  score += weights.checks / 2; // Partial match
236
236
  }
237
237
 
238
- // Beads issue
239
- maxScore += weights.beads;
240
- if (factors.beads.isInProgress && stage >= 2 && stage <= 7) {
241
- score += weights.beads; // Active work
242
- } else if (factors.beads.isClosed && stage >= 8) {
243
- score += weights.beads; // Closed for late stages
244
- } else if (!factors.beads.hasActiveIssue && stage === 1) {
245
- score += weights.beads; // No issue for fresh start
238
+ // Issue state
239
+ maxScore += weights.issue;
240
+ if (factors.issue.isInProgress && stage >= 2 && stage <= 7) {
241
+ score += weights.issue; // Active work
242
+ } else if (factors.issue.isClosed && stage >= 8) {
243
+ score += weights.issue; // Closed for late stages
244
+ } else if (!factors.issue.hasActiveIssue && stage === 1) {
245
+ score += weights.issue; // No issue for fresh start
246
246
  } else {
247
- score += weights.beads / 2; // Partial match
247
+ score += weights.issue / 2; // Partial match
248
248
  }
249
249
 
250
250
  const percentage = Math.round((score / maxScore) * 100);
@@ -275,21 +275,21 @@ function detectStage(context) {
275
275
  const fileFactors = analyzeFiles(context);
276
276
  const prFactors = analyzePR(context.pr);
277
277
  const checkFactors = analyzeChecks(context);
278
- const beadsFactors = analyzeBeads(context.beadsIssue);
278
+ const issueFactors = analyzeIssue(context.issueRecord);
279
279
 
280
280
  const factors = {
281
281
  branch: branchFactors,
282
282
  files: fileFactors,
283
283
  pr: prFactors,
284
284
  checks: checkFactors,
285
- beads: beadsFactors,
285
+ issue: issueFactors,
286
286
  };
287
287
 
288
288
  // Stage detection logic
289
289
  let stage;
290
290
 
291
291
  // Stage 9: PR merged, verify docs
292
- if (prFactors.prMerged && beadsFactors.isClosed) {
292
+ if (prFactors.prMerged && issueFactors.isClosed) {
293
293
  stage = 9;
294
294
  }
295
295
  // Stage 8: PR approved, ready to merge
@@ -317,7 +317,7 @@ function detectStage(context) {
317
317
  stage = 3;
318
318
  }
319
319
  // Stage 2: Research in progress
320
- else if (beadsFactors.issueType === 'research' && beadsFactors.isInProgress) {
320
+ else if (issueFactors.issueType === 'research' && issueFactors.isInProgress) {
321
321
  stage = 2;
322
322
  }
323
323
  // Stage 1: Fresh project (default)
@@ -612,7 +612,7 @@ function resolveWorkflowState(inputs) {
612
612
  issueId: inputs.issueId,
613
613
  issue: inputs.issue,
614
614
  comments: inputs.bdComments,
615
- preferBeads: inputs.preferBeads,
615
+ preferIssueLookup: inputs.preferIssueLookup,
616
616
  });
617
617
 
618
618
  return { workflowState: state, fallbackReason: null };
@@ -649,7 +649,7 @@ function buildAuthoritativeStatus(workflowState) {
649
649
  branch: {},
650
650
  pr: {},
651
651
  checks: { allChecksPass: workflowState.completedStages.includes('validate') },
652
- beads: { hasActiveIssue: true },
652
+ issue: { hasActiveIssue: true },
653
653
  },
654
654
  };
655
655
  }
@@ -757,9 +757,14 @@ module.exports = {
757
757
  '--json': 'Emit the machine-readable status envelope',
758
758
  },
759
759
  handler: async (args, flags, projectRoot) => {
760
+ // Injectable snapshot read, same convention as setup.js's `options.commandRunner`
761
+ // (lib/commands/setup.js:223): a real default with a test-supplied override. Tests
762
+ // need a snapshot with controlled ids and orderings, which a seeded kernel cannot
763
+ // give them — the kernel stamps its own updated_at.
764
+ const readSnapshot = flags?.readStatusSnapshot || readStatusSnapshot;
760
765
  const inputs = parseStatusInputs(args, flags);
761
766
  const isZeroArgStatus = !inputs.issueId && !inputs.workflowState && !inputs.bdComments;
762
- inputs.preferBeads = !isZeroArgStatus && Boolean(inputs.issueId || inputs.bdComments);
767
+ inputs.preferIssueLookup = !isZeroArgStatus && Boolean(inputs.issueId || inputs.bdComments);
763
768
  if (!inputs.projectRoot && projectRoot) {
764
769
  inputs.projectRoot = projectRoot;
765
770
  }
@@ -769,11 +774,11 @@ module.exports = {
769
774
 
770
775
  if (isZeroArgStatus) {
771
776
  context = detectRepoContext(effectiveProjectRoot);
772
- // Read from the active issue backend (Kernel by default; Beads only when
773
- // explicitly selected). Previously hard-wired to the retired Beads
777
+ // Read from the Kernel, the only issue backend. Previously hard-wired to
778
+ // the retired Beads
774
779
  // issues.jsonl, which is empty on a kernel-default repo — so the flagship
775
780
  // view showed "Ready: none" despite a populated kernel (bug 40f35797).
776
- snapshot = await readStatusSnapshot(effectiveProjectRoot, {
781
+ snapshot = await readSnapshot(effectiveProjectRoot, {
777
782
  now: inputs.now,
778
783
  staleAfterDays: inputs.staleAfterDays,
779
784
  issueBackend: inputs.issueBackend,
@@ -834,7 +839,7 @@ module.exports = {
834
839
  extractWorkflowStateFromComments,
835
840
  resolveWorkflowState,
836
841
  parseStatusInputs,
837
- readWorkflowStateFromBeads,
842
+ readWorkflowStateFromIssue,
838
843
  detectStage,
839
844
  detectRepoContext,
840
845
  discoverCurrentIssue,
@@ -842,7 +847,7 @@ module.exports = {
842
847
  analyzeFiles,
843
848
  analyzePR,
844
849
  analyzeChecks,
845
- analyzeBeads,
850
+ analyzeIssue,
846
851
  calculateConfidence,
847
852
  formatStatus,
848
853
  extractDesignSlugs,
@@ -34,12 +34,40 @@ const DIRECT_TEST_CANDIDATES = Object.freeze({
34
34
  'test/setup-github-sync.test.js',
35
35
  'test/setup-runtime-flags.test.js',
36
36
  ],
37
+ // The second CLI surface (command descriptions + shepherd wiring). Without an
38
+ // entry here it resolves to zero tests and pushes fall back to the full suite.
39
+ 'bin/forge-cmd.js': [
40
+ 'test/cli/forge-cmd.test.js',
41
+ 'test/forge-cmd-shepherd.test.js',
42
+ ],
43
+ 'bin/forge-preflight.js': ['test/bin/forge-preflight.test.js'],
37
44
  'lib/lefthook-check.js': ['test/lefthook-check.test.js', 'test/runtime-health.test.js'],
38
45
  'lib/runtime-health.js': ['test/runtime-health.test.js'],
46
+ // skill-eval hosts the accuracy-lint detectors (auditCommandDocumentation /
47
+ // auditRouterPrecision), so edits there must also run their detector suite.
48
+ 'lib/skill-eval.js': ['test/skill-eval.test.js', 'test/skill-accuracy.test.js'],
39
49
  'lib/upgrade-safety.js': ['test/commands/upgrade.test.js'],
40
50
  'scripts/test.js': ['test/scripts/test-runner.test.js'],
41
51
  });
42
52
 
53
+ // Skill sources (canonical `skills/**` and the committed `.agents/skills/**` mirror)
54
+ // map to the fast skill suite. This is what keeps a skills-only PR on the targeted
55
+ // lane instead of the full ~1500-test suite. `skills-sync-drift` guards the mirror.
56
+ const SKILL_TEST_TARGETS = Object.freeze([
57
+ 'test/skills-structure.test.js',
58
+ 'test/skill-coverage.test.js',
59
+ 'test/skill-eval.test.js',
60
+ 'test/skill-accuracy.test.js',
61
+ 'test/skill-dispatch-parity.test.js',
62
+ 'test/using-forge.test.js',
63
+ 'test/structural/skills-sync-drift.test.js',
64
+ 'test/skills/chain-integrity.test.js',
65
+ 'test/skills/skills-sync.test.js',
66
+ 'test/skills/stage-skills.test.js',
67
+ 'test/skills/using-forge-skill.test.js',
68
+ 'test/skills/context-cost.test.js',
69
+ ]);
70
+
43
71
  /**
44
72
  * Detect the package manager by checking which lockfile exists.
45
73
  *
@@ -183,6 +211,10 @@ function getTestCandidatesForChangedFile(file) {
183
211
  return [file];
184
212
  }
185
213
 
214
+ if (file.startsWith('skills/') || file.startsWith('.agents/skills/')) {
215
+ return [...SKILL_TEST_TARGETS];
216
+ }
217
+
186
218
  if (file === 'README.md'
187
219
  || file === 'QUICKSTART.md'
188
220
  || file === 'CHANGELOG.md'
@@ -406,12 +406,38 @@ async function handleList(projectRoot, opts) {
406
406
  const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
407
407
  driver = (await buildMigratedKernelIssueDeps({ projectRoot })).kernelDriver;
408
408
  }
409
- return { success: true, worktrees: driver.listWorktrees({}) };
409
+ // `forge worktree remove` / `forge clean` delete the checkout but do not yet
410
+ // reconcile the kernel_worktrees registry (issue ceeef92f), so the raw registry
411
+ // can still list already-removed paths. Filter to worktrees that still exist on
412
+ // disk so `forge worktree list` never surfaces stale entries agents could act on.
413
+ const existsSync = (opts._fs && opts._fs.existsSync) || require('node:fs').existsSync;
414
+ const worktrees = driver.listWorktrees({}).filter((w) => w && w.path && existsSync(w.path));
415
+ return { success: true, worktrees, output: renderWorktreeList(worktrees) };
410
416
  } catch (error) {
411
417
  return { success: false, error: error.message, worktrees: [] };
412
418
  }
413
419
  }
414
420
 
421
+ /**
422
+ * Render the worktree registry as human-readable text for the CLI. The dispatcher
423
+ * prints only `result.output`, so without this `forge worktree list` is silent.
424
+ * @param {Array<object>} worktrees - Rows from driver.listWorktrees()
425
+ * @returns {string} One line per worktree (path, branch, linked issue, state).
426
+ */
427
+ function renderWorktreeList(worktrees) {
428
+ if (!Array.isArray(worktrees) || worktrees.length === 0) {
429
+ return 'No worktrees registered.';
430
+ }
431
+ const lines = worktrees.map((w) => {
432
+ const parts = [w.path || '(unknown path)'];
433
+ if (w.branch) parts.push(`branch: ${w.branch}`);
434
+ if (w.issue_id) parts.push(`issue: ${w.issue_id}`);
435
+ if (w.state) parts.push(w.state);
436
+ return ` ${parts.join(' | ')}`;
437
+ });
438
+ return `Registered worktrees (${worktrees.length}):\n${lines.join('\n')}`;
439
+ }
440
+
415
441
  /**
416
442
  * Handle the "remove" subcommand.
417
443
  * @param {string} slug - Worktree slug