Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4a55e51
docs(openspec): propose unify-doc-classification (superpowers-bridge)
nthansen Jul 3, 2026
8b5d243
docs(openspec): fold exempt-categories (test-only auto-pass) into uni…
nthansen Jul 3, 2026
1a07d12
feat(doc-sweep): add shared doc-classify.mjs classifier
nthansen Jul 3, 2026
bc2c8c3
refactor(doc-sweep): docs-ci-check delegates to doc-classify.mjs
nthansen Jul 4, 2026
72f6b14
fix(doc-sweep): guard docs-ci-check JSON parse + array config arg
nthansen Jul 4, 2026
81a623f
refactor(doc-sweep): push-guard delegates to doc-classify.mjs; retire…
nthansen Jul 4, 2026
09d7491
fix(doc-sweep): classify merge-commit changes in push-guard; bash-3.2…
nthansen Jul 4, 2026
ddcf4bb
docs(doc-sweep): declare machine-readable docPatterns; retire docMode
nthansen Jul 4, 2026
c0f5b33
docs(doc-sweep): sync audience-rules exemptPatterns default with doc-…
nthansen Jul 4, 2026
e8be59d
feat(doc-sweep): installers vendor doc-classify.mjs and record docPat…
nthansen Jul 4, 2026
5f94af9
fix(doc-sweep): installers re-copy classifier on reconfigure; persist…
nthansen Jul 4, 2026
bd03186
test(doc-sweep): regenerate install-skill benchmarks for docPatterns/…
nthansen Jul 4, 2026
8cf49f5
ci+docs(doc-sweep): run doc-classify tests; document docPatterns/exem…
nthansen Jul 4, 2026
d66e650
fix(doc-sweep): trailing-slash excludeDirs; align empty-config spec; …
nthansen Jul 4, 2026
66ec4c6
chore(openspec): mark unify-doc-classification tasks complete
nthansen Jul 4, 2026
00ac285
docs(openspec): verify + retrospective for unify-doc-classification
nthansen Jul 4, 2026
260e510
chore(openspec): archive unify-doc-classification; sync living specs
nthansen Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
- name: Skill-gate library unit tests
run: node --test scripts/skill-gate-lib.test.mjs

- name: doc-classify unit tests
run: node --test plugins/doc-sweep/hooks/doc-classify.test.mjs

# Quality layer: every skill must have fresh eval artifacts meeting its
# threshold. Deterministic, no LLM, no Anthropic auth (artifacts are
# produced at author time by /skill-gate and committed).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: superpowers-bridge
created: 2026-07-03
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Brainstorm — unify doc classification

Raw capture of the design conversation (adversarial review → decision chain). Reorganized into
structured sections in `design.md`.

## Background

Triggered by an adversarial review of the just-merged `docs-staleness-ci` feature. The reviewer
asked: is `docs-ci-check.sh` useful, is it too specific, should it be node, and does it "miss out
on boundaries" — ideally the doc-sweep skills and scripts should share the same pattern logic.

Grounding the review in the files surfaced a concrete bug, not just a smell. "What is a doc?" is
currently defined in **three** places that disagree:

| Source | Used by | `.claude/**/*.md` | `CHANGELOG.md` | `docs/**` |
|---|---|---|---|---|
| `audience-rules-base.md` (prose) | the skills (revise/audit) | doc ("all `*.md` under `.claude/`") | overlay-only | overlay-only |
| `is_doc()` in `revise-push-guard.sh` | the hook | NOT a doc | doc | doc |
| `is_doc()` in `docs-ci-check.sh` | the CI check | NOT a doc | doc | doc |

Consequences:
- **Divergence bug**: a PR that edits only `.claude/context/audience-rules.md` — doc-sweep's own
canonical doc — is classified as a non-doc change with no docs touched, so `docs-ci-check.sh`
**fails it**. The guard flags you for editing the file that defines what a doc is. Untested.
- **Copy-paste**: `is_doc` + the excludeDirs loop are duplicated verbatim in the two shell files;
they will drift.
- **Hybrid bash+node smell**: `docs-ci-check.sh` wraps four separate `node -e` one-liners around
bash (re-parsing the config JSON twice) — worst of both.
- **Crudeness**: the check only knows "a non-doc changed and no doc was touched"; it can't tell if
docs were actually *warranted*. The `[skip docs]` hatch is the tell. Real value only for the
cases the local hook can't reach (humans, fork PRs, non-doc-sweep contributors); marginal on a
solo repo. Kept as advisory, not a required blocker.

Nuance that shaped scope: the **skills** and **scripts** don't do the same classification. Scripts
answer a binary *is this path a doc?* (doc vs non-doc). Skills answer *which audience does this
known doc serve?* (Claude vs human), driven by the prose table — they have no `is_doc`/`docMode`.
So "one function for everything" is a category error; the genuinely shareable thing is the
**doc-file-set** (which globs count as docs) + `excludeDirs`.

## Decision chain

**Q1 — Direction: unify up, simplify down, or leave it?**
Two directions weighed. (A) Unify up: one shared declarative doc-file-set + one classifier module,
fixing the divergence. (B) Simplify down: accept it's a crude nag, drop `docMode`, classify docs as
"any `*.md` or under `docs/`", delete the enum. Absent the divergence bug, B would win for a solo
repo — but the bug tips it: the fix and the unification are the same work.
→ **Decision: A (unify up), scoped tightly.**

**Q2 — Scope: where does the single source live, and does it touch skill runtime?**
Options: (1) scripts + shared source only; (2) also wire the skills to read `docPatterns`
programmatically; (3) just dedupe the scripts + fix `.claude/**`, no declarative source.
Option 2 rejected — the skills don't do doc-vs-non-doc classification, so wiring them to
`docPatterns` is a forced fit and would churn their eval benchmarks. Option 3 leaves two
definitions.
→ **Decision: (1)** — add a machine-readable `docPatterns` block to the audience-rules
(base + overlay) as the single doc-file-set source; one shared `doc-classify.mjs` consumed by
BOTH scripts; fix `.claude/**`; retire `docMode`. Skills stay prose-driven (unchanged runtime)
with a consistent machine-readable twin.

**Q3 — Where do the patterns physically live at runtime? (single source vs no new parser)**
Reading `docPatterns` straight from `audience-rules.md` would need a markdown-embedded-YAML parser
in node (new complexity). But `excludeDirs` already establishes the pattern: install persists it in
`audience-rules.md` (human-authoritative) and **mirrors** it into the per-install config JSON, which
the scripts read via `JSON.parse`. Reuse that exact mechanism for `docPatterns`.
→ **Decision:** `audience-rules.md` is the human source (install reads/writes it); the config JSON
is the machine-read mirror; the classifier reads `docPatterns`/`excludeDirs` from the config, else a
built-in default. No new parser; consistent with today's `excludeDirs` flow. The CI vendored
`docs-ci.json` carries the mirrored patterns; funbox's own no-config dogfood run uses the built-in
default (which now includes `.claude/**`).

**Q4 — `docMode` retirement: alias, hard-retire, or keep both?**
→ **Decision: hard retire.** The classifier understands only `docPatterns` + a built-in default;
`docMode` removed from both scripts, the config schema, and both install skills (which now write
`docPatterns`). Accepted risk: an old config still carrying `docMode` silently falls back to the
default set until regenerated — fine, effectively single-user.

**Q5 — node vs bash.**
→ **Decision:** git plumbing (merge-base, diff, log, per-commit `[skip docs]`) stays bash; the
classification + JSON + glob matching move into one `doc-classify.mjs` module both scripts shell
into once. Node is already a hard dependency ("no jq"); a module is unit-testable and shared rather
than copy-pasted. A reusable `uses:` action was previously rejected on supply-chain grounds; the
classifier is vendored alongside the check script instead.

**Q6 — Exempt categories (raised mid-apply).** Idea: predefined defaults where the guard doesn't
enforce — e.g. a test-only change, or "other things like that." This is the precision upgrade the
adversarial review's "crude nag" critique wanted: doc / non-doc becomes doc / exempt / doc-requiring.
Three sub-decisions: (a) **fold into this change** (the classifier is being built now — cheap
extension) vs a separate follow-up → fold in; (b) **model** — a simple `exemptPatterns` glob list
(reuses the classifier's ignore mechanism, distinct from `excludeDirs` by *intent*) vs a richer
named-category policy (tests/ci/deps/generated toggles) → simple `exemptPatterns` (YAGNI); (c)
**default breadth** — tests only vs tests + lockfiles + CI → **tests only, rest configurable** via
`exemptPatterns`. Mechanics: exempt is evaluated after `excludeDirs`, before `docPatterns`; an exempt
path is dropped from `nonDoc` and doesn't set `docChanged`, so a test-only PR passes without an ack
while tests + real code still enforces.

## Design shape (validated)

- **`doc-classify.mjs`** — file list on stdin, optional `--config <path>`; reads
`docPatterns` + `excludeDirs` (or built-in default: `CLAUDE*.md`, `README*.md`, `CHANGELOG.md`,
`docs/**`, `.claude/**/*.md`); emits `{"nonDoc":[…],"docChanged":bool}`. Tiny in-house glob
matcher (`*`, `**`), no external deps. Unit-tested with `node --test`.
- **`revise-push-guard.sh` / `docs-ci-check.sh`** — become thin git wrappers that pipe changed
files to the classifier; drop their duplicated `is_doc`/`docMode`/config-parsing.
- **audience-rules** — gains a `docPatterns:` block co-located with `excludeDirs`.
- **Install skills** — copy `doc-classify.mjs` alongside their script; write `docPatterns` instead
of `docMode`.

## Trade-offs / risks

- Hard-retire `docMode` → stale configs fall back to default until regenerated (accepted).
- Both scripts now depend on invoking a node **script file** (not just `node -e`) — vendored copies
must ship `doc-classify.mjs`.
- Per-commit `[skip docs]` logic in the hook may call the classifier per commit; ranges are small,
acceptable.
- The underlying "crude nag" limitation is unchanged — this change fixes correctness + duplication,
not the heuristic's inherent imprecision. Staleness check stays advisory.

## Non-goals

Wiring skills' runtime to `docPatterns`; changing `[skip docs]` semantics; any LLM-in-CI judgement;
altering the merge-base baseline or advisory/blocking posture.
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
## Context

doc-sweep classifies "what is a doc" in three disagreeing places: the audience-rules prose (the
skills' source of truth), and a verbatim-duplicated `is_doc()` in each of `revise-push-guard.sh`
(the local hook) and `docs-ci-check.sh` (the CI check). The scripts key off a `docMode` enum
(`minimal`/`with-skill`/`default`) hardcoded as bash globs. That set omits `.claude/**/*.md`, which
the audience-rules base explicitly calls Claude-facing docs — so a PR editing only
`.claude/context/audience-rules.md` is scored as "code changed, no docs" and the CI check fails it.
The duplication also guarantees future drift. Constraints from the repo: node is already a hard
dependency ("no jq"); the CI check must stay deterministic and secret-free; `excludeDirs` already
establishes a "persist in audience-rules, mirror into the per-install config JSON" flow the scripts
read via `JSON.parse`; a `uses:`-style shared action was previously rejected on supply-chain grounds
(the CI script is vendored into consumer repos instead).

## Goals / Non-Goals

**Goals**
- One definition of the script-side doc-file-set, fixing the `.claude/**` divergence bug.
- Kill the copy-pasted `is_doc` across the two scripts.
- Keep the classifier deterministic, dependency-free, and unit-testable.
- Preserve the existing `excludeDirs` mechanism rather than invent a parallel one.

**Non-Goals**
- Wiring the skills' runtime to `docPatterns` — they classify by *audience* (Claude vs human) among
known docs, not doc-vs-non-doc, so there is no `is_doc` to share. They stay prose-driven.
- Changing `[skip docs]` semantics, the merge-base baseline, or the advisory (non-blocking) posture.
- Fully solving whether docs are *warranted* — `exemptPatterns` (D7) coarsely reduces false
positives for known no-doc categories (tests by default), but the check still can't judge whether
an arbitrary code change needs docs; `[skip docs]` remains the escape hatch for the rest.

## Decisions

**D1 — Unify up, not simplify down.** Two directions: (A) one shared declarative doc-set + shared
classifier; (B) accept the crude nag and reduce docs to "any `*.md` or `docs/`", deleting `docMode`.
Absent the bug, B would win for a solo repo; the divergence bug tips it to A because the fix and the
unification are the same work.

**D2 — Scope: scripts + shared source; skills untouched at runtime.** Rejected wiring the skills to
read `docPatterns` (forced fit — they don't do doc-vs-non-doc classification, and it would churn
their eval benchmarks). Rejected script-dedupe-only (leaves two definitions). Chosen: a shared
declarative `docPatterns` source + one classifier the scripts consume; skills keep prose rules with a
consistent machine-readable twin.

**D3 — Patterns live in audience-rules, mirrored to the config JSON (no new parser).** Reading
`docPatterns` straight from markdown would need a YAML-in-markdown parser in node. Instead reuse the
established `excludeDirs` flow: `audience-rules.md` is the human-authoritative source that the
install skill reads/writes and **mirrors** into the per-install config JSON; the classifier reads
`docPatterns`/`excludeDirs` from that JSON, else a built-in default. The vendored CI `docs-ci.json`
carries the mirror; funbox's no-config dogfood run uses the built-in default.

**D4 — Hard-retire `docMode`.** The classifier understands only `docPatterns` + the built-in
default. Removed from both scripts, the config schema, and both installers (which now write
`docPatterns`). Alternatives (alias `docMode`→presets; keep both) rejected to avoid perpetuating the
two-ways-to-say-it problem. Accepted breaking edge: a stale config still carrying `docMode` falls
back to the default set until regenerated.

**D5 — node module for classification, bash for git.** git plumbing (merge-base, diff, log,
per-commit `[skip docs]`) stays bash; classification + JSON + glob matching move into one
`doc-classify.mjs` that both scripts shell into once. A tiny in-house glob matcher (`*`, `**`) avoids
any external dependency. Vendored alongside each installed script.

**D6 — Classifier interface.** `doc-classify.mjs` reads a newline-separated file list on stdin,
takes optional `--config <path>`, and emits `{"nonDoc":[…],"docChanged":bool}` on stdout. Config
resolution: `docPatterns`/`excludeDirs`/`exemptPatterns` from `--config` JSON if present, else
built-in defaults (`CLAUDE*.md`, `README*.md`, `CHANGELOG.md`, `docs/**`, `.claude/**/*.md`). The
hook additionally invokes it per-commit for its `[skip docs]` per-commit rule (small ranges,
acceptable cost).

**D7 — Exempt categories (three-way classification).** Add an `exemptPatterns` glob set for
first-party changes that don't require docs, evaluated AFTER `excludeDirs` and BEFORE `docPatterns`;
an exempt path is neither doc nor non-doc (dropped from `nonDoc`, doesn't set `docChanged`). Default:
common test globs (`**/*.test.*`, `**/*.spec.*`, `**/test/**`, `**/tests/**`, `**/__tests__/**`,
`**/*_test.go`, `**/*_test.py`); a configured list replaces the default. This directly addresses the
adversarial review's "crude nag" critique — a test-only PR passes without an ack, while tests +
`src/app.js` still enforces. *Alternatives:* a richer named-category policy (tests/ci/deps/generated
toggles) — rejected as over-built (YAGNI); merging into `excludeDirs` — rejected to preserve the
vendored-vs-doesn't-need-docs *intent* distinction, even though the mechanism is the same "drop from
nonDoc." Default kept to tests only; projects add lockfiles/CI/etc. via `exemptPatterns`.

## Risks / Trade-offs

- **Stale `docMode` config → default fallback** → acceptable (effectively single-user); installers
regenerate configs with `docPatterns`.
- **Vendored copies must now ship two files** (`docs-ci-check.sh` + `doc-classify.mjs`) → installers
copy both; uninstall removes both.
- **In-house glob matcher could mis-handle an exotic pattern** → keep the supported syntax explicit
(`*` within a segment, `**` across segments, literals) and unit-test the corners; `docPatterns`
authors stick to that vocabulary.
- **Per-commit classifier calls in the hook loop** → ranges are small; if ever hot, batch later.
- **Heuristic still crude** → unchanged by design; the check stays advisory, `[skip docs]` remains
the escape hatch.

## Migration Plan

1. Land `doc-classify.mjs` + unit tests; wire the node test into CI.
2. Refactor both scripts to delegate to it; update their shell test suites (incl. an
audience-rules-only-PR regression that must pass).
3. Add `docPatterns` to the audience-rules; update both install skills to vendor the classifier and
write `docPatterns` (drop `docMode`); regenerate their eval benchmarks.
4. Update doc-sweep README/CHANGELOG; funbox dogfood already runs no-config → picks up the fixed
default automatically.
5. Rollback: revert is additive — restore the inline `is_doc` and `docMode` reads; no data migration.

## Open Questions

None blocking. Whether to later expose `docPatterns` to the skills' runtime is deferred (see D2).
Loading