From caa2a8ccb46e7cce755ff5d815df8d940ef3ee2d Mon Sep 17 00:00:00 2001 From: Oleks Date: Wed, 29 Jul 2026 15:46:39 +0300 Subject: [PATCH] Fix catalog parser (fenced-block extraction) and drop overly strict local markdownlint config --- .markdownlint.json | 9 -- bin/plugin-inventory | 57 +++++++----- references/signals-catalog.md | 91 ++++++++----------- .../fixtures/sample-plugin/agents/fetcher.md | 2 + .../sample-plugin/skills/dupe-a/SKILL.md | 4 +- 5 files changed, 77 insertions(+), 86 deletions(-) delete mode 100644 .markdownlint.json diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index f272289..0000000 --- a/.markdownlint.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "default": true, - "MD013": { - "tables": false - }, - "MD024": { - "siblings_only": true - } -} diff --git a/bin/plugin-inventory b/bin/plugin-inventory index bdb62f2..128896a 100755 --- a/bin/plugin-inventory +++ b/bin/plugin-inventory @@ -29,35 +29,46 @@ CACHE_GLOB = os.path.expanduser("~/.claude/plugins/cache/*/{name}/*/") # signals-catalog.md loader # -------------------------------------------------------------------------- +KNOWN_SECTIONS = {"script-verbs", "judgment-verbs", "mechanical-tools", "thresholds"} + + def load_catalog(path): - sections = {} - current = None + """Reads only the fenced code block immediately following each + recognized `##
` heading; everything else is prose for humans + and is never parsed (see references/signals-catalog.md's own header for + why: markdown and a hand-rolled comment syntax collide on bare `#`).""" with open(path, encoding="utf-8") as f: - for raw in f: - stripped = raw.strip() - if not stripped: - continue - # Section headers look like comments (start with #) but must be - # checked before generic comment-stripping discards them. - m = re.match(r"^#{0,2}\s*\[([a-z-]+)\]$", stripped) - if m: - current = m.group(1) - sections[current] = {} if current == "thresholds" else set() - continue - if stripped.startswith("#"): - continue - line = raw.split("#", 1)[0].strip() - if not line or current is None: - continue - if current == "thresholds": + text = f.read() + + sections = {} + heading_re = re.compile(r"^##\s+([a-z-]+)\s*$", re.MULTILINE) + for m in heading_re.finditer(text): + name = m.group(1) + if name not in KNOWN_SECTIONS: + continue + block = re.search(r"```\n(.*?)```", text[m.end():], re.DOTALL) + if not block: + continue + body = block.group(1) + if name == "thresholds": + values = {} + for raw in body.split("\n"): + line = raw.split("#", 1)[0].strip() if ":" in line: k, v = line.split(":", 1) - sections[current][k.strip()] = float(v.strip()) - else: - for item in line.split(","): + values[k.strip()] = float(v.strip()) + sections[name] = values + else: + items = set() + for raw in body.split("\n"): + for item in raw.split(","): item = item.strip() if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", item): - sections[current].add(item) + items.add(item) + sections[name] = items + missing = KNOWN_SECTIONS - sections.keys() + if missing: + sys.exit(f"error: {path} is missing section(s): {', '.join(sorted(missing))}") return sections diff --git a/references/signals-catalog.md b/references/signals-catalog.md index 1425cc6..ad74adc 100644 --- a/references/signals-catalog.md +++ b/references/signals-catalog.md @@ -2,106 +2,93 @@ Loaded at runtime by `bin/plugin-inventory`. Kept out of the script body so a lexicon or threshold change is a reviewable diff, not a code change — per -`design/plan.md` §3. +`design/plan.md` §3. `plugin-inventory` parses this file itself — a genuine +`pure-script` read (T1–T5 all pass: closed format, one right parse, +deterministic, no missing context, bounded grammar), so it is not a +candidate for anything more than what it already is. -Format: one `key: value` per line, grouped under `[section]` headers. Lines -starting with `#` are comments. Whitespace-insensitive; commas separate list -values. `plugin-inventory` parses this file itself — a genuine `pure-script` -read (T1–T5 all pass: closed format, one right parse, deterministic, no -missing context, bounded grammar), so it is not a candidate for anything more -than what it already is. +Format: each `##
` heading below is followed by exactly one fenced +code block, which is the only part the parser reads — comma-separated lists +for the verb/tool sections, `key: value` lines (with `#` trailing comments) +for `## thresholds`. Everything outside a fenced block is prose for humans +and is ignored by the parser, so it's free to use normal markdown, including +literal `#` at the start of a line. -## [script-verbs] +## script-verbs Occurrences count toward the script-verb signal. Matched case-insensitively, whole-word, against skill/agent body prose (frontmatter excluded). +``` count, sum, rank, sort, diff, parse, validate, enumerate, dedupe, dedup, deduplicate, format, compare, aggregate, glob, grep, filter, normalize, normalise, tabulate, checksum, hash, lint, tally, group, join, merge, split, truncate, paginate, timestamp, serialize, serialise, deserialize, encode, decode +``` -## [judgment-verbs] +## judgment-verbs Occurrences count toward the judgment-verb signal. Same matching rule. +``` decide, judge, explain, prioritize, prioritise, name, write, weigh, interpret, recommend, assess, evaluate, justify, persuade, negotiate, empathize, empathise, phrase, rephrase, summarize, summarise, synthesize, synthesise, disambiguate, reconcile, adjudicate, arbitrate, narrate +``` -# Note: summarize/synthesize sit in judgment, not script — compressing text +Note: summarize/synthesize sit in judgment, not script — compressing text +into a good summary is not a total function of its inputs (T4), even though +"summarize" sounds mechanical. If a step turns out to be a fixed-format +extraction (e.g. "summarize" really means "pull these five fields"), that is +a digest problem — flag it as low/medium, not high, until a human confirms +the extraction is exhaustive. -# into a good summary is not a total function of its inputs (T4), even though - -# "summarize" sounds mechanical. If a step turns out to be a fixed-format - -# extraction (e.g. "summarize" really means "pull these five fields"), that is - -# a digest problem — flag it as low/medium, not high, until a human confirms - -# the extraction is exhaustive - -## [mechanical-tools] +## mechanical-tools Tools whose presence in an agent's `tools:` list leans that agent toward "built to fetch." Used for `mechanical_tool_share`. +``` Bash, Read, Grep, Glob, WebFetch, WebSearch, LS, NotebookRead, ListMcpResourcesTool, ReadMcpResourceTool, ReadMcpResourceDirTool, TaskGet, TaskList, TaskOutput, Monitor +``` -# Deliberately excluded (judgment-implying, even when mechanical-sounding) +Deliberately excluded (judgment-implying, even when mechanical-sounding): +Edit, Write, NotebookEdit (produce durable state — a mistake here is +consequential, T5/P4), AskUserQuestion (is itself an escalation), Agent +(spawns judgment, doesn't replace it). -# Edit, Write, NotebookEdit (produce durable state — a mistake here is - -# consequential, T5/P4), AskUserQuestion (is itself an escalation), Agent - -# (spawns judgment, doesn't replace it) - -## [thresholds] +## thresholds +``` verb_ratio_high: 0.6 - # verb_ratio (script / (script+judgment)) above this with zero bin/ script - -# coverage is the loudest static smell per spec.md §5.1 +# coverage is the loudest static smell per spec.md §5.1. script_coverage_low: 0.15 - # script LOC / (skill+agent body words) below this, combined with - -# verb_ratio_high, is the target profile for a real candidate +# verb_ratio_high, is the target profile for a real candidate. procedural_density_high: 0.35 - # fraction of a skill body that is numbered/imperative steps containing a - # literal command, above which the skill reads as a recipe being re-derived - -# every invocation rather than prose reasoning +# every invocation rather than prose reasoning. mechanical_tool_share_high: 0.75 - -# fraction of an agent's declared tools that are in [mechanical-tools] - -# above which the agent's own tool allowlist argues it was built to fetch +# fraction of an agent's declared tools that are in mechanical-tools, above +# which the agent's own tool allowlist argues it was built to fetch. duplicate_block_min_skills: 2 - -# a fenced command block (after normalization — see FR-3.5 masking rules - +# a fenced command block (after normalization — see FR-3.5 masking rules, # applied identically here) appearing in at least this many distinct skills - # counts as "a shared script that was never written." hook_prose_drift_min_shared_tokens: 2 - # a hook script and a skill body sharing at least this many identifier-shaped - # tokens (snake_case, SCREAMING_CASE, or a literal filename — NOT plain - # English words, which overlap constantly between topically-related prose) - -# counts as the hook's logic being restated in prose — two sources of truth +# counts as the hook's logic being restated in prose — two sources of truth. +``` diff --git a/tests/fixtures/sample-plugin/agents/fetcher.md b/tests/fixtures/sample-plugin/agents/fetcher.md index 3d78fa1..187ff52 100644 --- a/tests/fixtures/sample-plugin/agents/fetcher.md +++ b/tests/fixtures/sample-plugin/agents/fetcher.md @@ -5,4 +5,6 @@ model: sonnet tools: Bash, Read, Grep, Glob, Edit --- +# fetcher + Fetches and greps things. diff --git a/tests/fixtures/sample-plugin/skills/dupe-a/SKILL.md b/tests/fixtures/sample-plugin/skills/dupe-a/SKILL.md index ba3d022..df7a543 100644 --- a/tests/fixtures/sample-plugin/skills/dupe-a/SKILL.md +++ b/tests/fixtures/sample-plugin/skills/dupe-a/SKILL.md @@ -17,5 +17,5 @@ git status --short ``` | Finding | Action | -|---|---| -| foo | bar | +| ------- | ------ | +| foo | bar |