@rhize/skill-forge 0.12.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
@@ -172,7 +172,20 @@ target skills root until you decide.
172
172
  | `-t, --target <dir>` | Skills root to promote into. Defaults to the config's `defaultTarget`, then `skillsRoots[0]`. |
173
173
  | `--json` | Prints the gate result (profile, safety findings, overlap) as JSON instead of the terminal report box. Implies non-interactive: the decision is made the same way `--yes` makes it (verdict decides promote/hold/reject), never an interactive prompt. |
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
- | `--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. |
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
+
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
+
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.
180
+
181
+ **Extends-declared overlap exemption**: a candidate can declare `metadata.rhize.extends:
182
+ ["<skill-name>" | "<plugin>/<skill-name>"]` in its SKILL.md frontmatter to mark itself a
183
+ deliberate specialization/layering of an existing map skill. When the top `--skill-map` match is
184
+ a skill the candidate declares it extends, that finding is downgraded from a HOLD-level safety
185
+ finding to an informational notice printed above the report — it never escalates the verdict.
186
+ Overlap with any *other* skill (a different match, or a second candidate/skill pair) still holds
187
+ at full severity: the exemption applies per declared pair, not globally, so declaring an
188
+ extension of skill X never waives overlap with skill Y.
176
189
 
177
190
  A promoted skill gets a provenance entry appended to `<target>/SOURCES.md`, and every promote or
178
191
  hold decision is recorded to `~/.skill-forge/queue.json` (or `$SKILL_FORGE_HOME/queue.json`) — see
@@ -478,11 +491,41 @@ It is only ever printed, sanitized, as a suggestion for you (or an agent) to run
478
491
  **`--skill-map <path>` (Phase 4 of `rhize-plugins`' skill-map-graph-substrate plan)** additionally
479
492
  drift-checks every `fork-of` edge in a generated skill map: for each edge it resolves the local
480
493
  `skill` node's `path`/`contentHash` and the upstream (`external`) node's `url`/`path`, fetches or
481
- reads the upstream content, and compares content hashes. Each edge reports one of `in-sync`,
482
- `drifted`, `upstream-unreachable`, or `local-missing`. Same never-execute posture as the ledger
494
+ reads the upstream content, and compares content hashes. Same never-execute posture as the ledger
483
495
  check above: nothing derived from the map — including a fork-of edge's own `driftCheck`
484
496
  metadata — is ever executed; only fetch/read/hash/compare. A missing/unreadable map is a warning,
485
- never fatal.
497
+ never fatal. A node's repo-relative `path` (e.g. `rhize-context-manager/skills/x/SKILL.md`) is
498
+ resolved against cwd, the map's own directory, and that directory's parent (in that order) — not
499
+ cwd alone — so `watch --skill-map <path>` gives correct verdicts regardless of the directory you
500
+ run it from.
501
+
502
+ **Verdict**, per fork-of edge — three-way comparison, four verdict states when both hashes below
503
+ are present on the map, else the older two-way fallback (`in-sync`/`drifted`, `contentHash` vs
504
+ freshly-fetched upstream):
505
+
506
+ | local-normalized vs baseline | upstream-now vs baseline | status | actionable |
507
+ |---|---|---|---|
508
+ | == | == | `in-sync` | no |
509
+ | != | == | `local-only` | no (deliberate fork divergence, e.g. Rhize's added frontmatter) |
510
+ | == | != | `upstream-moved` | yes |
511
+ | != | != | `diverged` | yes |
512
+
513
+ The three-way matrix fires only when the local `skill` node carries `contentHashNormalized` (a
514
+ hash of the file with Rhize-injected frontmatter stripped, computed once by the rhize-plugins
515
+ compiler) **and** the upstream `external` node carries `baselineHash` (the upstream content hash
516
+ as of the last human review, recorded in `rhize-plugins`' SOURCES.md and copied onto the node by
517
+ that compiler — skill-forge never computes or fetches it itself). Either field missing on a given
518
+ edge falls back to the two-way compare for that edge, so older maps keep working. All four
519
+ three-way verdicts — `in-sync`, `local-only`, `upstream-moved`, and `diverged` — carry
520
+ `baselineHash`/`upstreamHash` in the JSON output; the two-way-fallback rows and
521
+ `upstream-unreachable`/`local-missing` never do (no verdict without a successful fetch/read, so
522
+ there is nothing to compare against a baseline). Every row also carries an `actionable` boolean
523
+ (from `isActionable`), so callers don't have to re-derive "needs attention" from `status`/`detail`
524
+ prose — it's `true` for `drifted`, `upstream-moved`, `diverged`, `upstream-unreachable`, and
525
+ `local-missing`; `false` for `in-sync` and `local-only`. **Re-baselining**: after reviewing and
526
+ adopting an `upstream-moved`/`diverged` change, re-run `rhize-plugins`' `scripts/baseline_upstreams.py`
527
+ and commit the updated SOURCES.md — that's the "I reviewed upstream, accept its state" action that
528
+ clears the row back to `in-sync`/`local-only`.
486
529
 
487
530
  ### `ingest` + `queue close` (v0.9, Pro)
488
531
 
package/dist/cli.js CHANGED
@@ -3131,6 +3131,33 @@ function extractDescription(block) {
3131
3131
  }
3132
3132
  return stripQuotes(first);
3133
3133
  }
3134
+ function extractExtends(block) {
3135
+ const lines = block.split("\n");
3136
+ const idx = lines.findIndex((l) => /^\s*extends:\s*(.*)$/.test(l));
3137
+ if (idx === -1) return void 0;
3138
+ const inline = lines[idx].match(/^\s*extends:\s*(.*)$/)[1].trim();
3139
+ if (inline.startsWith("[")) {
3140
+ const inner = inline.replace(/^\[/, "").replace(/\]\s*$/, "");
3141
+ const items = inner.split(",").map((s) => stripQuotes(s.trim())).filter(Boolean);
3142
+ return items.length ? items : void 0;
3143
+ }
3144
+ if (inline === "") {
3145
+ const items = [];
3146
+ for (let i = idx + 1; i < lines.length; i++) {
3147
+ const line = lines[i];
3148
+ const m = line.match(/^\s*-\s*(.+)$/);
3149
+ if (m) {
3150
+ items.push(stripQuotes(m[1].trim()));
3151
+ } else if (line.trim() === "") {
3152
+ continue;
3153
+ } else {
3154
+ break;
3155
+ }
3156
+ }
3157
+ return items.length ? items : void 0;
3158
+ }
3159
+ return [stripQuotes(inline)];
3160
+ }
3134
3161
  function parseFrontmatter(text) {
3135
3162
  if (!text.startsWith("---\n")) {
3136
3163
  return { validFrontmatter: false };
@@ -3141,7 +3168,8 @@ function parseFrontmatter(text) {
3141
3168
  name: extractSimpleField(block, "name"),
3142
3169
  version: extractSimpleField(block, "version"),
3143
3170
  license: extractSimpleField(block, "license"),
3144
- description: extractDescription(block)
3171
+ description: extractDescription(block),
3172
+ extends: extractExtends(block)
3145
3173
  };
3146
3174
  }
3147
3175
  function detectLicense(root, fm) {
@@ -3227,7 +3255,8 @@ function profileSkillDir(inputPath) {
3227
3255
  resources,
3228
3256
  mcpDependencies: mcpDeps,
3229
3257
  externalPythonDeps: externalDeps,
3230
- artifactType: detectArtifactType(root)
3258
+ artifactType: detectArtifactType(root),
3259
+ extends: fm.extends ?? []
3231
3260
  };
3232
3261
  } finally {
3233
3262
  cleanupTmp(tmpDir);
@@ -3407,9 +3436,13 @@ function loadSkillMap(path) {
3407
3436
  if (!Array.isArray(obj.nodes) || !Array.isArray(obj.edges)) {
3408
3437
  throw new Error(`skill map at ${resolved}: expected "nodes" and "edges" arrays`);
3409
3438
  }
3439
+ const nodes = obj.nodes.map((n) => ({
3440
+ ...n,
3441
+ type: typeof n.type === "string" ? n.type : n.kind
3442
+ }));
3410
3443
  return {
3411
3444
  schemaVersion: typeof obj.schemaVersion === "string" ? obj.schemaVersion : void 0,
3412
- nodes: obj.nodes,
3445
+ nodes,
3413
3446
  edges: obj.edges
3414
3447
  };
3415
3448
  }
@@ -3472,18 +3505,23 @@ function nodeText(map, node) {
3472
3505
  }
3473
3506
  function scanMapOverlap(candidate, map) {
3474
3507
  const candidateTokens = tokenize2(`${candidate.name ?? ""} ${candidate.description ?? ""}`);
3475
- if (candidateTokens.size === 0) return { matches: [] };
3508
+ if (candidateTokens.size === 0) return { matches: [], compared: 0 };
3476
3509
  const matches = [];
3510
+ let compared = 0;
3511
+ let topMatch;
3477
3512
  for (const node of map.nodes) {
3478
3513
  if (node.type !== "skill") continue;
3514
+ compared += 1;
3479
3515
  const otherTokens = tokenize2(nodeText(map, node));
3480
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;
3481
3519
  if (s >= REPORT_THRESHOLD) {
3482
- matches.push({ skillId: node.id, name: node.name ?? node.id, score: s });
3520
+ matches.push(match);
3483
3521
  }
3484
3522
  }
3485
3523
  matches.sort((a, b) => b.score - a.score);
3486
- return { matches };
3524
+ return { matches, compared, topMatch };
3487
3525
  }
3488
3526
  function mapOverlapFinding(result) {
3489
3527
  const top = result.matches[0];
@@ -3497,6 +3535,25 @@ function mapOverlapFinding(result) {
3497
3535
  context: top.skillId
3498
3536
  };
3499
3537
  }
3538
+ function isDeclaredExtension(match, extendsDeclarations) {
3539
+ const qualifiedId = match.skillId.replace(/^[a-z]+:/, "");
3540
+ const bareId = qualifiedId.split("/").pop() ?? qualifiedId;
3541
+ return extendsDeclarations.some((raw) => {
3542
+ const declared = raw.trim();
3543
+ if (!declared) return false;
3544
+ return declared === match.name || declared === bareId || declared === qualifiedId;
3545
+ });
3546
+ }
3547
+ function mapOverlapDecision(result, extendsDeclarations = []) {
3548
+ const top = result.matches[0];
3549
+ if (!top) return {};
3550
+ if (extendsDeclarations.length > 0 && isDeclaredExtension(top, extendsDeclarations)) {
3551
+ return {
3552
+ informational: `declared extension of "${top.name}" (score ${top.score}) \u2014 overlap expected; verify the layering claim in review`
3553
+ };
3554
+ }
3555
+ return { finding: mapOverlapFinding(result) };
3556
+ }
3500
3557
 
3501
3558
  // src/report/render.ts
3502
3559
  var SAFETY_VERDICT_MAP = {
@@ -3556,6 +3613,17 @@ function renderCapabilitiesLine(profile) {
3556
3613
  );
3557
3614
  return `${padLabel("Capabilities")}: ${counts} (declaredConfidence: ${declaredConfidence})`;
3558
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
+ }
3559
3627
  function renderReport(result) {
3560
3628
  const { profile, overlap, overlapLocked, safety, worthTaking, leaveBehind } = result;
3561
3629
  const { name, version, license } = profileHeaderFields(profile);
@@ -3570,6 +3638,7 @@ function renderReport(result) {
3570
3638
  `${padLabel("Artifact type")}: ${profile.artifactType}`,
3571
3639
  ...profile.kind === "mcp" ? [renderCapabilitiesLine(profile)] : [],
3572
3640
  ...overlapLines,
3641
+ ...renderMapOverlapLines(result.mapOverlap),
3573
3642
  renderListField("What's worth taking", worthTaking, "(pending \u2014 not yet determined)"),
3574
3643
  renderListField("What to leave behind", leaveBehind, "(pending \u2014 not yet determined)"),
3575
3644
  `${padLabel("Safety verdict")}: ${renderSafetyLine(safety)}`,
@@ -3623,14 +3692,24 @@ function tryMcpOverlap(profile, mcpTargets) {
3623
3692
  return void 0;
3624
3693
  }
3625
3694
  }
3626
- function tryMapOverlapFinding(skillMapPath, candidate) {
3627
- if (!skillMapPath) return void 0;
3695
+ function tryMapOverlapDecision(skillMapPath, candidate, extendsDeclarations) {
3696
+ if (!skillMapPath) return { report: { status: "not-requested" } };
3628
3697
  try {
3629
3698
  const map = loadSkillMap(skillMapPath);
3630
- return mapOverlapFinding(scanMapOverlap(candidate, map));
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
+ };
3631
3709
  } catch (err) {
3632
- console.error(`[skill-forge] skill-map overlap check skipped: ${err.message}`);
3633
- 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 } };
3634
3713
  }
3635
3714
  }
3636
3715
  function resolveMcpCandidatePath(entry) {
@@ -3653,20 +3732,31 @@ async function runGate(candidatePath, skillsRoot, {
3653
3732
  const proStatus = proFeatureStatus(proFeatureLabel);
3654
3733
  const safety = artifact === "mcp" ? await mcpScan(candidatePath, strictness) : await scanSafety(candidatePath, strictness);
3655
3734
  const overlap = !proStatus.enabled ? void 0 : artifact === "mcp" ? tryMcpOverlap(profile, mcpTargets) : tryOverlap(candidatePath, skillsRoot);
3656
- const mapFinding = artifact === "mcp" ? void 0 : tryMapOverlapFinding(skillMapPath, {
3657
- name: "name" in profile ? profile.name : void 0,
3658
- description: "description" in profile ? profile.description : void 0
3659
- });
3735
+ const mapOutcome = artifact === "mcp" ? { decision: void 0, report: { status: "not-requested" } } : tryMapOverlapDecision(
3736
+ skillMapPath,
3737
+ {
3738
+ name: "name" in profile ? profile.name : void 0,
3739
+ description: "description" in profile ? profile.description : void 0
3740
+ },
3741
+ profile.kind === "skill" ? profile.extends : []
3742
+ );
3743
+ const mapDecision = mapOutcome.decision;
3744
+ const mapFinding = mapDecision?.finding;
3660
3745
  const combinedSafety = mapFinding ? { verdict: verdictFor([...safety.findings, mapFinding], strictness), findings: [...safety.findings, mapFinding] } : safety;
3661
3746
  const advisory = formatAuditAdvisory(readAuditState(), {
3662
3747
  targetRoot: skillsRoot || void 0,
3663
3748
  candidateName: "name" in profile ? profile.name : void 0
3664
3749
  });
3750
+ const notices = [
3751
+ ...proStatus.notice ? [proStatus.notice] : [],
3752
+ ...mapDecision?.informational ? [mapDecision.informational] : []
3753
+ ];
3665
3754
  const gateResult = {
3666
3755
  profile,
3667
3756
  overlap,
3668
3757
  overlapLocked: !proStatus.enabled,
3669
- notices: proStatus.notice ? [proStatus.notice] : void 0,
3758
+ notices: notices.length > 0 ? notices : void 0,
3759
+ mapOverlap: mapOutcome.report,
3670
3760
  safety: {
3671
3761
  verdict: SAFETY_VERDICT_MAP[combinedSafety.verdict],
3672
3762
  findings: mapSafetyFindings(combinedSafety.findings)
@@ -6406,12 +6496,52 @@ import { join as join26, resolve as resolvePath8 } from "path";
6406
6496
  // src/gate/skillMapDrift.ts
6407
6497
  import { createHash as createHash4 } from "crypto";
6408
6498
  import { readFileSync as readFileSync18, existsSync as existsSync23 } from "fs";
6409
- import { resolve as resolve7 } from "path";
6499
+ import { dirname as dirname10, isAbsolute as isAbsolute2, resolve as resolve7 } from "path";
6410
6500
  function sha256(text) {
6411
6501
  return createHash4("sha256").update(text, "utf-8").digest("hex");
6412
6502
  }
6413
- function defaultReadLocalFile(path) {
6414
- const resolved = resolve7(expandHome(path));
6503
+ function shortHash(hash) {
6504
+ return hash.slice(0, 12);
6505
+ }
6506
+ var ACTIONABLE_STATUSES = /* @__PURE__ */ new Set([
6507
+ "upstream-moved",
6508
+ "diverged",
6509
+ "drifted",
6510
+ "upstream-unreachable",
6511
+ "local-missing"
6512
+ ]);
6513
+ function isActionable(status2) {
6514
+ return ACTIONABLE_STATUSES.has(status2);
6515
+ }
6516
+ function threeWayVerdict(localNormalized, upstreamHash, baselineHash) {
6517
+ const localMatchesBaseline = localNormalized === baselineHash;
6518
+ const upstreamMatchesBaseline = upstreamHash === baselineHash;
6519
+ const hashSuffix = `baseline=${shortHash(baselineHash)}\u2026, local-normalized=${shortHash(localNormalized)}\u2026, upstream=${shortHash(upstreamHash)}\u2026`;
6520
+ const TABLE = {
6521
+ TT: { status: "in-sync", detail: `local and upstream both match the recorded baseline (${hashSuffix})` },
6522
+ TF: { status: "upstream-moved", detail: `only upstream changed since baseline \u2014 review for adoption (${hashSuffix})` },
6523
+ FT: { status: "local-only", detail: `only local content changed since baseline \u2014 deliberate fork divergence (${hashSuffix})` },
6524
+ FF: { status: "diverged", detail: `both local and upstream changed since baseline \u2014 review for adoption (${hashSuffix})` }
6525
+ };
6526
+ const key = `${localMatchesBaseline ? "T" : "F"}${upstreamMatchesBaseline ? "T" : "F"}`;
6527
+ const { status: status2, detail } = TABLE[key];
6528
+ return { status: status2, detail, actionable: isActionable(status2) };
6529
+ }
6530
+ function resolveLocalPath(path, mapDir) {
6531
+ const expanded = expandHome(path);
6532
+ if (isAbsolute2(expanded)) return resolve7(expanded);
6533
+ const candidates = [resolve7(expanded)];
6534
+ if (mapDir) {
6535
+ candidates.push(resolve7(mapDir, expanded));
6536
+ candidates.push(resolve7(mapDir, "..", expanded));
6537
+ }
6538
+ for (const candidate of candidates) {
6539
+ if (existsSync23(candidate)) return candidate;
6540
+ }
6541
+ return candidates[0];
6542
+ }
6543
+ function defaultReadLocalFile(path, mapDir) {
6544
+ const resolved = resolveLocalPath(path, mapDir);
6415
6545
  if (!existsSync23(resolved)) return void 0;
6416
6546
  try {
6417
6547
  return readFileSync18(resolved, "utf-8");
@@ -6435,7 +6565,8 @@ function upstreamLabel(node, edge) {
6435
6565
  return node?.url ?? node?.path ?? node?.name ?? node?.id ?? edge.to;
6436
6566
  }
6437
6567
  async function computeMapDrift(map, deps = {}) {
6438
- const readLocalFile = deps.readLocalFile ?? defaultReadLocalFile;
6568
+ const mapDir = deps.mapPath ? dirname10(resolve7(expandHome(deps.mapPath))) : void 0;
6569
+ const readLocalFile = deps.readLocalFile ?? ((path) => defaultReadLocalFile(path, mapDir));
6439
6570
  const fetchUpstream = deps.fetchUpstream ?? defaultFetchUpstream;
6440
6571
  const rows = [];
6441
6572
  for (const edge of edgesOfType(map, "fork-of")) {
@@ -6448,13 +6579,20 @@ async function computeMapDrift(map, deps = {}) {
6448
6579
  skill: skillLabel,
6449
6580
  upstream,
6450
6581
  status: "local-missing",
6451
- detail: !localNode ? `fork-of edge "from" node not found in map: ${edge.from}` : 'local skill node missing "path" or "contentHash" \u2014 cannot compare'
6582
+ detail: !localNode ? `fork-of edge "from" node not found in map: ${edge.from}` : 'local skill node missing "path" or "contentHash" \u2014 cannot compare',
6583
+ actionable: isActionable("local-missing")
6452
6584
  });
6453
6585
  continue;
6454
6586
  }
6455
- const localResolved = resolve7(expandHome(localNode.path));
6587
+ const localResolved = resolveLocalPath(localNode.path, mapDir);
6456
6588
  if (!existsSync23(localResolved)) {
6457
- rows.push({ skill: skillLabel, upstream, status: "local-missing", detail: `local path does not exist: ${localResolved}` });
6589
+ rows.push({
6590
+ skill: skillLabel,
6591
+ upstream,
6592
+ status: "local-missing",
6593
+ detail: `local path does not exist: ${localResolved}`,
6594
+ actionable: isActionable("local-missing")
6595
+ });
6458
6596
  continue;
6459
6597
  }
6460
6598
  const upstreamLocation = upstreamNode?.url ?? upstreamNode?.path;
@@ -6463,24 +6601,45 @@ async function computeMapDrift(map, deps = {}) {
6463
6601
  skill: skillLabel,
6464
6602
  upstream,
6465
6603
  status: "upstream-unreachable",
6466
- detail: 'upstream node has no "url" or "path" to fetch/read'
6604
+ detail: 'upstream node has no "url" or "path" to fetch/read',
6605
+ actionable: isActionable("upstream-unreachable")
6467
6606
  });
6468
6607
  continue;
6469
6608
  }
6470
6609
  const upstreamText = isHttpUrl(upstreamLocation) ? await fetchUpstream(upstreamLocation) : readLocalFile(upstreamLocation);
6471
6610
  if (upstreamText === void 0) {
6472
- rows.push({ skill: skillLabel, upstream, status: "upstream-unreachable", detail: `could not fetch/read upstream: ${upstreamLocation}` });
6611
+ rows.push({
6612
+ skill: skillLabel,
6613
+ upstream,
6614
+ status: "upstream-unreachable",
6615
+ detail: `could not fetch/read upstream: ${upstreamLocation}`,
6616
+ actionable: isActionable("upstream-unreachable")
6617
+ });
6473
6618
  continue;
6474
6619
  }
6475
6620
  const upstreamHash = sha256(upstreamText);
6621
+ const baselineHash = upstreamNode?.baselineHash;
6622
+ const localNormalized = localNode.contentHashNormalized;
6623
+ if (baselineHash && localNormalized) {
6624
+ const { status: status2, detail, actionable } = threeWayVerdict(localNormalized, upstreamHash, baselineHash);
6625
+ rows.push({ skill: skillLabel, upstream, status: status2, detail, actionable, baselineHash, upstreamHash });
6626
+ continue;
6627
+ }
6476
6628
  if (upstreamHash === localNode.contentHash) {
6477
- rows.push({ skill: skillLabel, upstream, status: "in-sync", detail: `contentHash matches (${upstreamHash.slice(0, 12)}\u2026)` });
6629
+ rows.push({
6630
+ skill: skillLabel,
6631
+ upstream,
6632
+ status: "in-sync",
6633
+ detail: `contentHash matches (${shortHash(upstreamHash)}\u2026)`,
6634
+ actionable: isActionable("in-sync")
6635
+ });
6478
6636
  } else {
6479
6637
  rows.push({
6480
6638
  skill: skillLabel,
6481
6639
  upstream,
6482
6640
  status: "drifted",
6483
- detail: `recorded ${localNode.contentHash.slice(0, 12)}\u2026, upstream now hashes to ${upstreamHash.slice(0, 12)}\u2026`
6641
+ detail: `recorded ${shortHash(localNode.contentHash)}\u2026, upstream now hashes to ${shortHash(upstreamHash)}\u2026`,
6642
+ actionable: isActionable("drifted")
6484
6643
  });
6485
6644
  }
6486
6645
  }
@@ -6593,10 +6752,10 @@ async function runWatch(config, options = {}, deps = {}) {
6593
6752
  if (options.skillMap) {
6594
6753
  try {
6595
6754
  const map = loadSkillMap(options.skillMap);
6596
- mapDrift = await computeMapDrift(
6597
- map,
6598
- options.offline ? { fetchUpstream: async () => void 0 } : void 0
6599
- );
6755
+ mapDrift = await computeMapDrift(map, {
6756
+ mapPath: options.skillMap,
6757
+ ...options.offline ? { fetchUpstream: async () => void 0 } : {}
6758
+ });
6600
6759
  } catch (err) {
6601
6760
  warnings.push(`skill map at ${options.skillMap}: ${err.message}`);
6602
6761
  }
@@ -6921,7 +7080,7 @@ async function routineCommand(options = {}) {
6921
7080
 
6922
7081
  // src/commands/ingest.ts
6923
7082
  import { realpathSync as realpathSync6 } from "fs";
6924
- import { dirname as dirname11, isAbsolute as isAbsolute2, relative as relative2, resolve as resolvePath9 } from "path";
7083
+ import { dirname as dirname12, isAbsolute as isAbsolute3, relative as relative2, resolve as resolvePath9 } from "path";
6925
7084
  var PRO_FEATURE_LABEL7 = "the pending-ingestion queue";
6926
7085
  var VALID_STATUSES = ["ingested", "dismissed"];
6927
7086
  function requireProFeature() {
@@ -6969,12 +7128,12 @@ function realpathOrNull(p) {
6969
7128
  }
6970
7129
  function isUnderRoot(candidate, root) {
6971
7130
  const rel = relative2(root, candidate);
6972
- return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
7131
+ return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
6973
7132
  }
6974
7133
  function validatePendingEntries(entries, config) {
6975
7134
  const quarantineRoot = realpathOrNull(resolvePath9(config.quarantineDir));
6976
7135
  const skillsRoots = config.skillsRoots.map((r) => realpathOrNull(resolvePath9(r))).filter((r) => r !== null);
6977
- const mcpTargetDirs = (config.mcpTargets ?? []).map((f) => realpathOrNull(dirname11(resolvePath9(f)))).filter((r) => r !== null);
7136
+ const mcpTargetDirs = (config.mcpTargets ?? []).map((f) => realpathOrNull(dirname12(resolvePath9(f)))).filter((r) => r !== null);
6978
7137
  const allowedRoots = [quarantineRoot, ...skillsRoots, ...mcpTargetDirs].filter((r) => r !== null);
6979
7138
  const valid = [];
6980
7139
  const excluded = [];
@@ -7048,12 +7207,12 @@ async function queueClose(id, options = {}) {
7048
7207
  // src/commands/refine.ts
7049
7208
  import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
7050
7209
  import { chmodSync, existsSync as existsSync27, lstatSync as lstatSync4, mkdirSync as mkdirSync9, readFileSync as readFileSync20, realpathSync as realpathSync9, renameSync as renameSync6, rmSync as rmSync9, writeFileSync as writeFileSync9 } from "fs";
7051
- import { dirname as dirname13, isAbsolute as isAbsolute4, join as join30, relative as relative4, resolve as resolvePath12, sep as sep4 } from "path";
7210
+ import { dirname as dirname14, isAbsolute as isAbsolute5, join as join30, relative as relative4, resolve as resolvePath12, sep as sep4 } from "path";
7052
7211
  import { createInterface as createInterface6 } from "readline/promises";
7053
7212
 
7054
7213
  // src/refine/context.ts
7055
7214
  import { existsSync as existsSync26, realpathSync as realpathSync8, statSync as statSync12 } from "fs";
7056
- import { isAbsolute as isAbsolute3, join as join29, relative as relative3, resolve as resolvePath11 } from "path";
7215
+ import { isAbsolute as isAbsolute4, join as join29, relative as relative3, resolve as resolvePath11 } from "path";
7057
7216
 
7058
7217
  // src/refine/models.ts
7059
7218
  import { randomBytes } from "crypto";
@@ -7465,7 +7624,7 @@ function realpathOrNull2(p) {
7465
7624
  }
7466
7625
  function isUnderRoot2(candidate, root) {
7467
7626
  const rel = relative3(root, candidate);
7468
- return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
7627
+ return rel === "" || !rel.startsWith("..") && !isAbsolute4(rel);
7469
7628
  }
7470
7629
  function resolveScopes(config, cwd, skill, userRootFlag) {
7471
7630
  assertPlainSkillName(skill);
@@ -7796,7 +7955,7 @@ function resolveConfiguredRoot3(root) {
7796
7955
  }
7797
7956
  function isUnderRoot3(candidate, root) {
7798
7957
  const rel = relative4(root, candidate);
7799
- return rel === "" || !rel.startsWith("..") && !isAbsolute4(rel);
7958
+ return rel === "" || !rel.startsWith("..") && !isAbsolute5(rel);
7800
7959
  }
7801
7960
  function sanitizeDiffForConsole(diff) {
7802
7961
  return diff.split("\n").map((line) => sanitizeForMarkdown(line)).join("\n");
@@ -7956,7 +8115,7 @@ function ensureScopeDirSafe(scopeDir, resolvedCwd) {
7956
8115
  function writeOverrideFile(scopeDir, relPath, content, resolvedCwd) {
7957
8116
  const realScopeDir = ensureScopeDirSafe(scopeDir, resolvedCwd);
7958
8117
  const destPath = join30(scopeDir, relPath);
7959
- const destDir = dirname13(destPath);
8118
+ const destDir = dirname14(destPath);
7960
8119
  mkdirSync9(destDir, { recursive: true });
7961
8120
  const realDestDir = realpathSync9(destDir);
7962
8121
  if (!isUnderRoot3(realDestDir, realScopeDir)) {
@@ -8313,7 +8472,7 @@ function resolveTargetRealPath(path) {
8313
8472
  const remainder = relative4(lexicalAncestor, resolvePath12(path));
8314
8473
  return remainder === "" ? real : join30(real, remainder);
8315
8474
  } catch {
8316
- const parent = dirname13(lexicalAncestor);
8475
+ const parent = dirname14(lexicalAncestor);
8317
8476
  if (parent === lexicalAncestor) return resolvePath12(path);
8318
8477
  lexicalAncestor = parent;
8319
8478
  }
@@ -8357,7 +8516,7 @@ function findLexicalRoot(filePath, config) {
8357
8516
  for (const root of config.skillsRoots) {
8358
8517
  const rootLexical = resolveConfiguredRoot3(root);
8359
8518
  const rel = relative4(rootLexical, lexicalTarget);
8360
- if (rel !== "" && (rel === ".." || rel.startsWith(`..${sep4}`) || isAbsolute4(rel))) continue;
8519
+ if (rel !== "" && (rel === ".." || rel.startsWith(`..${sep4}`) || isAbsolute5(rel))) continue;
8361
8520
  let rootReal;
8362
8521
  try {
8363
8522
  rootReal = realpathSync9(rootLexical);