Files
gbrain/scripts/check-skill-refs.mjs
Garry TanandClaude Fable 5 3f22f51e5d v0.45.6.0 feat(skills): giftable-skills import wave — 17 new production skills + 8 upgrades (#4033)
* feat(skills): check-skill-refs gate + per-commit skills gate runner

Three integrity checks over skills/ markdown: dangling refs (backtick
paths, composes: frontmatter, RESOLVER dispatcher slugs), donor-remnant
paths behind a ratchet allowlist, and warn-only CLI-reference drift
against the live tool surface. Wired as check:skill-refs in verify and
bundled into scripts/skills-commit-gate.sh (conformance+resolver tests,
check-resolvable --strict, lock regen+freshness, refs, optional per-file
privacy lint) for any commit touching the skills pack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skillpack): machine-readable plugin curation record + membership assertions

skills/plugin-exclusions.json records why each manifest skill is not
bundled into openclaw.plugin.json (Tier B host-only decisions, seeded
from the pre-existing split). test/openclaw-plugin-manifest.test.ts now
asserts manifest = bundled + exclusions with no overlap, no orphans, no
stale exclusions, and a sorted skills array (two pre-existing sort
inversions fixed). Bundling decisions are now review-visible: moving a
skill between tiers requires touching both files in one commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): skillify reconciled with the evolved upstream line (v2.0.0)

Adopts eval contracts, the NO-REGRESSION LAW, idempotency guidance, and
the 14-item checklist from the upstream skillify line while preserving
this repo's divergent content. bulk-ingestion (later in this wave)
delegates its codify phase to this checklist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): book-mirror 0.5.0 backport

Table-misalignment root-cause fix, anti-repetition hard constraints, and
the resonant-stance rewrite from the upstream line, generalized onto
native gbrain surfaces (no external storage libs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(conventions): test-before-bulk gains the progressive ramp + output-existence checks

10 -> 100 -> 500 -> full ramp with per-round stop gates, the
count-before/count-after output-existence check (catches silent zero-row
writes), the <2% error-rate gate, the copy-paste verification checklist,
cost estimation, and pointers to native pace mode + progress flags so
nobody rebuilds throttling in bash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(conventions): regex-discipline — judgment vs heuristic epistemics

The one-question test (deterministic-and-rote vs judgment), the earned-
regex sequence, red flags/green lights, never-regex-what-an-attacker-can-
imitate, and two genericized cautionary tales. Pairs with
test-before-bulk: look before you compress, compress only the rote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(conventions): path-discipline — display strings are not path strings

Never pass a display-formatted link/URL to a file tool; a "successful"
write to a poisoned path lies. Poisoned-path tells, verify-before-done,
retry discipline, junk-tree recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(conventions): exec-output — buffer to file, read bounded slices

Empty exec results usually mean truncation, not a broken shell. The
buffer-then-slice rule, shell patterns, and the diagnostic ladder,
generalized to any harness. Conventions half of the durable-execution
doctrine (the minion-orchestrator half lands separately).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): concept-synthesis 0.2.0 — curation cull phase

Adds the keep/delete/merge muscle the synthesis loop lacked: minimum
substance gates (style cannot rescue hollow ideas), weighted verdict
bands with hard delete triggers, grounding labels wired to the citation
conventions, a reputational-risk gate, cluster budgets, and reversible
merge-with-backlinks with merge-count as an emergent-salience signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): measure-before-you-fix — measure-first ops triage

Stopwatch-verify before raising timeouts; act-line vs alert-line. Trigger
vocabulary maps onto the shipped doctor/autopilot/sync alert classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): data-loss-gate — confirmation gate for irreversible operations

Recoverability checklist + alternatives + explicit-yes before any bulk
delete, forget sweep, source/mount removal, or history rewrite. No
category-level exception classes: disposability is a property of the
inspected target. Routing-convention honesty: the native operation-
boundary confirm remains a filed follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): fact-check — pre-publication claim verification

Breadth gate: extract every verifiable claim, verify each against live
sources with a confidence scale. Data-derived claims section carries the
two laws: producer is never verifier (independent re-derivation via a
different query path) and affiliation is not authorship (typed edges,
not co-occurrence). Hard-block on unsupported claims.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): resolve-before-asking — exhaust the brain before interrupting the human

Query, timeline, and every mounted source before asking; when asking is
justified, ask WITH a hypothesis (pairs with ask-user). Includes the
no-placeholders-at-ingest rule. Owns WHETHER asking is justified; query
owns the lookup verb, brain-ops the read-write cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): brain-ingest-gate — ingest entry gate (no raw copies, entity resolution, dedup)

No-raw-copies entry rule; the Named-Entity Resolution Gate (registry-
first + alias expansion — a vector score is a floor for prose, never a
gate for named things); the qualitative dedup decision tree (open and
read the top hit before writing). The gate keeps full dedup
responsibility: native import skips only matching frontmatter IDs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): correction-pipeline — trace corrections to the contamination source

When the user corrects a fact: root-cause it (7-class taxonomy), fix the
source surface (facts table via recall/forget/remember; pages via edit +
re-sync; identity files via bootstrap templates), then run the
propagation check. Never just note it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): company-brainify — personal-to-team brain sanitization

Strip/keep tables, triage grep pipelines, verification greps, and the
history-purge procedure for publishing a sanitized team brain from a
personal one. Scan scope covers meetings, dailies, facts, and takes, not
just people pages. History rewrite is gated through data-loss-gate with
a mirror-clone backup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): citation-graph-ingest — typed inter-document citation edges

Extract every inter-document reference from an ingested corpus and
classify the edge type (overrules/distinguishes/relies-on/extends...),
written via native link ops and queryable via graph-query. States
plainly that natural-language relational retrieval walks a fixed edge-
type set today; wiring these types in is a filed follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): two-tier-extraction — triage/deep-read model routing for archives

Cheap-model triage pass, gate, then strong-model deep read for archive
ingestion, expressed through the model-routing conventions. Deterministic
privacy wall: named-entity and sensitive-pattern classes are stripped
before any model call; ambiguous content fails closed to human review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): brain-link-discipline — verified links in every deliverable

Composite: same-message link rule, path derivation via git ls-files,
push-before-link ordering, subagent-relay path rewrite, bulk-list
formatting. Fallback chain: verified hosted-remote URL, then repo-
relative path with a scope note, then publish output offered as an
attachable artifact (never promised as a URL). Extends _output-rules.md
with the verified-deliverable-link canon and the in-page (relative) vs
in-message (absolute) scope split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): draft-in-voice — memory-grounded ghostwriting from validated profiles

Load a validated voice profile, obey its hard rules, produce 2-3 drafts,
run the voice-fidelity self-check, never auto-post. Ships the build-a-
profile appendix (corpus threshold, fingerprint schema, profile page
schema with provenance/consent/versioning) so users without a donor
profile aren't dead-ended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): bulk-ingestion — the disciplined bulk pipeline lifecycle + manifest substrate

Composite: the schema-trial-evaluate-codify-bulk-monitor lifecycle spine
plus the manifest.json durable-state substrate (stable source-derived
ids, derived-not-asserted status, idempotent workers, per-worker
progress-file merge, never trust a subagent's 'done' — verify on disk).
Codify delegates to skillify; execution to jobs/minions; ramping to
test-before-bulk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scripts): check-skill-refs reads cliHints from operations source

--tools-json does not serialize every op's cliHints, so real commands
(get, timeline, backlinks, graph, link) warned as unknown. The checker
now also derives the known-command set from operations.ts cliHints
names + aliases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): meeting-ingestion 2.0.0 — recorder-agnostic pipeline backport

Normalized transcript record as the any-recorder contract, multi-meeting
split detection, cross-recorder dedup, evidence-based speaker resolution
(never guess; ASR-variant search-before-create), required page sections
with explicit-reason-for-empty, claim-verification table, and the brain-
contradiction consistency check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): meeting-ingestion 2.1.0 — verify-before-done phase

Hard gate before reporting ingested: required sections have substance,
every people/companies slug has a page + timeline backlink, speaker map
resolved, every quote grounded verbatim in the transcript (deterministic
sidecar checks where transcripts are retained), fabricated-attendee
sanity checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): meeting-ingestion 2.2.0 — sequence verify (chronology checks)

Deterministic order lint before a meeting page ships: phase-inversion
and impossible-transit checks, day-timeline gaps corroborated against
`gbrain day`, with a pass/block/explicit-waive contract (waives logged
as acknowledged, not resolved). Adds routing-eval fixtures for the
upgraded skill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): reports 1.1.0 — Actionability Gate

Four link failure modes (Broken/Dead/Indirect/Missing) with the rule "a
missing link is honest, an indirect link is a broken promise", the
blocked-vs-warning table, retry loop as agent-run checks, and the prompt
gate snippet. Canon lives in _output-rules.md; this section carries the
taxonomy delta. Routing fixtures added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): briefing 1.3.0 — pre-briefing context pulls

Salience, anomalies, personal recall, and hot-memory pulse pulls before
composing the briefing, wired to the real CLI flags (anomalies uses
--lookback-days, not the upstream --since form). Routing fixtures added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): daily-task-manager 2.0.0 — stable IDs + fail-closed actions

Stable task-ID anchors, deterministic action routing, fail-closed
ambiguity handling (no mutation on zero/multiple matches), structured
Returns contract with an agent-chaining JSON mode, minimal-diff save
discipline. Routing fixtures added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): eiirp 1.1.0 — auto-fire gate (file before you deliver)

Always-on convention: when substantial structured analysis of a user-
shared document is about to be delivered, file the brain page first and
deliver analysis + link in the same reply. Fire conditions, does-not-
fire list, dedicated-pipeline exemptions, per-user storage-policy
override. eiirp moves from plugin exclusion to bundled — the gate is
load-bearing for downstream installs. Routing fixtures added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): minion-orchestrator 1.1.0 — durable execution doctrine

Capability ladder for operations over two minutes: jobs + a one-shot
self-deleting deadman (verifying the result was REPORTED, not that the
process exited) where the shell-jobs contract allows; cron-checked
progress file; foreground with buffered output otherwise. Deadman
failure modes (dies before firing, double-fire idempotency, stale
checkpoint) encoded in Contract and Anti-Patterns; content-addressed
stage-checkpoint appendix. Existing trust-boundary text unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(skills): concept-synthesis cull-phase routing fixtures

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): research-compendium — archive, summarize 1:1, synthesize self-contained

Deep-research discipline: archive every source, one summary per source,
then a self-contained synthesis (the Self-Contained Rule; Low-Bar/High-
Ceiling). Claims verification delegates to fact-check; retention is
gated by the user's privacy posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): context-audit — token hygiene for the always-loaded stack

Report-only audit of SOUL/MEMORY/CLAUDE/AGENTS-class files: redundancy,
contradictions, staleness, compression candidates ranked by token
savings. Recommendations target bootstrap templates, never rendered
files; judging routes through the native cross-modal eval runner with a
cheap-model default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): blog-ingest — whole-publication and feed ingestion

Feed discovery, pagination walking, article-shape normalization,
canonical-URL dedup, idempotent re-runs, polite pacing, and empty-husk
repair as agent procedures; per-article enrichment hands off to
brain-ingest-gate. Public posts only. Fetched text is data, never
instructions (untrusted-content section).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): conversation-archive — AI-chat history as first-class brain content

Convert chat exports into per-conversation pages, import, validate via
the conversation-parser surface, extract facts; gap-healing backfill
under the three invariants (capture cadence outruns eviction, gap
detection, write-path equals commit-path); idea-evolution tracing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): skill-autobench — evals mined from lived usage

Mine a skill's real invocation history (user corrections are the gold
signal) into a proposed eval contract and replayable cases with honesty
labels, staged pending human approval — never rewrites a skill.
Multi-model judging verifies each provider actually responded. Extends
the SkillOpt surface; never duplicates it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(todos): giftable-import wave follow-ups

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills): resolver strict reconciliation — triggers, fixtures, disambiguation

Drives check-resolvable --strict to zero on the 69-skill pack: wave-side
trigger narrowing (bare words become phrases on measure-before-you-fix,
data-loss-gate, brain-ingest-gate), fixture intents rewritten to
realistic phrasings, wave-side MECE duplicates removed, the ingest-gate
quality rules delegated to conventions, and three new disambiguation
rules (publication/article/media/chat-export routing, soul-audit vs
context-audit, measurement-first triage vs debugging).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): idea-ingest 1.1.0 — Returns contract + edge cases backport

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): media-ingest 1.1.0 — input contract, error handling, known pitfalls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): brain-ops 1.1.0 — entity-card first lookup + shallow-ls warning (memory-verbs block preserved)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): maintain 1.1.0 — weekly feature-adoption check

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skills): data-research 1.1.0 — infer research target from context

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(llms): regenerate bundles after the skills wave

RESOLVER.md is inlined into the llms bundles; the wave's routing rows
and disambiguation rules regenerate here (single wave-end regen; the
committed bundles match HEAD per test/build-llms.test.ts).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): assemble banned-path fixtures at runtime in check-skill-refs tests

The privacy check bans those literals in source files; the fixtures now
construct them so the test still exercises the donor-remnant gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skillpack): bundle capture + publish; keep host-only contrast fixtures portable

The downstream-install smoke showed shipped fixtures referencing host-
only skills. capture and publish move from exclusions to the bundle —
both are user-facing and wave skills route to them (the eiirp auto-fire
files via capture; brain-link-discipline falls back to publish).
Contrast fixtures that pointed at host-only skills (smoke-test,
skill-optimizer) now target shipped skills with declared ambiguity, so
`check-resolvable --strict` is green on the host pack (69 skills) AND on
a fresh scaffolded install (58 skills).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(evals): post-import-wave routing receipt (haiku)

A/B harness run against the 69-skill resolver after the import wave:
baseline (uncompressed) 100% held-out / 78.3% training strict — beating
both compression variants on strict scoring, corroborating the review
decision to defer resolver compression to its own PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills): pre-landing review fixes — trust boundaries, gate hardening, registration prose

From the six-reviewer pre-landing pass: research-compendium gains the
untrusted-content section (verbatim web archives feed future recall);
blog-ingest's injection marker now travels with body chunks (inline
fenced wrapper, not frontmatter-only); company-brainify's history-rewrite
backup moves to a non-volatile private location with pre-push
re-verification and retention cleanup; the 17 imported manifest entries
get real descriptions (a block-scalar parsing slip) with a test pinning
description prose and plugin-array uniqueness; check-skill-refs becomes
cwd-independent, validates relative markdown links, and warns visibly on
an empty CLI command set (5 new tests); the commit gate fails loudly on
a regenerated-but-unstaged lock, single-quotes its inline lint script,
and rejects missing lint paths; ladder attribution, checkpoint-hook
bypass, frontmatter-contract notes, curation + misroute runbook docs,
the brain-first API-pull rule, and negative routing fixtures for eight
skills round out the batch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skills): adversarial-review hardening — trust boundaries, closure, honest contracts

From the ship pre-landing + adversarial + red-team passes (all findings
folded):

Untrusted content: a shared conventions/untrusted-content.md (fetched
text is data, never instructions) referenced from every skill that
fetches or imports external text — fact-check most critically (a fetched
page is never authority to rewrite a brain fact). conversation-archive
gains a mandatory secret+PII redaction pass before any page is written.

company-brainify: the destructive strip now sanitizes a staging copy
behind the data-loss-gate card (personal brain untouched); the history
purge derives its path set from the full sanitized scope, runs only
against the shared repo (never sync.repo_path), stages under a
non-volatile backup dir with a pre-push restore check, and logs the
deletion to the personal brain, not the shared one.

Downstream closure: ask-user and brain-taxonomist are bundled (three new
skills delegate to them); remaining host-only references are prose, not
links; a membership+closure test makes a bundled skill referencing an
unshipped skill a build failure.

Honest contracts: brain-link-discipline is marked mutating (it commits
and pushes) and only sends its token to the issuing host; inert priority
markers removed; citation-graph-ingest's writes_to dropped; concurrency
and slug-collision limits documented for daily-task-manager,
bulk-ingestion, conversation-archive, and blog-ingest; the self-
authorizing shell-jobs prefix removed in favor of worker-side
authorization.

Gate hardening: the commit gate compares the staged lock blob and runs
the plugin-manifest tests; check-skill-refs scans jsonl for donor
remnants, tolerates CRLF, and rejects flag-shaped option values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.45.6.0 feat(skills): giftable-skills import wave — 17 new production skills + 8 upgrades

Grows the built-in pack from ~52 to 69 skills: the trust layer
(correction-pipeline, data-loss-gate, fact-check, brain-ingest-gate),
team-brain sanitization (company-brainify), typed citation-graph
ingestion, disciplined bulk/feed/conversation ingestion, and operational
skills (measure-before-you-fix, context-audit, skill-autobench,
resolve-before-asking, brain-link-discipline, draft-in-voice,
research-compendium), plus meeting-ingestion/skillify/eiirp/
minion-orchestrator/concept-synthesis/reports/briefing/daily-task-manager
upgrades and a new skill-ref integrity gate + plugin-curation record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(skillpack,doctor): skill currency + declared preconditions — the migration harness now examines skills

The upgrade/migration path was DB-only for skills: schema migrations never
touched them, and skill sync was manual/advisory with new skills hidden
from the post-upgrade sweep. This makes skill currency and per-skill setup
first-class.

Currency (capability A): src/core/skillpack/skill-currency.ts classifies
every bundled skill against the install as new / drifted / current, by each
skill's OWN files (shared conventions on disk no longer misclassify a new
skill as drifted). `gbrain skillpack status` reports it; `gbrain skillpack
sync` scaffolds only the new ones and never overwrites local edits; the
post-upgrade sweep surfaces new skills with the sync command; `gbrain
doctor` gains a `skill_currency` check (WARN when new skills are available,
drift stays advisory).

Preconditions (capability B): a `requires:` frontmatter field
(source / dir:<path> / config:<key> / pages:<n>), a pure DI-seam checker in
src/core/skillpack/preconditions.ts, `gbrain skillpack setup <skill>` for
the static list + hints, and a `skill_preconditions` doctor check that
verifies them live against the connected brain. Annotated only the two
skills with genuine preconditions (citation-graph-ingest needs a corpus;
skill-autobench needs the conversations/ archive).

Semantic skill search (capability C) is deliberately deferred as its own
subsystem (tenancy + source isolation + embedding backfill) — filed in
TODOS, not crammed in unreviewed.

Tests: skill-currency, skill-preconditions, skillpack-status (31 new
assertions). doctor + skillpack stay engine-safe (currency is filesystem-
only; live precondition checks skip cleanly without a brain).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(doctor,skillpack): categorize new skill checks, refresh flag registry, sweep test + noise gate

CI on the pushed feature surfaced three real in-branch failures:
- doctor-categories: register skill_currency + skill_preconditions under
  SKILL_CHECK_NAMES (every doctor check must be categorized).
- flag registry: doctor's import of findGbrainRoot pulls bundle.ts (which
  runs git rev-parse --verify) into its flag scan; regenerated the
  known-flags allowlist to match (harmless over-inclusion, per the
  generator's design).
- upgrade sweep: header renamed to 'Skillpack sweep (post-upgrade)'; new-skill
  surfacing gated on the host having scaffolded at least one skill, so a
  zero-skill (opted-out) host stays silent instead of getting 'N new skills'
  on every upgrade. Tests updated + a case added locking the surfacing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 10:30:56 -07:00

244 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env bun
// check-skill-refs — three integrity gates over the skills/ markdown tree.
//
// 1. DANGLING REFS (fail): every backtick `skills/<x>/...` path, every
// relative markdown link (`](./x.md)` / `](../x/y.md)`), every frontmatter
// `composes:` slug, and every `(dispatcher for: a, b)` slug in RESOLVER.md
// must resolve to an existing file/dir. Placeholder templates
// (`skills/X/`, `skills/<slug>/`, `{...}` / `<...>` targets, example-slug
// brain-page paths like `../people/alice-example.md`) and
// skills/migrations/** are exempt — migrations are historical record,
// placeholders are documentation idiom.
// 2. DONOR REMNANTS (fail, allowlist-ratcheted): donor-workspace path prefixes
// must not appear outside files listed in scripts/skill-refs-allowlist.txt.
// The allowlist is a ratchet: it may shrink, never silently grow — add a
// line only with a review-visible commit.
// 3. CLI REFS (warn only): `gbrain <cmd>` tokens inside fenced code blocks are
// checked against the CLI's --tools-json surface. Warnings never fail the
// build; they exist so a skill body promising a nonexistent command is
// visible in CI logs before a user hits it.
//
// Usage: bun scripts/check-skill-refs.mjs [--skills-dir skills/] [--allowlist scripts/skill-refs-allowlist.txt] [--no-cli-refs]
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { execSync } from 'node:child_process';
const args = process.argv.slice(2);
function argVal(flag, dflt) {
const i = args.indexOf(flag);
const v = i >= 0 ? args[i + 1] : undefined;
// A value that looks like a flag (starts with --) means this option's value
// was omitted; treat it as missing rather than swallowing the next flag.
return v && !v.startsWith('--') ? v : dflt;
}
const SKILLS_DIR = argVal('--skills-dir', 'skills');
const ALLOWLIST_PATH = argVal('--allowlist', 'scripts/skill-refs-allowlist.txt');
const RUN_CLI_REFS = !args.includes('--no-cli-refs');
const DONOR_PREFIXES = ['/data/brain', '/data/.openclaw', '/data/gbrain', '/data/tmp'];
const PLACEHOLDER_RE = /skills\/(X|<[^>]+>|\{[^}]+\}|\$\{[^}]+\}|\.\.\.)\/?/;
// Relative-markdown-link exemptions: skill bodies illustrate BRAIN-repo page
// links (`[Alice Example](../people/alice-example.md)`). Those targets live in
// a brain repo, not the skills tree — any relative target whose first real
// path segment is a brain-content top-level dir is a documentation example,
// not a skills cross-link. Example-slug segments (`*-example`) are likewise
// placeholders per the privacy rule.
const BRAIN_CONTENT_DIRS = new Set([
'people', 'companies', 'meetings', 'daily', 'concepts', 'sources',
'research', 'projects', 'media', 'conversations', 'analysis', 'notes',
'ideas', 'takes', 'funds', 'deals',
]);
function isPlaceholderLinkTarget(target) {
if (/[<{$]/.test(target)) return true; // <slug>, {slug}, ${var} templates
const segs = target.split('/').filter((s) => s && s !== '.' && s !== '..');
if (segs.length === 0) return true;
if (BRAIN_CONTENT_DIRS.has(segs[0])) return true; // brain-page path example
if (segs.some((s) => /-example(\.|\/|$)/.test(s))) return true; // alice-example, acme-example, ...
return false;
}
function walk(dir) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) out.push(...walk(p));
// .md feeds every lane; .jsonl feeds the donor-remnant scan only —
// routing-eval fixtures can carry a donor-workspace path too.
else if (e.name.endsWith('.md') || e.name.endsWith('.jsonl')) out.push(p);
}
return out;
}
if (!existsSync(SKILLS_DIR)) {
console.error(`check-skill-refs: skills dir not found: ${SKILLS_DIR}`);
process.exit(2);
}
const allowlist = new Set(
existsSync(ALLOWLIST_PATH)
? readFileSync(ALLOWLIST_PATH, 'utf8')
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
: [],
);
const files = walk(SKILLS_DIR);
const failures = [];
const warnings = [];
for (const file of files) {
// Path identity is always "skills/<path-under-skills-dir>", independent of cwd
// canonicalization (macOS /var vs /private/var) or an absolute --skills-dir.
const underSkills = relative(SKILLS_DIR, file);
const rel = join('skills', underSkills);
const inMigrations = underSkills.startsWith('migrations/');
const text = readFileSync(file, 'utf8');
// --- 2. donor remnants (skip migrations wholesale) ---
if (!inMigrations && !allowlist.has(rel)) {
for (const prefix of DONOR_PREFIXES) {
if (text.includes(prefix)) {
const line = text.split('\n').findIndex((l) => l.includes(prefix)) + 1;
failures.push(`[donor-remnant] ${rel}:${line} — contains "${prefix}" (add to ${ALLOWLIST_PATH} only with review)`);
break;
}
}
}
if (inMigrations) continue;
// The lanes below are markdown-only (backtick refs, relative md-links,
// frontmatter). .jsonl files are scanned for donor remnants above only.
if (!file.endsWith('.md')) continue;
// --- 1a. backtick skills/ path refs ---
for (const m of text.matchAll(/`(skills\/[^`\s]+)`/g)) {
let ref = m[1].replace(/[.,;:]+$/, '');
if (PLACEHOLDER_RE.test(ref)) continue;
// strip trailing anchors / line refs like skills/foo/SKILL.md:12
ref = ref.replace(/:\d+(-\d+)?$/, '').replace(/#.*$/, '');
if (ref.endsWith('/')) ref = ref.slice(0, -1);
// Resolve against the PARENT of the skills dir (refs are written as
// "skills/<x>/..."), never bare cwd — the check must be cwd-independent.
if (!existsSync(join(SKILLS_DIR, '..', ref))) {
const line = text.split('\n').findIndex((l) => l.includes(m[1])) + 1;
failures.push(`[dangling-ref] ${rel}:${line} — \`${m[1]}\` does not exist`);
}
}
// --- 1d. relative markdown links ---
// `](./x.md)` / `](../x/y.md)` targets must resolve against the linking
// file's own directory. http(s) and anchor-only targets never match the
// leading ./ or ../ pattern; placeholder/example targets are exempt.
for (const m of text.matchAll(/\]\((\.{1,2}\/[^)\s]+)\)/g)) {
const raw = m[1];
const target = raw.split('#')[0];
if (!target) continue; // anchor-only after a ./ prefix — nothing to resolve
if (isPlaceholderLinkTarget(target)) continue;
if (!existsSync(join(dirname(file), target))) {
const line = text.split('\n').findIndex((l) => l.includes(raw)) + 1;
failures.push(`[dangling-md-link] ${rel}:${line} — \`](${raw})\` does not resolve from ${rel}'s directory`);
}
}
// --- 1b. frontmatter composes: slugs ---
const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (fmMatch) {
const fm = fmMatch[1];
const composesMatch = fm.match(/^composes:\s*(.*)$/m);
if (composesMatch) {
const inline = composesMatch[1].trim();
let slugs = [];
if (inline && inline !== '|' && !inline.startsWith('#')) {
slugs = inline.replace(/^\[|\]$/g, '').split(',').map((s) => s.trim()).filter(Boolean);
} else {
// block-list form: lines " - slug" following the key
const after = fm.slice(fm.indexOf(composesMatch[0]) + composesMatch[0].length);
for (const line of after.split(/\r?\n/)) {
const lm = line.match(/^\s+-\s+(\S+)/);
if (lm) slugs.push(lm[1]);
else if (line.trim() && !line.startsWith(' ')) break;
}
}
for (const slug of slugs) {
if (!existsSync(join(SKILLS_DIR, slug))) {
failures.push(`[dangling-composes] ${rel} — composes: "${slug}" is not a skill dir under ${SKILLS_DIR}/`);
}
}
}
}
}
// --- 1c. RESOLVER.md dispatcher clauses ---
const resolverPath = join(SKILLS_DIR, 'RESOLVER.md');
if (existsSync(resolverPath)) {
const rtext = readFileSync(resolverPath, 'utf8');
for (const m of rtext.matchAll(/\(dispatcher for:\s*([^)]+)\)/g)) {
for (const slug of m[1].split(',').map((s) => s.trim()).filter(Boolean)) {
const cleaned = slug.replace(/`/g, '');
if (!/^[a-z0-9-]+$/.test(cleaned)) continue; // prose, not a slug
if (!existsSync(join(SKILLS_DIR, cleaned))) {
failures.push(`[dangling-dispatcher] ${SKILLS_DIR}/RESOLVER.md — dispatcher slug "${cleaned}" is not a skill dir`);
}
}
}
}
// --- 3. CLI refs (warn-only) ---
if (RUN_CLI_REFS) {
let known = null;
try {
const raw = execSync('bun src/cli.ts --tools-json 2>/dev/null', { encoding: 'utf8', timeout: 30_000 });
const parsed = JSON.parse(raw.slice(raw.indexOf('[') >= 0 && raw.indexOf('[') < (raw.indexOf('{') + 1 || Infinity) ? raw.indexOf('[') : raw.indexOf('{')));
const list = Array.isArray(parsed) ? parsed : parsed.tools || [];
known = new Set();
for (const t of list) {
const n = (t.cliHints && t.cliHints.name) || t.cli_name || t.name;
if (n) known.add(String(n).replaceAll('_', '-'));
for (const a of (t.cliHints && t.cliHints.aliases) || []) known.add(String(a));
}
} catch {
warnings.push('[cli-refs] could not load --tools-json; skipping CLI-ref check');
}
if (known && known.size === 0) {
warnings.push('[cli-refs] --tools-json parsed to an EMPTY command set; skipping CLI-ref check (the warn-only lane is not running)');
}
if (known && known.size > 0) {
// top-level commands defined directly in src/cli.ts (not ops): derive from source
try {
const cliSrc = readFileSync('src/cli.ts', 'utf8');
for (const m of cliSrc.matchAll(/(?:command === |case )'([a-z][a-z0-9-]*)'/g)) known.add(m[1]);
} catch {}
// ops cliHints that --tools-json does not serialize: read them from source
try {
const opsSrc = readFileSync('src/core/operations.ts', 'utf8');
for (const m of opsSrc.matchAll(/cliHints:\s*\{\s*name:\s*'([a-z][a-z0-9-]*)'/g)) known.add(m[1]);
for (const m of opsSrc.matchAll(/aliases:\s*\[([^\]]*)\]/g)) {
for (const a of m[1].matchAll(/'([a-z][a-z0-9-]*)'/g)) known.add(a[1]);
}
} catch {}
for (const file of files) {
if (file.includes('/migrations/')) continue;
if (!file.endsWith('.md')) continue; // fenced gbrain-cmd scan is markdown-only
const rel = relative('.', file);
const text = readFileSync(file, 'utf8');
for (const block of text.matchAll(/```[a-z]*\n([\s\S]*?)```/g)) {
for (const cmd of block[1].matchAll(/(?:^|[|&;(]\s*)gbrain\s+([a-z][a-z0-9-]*)/gm)) {
if (!known.has(cmd[1])) warnings.push(`[cli-refs] ${rel} — \`gbrain ${cmd[1]}\` not found in CLI surface (warn-only)`);
}
}
}
}
}
for (const w of warnings) console.error(`WARN ${w}`);
if (failures.length) {
for (const f of failures) console.error(`FAIL ${f}`);
console.error(`check-skill-refs: ${failures.length} failure(s), ${warnings.length} warning(s)`);
process.exit(1);
}
console.log(`check-skill-refs: OK (${files.length} files scanned, ${warnings.length} warning(s))`);