@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 +47 -4
- package/dist/cli.js +200 -41
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
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.
|
|
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
|
|
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(
|
|
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
|
|
3627
|
-
if (!skillMapPath) return
|
|
3695
|
+
function tryMapOverlapDecision(skillMapPath, candidate, extendsDeclarations) {
|
|
3696
|
+
if (!skillMapPath) return { report: { status: "not-requested" } };
|
|
3628
3697
|
try {
|
|
3629
3698
|
const map = loadSkillMap(skillMapPath);
|
|
3630
|
-
|
|
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
|
-
|
|
3633
|
-
|
|
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
|
|
3657
|
-
|
|
3658
|
-
|
|
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:
|
|
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
|
|
6414
|
-
|
|
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
|
|
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 =
|
|
6587
|
+
const localResolved = resolveLocalPath(localNode.path, mapDir);
|
|
6456
6588
|
if (!existsSync23(localResolved)) {
|
|
6457
|
-
rows.push({
|
|
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({
|
|
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({
|
|
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
|
|
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
|
-
|
|
6598
|
-
options.offline ? { fetchUpstream: async () => 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
|
|
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("..") && !
|
|
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(
|
|
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
|
|
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
|
|
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("..") && !
|
|
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("..") && !
|
|
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 =
|
|
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 =
|
|
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}`) ||
|
|
8519
|
+
if (rel !== "" && (rel === ".." || rel.startsWith(`..${sep4}`) || isAbsolute5(rel))) continue;
|
|
8361
8520
|
let rootReal;
|
|
8362
8521
|
try {
|
|
8363
8522
|
rootReal = realpathSync9(rootLexical);
|