forgedock 1.1.1 → 1.1.3

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 (174) hide show
  1. package/README.md +53 -8
  2. package/bin/batch-runner.mjs +339 -0
  3. package/bin/demo.mjs +92 -7
  4. package/bin/engine/phases.mjs +24 -2
  5. package/bin/engine/state.mjs +8 -3
  6. package/bin/engine-cli.mjs +177 -5
  7. package/bin/forgedock.mjs +409 -26
  8. package/bin/hooks/interactive-engine.mjs +509 -0
  9. package/bin/hooks/pre-tool-use.mjs +498 -0
  10. package/bin/hooks/subagent-stop-enforce.mjs +197 -0
  11. package/bin/journey.mjs +72 -14
  12. package/bin/registry.mjs +139 -0
  13. package/bin/report.mjs +447 -0
  14. package/bin/runner.mjs +70 -20
  15. package/bin/settings-hook.mjs +235 -1
  16. package/commands/adopt.md +4 -1
  17. package/commands/analytics.md +4 -1
  18. package/commands/audit-agents.md +110 -27
  19. package/commands/audit.md +20 -1
  20. package/commands/autopilot.md +518 -361
  21. package/commands/changelog.md +2 -1
  22. package/commands/ci-audit.md +2 -1
  23. package/commands/cleanup.md +161 -1
  24. package/commands/compat-audit.md +2 -1
  25. package/commands/deploy-info.md +58 -1
  26. package/commands/deploy-pr.md +485 -0
  27. package/commands/design-bench.md +5 -1
  28. package/commands/design-render-critique-loop.md +1 -1
  29. package/commands/diagnose.md +2 -1
  30. package/commands/explain.md +2 -1
  31. package/commands/fix-ci.md +577 -0
  32. package/commands/forge-stats.md +121 -2
  33. package/commands/forgedock-init.md +1 -1
  34. package/commands/geo-audit.md +3 -0
  35. package/commands/incident-response.md +2 -1
  36. package/commands/issue.md +23 -2
  37. package/commands/milestone.md +77 -12
  38. package/commands/optimize.md +2 -1
  39. package/commands/orchestrate/config.md +67 -0
  40. package/commands/orchestrate/phase-1-resolve.md +159 -0
  41. package/commands/orchestrate/phase-2-triage.md +126 -0
  42. package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
  43. package/commands/orchestrate/phase-3-dependency.md +591 -0
  44. package/commands/orchestrate/phase-4-execution.md +962 -0
  45. package/commands/orchestrate/phase-5-cleanup.md +47 -0
  46. package/commands/orchestrate/phase-6-report.md +198 -0
  47. package/commands/orchestrate/safety.md +62 -0
  48. package/commands/orchestrate.md +25 -2045
  49. package/commands/pipeline-health/config.md +34 -0
  50. package/commands/pipeline-health/phase-1-context.md +180 -0
  51. package/commands/pipeline-health/phase-2-metrics.md +1729 -0
  52. package/commands/pipeline-health/phase-3-analyze.md +204 -0
  53. package/commands/pipeline-health/phase-4-proposals.md +117 -0
  54. package/commands/pipeline-health/phase-5-report.md +284 -0
  55. package/commands/pipeline-health/phase-6-summary.md +52 -0
  56. package/commands/pipeline-health.md +25 -2401
  57. package/commands/pipeline-resume.md +24 -1
  58. package/commands/pipeline-status.md +65 -0
  59. package/commands/qa-sweep.md +4 -1
  60. package/commands/quality-gate.md +248 -10
  61. package/commands/recover-orphans.md +576 -0
  62. package/commands/replay.md +2 -1
  63. package/commands/review-pr-agents/api.md +135 -0
  64. package/commands/review-pr-agents/auth.md +137 -0
  65. package/commands/review-pr-agents/billing.md +101 -0
  66. package/commands/review-pr-agents/concurrency.md +145 -0
  67. package/commands/review-pr-agents/database.md +103 -0
  68. package/commands/review-pr-agents/frontend.md +89 -0
  69. package/commands/review-pr-agents/infra.md +385 -0
  70. package/commands/review-pr-agents/protocols.md +185 -0
  71. package/commands/review-pr-agents/scraper.md +185 -0
  72. package/commands/review-pr-agents/security.md +406 -0
  73. package/commands/review-pr-agents.md +45 -1722
  74. package/commands/review-pr-staging.md +196 -40
  75. package/commands/review-pr.md +591 -52
  76. package/commands/rollback.md +2 -1
  77. package/commands/scope.md +2 -1
  78. package/commands/security-audit.md +2 -1
  79. package/commands/signal-planner.md +623 -0
  80. package/commands/sync-ecosystem.md +1 -0
  81. package/commands/test-gate.md +17 -1
  82. package/commands/validate.md +2 -1
  83. package/commands/work-on/build/architect.md +28 -0
  84. package/commands/work-on/build/context.md +2 -0
  85. package/commands/work-on/build/implement.md +3 -1
  86. package/commands/work-on/build/validate.md +26 -7
  87. package/commands/work-on/build.md +163 -24
  88. package/commands/work-on/close.md +130 -3
  89. package/commands/work-on/decompose.md +19 -1
  90. package/commands/work-on/investigate.md +117 -2
  91. package/commands/work-on/review.md +24 -4
  92. package/commands/work-on-monolithic.md +7 -2
  93. package/commands/work-on.md +202 -4
  94. package/docs/BRAND.md +14 -7
  95. package/docs/CI.md +16 -2
  96. package/docs/CODEX.md +36 -1
  97. package/docs/CONFIG.md +5 -8
  98. package/docs/FORGE-PROTOCOL.md +15 -1064
  99. package/docs/anthropic-devrel-outreach.md +2 -2
  100. package/docs/articles/command-decomposition-token-savings.md +214 -0
  101. package/docs/articles/model-release-playbook.md +131 -0
  102. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
  103. package/docs/claude-breakpoints.json +75 -5
  104. package/docs/design/abc-benchmark.md +1 -1
  105. package/docs/design/design-spec-schema.md +1 -1
  106. package/docs/design/divergent-generation.md +1 -1
  107. package/docs/design/eval-gate-runbook.md +178 -0
  108. package/docs/design/show-hn-draft.md +26 -1
  109. package/docs/design/token-cost-baseline-1298.md +59 -0
  110. package/docs/eval/README.md +120 -0
  111. package/docs/eval/corpus/seed-v1.json +6 -0
  112. package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
  113. package/docs/site/annotations-explained.md +24 -0
  114. package/docs/site/command-learning-path.md +51 -14
  115. package/docs/site/command-reference.md +386 -111
  116. package/docs/site/for-companies.md +95 -0
  117. package/docs/site/forge-annotation-protocol.md +12 -256
  118. package/docs/site/getting-started.md +43 -4
  119. package/docs/site/github-is-the-memory.md +113 -0
  120. package/docs/site/how-it-works.md +3 -13
  121. package/docs/site/index.md +9 -1
  122. package/docs/spec/eval-run-result.md +147 -0
  123. package/docs/spec/forge-protocol-v1.md +441 -18
  124. package/docs/spec/label-state-machine.md +55 -0
  125. package/docs/spec/review-protocol.md +158 -0
  126. package/docs/spec-graph-schema.md +28 -0
  127. package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
  128. package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
  129. package/examples/forgedock-demo/README.md +41 -22
  130. package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
  131. package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
  132. package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
  133. package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
  134. package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
  135. package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
  136. package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
  137. package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
  138. package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
  139. package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
  140. package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
  141. package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
  142. package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
  143. package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
  144. package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
  145. package/examples/forgedock-demo/scripts/smoke.js +18 -1
  146. package/examples/forgedock-demo/src/db.js +36 -11
  147. package/examples/forgedock-demo/src/routes/notes.js +106 -9
  148. package/examples/forgedock-demo/src/server.js +4 -1
  149. package/package.json +3 -1
  150. package/packages/protocol/LICENSE +21 -0
  151. package/packages/protocol/src/cli.js +135 -0
  152. package/packages/protocol/src/emit.js +146 -0
  153. package/packages/protocol/src/index.js +43 -0
  154. package/packages/protocol/src/parse.js +254 -0
  155. package/packages/protocol/src/types.js +171 -0
  156. package/packages/protocol/src/validate.js +122 -0
  157. package/scripts/bench-topology-cost.mjs +372 -0
  158. package/scripts/check-command-docs-drift.sh +198 -0
  159. package/scripts/check-protocol-restatements.sh +176 -0
  160. package/scripts/check-registry/README.md +72 -0
  161. package/scripts/check-registry/manifest.json +6 -0
  162. package/scripts/code-index.sh +454 -0
  163. package/scripts/conformance-check.mjs +168 -0
  164. package/scripts/doctor-pipeline-state.sh +484 -0
  165. package/scripts/eval-gate-baseline.json +17 -0
  166. package/scripts/eval-gate-scorecard.mjs +242 -0
  167. package/scripts/eval-scorecard-aggregator.mjs +252 -0
  168. package/scripts/flaky-quarantine.sh +241 -0
  169. package/scripts/forge-annotation.sh +567 -0
  170. package/scripts/issue-dedup.sh +236 -0
  171. package/scripts/validate-annotation-node.mjs +233 -0
  172. package/scripts/worktree-lifecycle.sh +3 -3
  173. package/templates/devdocs/agent/using-forgedock.md +14 -0
  174. package/docs/site/.vitepress/config.mjs +0 -85
package/bin/journey.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * init-detect.mjs, init-enrich-api.mjs, tui.annotatedReviewScreen, registry.mjs
10
10
  */
11
11
 
12
- import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
12
+ import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
13
13
  import { join, basename } from "path";
14
14
 
15
15
  // ---------------------------------------------------------------------------
@@ -114,7 +114,17 @@ branches:
114
114
  # health_patterns:
115
115
  # - '"status": "ok"'
116
116
  `;
117
- writeFileSync(outputPath, content, "utf-8");
117
+ // Atomic write: write to a temp file first, then rename into place.
118
+ // If the write fails (e.g. ENOSPC), the original file is untouched and
119
+ // any partial .tmp is cleaned up — no corrupt or missing forge.yaml.
120
+ const tmpPath = outputPath + ".tmp";
121
+ try {
122
+ writeFileSync(tmpPath, content, "utf-8");
123
+ renameSync(tmpPath, outputPath);
124
+ } catch (err) {
125
+ try { unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
126
+ throw err;
127
+ }
118
128
  const todoCount = (content.match(/# TODO\(forgedock:/g) || []).length;
119
129
  return { todoCount };
120
130
  }
@@ -349,7 +359,12 @@ export async function preflight(ctx) {
349
359
 
350
360
  import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink } from "fs/promises";
351
361
  import { relative, dirname as pathDirname } from "path";
352
- import { installSessionStartHook } from "./settings-hook.mjs";
362
+ import {
363
+ installSessionStartHook,
364
+ installSubagentStopHook,
365
+ installPreToolUseHook,
366
+ removeSubagentStopEnforceHook,
367
+ } from "./settings-hook.mjs";
353
368
 
354
369
  /**
355
370
  * Parse the `install:` tier from a command spec's YAML frontmatter block.
@@ -361,7 +376,7 @@ import { installSessionStartHook } from "./settings-hook.mjs";
361
376
  *
362
377
  * Tier values:
363
378
  * 'core' — install for all users (default when key is absent or on any error)
364
- * 'extras' — opt-in, not yet installed by default (reserved for #1257)
379
+ * 'extras' — opt-in, not installed by default; use `npx forgedock install --extras` (#1257)
365
380
  * 'internal' — ForgeDock development only; never installed to user machines
366
381
  *
367
382
  * Fail-open: any read error, malformed frontmatter, or unrecognised value falls
@@ -407,22 +422,27 @@ export function parseInstallTier(filePath) {
407
422
  * Files with `install: internal` in their YAML frontmatter are excluded — they
408
423
  * are ForgeDock-development-only specs that must never reach user machines.
409
424
  * Files with `install: core` (or no `install:` key) are included. Files with
410
- * `install: extras` are excluded from the default install surface (reserved for
411
- * the opt-in extras mechanism tracked in #1257).
425
+ * `install: extras` are excluded from the default install surface; they are
426
+ * available via `npx forgedock install --extras` (implemented in #1257).
427
+ *
428
+ * @param {string} dir - Directory to search recursively.
429
+ * @param {{ includeExtras?: boolean }} [opts] - Options.
430
+ * includeExtras: when true, also include `install: extras` specs (opt-in tier).
412
431
  */
413
- export async function findMarkdownFiles(dir) {
432
+ export async function findMarkdownFiles(dir, opts = {}) {
433
+ const { includeExtras = false } = opts;
414
434
  const results = [];
415
435
  const entries = await readdir(dir, { withFileTypes: true });
416
436
  for (const entry of entries) {
417
437
  const full = join(dir, entry.name);
418
438
  if (entry.isDirectory()) {
419
- results.push(...(await findMarkdownFiles(full)));
439
+ results.push(...(await findMarkdownFiles(full, opts)));
420
440
  } else if (entry.name.endsWith(".md")) {
421
441
  const tier = parseInstallTier(full);
422
- if (tier === "core") {
442
+ if (tier === "core" || (includeExtras && tier === "extras")) {
423
443
  results.push(full);
424
444
  }
425
- // 'internal' and 'extras' are excluded from the default install surface
445
+ // 'internal' is always excluded from the install surface
426
446
  }
427
447
  }
428
448
  return results.sort();
@@ -473,7 +493,7 @@ export async function forge(ctx) {
473
493
  const manifest = await loadCopiedManifest(manifestPath);
474
494
  let manifestChanged = false;
475
495
 
476
- const files = await findMarkdownFiles(commandsDir);
496
+ const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
477
497
  let installed = 0, updated = 0, skipped = 0, copied = 0;
478
498
  const barWidth = 24;
479
499
  let barShown = false;
@@ -595,6 +615,23 @@ export async function forge(ctx) {
595
615
  const settingsPath = join(ctx.home, ".claude", "settings.json");
596
616
  const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
597
617
 
618
+ // Install enforcement hooks (#1250): PreToolUse (branch/label validation)
619
+ // and SubagentStop interactive engine adapter. Both are idempotent and
620
+ // always installed (fail-open if settings.json is malformed, same
621
+ // contract as SessionStart hook).
622
+ const preToolUseScript = join(ctx.forgeHome, "bin", "hooks", "pre-tool-use.mjs");
623
+ const subagentStopScript = join(ctx.forgeHome, "bin", "hooks", "interactive-engine.mjs");
624
+ const { status: preToolUseStatus } = installPreToolUseHook(settingsPath, preToolUseScript);
625
+ installSubagentStopHook(settingsPath, subagentStopScript);
626
+
627
+ // SubagentStop annotation-verifier hook (#1250) is NOT installed: its
628
+ // trigger condition (a `FORGE:PHASE_START` marker in the transcript) is
629
+ // never emitted anywhere in the pipeline, so it always exits 0 with zero
630
+ // enforcement effect while still spawning a process + reading the
631
+ // transcript on every SubagentStop (forge#1527). Actively clean up any
632
+ // prior installation instead of installing it.
633
+ const { status: subagentStopEnforceStatus } = removeSubagentStopEnforceHook(settingsPath);
634
+
598
635
  // Housekeeping — must never abort the receipt or the hook.
599
636
  let manifestSaveFailed = false;
600
637
  if (manifestChanged) {
@@ -624,7 +661,23 @@ export async function forge(ctx) {
624
661
  w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
625
662
  }
626
663
 
627
- return { installed, updated, skipped, copied, total: files.length, hookStatus };
664
+ // Report enforcement hook status.
665
+ if (preToolUseStatus !== null) {
666
+ w.write(` ${glyph(true)} PreToolUse enforcement hook ${preToolUseStatus === "already" ? "active" : "registered"} ${dimLine(ctx, "(branch/label enforcement)")}\n`);
667
+ } else {
668
+ w.write(" " + dimLine(ctx, "PreToolUse hook skipped — requires Claude Code v2.1.163+") + "\n");
669
+ }
670
+ // "skipped-malformed" and "absent" are intentionally silent here:
671
+ // - skipped-malformed: installSessionStartHook() above reads the same
672
+ // settingsPath and already prints the malformed-JSON fix-card, so a
673
+ // second message would just repeat the same signal.
674
+ // - absent: the expected steady state (nothing to remove) — this
675
+ // function only reports state *changes*, not no-ops.
676
+ if (subagentStopEnforceStatus === "removed") {
677
+ w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
678
+ }
679
+
680
+ return { installed, updated, skipped, copied, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus };
628
681
  }
629
682
 
630
683
  // ---------------------------------------------------------------------------
@@ -744,11 +797,16 @@ export function celebrate(ctx, summary) {
744
797
  w.write(` ${glyph} ${summary.total} commands · hook ${summary.hookStatus === "skipped-malformed" ? "skipped" : "active"} ${dimLine(ctx, hookNote)}\n`);
745
798
  }
746
799
  w.write("\n");
800
+ if (summary.isMinimal) {
801
+ w.write(" " + dimLine(ctx, "see docs/CONFIG.md for optional sections") + "\n");
802
+ }
747
803
  w.write(
748
804
  box(
749
805
  [
750
- ` 1. open claude in this repo`,
751
- ` 2. run /work-on next — watch an issue become a merged PR`,
806
+ ` 1. run npx forgedock labels setup — bootstrap GitHub labels`,
807
+ ` 2. run npx forgedock doctor — verify the install is green`,
808
+ ` 3. open claude in this repo`,
809
+ ` 4. run /issue <title> then /work-on <number>`,
752
810
  ],
753
811
  { title: "what's next" },
754
812
  ),
package/bin/registry.mjs CHANGED
@@ -376,3 +376,142 @@ export async function clearNudgeSeen(dir) {
376
376
  delete registry.nudgeSeen[absDir];
377
377
  });
378
378
  }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Claude Code version detection and breakpoints resolution (issue #1252)
382
+ // ---------------------------------------------------------------------------
383
+
384
+ // execFileSync is not imported at the top of this module (it only imports fs
385
+ // builtins). Import it lazily here using createRequire — safe in Node ESM.
386
+ import { createRequire } from "module";
387
+ const _require = createRequire(import.meta.url);
388
+
389
+ /**
390
+ * Detect the installed Claude Code version by running `claude --version`.
391
+ *
392
+ * Returns the version string (e.g. "2.1.197") or null if Claude Code is not
393
+ * installed or the version cannot be parsed. Never throws — callers can safely
394
+ * check for null and degrade gracefully.
395
+ *
396
+ * @returns {string | null} Semver-style version string or null.
397
+ */
398
+ export function detectClaudeVersion() {
399
+ try {
400
+ const { execFileSync } = _require("child_process");
401
+ const raw = execFileSync("claude", ["--version"], {
402
+ encoding: "utf-8",
403
+ stdio: ["pipe", "pipe", "pipe"],
404
+ timeout: 5000,
405
+ }).trim();
406
+ // Claude Code outputs e.g. "Claude Code 2.1.197" or just "2.1.197"
407
+ const m = raw.match(/(\d+\.\d+\.\d+)/);
408
+ return m ? m[1] : null;
409
+ } catch {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Compare two semver-style version strings (e.g. "2.1.152" vs "2.1.197").
416
+ *
417
+ * Returns:
418
+ * -1 if a < b
419
+ * 0 if a === b
420
+ * 1 if a > b
421
+ *
422
+ * Non-numeric components are compared lexicographically. Handles missing
423
+ * components by treating them as 0.
424
+ *
425
+ * @param {string} a
426
+ * @param {string} b
427
+ * @returns {-1 | 0 | 1}
428
+ */
429
+ export function compareVersions(a, b) {
430
+ const pa = String(a).split(".").map((s) => parseInt(s, 10) || 0);
431
+ const pb = String(b).split(".").map((s) => parseInt(s, 10) || 0);
432
+ const len = Math.max(pa.length, pb.length);
433
+ for (let i = 0; i < len; i++) {
434
+ const va = pa[i] ?? 0;
435
+ const vb = pb[i] ?? 0;
436
+ if (va < vb) return -1;
437
+ if (va > vb) return 1;
438
+ }
439
+ return 0;
440
+ }
441
+
442
+ /**
443
+ * Load and parse docs/claude-breakpoints.json relative to FORGE_HOME.
444
+ *
445
+ * Returns the parsed breakpoints array or an empty array on any error (file
446
+ * missing, malformed JSON, etc.). Consumers must pass the FORGE_HOME path.
447
+ *
448
+ * @param {string} forgeHome - Absolute path to the ForgeDock repo root.
449
+ * @returns {Array<object>} Array of breakpoint objects.
450
+ */
451
+ export function loadBreakpoints(forgeHome) {
452
+ try {
453
+ const bpPath = join(forgeHome, "docs", "claude-breakpoints.json");
454
+ const raw = readFileSync(bpPath, "utf-8");
455
+ const parsed = JSON.parse(raw);
456
+ return Array.isArray(parsed.breakpoints) ? parsed.breakpoints : [];
457
+ } catch {
458
+ return [];
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Resolve all breakpoints applicable to a given Claude Code version.
464
+ *
465
+ * A breakpoint is "applicable" if its version is > the supplied version,
466
+ * meaning the user is on an older version and will be affected by this
467
+ * breakpoint. This surfaces the set of features/changes the user is missing.
468
+ *
469
+ * If no version is supplied, all breakpoints are returned (useful for
470
+ * listing the full registry).
471
+ *
472
+ * Results are sorted by version ascending.
473
+ *
474
+ * @param {Array<object>} breakpoints - Array from loadBreakpoints().
475
+ * @param {string | null} installedVersion - The user's installed Claude Code version, or null.
476
+ * @returns {Array<object>} Breakpoints the user is missing or all breakpoints if no version.
477
+ */
478
+ export function resolveBreakpoints(breakpoints, installedVersion) {
479
+ if (!installedVersion) return [...breakpoints].sort((a, b) => compareVersions(a.version, b.version));
480
+ return breakpoints
481
+ .filter((bp) => compareVersions(bp.version, installedVersion) > 0)
482
+ .sort((a, b) => compareVersions(a.version, b.version));
483
+ }
484
+
485
+ /**
486
+ * Resolve a single breakpoint by feature_key.
487
+ *
488
+ * Returns the breakpoint object with the matching `feature_key`, or null
489
+ * if not found. Useful for conditional logic: "does this install support
490
+ * effort_levels?" → `resolveBreakpoint(bps, 'effort_levels')`.
491
+ *
492
+ * @param {Array<object>} breakpoints - Array from loadBreakpoints().
493
+ * @param {string} featureKey - The `feature_key` value to look up.
494
+ * @returns {object | null} The matching breakpoint or null.
495
+ */
496
+ export function resolveBreakpoint(breakpoints, featureKey) {
497
+ return breakpoints.find((bp) => bp.feature_key === featureKey) ?? null;
498
+ }
499
+
500
+ /**
501
+ * Check whether a detected Claude Code version meets or exceeds the version
502
+ * required for a given feature_key.
503
+ *
504
+ * Returns true if the feature is available, false if not, and null if the
505
+ * feature_key is unknown or the installed version is not detectable.
506
+ *
507
+ * @param {Array<object>} breakpoints - Array from loadBreakpoints().
508
+ * @param {string} featureKey - Feature to check.
509
+ * @param {string | null} installedVersion - Detected Claude Code version.
510
+ * @returns {boolean | null}
511
+ */
512
+ export function hasFeature(breakpoints, featureKey, installedVersion) {
513
+ if (!installedVersion) return null;
514
+ const bp = resolveBreakpoint(breakpoints, featureKey);
515
+ if (!bp) return null;
516
+ return compareVersions(installedVersion, bp.version) >= 0;
517
+ }