@rhize/skill-forge 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -174,6 +174,8 @@ target skills root until you decide.
174
174
  | `--ingest` | Pro (free during the 0.x beta). After a successful promote, hands off to a coding agent — see [Ingestion handoff](#ingestion-handoff---ingest). |
175
175
  | `--skill-map <path>` | Path to a generated `rhize-plugins` skill map (Phase 4 of that repo's skill-map-graph-substrate plan). When given, ranks the candidate's name/description against every `skill` node in the map — a near-duplicate of an already-shipped marketplace skill is folded into the safety findings as a `HIGH`-severity finding (escalates the verdict to `block`, so it's held rather than silently promoted); a moderate overlap is `MEDIUM` (`warn`). Free (not Pro-gated) — this enforces the marketplace's own curation rule ("close the gap, don't duplicate"), not a premium ranking feature. Missing/unreadable map: printed to stderr, never fatal. **Two map variants exist, with different coverage**: the **static** map (`rhize-plugins/generated/skill-map.static.json`) is first-party-only — it only knows about that marketplace's own skills, so it cannot catch a candidate that duplicates an *installed third-party plugin's* skill. The **resolved** map (`~/.claude/context-manager/skill-map.resolved.json`, produced by `rhize-context-manager`) additionally carries the full third-party ecosystem inventory. Point `--skill-map` at the resolved map when you want ecosystem-wide duplicate detection; the static map is enough only when you're curating a single first-party marketplace against itself. |
176
176
 
177
+ **Observability (v0.14.0):** the `--skill-map` curation check now always reports that it ran. A below-threshold PASS previously left no trace at all — no line in the report, no field in `--json` — making "checked 45 skills, nothing crossed the threshold" indistinguishable from "the check never ran". That ambiguity caused a correct PASS to be written up as a broken gate. The report now carries a `Marketplace overlap` line (`checked N skills — closest "x" (score), none over threshold` / `skipped — <reason>`) and `--json` a `mapOverlap` object with `status`, `compared`, `topMatch`, and `matches`. `topMatch` is informational only: it is never graded and never produces a finding. **Scoring and both thresholds are unchanged.** Note the check is lexical, not semantic — two skills can claim the same invocation trigger in different words and score far below threshold (measured: 0.134), so competing `description:` triggers still need a human read. See `gate/mapOverlap.ts`'s module doc.
178
+
177
179
  **Bugfix (v0.13.0):** `--skill-map` previously matched nothing against either real map variant — the loader read a `type` field that the real generated artifact never sets (it uses `kind`), so every node was silently invisible to the overlap check. `loadSkillMap()` now normalizes `kind` → `type` at load time; regression coverage lives in `test/mapOverlap.realmap.test.ts` against vendored real-map snapshots. If you were relying on `--skill-map` before v0.13.0, it was a no-op — re-run `add`/`scan` on anything you're unsure about.
178
180
 
179
181
  **Extends-declared overlap exemption**: a candidate can declare `metadata.rhize.extends:
package/dist/cli.js CHANGED
@@ -3505,18 +3505,23 @@ function nodeText(map, node) {
3505
3505
  }
3506
3506
  function scanMapOverlap(candidate, map) {
3507
3507
  const candidateTokens = tokenize2(`${candidate.name ?? ""} ${candidate.description ?? ""}`);
3508
- if (candidateTokens.size === 0) return { matches: [] };
3508
+ if (candidateTokens.size === 0) return { matches: [], compared: 0 };
3509
3509
  const matches = [];
3510
+ let compared = 0;
3511
+ let topMatch;
3510
3512
  for (const node of map.nodes) {
3511
3513
  if (node.type !== "skill") continue;
3514
+ compared += 1;
3512
3515
  const otherTokens = tokenize2(nodeText(map, node));
3513
3516
  const s = score3(candidateTokens, otherTokens);
3517
+ const match = { skillId: node.id, name: node.name ?? node.id, score: s };
3518
+ if (!topMatch || s > topMatch.score) topMatch = match;
3514
3519
  if (s >= REPORT_THRESHOLD) {
3515
- matches.push({ skillId: node.id, name: node.name ?? node.id, score: s });
3520
+ matches.push(match);
3516
3521
  }
3517
3522
  }
3518
3523
  matches.sort((a, b) => b.score - a.score);
3519
- return { matches };
3524
+ return { matches, compared, topMatch };
3520
3525
  }
3521
3526
  function mapOverlapFinding(result) {
3522
3527
  const top = result.matches[0];
@@ -3608,6 +3613,17 @@ function renderCapabilitiesLine(profile) {
3608
3613
  );
3609
3614
  return `${padLabel("Capabilities")}: ${counts} (declaredConfidence: ${declaredConfidence})`;
3610
3615
  }
3616
+ function renderMapOverlapLines(report) {
3617
+ if (!report || report.status === "not-requested") return [];
3618
+ if (report.status === "skipped") {
3619
+ return [`${padLabel("Marketplace overlap")}: skipped \u2014 ${report.reason ?? "map unavailable"}`];
3620
+ }
3621
+ const top = report.topMatch;
3622
+ const flagged = report.matches?.length ?? 0;
3623
+ const closest = top ? `closest "${top.name}" (${top.score})` : "no comparable skills";
3624
+ const verdict = flagged > 0 ? `${flagged} at/over threshold` : "none over threshold";
3625
+ return [`${padLabel("Marketplace overlap")}: checked ${report.compared ?? 0} skills \u2014 ${closest}, ${verdict}`];
3626
+ }
3611
3627
  function renderReport(result) {
3612
3628
  const { profile, overlap, overlapLocked, safety, worthTaking, leaveBehind } = result;
3613
3629
  const { name, version, license } = profileHeaderFields(profile);
@@ -3622,6 +3638,7 @@ function renderReport(result) {
3622
3638
  `${padLabel("Artifact type")}: ${profile.artifactType}`,
3623
3639
  ...profile.kind === "mcp" ? [renderCapabilitiesLine(profile)] : [],
3624
3640
  ...overlapLines,
3641
+ ...renderMapOverlapLines(result.mapOverlap),
3625
3642
  renderListField("What's worth taking", worthTaking, "(pending \u2014 not yet determined)"),
3626
3643
  renderListField("What to leave behind", leaveBehind, "(pending \u2014 not yet determined)"),
3627
3644
  `${padLabel("Safety verdict")}: ${renderSafetyLine(safety)}`,
@@ -3676,13 +3693,23 @@ function tryMcpOverlap(profile, mcpTargets) {
3676
3693
  }
3677
3694
  }
3678
3695
  function tryMapOverlapDecision(skillMapPath, candidate, extendsDeclarations) {
3679
- if (!skillMapPath) return void 0;
3696
+ if (!skillMapPath) return { report: { status: "not-requested" } };
3680
3697
  try {
3681
3698
  const map = loadSkillMap(skillMapPath);
3682
- return mapOverlapDecision(scanMapOverlap(candidate, map), extendsDeclarations);
3699
+ const result = scanMapOverlap(candidate, map);
3700
+ return {
3701
+ decision: mapOverlapDecision(result, extendsDeclarations),
3702
+ report: {
3703
+ status: "checked",
3704
+ compared: result.compared,
3705
+ topMatch: result.topMatch,
3706
+ matches: result.matches
3707
+ }
3708
+ };
3683
3709
  } catch (err) {
3684
- console.error(`[skill-forge] skill-map overlap check skipped: ${err.message}`);
3685
- return void 0;
3710
+ const reason = err.message;
3711
+ console.error(`[skill-forge] skill-map overlap check skipped: ${reason}`);
3712
+ return { report: { status: "skipped", reason } };
3686
3713
  }
3687
3714
  }
3688
3715
  function resolveMcpCandidatePath(entry) {
@@ -3705,7 +3732,7 @@ async function runGate(candidatePath, skillsRoot, {
3705
3732
  const proStatus = proFeatureStatus(proFeatureLabel);
3706
3733
  const safety = artifact === "mcp" ? await mcpScan(candidatePath, strictness) : await scanSafety(candidatePath, strictness);
3707
3734
  const overlap = !proStatus.enabled ? void 0 : artifact === "mcp" ? tryMcpOverlap(profile, mcpTargets) : tryOverlap(candidatePath, skillsRoot);
3708
- const mapDecision = artifact === "mcp" ? void 0 : tryMapOverlapDecision(
3735
+ const mapOutcome = artifact === "mcp" ? { decision: void 0, report: { status: "not-requested" } } : tryMapOverlapDecision(
3709
3736
  skillMapPath,
3710
3737
  {
3711
3738
  name: "name" in profile ? profile.name : void 0,
@@ -3713,6 +3740,7 @@ async function runGate(candidatePath, skillsRoot, {
3713
3740
  },
3714
3741
  profile.kind === "skill" ? profile.extends : []
3715
3742
  );
3743
+ const mapDecision = mapOutcome.decision;
3716
3744
  const mapFinding = mapDecision?.finding;
3717
3745
  const combinedSafety = mapFinding ? { verdict: verdictFor([...safety.findings, mapFinding], strictness), findings: [...safety.findings, mapFinding] } : safety;
3718
3746
  const advisory = formatAuditAdvisory(readAuditState(), {
@@ -3728,6 +3756,7 @@ async function runGate(candidatePath, skillsRoot, {
3728
3756
  overlap,
3729
3757
  overlapLocked: !proStatus.enabled,
3730
3758
  notices: notices.length > 0 ? notices : void 0,
3759
+ mapOverlap: mapOutcome.report,
3731
3760
  safety: {
3732
3761
  verdict: SAFETY_VERDICT_MAP[combinedSafety.verdict],
3733
3762
  findings: mapSafetyFindings(combinedSafety.findings)