diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 532c81d..edc9637 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,18 +6,18 @@ "url": "https://github.com/ccf/humanize" }, "metadata": { - "description": "Remove AI tells from prose. Skill + /humanize command grounded in StoryScope (arXiv 2604.03136).", - "version": "0.2.0" + "description": "Measures prose against 13 studies of how AI and human writing differ, from word choice to sentence rhythm to story shape, then rewrites the tells out.", + "version": "0.3.0" }, "plugins": [ { "name": "humanize", - "description": "Audit prose for AI tells and rewrite it to read as natural human writing, grounded in StoryScope's measured human-vs-AI feature gaps", - "source": "./plugins/humanize", - "version": "0.2.0", + "description": "Audits a draft layer by layer (vocabulary, punctuation, sentence shape, openers, closers, grammar, narrative, per-model habits) against 13 studies, then rewrites what it flags in the author's voice.", + "source": "./", + "version": "0.3.0", "author": { "name": "ccf" }, "license": "MIT", - "keywords": ["writing", "prose", "editing", "ai-detection", "storyscope", "humanize"], + "keywords": ["humanize", "writing", "prose", "editing", "style", "ai-writing", "rewriting", "linguistics"], "category": "writing", "strict": true } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..8670ef9 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "humanize", + "version": "0.3.0", + "description": "Audits a draft layer by layer (vocabulary, punctuation, sentence shape, openers, closers, grammar, narrative, per-model habits) against 13 studies, then rewrites what it flags in the author's voice.", + "author": { "name": "ccf" }, + "license": "MIT", + "keywords": ["humanize", "writing", "prose", "editing", "style", "ai-writing", "rewriting", "linguistics"] +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..3607618 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "humanize", + "version": "0.3.0", + "description": "Audits a draft layer by layer (vocabulary, punctuation, sentence shape, openers, closers, grammar, narrative, per-model habits) against 13 studies, then rewrites what it flags in the author's voice.", + "author": { "name": "ccf" }, + "homepage": "https://github.com/ccf/humanize", + "repository": "https://github.com/ccf/humanize", + "license": "MIT", + "keywords": ["humanize", "writing", "prose", "editing", "style", "ai-writing", "rewriting", "linguistics"], + "skills": "./skills/", + "interface": { + "displayName": "humanize", + "shortDescription": "Audit prose for AI tells at every layer, then rewrite it in the author's voice.", + "longDescription": "Reads a draft against what 13 studies measured about AI writing: word choice, punctuation, sentence shape, openers, closers, grammar, register, fiction habits, per-model quirks. A dependency-free scanner supplies the counts a model cannot eyeball, and each flag cites the sentence that triggered it.", + "developerName": "ccf", + "category": "Developer Tools", + "capabilities": ["Read", "Write"], + "defaultPrompt": ["Humanize this draft.", "Audit this text for AI tells, don't rewrite it."], + "websiteURL": "https://github.com/ccf/humanize" + } +} diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 4a28ce5..4403e85 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -1,17 +1,24 @@ # Bugbot review guide — humanize -This repo is a Claude Code plugin that audits prose for AI tells and rewrites -it. Design spec: `docs/design/2026-09-13-humanize-plugin-design.md` and -`docs/design/2026-09-14-humanize-v0.2-design.md`. +This repo is an agent skill that audits prose for AI tells and rewrites it, on +evidence from the studies in `skills/humanize/references/SOURCES.md`. Design specs: +`docs/design/2026-09-1{3,4}-humanize-*-design.md`. ## Invariants to enforce -- `plugins/humanize/skills/humanize/scripts/surface_scan.py` imports only the +- `skills/humanize/scripts/surface_scan.py` imports only the Python standard library and runs on Python 3.9+. Flag any third-party import or 3.10+ syntax (match statements, `X | Y` in runtime positions, PEP 604 in non-annotation code). -- Nothing under `tests/` or `plugins/**/scripts/` makes network or LLM calls. -- A `Base rate:` line in `plugins/humanize/skills/humanize/references/*.md` +- Nothing under `tests/`, `skills/**/scripts/`, or `tools/package_skill_zip.py` makes network or LLM calls. +- `skills/` is harness-agnostic: no harness variables (`${CLAUDE_…}`, `HERMES_SKILL_DIR`, + `CURSOR_…`, `CODEX_…`) and no install commands inside it; frontmatter is exactly the Agent + Skills fields (`argument-hint` is a hard error outside Claude Code). `tests/test_portability.py` + enforces both. +- Version lives in `plugin.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, both + `marketplace.json` fields, `pyproject.toml`, and the newest CHANGELOG heading — + `tests/test_portability.py` fails on drift. +- A `Base rate:` line in `skills/humanize/references/*.md` must trace to a row in `data/storyscope_feature_gaps.csv` (or a future CSV documented in `data/README.md`; none added in v0.2). If a PR changes a number, check the CSV row. Any other number must sit on a `Scan:`, `Rule diff --git a/.gitignore b/.gitignore index 54336b0..0707e34 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ __pycache__/ .pytest_cache/ .venv/ .superpowers/ +dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2295bd0..38203d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.3.0] - 2026-09-14 + +### Added +- Agent Plugins 1.0 manifest (`plugin.json`) and Codex manifest (`.codex-plugin/plugin.json`), + so the plugin installs into Codex, Cursor, and Hermes with their own tooling. +- `tests/test_portability.py`: spec-only frontmatter, no harness-specific content under + `skills/`, runtime references named, guard-clean scripts, manifests and CHANGELOG in + version lockstep, zip packager contract, study count matches `SOURCES.md`. +- `tools/package_skill_zip.py`: builds the claude.ai skill upload from the plugin. +- `tools/smoke_harnesses.sh`: local acceptance matrix with scanner-evidence PASS rule; + transcripts under `docs/acceptance/v0.3/`. +- README install table for Claude Code, Codex CLI, Cursor, Hermes Agent, Claude Desktop. + +### Changed +- Repository layout: the plugin now lives at the root (`skills/humanize/`); the marketplace + `source` is `./`. +- `SKILL.md` is harness-agnostic: relative `scripts/surface_scan.py` path, plain-words options, + document extraction described generically; `argument-hint` removed (a hard error outside + Claude Code). The `/plugin` install lines moved to the README. +- Public copy describes humanize as grounded in the thirteen studies in + `references/SOURCES.md` and covering every layer of a text; StoryScope is named where it is + the source of a number. +- `pyproject.toml` version now tracks the plugin version. + +### Migration +- Existing Claude Code installs: `claude plugin marketplace update humanize && claude plugin + update humanize@humanize`. + ## [0.2.0] - 2026-09-14 ### Added @@ -87,7 +115,8 @@ Initial release (#1). CI (pre-commit, pytest on Python 3.9 and 3.13, `claude plugin validate --strict`), and a Bugbot review guide. -[Unreleased]: https://github.com/ccf/humanize/compare/v0.2.0...HEAD +[Unreleased]: https://github.com/ccf/humanize/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/ccf/humanize/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/ccf/humanize/compare/v0.1.2...v0.2.0 [0.1.2]: https://github.com/ccf/humanize/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/ccf/humanize/compare/v0.1.0...v0.1.1 diff --git a/CLAUDE.md b/CLAUDE.md index 97e00fb..f247fe0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,16 +1,16 @@ # humanize — working notes for Claude -Claude Code plugin that audits prose for AI tells and rewrites it. Evidence base: -StoryScope (arXiv 2604.03136). Spec and plan: `docs/design/`. Changelog: `CHANGELOG.md`. +Agent skill that audits prose for AI tells and rewrites it. Evidence base: 13 studies +in `references/SOURCES.md`. Spec and plan: `docs/design/`. Changelog: `CHANGELOG.md`. ## Commands ``` uv sync # first time -uv run pytest -q # 100 tests, must be warning-free +uv run pytest -q # must be warning-free uv run ruff format && uv run ruff check --fix -claude plugin validate --strict . -uv run python plugins/humanize/skills/humanize/scripts/surface_scan.py --text +claude plugin validate --strict . # marketplace JSON only; does not inspect plugin or skill contents +uv run python skills/humanize/scripts/surface_scan.py --text python3 tools/gen_tell_scaffold.py style|narrative # regenerate reference scaffolds ``` @@ -47,8 +47,16 @@ Never run bare `ruff format .` — ruff 0.16 formats Python fences inside - `SKILL.md` stays under ~150 lines. `/humanize` is the skill itself — do not add a `commands/` directory (it registers a duplicate skill named `humanize`). - Marketplace entry stays `strict: true` with no component arrays; `plugin.json` - is authoritative. Bump `version` in both when shipping. -- Nothing under `tests/` or `plugins/**/scripts/` touches the network. + is authoritative. Version lives in `plugin.json`, `.claude-plugin/plugin.json`, + `.codex-plugin/plugin.json`, both `marketplace.json` fields, `pyproject.toml`, + and the newest CHANGELOG heading — `tests/test_portability.py` fails on drift. +- Nothing under `tests/`, `skills/**/scripts/`, or `tools/package_skill_zip.py` touches the network. +- `skills/` is harness-agnostic: no harness variables (`${CLAUDE_…}`, `HERMES_SKILL_DIR`, + `CURSOR_…`, `CODEX_…`) and no install commands inside it; frontmatter is exactly the Agent + Skills fields (`argument-hint` is a hard error outside Claude Code). `tests/test_portability.py` + enforces both. +- Public copy names StoryScope only where it is the specific source of a number or a file; the + tool is described as grounded in the `SOURCES.md` registry (the study count is tested). ## Workflow @@ -57,7 +65,13 @@ linear history required, the four CI jobs must pass on an up-to-date branch, and every review thread must be resolved — so after fixing a Bugbot finding, resolve its thread (GraphQL `resolveReviewThread`) before merging. Merge with `gh pr merge --rebase` (rebase is the only enabled method; merged branches are -deleted automatically). Tag releases on `main` after the merge (`vX.Y.Z`). CI runs pre-commit, pytest (3.9 and +deleted automatically). Release: `tools/smoke_harnesses.sh` must PASS on every harness installed +here (transcripts under `docs/acceptance/`), then merge, then post-merge acceptance from `main` +(Codex `plugin marketplace add ccf/humanize` without a ref, Hermes `skills install`, Claude plugin +upgrade, Cowork marketplace add), then tag and `gh release create vX.Y.Z +dist/humanize-skill-X.Y.Z.zip` (built by `python3 tools/package_skill_zip.py`), then verify the zip +uploads and triggers in claude.ai chat. Anything found after the merge is fixed forward as a patch +release. CI runs pre-commit, pytest (3.9 and 3.13), and plugin validation; Cursor Bugbot reviews every PR and re-reviews on push. Pre-commit hooks run on every commit; never `--no-verify`. After merging a plugin change: `claude plugin marketplace update humanize && claude plugin update humanize@humanize`. diff --git a/README.md b/README.md index 12c816f..6e730cc 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,55 @@ # humanize -A Claude Code plugin that finds and removes the tells that mark prose as -AI-generated, and rewrites it to read as natural human writing — without -flattening the author's voice. - -It is grounded in [StoryScope](https://github.com/jenna-russell/storyscope) -(Russell, Rajendhran, Pham, Iyyer, Wieting, *StoryScope: Investigating -idiosyncrasies in AI fiction*, -[arXiv:2604.03136](https://arxiv.org/abs/2604.03136)), which measured 304 -narrative and stylistic features on 61,575 stories and found that AI writing -converges on shared defaults while human writing disperses. This plugin turns -the 77 features with the largest human-vs-AI gaps into an audit checklist, -adds the surface-level tells StoryScope deliberately excluded, and pairs both -with a dependency-free scanner for the numbers a model can't eyeball. v0.2 -adds a grammar and repetition layer from register and reader-perception -studies (Reinhart et al. 2025; Jakesch et al. 2023; Herbold et al. 2023 and -others); every cited number resolves in `references/SOURCES.md`. +An agent skill that audits prose for the tells of AI writing and rewrites them +out, without flattening the author's voice. Runs in Claude Code, Codex, Cursor, +Hermes Agent, Claude Desktop. + +The reference entries rest on thirteen studies. StoryScope (2026) measured 304 +narrative and stylistic features over 61,575 stories, and its widest human-vs-AI +gaps supply the base rates. Reinhart et al. (2025) put participial modifiers at +5.3 times the human rate and nominalization at 2.1 times. Jakesch et al. (2023) +found repeated phrasing the strongest true signal of a text's source. Herbold et +al. (2023) recorded a lexical-diversity reversal between model generations, a +reminder that findings expire. humanize turns that work into an audit checklist +plus a dependency-free scanner for the counts a model cannot eyeball; every flag +quotes the line it came from. One pattern organizes the whole checklist: AI +converges on shared defaults while human writing disperses. Every cited number +resolves in `references/SOURCES.md`. ## Install -``` -/plugin marketplace add ccf/humanize -/plugin install humanize@humanize -``` - Requires Python 3.9+ on `PATH` for the scanner. No other dependencies. - -Optional, for Word/PDF/PowerPoint inputs: Anthropic's `document-skills` -plugin, which humanize delegates extraction to (its `docx` read path uses -`pandoc`; `brew install pandoc` on macOS). - -``` -/plugin marketplace add anthropics/skills -/plugin install document-skills@anthropic-agent-skills -``` +Commands were checked against each CLI's `--help` for this release; installs +that fetch from GitHub (Codex, Hermes) are exercised at the acceptance steps +recorded in `docs/acceptance/`. + +| Harness | Install | Verify | Invoke | +|---|---|---|---| +| Claude Code | `/plugin marketplace add ccf/humanize` then `/plugin install humanize@humanize` | `claude plugin list` | `/humanize …` or automatically | +| Codex CLI | `codex plugin marketplace add ccf/humanize` then `codex plugin add humanize@humanize` — or `cp -R skills/humanize ~/.agents/skills/` | `codex debug prompt-input "hi"` lists the skill | `$humanize …` or automatically | +| Cursor | `cp -R skills/humanize ~/.cursor/skills/` (every project) or `.cursor/skills/` (this project) | the skill appears in the `/` menu | `/humanize …` or automatically | +| Hermes Agent | `hermes skills install ccf/humanize/humanize --category writing`; in a running session, `/reload-skills` | `hermes skills list` | `/humanize …` or automatically | +| Any Agent-Skills harness | `npx skills add ccf/humanize` or `cp -R skills/humanize ~/.agents/skills/` | harness-specific | harness-specific | +| Claude Desktop — Cowork | Customize → Plugins → Add from repository `ccf/humanize` → install `humanize` | listed under Customize → Plugins | `/` or `+` picker, or automatically | +| Claude Desktop — chat (claude.ai) | Customize → Skills → upload `humanize-skill-.zip` from the [latest release](https://github.com/ccf/humanize/releases); "Code execution and file creation" must be on | listed under Customize → Skills | automatically, or the sidebar `/` menu | + +Notes: + +- **Codex** reads `/.agents/skills/` and `~/.agents/skills/` but not `.claude/skills/`, and + caps the injected skills catalog at 2% of the context window; a long catalog drops skills. +- **Cursor** syncs only `~/.cursor/skills/` to Cloud Agents, and only with **Sync Skills for Cloud + Agents** on (Settings → Agents). The Cursor marketplace is reviewed by hand and has no CLI. +- **Hermes** scans installed scripts (`surface_scan.py` is standard-library and passes) and runs + the scanner on the host, so on a remote terminal backend (docker, modal, ssh) the scan step is + skipped and the audit proceeds from reading alone. `hermes plugins install` also works on + recent builds but installs the package disabled and read-only; prefer `hermes skills install`. + Installing from a raw `SKILL.md` URL fetches one file and is not supported. +- **Claude Desktop** picks up a Cowork plugin update only when `version` changes. In claude.ai + chat the skill triggers by description; picking it from the `/` menu passes no arguments. +- **Word, PDF, PowerPoint inputs.** humanize delegates extraction to the harness's document + skills. In Claude Code that is Anthropic's `document-skills` plugin (`/plugin marketplace add + anthropics/skills`, `/plugin install document-skills@anthropic-agent-skills`; its `docx` read + path uses `pandoc` — `brew install pandoc` on macOS); Claude Desktop ships the same skills. ## Use @@ -100,23 +116,22 @@ Rewrite (excerpt): ## What's inside ``` -plugins/humanize/ - skills/humanize/ - SKILL.md the procedure - references/ - principles.md - surface-tells.md vocabulary, punctuation, shape, discourse moves - style-tells.md 20 StoryScope style features with base rates - narrative-tells.md 57 StoryScope narrative features (fiction only) - model-fingerprints.md Claude / GPT / Gemini / DeepSeek / Kimi tendencies - SOURCES.md citation registry (not loaded at runtime) - scripts/surface_scan.py stdlib-only metrics: burstiness and sentence tails, punctuation, - tricolons, not-but, wordlists, closers, repeated phrases, - participial tails, container nouns, nominalization hits, - disclaimer opener -data/ StoryScope taxonomy + computed feature gaps -tools/gen_tell_scaffold.py regenerate reference scaffolds from the data -tests/ pytest; no network, no LLM calls +skills/humanize/ + SKILL.md the procedure + references/ + principles.md + surface-tells.md vocabulary, punctuation, shape, discourse moves + style-tells.md 20 style features with StoryScope base rates + narrative-tells.md 57 narrative features (fiction only), StoryScope base rates + model-fingerprints.md Claude / GPT / Gemini / DeepSeek / Kimi tendencies + SOURCES.md citation registry (not loaded at runtime) + scripts/surface_scan.py stdlib-only metrics: burstiness and sentence tails, punctuation, + tricolons, not-but, wordlists, closers, repeated phrases, + participial tails, container nouns, nominalization hits, + disclaimer opener +data/ StoryScope taxonomy + computed feature gaps +tools/gen_tell_scaffold.py regenerate reference scaffolds from the data +tests/ pytest; no network, no LLM calls ``` ## Development @@ -124,8 +139,8 @@ tests/ pytest; no network, no LLM calls ``` uv sync uv run pytest -q -claude plugin validate . -uv run python plugins/humanize/skills/humanize/scripts/surface_scan.py --text some.txt +claude plugin validate --strict . +uv run python skills/humanize/scripts/surface_scan.py --text some.txt ``` CI (`.github/workflows/ci.yml`) runs the same pre-commit hooks, pytest on diff --git a/docs/acceptance/v0.3/README.md b/docs/acceptance/v0.3/README.md new file mode 100644 index 0000000..7bbedfc --- /dev/null +++ b/docs/acceptance/v0.3/README.md @@ -0,0 +1,191 @@ +# v0.3 pre-merge acceptance + +Local acceptance matrix for the portable `humanize` skill (v0.3), run before merging +`feat/v0.3`. Not run by CI or pytest; run manually with `tools/smoke_harnesses.sh`. + +## Date + +2026-09-14 + +## What the transcripts are + +Each `.md` file in this directory is the captured transcript of one headless +run of a harness (Claude Code, Codex, Cursor, or Hermes) asked to run the humanize +skill, audit-only, against `tests/fixtures/ai_report.txt`. The script installs the +working-tree copy of `skills/humanize` into each harness the harness's own way (a +temp project for Codex and Cursor, `--plugin-dir .` for Claude Code, a copy into +`~/.hermes/skills/writing/humanize` for Hermes), sends one non-interactive request, +and captures the output. + +A PASS or FAIL transcript has three H2 sections, in this fixed order; a SKIP +transcript (authentication failure) is the harness's raw CLI output with no section +headings at all: + +- `## Tool calls` — the commands or tool inputs the agent issued (for Claude Code and + Codex, only the actual scanner invocation; reads of `SKILL.md` never appear here). +- `## Scanner output` — the raw output of any scanner invocation. +- `## Output` — the agent's own final text only. For Claude Code this excludes the + Skill launch's injected `SKILL.md` dump (it lands in neither section). Where a + harness's headless mode doesn't expose tool-call/tool-result data at all (Cursor's + `--output-format text`, Hermes's oneshot/quiet mode), the first two sections say so + and the full raw response goes under `## Output`. + +The only CLI state the script touches across a run is a transient disable/enable of +the installed `humanize@humanize` plugin around the Claude Code run (so `--plugin-dir +.` is exercised instead of the installed copy); it is re-enabled whether or not the +run succeeds, and nothing else on the machine is reconfigured. + +## PASS rule + +A harness is judged PASS only with evidence that both the skill and the scanner ran. +The judge is section-aware: each check below runs against the section named, not the +whole transcript. + +- All four expected audit rows are present in `## Output` (case-insensitive), matched + by concept rather than by our own reference titles: a participial-tail row, a + repetition row, a container-noun row, and a disclaimer-opener row. +- No nominalization row in `## Output` (nominalization is reported-only per spec, + never a row). +- The scanner's exact participial-tails count and repetition rate (computed by the + script from a live `surface_scan.py` run against the same fixture) appear verbatim + in `## Scanner output` or `## Output`. +- Where the harness exposes tool-call events (Claude Code, Codex), `## Tool calls` + has a tool event naming `surface_scan.py`. +- No rewrite section (`## Rewrite` or "Choices you may want to reverse") anywhere in + `## Output` — the request was audit-only. + +`tools/smoke_harnesses.sh` prints one `PASS `, `FAIL `, or +`SKIP (reason)` line per harness, with reason lines above a FAIL. + +The container-noun row is the concept the Codex smoke run previously dropped while +paraphrasing our reference titles, so its regex now also accepts the scanner's own +`container_of` hit text on the fixture (`a sense of`, `The weight of`), computed fresh +from a live scan each run rather than hardcoded, in addition to the word "container" +itself — a model quoting either satisfies the row. On the 2026-09-14 re-run under this +change, Codex named the row explicitly ("Abstract container phrases — weak signal"), +quoting both hits verbatim, and judged PASS alongside Claude Code; a Codex run that +names only that row incompletely, or omits it, would still FAIL and is a recorded +outcome, not grounds to loosen the judge further. + +## SKIP on authentication failure + +If a harness's raw output contains a recognized authentication-failure signature +(`Authentication required`, `AuthError`, `token refresh failed`, or +`Please run 'agent login'`), the script prints +`SKIP (not authenticated: )` instead of running the +judge, and still saves the raw output as that harness's transcript. The script never +runs a login command itself — an auth failure means the user needs to sign that CLI +in before the next run. + +## CLI versions (pinned before running, 2026-09-14) + +| CLI | Version | +| --- | --- | +| `claude` | 2.1.270 (Claude Code) | +| `codex` | codex-cli 0.154.0 | +| `agent` (Cursor) | 2026.01.23-916f423 | +| `hermes` | Hermes Agent v0.11.0 (2026.4.23) | + +## Deviation from the brief + +The brief's `run_cursor` used `agent -p "$REQUEST"`. The installed `agent` CLI's +config (`~/.cursor/cli-config.json`) has `approvalMode: allowlist` with only +`Shell(ls)` allowed, so a headless run could not execute the scanner. The script +instead runs `agent -p --force --output-format text ""` — `--force` and +`--output-format` are both flags on the installed CLI (confirmed via `agent --help`). +This is an authorized deviation from the controller; it does not change what the CLI +is configured to do outside of this one invocation, and the script never edits Cursor +config. + +## Side effects + +- Codex and Cursor runs use temp projects; Codex additionally runs with `--ephemeral` + (nothing written under `$HOME`). +- Hermes has no per-project skill scope on v0.11.0: the script copies + `skills/humanize` into `~/.hermes/skills/writing/humanize` for the run and removes + only that directory afterward. If that path already exists, the run is SKIPped and + nothing is touched. +- Acceptance 3 (spec §8; `codex plugin marketplace add ccf/humanize@feat/v0.3`, see + below) adds a marketplace to the user's Codex config; the same run removes the + plugin and the marketplace afterward and verifies with `codex plugin marketplace + list`. +- The script never updates or reconfigures any harness CLI. + +## Acceptance 3 (spec §8) + +Spec §8.3: `codex plugin marketplace add ccf/humanize@feat/v0.3 && codex plugin add +humanize@humanize` installs and the skill surfaces in `codex debug prompt-input "hi"` +(pre-merge); the same without `@ref` after the merge. Run 2026-09-14, verbatim: + +``` +$ git push origin feat/v0.3 +Everything up-to-date + +$ codex plugin marketplace add ccf/humanize@feat/v0.3 +Added marketplace `humanize` from https://github.com/ccf/humanize.git#feat/v0.3. +Installed marketplace root: /Users/ccf/.codex/.tmp/marketplaces/humanize + +$ codex plugin add humanize@humanize +Added plugin `humanize` from marketplace `humanize`. +Installed plugin root: /Users/ccf/.codex/plugins/cache/humanize/humanize/0.3.0 + +$ codex debug prompt-input "hi" | grep -i humanize +- humanize:humanize: Use when drafting or editing any prose — email, essay, + documentation, blog post, story, chat reply — or when asked to "humanize" text, + make it "sound less like AI", "more natural", "less robotic", or remove AI tells. + Also use when reviewing prose someone else wrote. Not for code, config, or commit + messages. (file: r5/humanize/0.3.0/skills/humanize/SKILL.md) + +$ codex plugin remove humanize@humanize +Removed plugin `humanize` from marketplace `humanize`. + +$ codex plugin marketplace remove humanize +Removed marketplace `humanize`. +Removed installed marketplace root: /Users/ccf/.codex/.tmp/marketplaces/humanize + +$ codex plugin marketplace list +MARKETPLACE ROOT +openai-primary-runtime /Users/ccf/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime +openai-bundled /Users/ccf/.codex/.tmp/bundled-marketplaces/openai-bundled +openai-curated /Users/ccf/.codex/.tmp/plugins +agentcairn /Users/ccf/git/agentcairn +``` + +Outcome: install resolved cleanly to `0.3.0`, matching the version pinned across the +repo's manifests; the skill was listed by `codex debug prompt-input`; the plugin and +marketplace were both removed afterward, and the final `codex plugin marketplace +list` matches the pre-run baseline exactly (no `humanize` row). PASS. + +## Acceptance 7 (spec §8) + +Spec §8.7 (amended — see the design doc's Post-review amendments): `grep -ri +storyscope` over the manifests, `pyproject.toml`, and SKILL.md returns nothing; the +README's StoryScope mentions occur only at sourced positions; every README command +was checked against its CLI's `--help`; the README grounding paragraph scans clean +of wordlist hits. Run 2026-09-14: + +- `grep -ri storyscope plugin.json .codex-plugin/plugin.json .claude-plugin/*.json + pyproject.toml skills/humanize/SKILL.md` — no output (empty). +- README StoryScope positions (`grep -n -i storyscope README.md`): lines 7 (the + grounding paragraph, sourced explicitly), 124–125 (`style-tells.md` / + `narrative-tells.md` file descriptions — source of the base rates), 132 (`data/` + tree line — the directory holds StoryScope's taxonomy), 155–156 (the license/credit + paragraph — attribution is required there), 160 (the AI fiction fixture's + provenance). All seven are sourced positions per spec §9; none are general + positioning copy. +- Every README command checked against its CLI's `--help`: Claude Code, Codex, and + Cursor rows verified directly; `hermes skills install --help` confirms the Hermes + row's `identifier` (`owner/repo/path`) and `--category`; `npx skills --help` / + `npx skills add --help` (skills.sh CLI 1.5.26) confirm `add ` accepts the + `owner/repo` shorthand the skills.sh row uses. +- `python3 skills/humanize/scripts/surface_scan.py --text` on the README grounding + paragraph (`README.md:7-17`): `wordlist: 0.0/1k — none`. No wordlist hits. + +PASS. + +## Pre-merge status + +Claude Code: PASS. Codex: PASS. Cursor: SKIP (not authenticated on this machine). +Hermes: SKIP (not authenticated on this machine). Both SKIPs move to phase 2 +(post-merge, pre-tag) per the design doc's Post-review amendments, and are re-run +after login, with transcripts committed to this directory. diff --git a/docs/acceptance/v0.3/claude-code.md b/docs/acceptance/v0.3/claude-code.md new file mode 100644 index 0000000..f62439d --- /dev/null +++ b/docs/acceptance/v0.3/claude-code.md @@ -0,0 +1,175 @@ +# Claude Code + +## Tool calls +{ + "command": "python3 skills/humanize/scripts/surface_scan.py --text tests/fixtures/ai_report.txt", + "description": "Run the surface scanner on the fixture" +} +{ + "command": "python3 skills/humanize/scripts/surface_scan.py --text tests/fixtures/ai_report.txt --json 2>/dev/null | python3 -c \"\nimport json,sys\nd=json.load(sys.stdin)\nfor k in ('discourse','grammar','repetition','structures'):\n print(k, json.dumps(d.get(k), indent=1)[:3000])\n\" || python3 skills/humanize/scripts/surface_scan.py --help", + "description": "Dump scanner JSON blocks for discourse, grammar, repetition, structures" +} +{ + "command": "python3 skills/humanize/scripts/surface_scan.py tests/fixtures/ai_report.txt | python3 -c \"\nimport json,sys\nd=json.load(sys.stdin)\nprint(json.dumps({k:d.get(k) for k in ('discourse','grammar','repetition','structures')}, indent=1))\n\"", + "description": "Dump scanner JSON blocks for discourse, grammar, repetition, structures" +} + +## Scanner output +words 620 · sentences 34 · paragraphs 12 +sentence length: mean 18.24, stdev 3.46, cv 0.19 (min 14, max 27) +paragraph length: mean 2.83 sentences, stdev 0.39, cv 0.137 +per 1k words: em-dash 0.0 (0) · en-dash 0.0 (0) · semicolon 0.0 (0) · colon 0.0 (0) · ellipsis 0.0 (0) · exclamation 0.0 (0) +structures: tricolon 1 · not-but 1 · rhetorical-q 0 · parallel-opener runs 0 · distinct openers 0.735 +wordlist: 0.0/1k — none +hedges 0.0/1k · intensifiers 0.0/1k +summary closer: yes · dialogue paragraphs: 0% +repetition: 3.2/1k · longest repeat 5 · "across all workstreams and teams"×3 +grammar: participial tails 14 (22.6/1k) ", reflecting the late discovery of schema drift in the" · container-of 2 "a sense of" +sentence tail: over-30 0.0% · p90 25 · longest flat run 8 +nominalization hits: 24 (validation×2, migration×2, implementation×1) · frames: "the implementation of", "the migration of" +Traceback (most recent call last): + File "", line 3, in + File "/Users/ccf/.pyenv/versions/3.12.2/lib/python3.12/json/__init__.py", line 293, in load + return loads(fp.read(), + ^^^^^^^^^^^^^^^^ + File "/Users/ccf/.pyenv/versions/3.12.2/lib/python3.12/json/__init__.py", line 346, in loads + return _default_decoder.decode(s) + ^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/ccf/.pyenv/versions/3.12.2/lib/python3.12/json/decoder.py", line 337, in decode + obj, end = self.raw_decode(s, idx=_w(s, 0).end()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/ccf/.pyenv/versions/3.12.2/lib/python3.12/json/decoder.py", line 355, in raw_decode + raise JSONDecodeError("Expecting value", s, err.value) from None +json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) +usage: surface_scan.py [-h] [--text] [path] + +Surface-level prose metrics (stdlib only). + +positional arguments: + path file to scan; reads stdin if omitted + +options: + -h, --help show this help message and exit + --text print a short summary instead of JSON +{ + "discourse": { + "summary_closer": true, + "disclaimer_opener": { + "fired": true, + "hits": [ + { + "text": "it's important to approach", + "sentence": 0 + }, + { + "text": "i'm not able to", + "sentence": 1 + } + ] + } + }, + "grammar": { + "participial_tail": { + "count": 14, + "rate": 22.6, + "hits": [ + { + "text": ", reflecting the late discovery of schema drift in the", + "sentence": 2 + }, + { + "text": ", ensuring alignment across teams before any record reaches", + "sentence": 3 + }, + { + "text": ", exceeding the target we set in March", + "sentence": 4 + }, + { + "text": ", creating a sense of momentum that was missing last quarter", + "sentence": 5 + }, + { + "text": ", allowing designers to retire eleven legacy patterns", + "sentence": 6 + }, + { + "text": ", giving on-call staff a clearer signal during incidents", + "sentence": 9 + }, + { + "text": ", matching the level the platform group had proposed", + "sentence": 10 + }, + { + "text": ", resolving most of them within the first business day", + "sentence": 11 + }, + { + "text": ", leaving two senior positions unfilled going into the next", + "sentence": 14 + }, + { + "text": ", assuming the compensation adjustments are approved", + "sentence": 16 + } + ] + }, + "container_of": { + "count": 2, + "hits": [ + { + "text": "a sense of", + "sentence": 5 + }, + { + "text": "The weight of", + "sentence": 15 + } + ] + } + }, + "repetition": { + "too_short": false, + "repeated_phrase_rate": 3.2, + "longest_repeat": 5, + "phrases": [ + { + "text": "across all workstreams and teams", + "count": 3, + "sentences": [ + 5, + 17, + 32 + ] + } + ] + }, + "structures": { + "tricolon": 1, + "not_but": 1, + "rhetorical_q": 0, + "parallel_openers": 0 + } +} + +## Output +I'll invoke the humanize skill in audit-only mode on the fixture. +Scan and references are all in hand. Writing up the audit now. + +Class: expository (quarterly status report, 620 words, 34 sentences, 12 paragraphs). Scanner ran on the file. Audit only, so no rewrite follows. + +| # | Tell | Evidence | Base rate / metric | +|---|------|----------|--------------------| +| 1 | Trailing participial clause | ", ensuring alignment across teams before any record reaches" (×14) | scan: 14 tails, 22.6/1k; ratio 5.3×, d = 1.38 [reinhart-2025] | +| 2 | Safety disclaimer opener | "It's important to approach this update carefully" / "I'm not able to share vendor pricing here, but" | scan: fired, 2 hits; 46% vs 0.2% of responses [rudnicka-2026] | +| 3 | Verbatim repetition | "across all workstreams and teams" (×3, sentences 5, 17, 32) | scan: 3.2/1k, longest repeat 5; OR 1.47 [jakesch-2023] | +| 4 | Uniform sentence length | every sentence 14–27 words; 0% over 30; longest flat run 8 | scan: cv 0.19; rule of thumb human 0.5–0.9, AI often below 0.4; humans 31.2% over 30 vs 17.5–21.0% [munoz-ortiz-2024] | +| 5 | Uniform paragraph length | 12 paragraphs of 2–3 sentences, topic sentence then two supports | scan: cv 0.137; rule of thumb below 0.3 is uniform | +| 6 | Summary closer | "In summary, delivery across all workstreams and teams stayed on plan despite the schema issue" | scan: summary_closer true | +| 7 | Abstract container-noun phrase | "creating a sense of momentum" / "The weight of the decision to pause backfills fell mostly on" | scan: container_of 2 (LAMP Table 8 heads) [chakrabarty-2025] | +| 8 | Predominant tonal quality (earnest) | "the overall picture is encouraging and the risks are manageable"; no dry line anywhere | AI 71% / human 40% (StoryScope STY_TON_021) | + +Two notes outside the table. The scanner's nominalization block is a prompt to look, not a row, and in a report it is native register. It still stands out here for the length: seven "the X of" frames open sentences ("the implementation of", "the migration of", "the optimization of", "the escalation of", "the transformation of", "the remediation of", "the integration of"). The flat sentence profile in row 4 is also the natural shape of plain-language status writing, so it is supporting evidence rather than authorship evidence on its own. + +Everything else stayed quiet: no wordlist hits, no hedges or intensifiers, no em-dashes, one tricolon, no rhetorical questions, opener variety at 0.735. diff --git a/docs/acceptance/v0.3/codex.md b/docs/acceptance/v0.3/codex.md new file mode 100644 index 0000000..5e9778c --- /dev/null +++ b/docs/acceptance/v0.3/codex.md @@ -0,0 +1,287 @@ +# Codex + +## Tool calls +/bin/zsh -lc 'python3 .agents/skills/humanize/scripts/surface_scan.py --text ai_report.txt' +/bin/zsh -lc 'python3 .agents/skills/humanize/scripts/surface_scan.py ai_report.txt' + +## Scanner output +words 620 · sentences 34 · paragraphs 12 +sentence length: mean 18.24, stdev 3.46, cv 0.19 (min 14, max 27) +paragraph length: mean 2.83 sentences, stdev 0.39, cv 0.137 +per 1k words: em-dash 0.0 (0) · en-dash 0.0 (0) · semicolon 0.0 (0) · colon 0.0 (0) · ellipsis 0.0 (0) · exclamation 0.0 (0) +structures: tricolon 1 · not-but 1 · rhetorical-q 0 · parallel-opener runs 0 · distinct openers 0.735 +wordlist: 0.0/1k — none +hedges 0.0/1k · intensifiers 0.0/1k +summary closer: yes · dialogue paragraphs: 0% +repetition: 3.2/1k · longest repeat 5 · "across all workstreams and teams"×3 +grammar: participial tails 14 (22.6/1k) ", reflecting the late discovery of schema drift in the" · container-of 2 "a sense of" +sentence tail: over-30 0.0% · p90 25 · longest flat run 8 +nominalization hits: 24 (validation×2, migration×2, implementation×1) · frames: "the implementation of", "the migration of" + +{ + "discourse": { + "summary_closer": true, + "disclaimer_opener": { + "fired": true, + "hits": [ + { + "text": "it's important to approach", + "sentence": 0 + }, + { + "text": "i'm not able to", + "sentence": 1 + } + ] + } + }, + "dialogue": { + "ratio": 0.0 + }, + "words": 620, + "sentences": 34, + "paragraphs": 12, + "sentence_len": { + "mean": 18.24, + "stdev": 3.46, + "cv": 0.19, + "min": 14, + "max": 27, + "pct_over_30": 0.0, + "p90": 25, + "longest_flat_run": 8 + }, + "paragraph_len": { + "mean": 2.83, + "stdev": 0.39, + "cv": 0.137, + "min": 2, + "max": 3 + }, + "punct": { + "em_dash": 0.0, + "en_dash": 0.0, + "semicolon": 0.0, + "colon": 0.0, + "ellipsis": 0.0, + "exclamation": 0.0, + "counts": { + "em_dash": 0, + "en_dash": 0, + "semicolon": 0, + "colon": 0, + "ellipsis": 0, + "exclamation": 0 + } + }, + "structures": { + "tricolon": 1, + "not_but": 1, + "rhetorical_q": 0, + "parallel_openers": 0 + }, + "openers": { + "distinct_ratio": 0.735 + }, + "wordlist": { + "hits": [], + "rate": 0.0 + }, + "hedges": { + "rate": 0.0 + }, + "intensifiers": { + "rate": 0.0 + }, + "repetition": { + "too_short": false, + "repeated_phrase_rate": 3.2, + "longest_repeat": 5, + "phrases": [ + { + "text": "across all workstreams and teams", + "count": 3, + "sentences": [ + 5, + 17, + 32 + ] + } + ] + }, + "grammar": { + "participial_tail": { + "count": 14, + "rate": 22.6, + "hits": [ + { + "text": ", reflecting the late discovery of schema drift in the", + "sentence": 2 + }, + { + "text": ", ensuring alignment across teams before any record reaches", + "sentence": 3 + }, + { + "text": ", exceeding the target we set in March", + "sentence": 4 + }, + { + "text": ", creating a sense of momentum that was missing last quarter", + "sentence": 5 + }, + { + "text": ", allowing designers to retire eleven legacy patterns", + "sentence": 6 + }, + { + "text": ", giving on-call staff a clearer signal during incidents", + "sentence": 9 + }, + { + "text": ", matching the level the platform group had proposed", + "sentence": 10 + }, + { + "text": ", resolving most of them within the first business day", + "sentence": 11 + }, + { + "text": ", leaving two senior positions unfilled going into the next", + "sentence": 14 + }, + { + "text": ", assuming the compensation adjustments are approved", + "sentence": 16 + } + ] + }, + "container_of": { + "count": 2, + "hits": [ + { + "text": "a sense of", + "sentence": 5 + }, + { + "text": "The weight of", + "sentence": 15 + } + ] + } + }, + "nominalization": { + "count": 24, + "hits": [ + { + "text": "validation", + "count": 2 + }, + { + "text": "migration", + "count": 2 + }, + { + "text": "implementation", + "count": 1 + }, + { + "text": "ingestion", + "count": 1 + }, + { + "text": "alignment", + "count": 1 + }, + { + "text": "adoption", + "count": 1 + }, + { + "text": "completion", + "count": 1 + }, + { + "text": "optimization", + "count": 1 + }, + { + "text": "deflection", + "count": 1 + }, + { + "text": "decision", + "count": 1 + }, + { + "text": "compensation", + "count": 1 + }, + { + "text": "adjustments", + "count": 1 + }, + { + "text": "coordination", + "count": 1 + }, + { + "text": "escalation", + "count": 1 + }, + { + "text": "transformation", + "count": 1 + } + ], + "of_frames": [ + { + "text": "the implementation of", + "sentence": 2 + }, + { + "text": "the migration of", + "sentence": 7 + }, + { + "text": "the completion of", + "sentence": 7 + }, + { + "text": "the optimization of", + "sentence": 8 + }, + { + "text": "the escalation of", + "sentence": 18 + }, + { + "text": "the transformation of", + "sentence": 19 + }, + { + "text": "the remediation of", + "sentence": 20 + }, + { + "text": "the integration of", + "sentence": 26 + } + ] + } +} + + +## Output +I’ll use the humanize skill to audit `ai_report.txt` and leave the file unchanged. +Audited `ai_report.txt` as **expository prose**. File unchanged. The strongest patterns are repetition and uniform rhythm; these do not establish AI authorship. + +| # | Tell | Evidence | Base rate / metric | +|---|------|----------|--------------------| +| 1 | Verbatim repetition | “across all workstreams and teams” (×3) adds repetitive wording without technical necessity. | 3 occurrences of the same five-word phrase | +| 2 | Trailing participial clauses | “, ensuring alignment across teams before any record reaches”; “, allowing designers to retire eleven legacy patterns” | 14 hits; 22.6 per 1,000 words. Common in reports, but frequent here. | +| 3 | Uniform sentence length | “Four product surfaces migrated to the new components, allowing designers to retire eleven legacy patterns.” exemplifies the steady cadence. | All 34 sentences span 14–27 words; coefficient of variation (CV) 0.19. Skill heuristic: below 0.4 is unusually uniform. | +| 4 | Uniform paragraph length | The paragraph beginning “On the reliability side” follows the recurring three-sentence format. | All 12 paragraphs contain 2–3 sentences; CV 0.137. Skill heuristic: below 0.3 is uniform. | +| 5 | Disclaimer opener | “It's important to approach” and “I'm not able to” delay the substantive update. The vendor-pricing restriction may be justified. | Scanner fired on 2 opening phrases. | +| 6 | Summary closer | “In summary, delivery across all workstreams and teams stayed on plan” repeats the broad reassurance. “Stayed on plan” also needs reconciling with the reported two-week delay. | Summary-closer flag: true | +| 7 | Abstract container phrases — weak signal | “a sense of” momentum; “The weight of” the decision. These obscure the specific improvement or burden. | 2 hits; ordinary report language at this density, so not strong evidence. | diff --git a/docs/acceptance/v0.3/cursor.md b/docs/acceptance/v0.3/cursor.md new file mode 100644 index 0000000..bcdf086 --- /dev/null +++ b/docs/acceptance/v0.3/cursor.md @@ -0,0 +1 @@ +Error: Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable. diff --git a/docs/acceptance/v0.3/hermes.md b/docs/acceptance/v0.3/hermes.md new file mode 100644 index 0000000..9dbd7fa --- /dev/null +++ b/docs/acceptance/v0.3/hermes.md @@ -0,0 +1,25 @@ +Traceback (most recent call last): + File "/Users/ccf/.local/bin/hermes", line 10, in + sys.exit(main()) + ^^^^^^ + File "/Users/ccf/.hermes/hermes-agent/hermes_cli/main.py", line 10068, in main + sys.exit(run_oneshot( + ^^^^^^^^^^^^ + File "/Users/ccf/.hermes/hermes-agent/hermes_cli/oneshot.py", line 80, in run_oneshot + response = _run_agent(prompt, model=model, provider=provider) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/ccf/.hermes/hermes-agent/hermes_cli/oneshot.py", line 149, in _run_agent + runtime = resolve_runtime_provider( + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/ccf/.hermes/hermes-agent/hermes_cli/runtime_provider.py", line 1008, in resolve_runtime_provider + creds = resolve_codex_runtime_credentials() + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/ccf/.hermes/hermes-agent/hermes_cli/auth.py", line 2376, in resolve_codex_runtime_credentials + tokens = _refresh_codex_auth_tokens(tokens, refresh_timeout_seconds) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/ccf/.hermes/hermes-agent/hermes_cli/auth.py", line 2302, in _refresh_codex_auth_tokens + refreshed = refresh_codex_oauth_pure( + ^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/ccf/.hermes/hermes-agent/hermes_cli/auth.py", line 2257, in refresh_codex_oauth_pure + raise AuthError( +hermes_cli.auth.AuthError: Codex token refresh failed: Could not validate your refresh token. Please try signing in again. diff --git a/docs/design/2026-09-14-humanize-v0.3-design.md b/docs/design/2026-09-14-humanize-v0.3-design.md new file mode 100644 index 0000000..391dee0 --- /dev/null +++ b/docs/design/2026-09-14-humanize-v0.3-design.md @@ -0,0 +1,459 @@ +# humanize v0.3 — portable skill (design) + +Date: 2026-09-14 +Status: implemented on feat/v0.3 as 0.3.0 (revision 2 + post-review amendments); pre-merge acceptance 1, 2 (Claude Code, Codex), 3, 7 recorded in docs/acceptance/v0.3/; Cursor and Hermes smoke runs moved to phase 2 (unauthenticated at pre-merge). Revision 1 had a three-lens adversarial review (harness +facts, repo mechanics, tests/acceptance): 3 blockers, 16 important, 2 minor, all adjudicated +below; a `claude plugin validate` dry run and a full pytest run on a rearranged scratch copy +confirmed the layout. +Builds on: `2026-09-14-humanize-v0.2-design.md` (v0.2, shipped as 0.2.0 at main 00a3d74). +Evidence: verified harness research run 2026-09-14 (Codex CLI 0.154.0 empirically; Cursor from +primary docs and the installed `agent` 2026.01.23; Hermes Agent v0.11.0 installed + `origin/main` +source), Anthropic documentation for claude.ai skills and Cowork plugins, the superpowers 6.3.0 +and agentcairn packaging layouts, and local inspection of Claude Desktop 1.52386.6. + +## 0. Goal and non-goals + +**Goal.** The same `humanize` skill installs and runs on Claude Code, Codex CLI, Cursor, Hermes +Agent, and the Claude Desktop app, using each harness's own installer, and is described +everywhere as a research-grounded tool that covers every layer of a text (§9). No behaviour of +the scanner or the skill's method changes; only packaging, paths, wording, and tests. + +**Decision (user, 2026-09-14): native installers + compliance tests.** No custom installer CLI, +no setup meta-skill. The harnesses have converged on the Agent Skills folder contract +(`SKILL.md` + `scripts/` + `references/`) and on `~/.agents/skills/` as a shared root, so the +work is to *be* a compliant skill and to prove it. + +**Non-goals (v0.3):** Gemini CLI, OpenCode, Kimi, Antigravity, Devin (the layout admits them; +no untested rows); an installer CLI; a `humanize-setup` skill; any scanner change; website-copy +extraction (v0.4, separate design). + +## 1. Repository layout + +The plugin moves from `plugins/humanize/` to the repository root — the layout Codex, Cursor, +Hermes taps, and the skills.sh CLI discover without flags, and the one superpowers ships. + +``` +plugin.json Agent Plugins 1.0 manifest (Codex, Cursor, Hermes main) +.claude-plugin/plugin.json Claude Code (also accepted by Codex) +.claude-plugin/marketplace.json Claude Code marketplace; source "./" +.codex-plugin/plugin.json Codex canonical manifest + marketplace `interface` block +skills/humanize/SKILL.md the skill (unchanged location relative to `skills/`) +skills/humanize/references/ principles, tells, fingerprints, SOURCES +skills/humanize/scripts/surface_scan.py +tests/ tools/ data/ docs/ unchanged +``` + +`git mv plugins/humanize/skills skills`; `git mv plugins/humanize/.claude-plugin/plugin.json +.claude-plugin/plugin.json`; delete `plugins/`. + +**Files that name the old path** (verified by grep on a rearranged scratch copy — with these +patched, all 100 existing tests pass at the new layout): +- `tests/conftest.py:4` — adds the script directory to `sys.path`; this is the line whose omission + makes collection fail with `ModuleNotFoundError: surface_scan`. `tests/test_surface_scan.py` and + `tests/test_fixtures.py` import through it and need no edit. +- `tests/test_manifests.py` — the three path constants (lines 38/43/53) and, in the same commit, + the `argument-hint` assertion at line 23 (removed; see §2). +- `.claude-plugin/marketplace.json:16` (`source`), `CLAUDE.md:13` (scan command), `README.md:103` + and `:128` (tree and command), `.cursor/BUGBOT.md:9` and `:14`, the headless smoke command + (`--plugin-dir .`). +- Unchanged: `tools/gen_tell_scaffold.py`, `.pre-commit-config.yaml`, `.github/workflows/ci.yml` + (its `claude plugin validate --strict .` already runs at the repo root). + +### 1.1 Manifests (verbatim; `` and `keywords` are the §9 strings) + +`plugin.json` — Agent Plugins 1.0. Cursor requires the `$schema` identifier; Codex 0.154.0 reads +this "portable root format" and discovers `skills/` automatically; Hermes `origin/main` installs +it with `hermes plugins install` — installed disabled, read-only, namespaced +`agent-plugin--`, which is why §4 recommends `hermes skills install` instead. Fields +are the subset shared with the Claude manifest. + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "humanize", + "version": "0.3.0", + "description": "", + "author": { "name": "ccf" }, + "license": "MIT", + "homepage": "https://github.com/ccf/humanize", + "repository": "https://github.com/ccf/humanize", + "keywords": +} +``` + +`.claude-plugin/plugin.json` — `version` 0.3.0, `description` and `keywords` from §9, other +fields unchanged. + +`.claude-plugin/marketplace.json` — `"source": "./"`, both `version` fields 0.3.0, +`metadata.description` and `plugins[0].description`/`keywords` from §9, `strict: true` kept. +(superpowers' root `.claude-plugin/` holds both files the same way; Codex 0.154.0 reads this +file with a plain-string `source`.) `claude plugin validate --strict .` validates the +**marketplace JSON only** — it exits 0 with a nonexistent `source` and with broken SKILL.md +frontmatter — so the `source` is proven by §3 test 5 and acceptance 6, not by the validator. It +warns about a `CLAUDE.md` at the plugin root; the warning is accepted and noted in CLAUDE.md. + +`.codex-plugin/plugin.json` — Codex's bundled `validate_plugin.py` accepts only this layout, and +the `interface` block is what its marketplace UI renders. `shortDescription`/`longDescription` +are the §9 strings. + +```json +{ + "name": "humanize", + "version": "0.3.0", + "description": "", + "author": { "name": "ccf" }, + "homepage": "https://github.com/ccf/humanize", + "repository": "https://github.com/ccf/humanize", + "license": "MIT", + "keywords": , + "skills": "./skills/", + "interface": { + "displayName": "humanize", + "shortDescription": "", + "longDescription": "", + "developerName": "ccf", + "category": "Developer Tools", + "capabilities": ["Read", "Write"], + "defaultPrompt": ["Humanize this draft.", "Audit this text for AI tells, don't rewrite it."], + "websiteURL": "https://github.com/ccf/humanize" + } +} +``` + +No `.cursor-plugin/` (Cursor accepts Agent Plugins; its own manifest adds nothing we ship) and +no `.hermes-plugin/` (not a Hermes convention). No `hooks`, `commands`, `agents`, or MCP. + +## 2. SKILL.md — portable wording, at most 150 lines + +**Budget.** The invariant is `wc -l ≤ 150`, guarded by `test_skill_line_budget`; the plan gives +exact replacement text and the result is 149 or 150 depending on one wrap. Nothing else in the +file changes. + +**Frontmatter drops `argument-hint`** (−1 line). Outside Claude Code it is a hard error — +claude.ai uploads, the Skills API, and `package_skill.py` reject any key beyond the six Agent +Skills fields (`Unexpected key(s) in SKILL.md frontmatter: argument-hint. Allowed properties +are: allowed-tools, compatibility, description, license, metadata, name`), and Codex's validator +rejects it too. Claude Code loses only the placeholder text in its `/` menu; `/humanize ` +still works. Frontmatter is then exactly `name` and `description`. + +**A. Invocation (lines 25–28).** The heading stays. Replace the two-line paragraph with: + +``` +Applies only when the user typed `/humanize …` (Codex: `$humanize …`). On +auto-invoke there are no arguments: skip this section. Options below also +work as plain words in a request ("audit only", "this is fiction"). +``` +(+1 line.) The `$ARGUMENTS` line and its literal-text fallback stay: Codex, Cursor, Hermes, and +the Desktop pickers substitute nothing, so the fallback is what makes the section safe there. + +**B. Scan command (lines 69–75).** The fenced command becomes +`python3 scripts/surface_scan.py --text `, and the sentence "Run it from this skill's +folder (the directory holding this SKILL.md)." is added to the paragraph below it (+0 or +1 +line). No harness variable appears: `${CLAUDE_PLUGIN_ROOT}` leaves the skill and +`${HERMES_SKILL_DIR}` never enters it — the Hermes path note lives in the README (§4). + +**C. Non-text sources (lines 77–84, 8 lines → 7).** Replace with: + +``` +**Non-text sources** (`.docx`, `.pdf`, `.pptx`, `.odt`, `.rtf`): the scanner reads +plain text only. Extract first with the harness's document skills (Anthropic's +`docx`/`pdf` skills ship with Claude Code's `document-skills` plugin and with +Claude Desktop) to write a temp `.md`, then scan and audit that. In claude.ai the +file is an attachment: convert it in the sandbox (pandoc, pdfplumber). Without +document skills, read the file yourself if you can and write the text to a temp +file; otherwise ask for a text export. Deliver Markdown; a Word file via `docx`. +``` +(−1 line.) The `/plugin …` commands leave the skill; they live in the README. + +**D. Grounding (lines 10–11)** — the two §9 lines. + +Net: −1 (frontmatter) +1 (A) +0/+1 (B) −1 (C) +0 (D) = 149 or 150. No `!` command injection or +other Claude Code-only body features are used (none exist today; test 2 guards the paths). + +## 3. Compliance tests — `tests/test_portability.py` + +Pure pytest, no network, in CI. Constants come from the verified harness limits (Codex +`quick_validate.py`, agentskills.io, Cursor docs, Hermes `tools/skills_guard.py`). + +1. **Frontmatter.** Keys ⊆ `{name, description, license, compatibility, metadata, allowed-tools}` + and nothing else (so `argument-hint` can never return); `name` matches + `^[a-z0-9]+(-[a-z0-9]+)*$`, ≤ 64 chars, contains neither `claude` nor `anthropic`, and equals + the skill's folder name (Cursor hard-requires this); `description` ≤ 1024 chars, contains + neither `<` nor `>`, does not start with `[TODO:`. +2. **No harness-specific content under `skills/`.** None of these substrings appears in any + file under `skills/`: `${CLAUDE_`, `CLAUDE_PLUGIN_ROOT`, `HERMES_SKILL_DIR`, `CURSOR_`, + `CODEX_`, `/plugin `, `npx skills`. SKILL.md, with whitespace collapsed, contains the + substring ``reads literally as `$ARGUMENTS` `` (the fallback sentence wraps across two lines). +3. **Runtime references are named.** Each of the five references SKILL.md loads at runtime + (`principles`, `surface-tells`, `style-tells`, `narrative-tells`, `model-fingerprints`) and + `scripts/surface_scan.py` are named by relative path in SKILL.md. `SOURCES.md` is exempt: it + is a maintainer registry never loaded at runtime. (Hermes GitHub-source installs copy the whole + skill directory; bare-URL installs are single-file and unsupported — the README says so.) +4. **Guard-clean scripts.** No bare token from this list in `skills/**/scripts/*.py`: + `subprocess`, `os.system`, `os.popen`, `os.environ`, `getenv`, `shutil.rmtree`, `eval(`, + `exec(`, `compile(`, `getattr(`, `codecs`, `socket`, `urllib`, `requests`, `# /// script`. + Docstring: deliberately stricter than Hermes's line-by-line regexes; also our own no-network + invariant. (The current script is clean against every pattern.) +5. **Manifests agree.** `name` is `humanize` in all four manifests; `version` is identical in + `plugin.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and both + `marketplace.json` version fields, and equals the newest `## [x.y.z]` heading in + `CHANGELOG.md` (the `[Unreleased]` heading is skipped); `plugin.json.$schema` is exactly the + 1.0.0 URL; `.codex-plugin` `skills` resolves to a directory containing `humanize/SKILL.md`; + marketplace `source` resolves to a directory containing `.claude-plugin/plugin.json`. +6. **Zip packager.** `tools/package_skill_zip.py` (§6) is importable; its builder, run into a + temp dir, produces a zip whose member set equals exactly `humanize/SKILL.md`, + `humanize/references/`, `humanize/scripts/surface_scan.py`; the filename version + equals `plugin.json`'s; the builder raises on a SKILL.md with a seventh frontmatter key. +7. **Study count.** The number of `` ## `key` `` entries in `SOURCES.md` equals the count written + on every §9 surface (digits in the manifests, `pyproject.toml`, `CLAUDE.md`, README; the word + in SKILL.md and the README grounding paragraph, via a number-word map). + +`tests/test_manifests.py` keeps its current checks at the new paths, minus the `argument-hint` +assertion. `test_skill_line_budget` stays `≤ 150`. + +## 4. Documentation + +**README — "Install"** replaces the current single-harness instructions: one table, then notes. +Every command in the table is run against the CLI's `--help` (or the GUI path against the docs) +before the row ships — acceptance 7. ⚠ marks a cell whose command is verified only by that +acceptance step. + +| Harness | Install | Verify | Invoke | +|---|---|---|---| +| Claude Code | `/plugin marketplace add ccf/humanize` · `/plugin install humanize@humanize` | `claude plugin list` | `/humanize` or auto | +| Codex CLI | ⚠ `codex plugin marketplace add ccf/humanize` · `codex plugin add humanize@humanize` — or `cp -R skills/humanize ~/.agents/skills/` | `codex debug prompt-input "hi"` lists it | `$humanize …` or auto | +| Cursor | `cp -R skills/humanize ~/.cursor/skills/` (user) or `.cursor/skills/` (project); the Cursor marketplace is GUI-only and human-reviewed, not a CLI path | skill appears in the `/` menu | `/humanize` or auto | +| Hermes Agent | `hermes skills install ccf/humanize/humanize --category writing`, then `/reload-skills` in a running session | `hermes skills list` | `/humanize …` or auto | +| Any Agent-Skills harness | ⚠ `npx skills add ccf/humanize` (→ `~/.agents/skills/`) or `cp -R skills/humanize ~/.agents/skills/` | harness-specific | harness-specific | +| Claude Desktop — Cowork | Customize → Plugins → Add from repository `ccf/humanize` → install `humanize` (route confirmed in acceptance 5; zip fallback below) | plugin listed under Customize → Plugins | `/` or `+` picker, or auto | +| Claude Desktop — chat (claude.ai) | Customize → Skills → Upload `humanize-skill-.zip` (from the GitHub release); "Code execution and file creation" on | skill listed under Customize → Skills | auto (description match) or the sidebar `/` menu; picking a skill passes no arguments | + +Notes carry the verified gotchas: Codex reads `/.agents/skills/` but not `.claude/skills/`; +Codex caps the injected skills catalog at 2% of context; Cursor user-level skills reach Cloud +Agents only with **Sync Skills for Cloud Agents** on (Settings → Agents) and only from +`~/.cursor/skills/`; Hermes scans installed scripts (ours is stdlib and guard-clean), resolves +`${HERMES_SKILL_DIR}` to a host path — so on a remote terminal backend (docker, modal, ssh) the +scan step is skipped and the audit proceeds from reading alone — and its `hermes plugins install` +route installs portable packages disabled and read-only, so `hermes skills install` is the +recommended path; bare-URL skill installs (a raw `SKILL.md` link) fetch one file and are not +supported; in claude.ai the scanner runs in Claude's sandbox; Cowork plugin updates require a +`version` bump. + +**CLAUDE.md** — lines 3–4 from §9; Commands: scan path `skills/humanize/scripts/surface_scan.py`, +`uv run pytest -q` comment loses its test count ("must be warning-free"), `claude plugin validate +--strict .` annotated "validates marketplace JSON only; warns about the root CLAUDE.md +(accepted)"; invariants: "`skills/` is harness-agnostic: no harness variables (`${CLAUDE_…}`, +`HERMES_SKILL_DIR`, `CURSOR_…`, `CODEX_…`) and no install commands inside it; frontmatter is +exactly the Agent Skills fields — `tests/test_portability.py` enforces both"; "version lives in +four manifests plus the CHANGELOG heading — the same test fails on drift" (replaces "bump +`version` in both"); the network rule's glob becomes `skills/**/scripts/`; the §9 wording rule. +Workflow: the smoke matrix and the phased acceptance (§7) become the release steps. +**BUGBOT.md** mirrors: line 3 sentence, the two invariants, the glob at lines 9/14. + +## 5. Smoke matrix — `tools/smoke_harnesses.sh` (local, not CI) + +For each harness whose CLI is on `PATH`, install the working-tree skill in an isolated way, run +the audit-only fixture headlessly, and judge PASS by evidence that the *skill and the scanner* +ran — not by row names alone (three of the four are guessable from the fixture text). + +**Request wording.** No harness but Claude Code substitutes `$ARGUMENTS`, so runs are phrased in +words: "Use the humanize skill on tests/fixtures/ai_report.txt. Audit only — do not rewrite." + +**PASS requires all of:** (a) the four rows — `trailing participial clause`, `verbatim +repetition`, `container-noun phrase`, `safety disclaimer opener` — matched case-insensitively by +regex against the audit table; (b) no `nominalization` row (negative scoped to table rows, not +prose); (c) at least two numbers from a pre-run `python3 skills/humanize/scripts/surface_scan.py +--text tests/fixtures/ai_report.txt` appear verbatim (e.g. the participial count and the +repetition rate), and where the harness exposes tool events, a tool call naming +`surface_scan.py` (Claude: `--output-format stream-json --verbose`; Codex: `--json`); (d) no +rewrite and no "Choices you may want to reverse" section. Two attempts per harness; transcripts +saved to `docs/acceptance/v0.3/.md` (the audit table plus the tool event), the +committed evidence a release needs. + +- **Claude Code:** `claude plugin disable humanize@humanize; claude -p "" --plugin-dir . + --output-format stream-json --verbose --allowedTools "Bash,Read,Glob,Grep"; claude plugin + enable humanize@humanize`. +- **Codex:** copy `skills/humanize` into a temp project's `.agents/skills/`; pre-check with + `codex debug prompt-input "hi"` that the skill is listed; run `codex exec -C + --skip-git-repo-check --ephemeral -s read-only --json -o out.md "" "` from that + directory, flags pinned from `agent --help` (the installed CLI is 2026.01.23; the script prints + the version and updates first). +- **Hermes:** abort if `~/.hermes/skills/writing/humanize` already exists; `cp -R` the skill + there; run non-interactively (`hermes -z ""` or `hermes chat -q "" -Q`, + pinned from `hermes --help` on v0.11.0); remove only the directory the script created. + +## 6. Claude Desktop + +Verified (Anthropic docs, 2026-09-14): Cowork installs plugins in the Claude Code plugin format +(`.claude-plugin/plugin.json` + `skills/`), invokes skills from the `/` or `+` picker or by +description, runs them in a Linux VM with bash and Python, and picks up an update only when +`version` changes. claude.ai chat accepts a custom skill as a ZIP whose root is the skill folder +named exactly `name`, honours only the six spec frontmatter fields, triggers by description or +from the sidebar `/` menu (picking passes no arguments, so §2A self-skips), and runs scripts in +the code-execution sandbox when "Code execution and file creation" is on. Both are paid-plan +features; Enterprise admins may block skills containing `*.py` as high risk. The Claude Code tab +in Desktop is Claude Code and needs nothing. + +Two delivery paths from the same SKILL.md: + +1. **Cowork plugin (preferred, route to confirm).** The repo has the plugin format Cowork loads. + Whether a user can add `ccf/humanize` as a marketplace (Customize → Plugins → Add from + repository) is the one point the two verification runs disagreed on; acceptance 5 settles it + in a Cowork session. If it works, the §4 row stands; if not, the row points to path 2. +2. **claude.ai skill upload (always available).** `tools/package_skill_zip.py` (stdlib) writes + `dist/humanize-skill-.zip` containing `humanize/SKILL.md`, `humanize/references/`, + and `humanize/scripts/surface_scan.py`, version read from `plugin.json`, refusing to build if + the frontmatter has any key outside the six. The zip is attached to the GitHub release. + +Nothing Desktop-specific enters the skill: the relative `scripts/` path (§2B) is what both the +Cowork VM and the claude.ai sandbox resolve, and `${CLAUDE_PLUGIN_ROOT}` — whose substitution in +Cowork is unverified — is gone. + +## 7. Versioning and phased release + +Version **0.3.0** in the four manifests; `CHANGELOG.md` `[0.3.0]` with Added (Agent Plugins +manifest, Codex manifest, portability tests, smoke matrix, skill-zip packager, install table), +Changed (repo layout; SKILL.md portable wording; document-skills instructions moved to README; +positioning copy per §9; `argument-hint` removed), and a **Migration** note: existing Claude +Code installs run `claude plugin marketplace update humanize && claude plugin update +humanize@humanize`; the marketplace `source` moved from `./plugins/humanize` to `./`. + +`main` is protected with linear history, so anything that installs from GitHub cannot run before +the merge. The release is phased: + +1. **Pre-merge (on the PR):** acceptance 1, 2, 7; acceptance 3 against the branch + (`codex plugin marketplace add ccf/humanize@feat/v0.3`). +2. **Merge** (rebase) → **post-merge, pre-tag:** acceptance 4 and 6 from `main`; the Cowork + marketplace attempt (acceptance 5a). +3. **Tag `v0.3.0`**, `gh release create v0.3.0 dist/humanize-skill-0.3.0.zip`, plugin update on + this machine → **post-release:** acceptance 5b (zip upload). +4. Anything found in phases 2–3 is fixed forward as 0.3.1 through the normal PR path. + +## 8. Acceptance + +1. `uv run pytest -q -W error` passes (existing 100 + §3 tests) on 3.9 and 3.13; + `claude plugin validate --strict .` passes at the new layout (marketplace JSON); + `wc -l skills/humanize/SKILL.md` ≤ 150. +2. Smoke matrix PASS on Claude Code, Codex, Cursor, and Hermes on this machine under the §5 + evidence rule, transcripts under `docs/acceptance/v0.3/`. +3. `codex plugin marketplace add ccf/humanize@feat/v0.3 && codex plugin add humanize@humanize` + installs and the skill surfaces in `codex debug prompt-input "hi"` (pre-merge); the same + without `@ref` after the merge. +4. `hermes skills install ccf/humanize/humanize --category writing` copies the whole skill + directory (six references, the script); `hermes skills list` shows it with a clean guard + verdict. +5. (a) Cowork: `ccf/humanize` added as a marketplace, `humanize` installed, fixture audit + produces the four rows in a Cowork session — or the route is documented as unavailable. + (b) claude.ai: the zip uploads with the current 308-character description (spec limit 1024; + Anthropic's best-practice note says ~200 — if the uploader rejects it, shorten the description + in 0.3.1 and record the limit in CLAUDE.md), and the skill triggers on a pasted paragraph. +6. Existing Claude Code install upgrades 0.2.0 → 0.3.0 via the migration command with one skill + registered — this is what proves `source: "./"`. +7. Every §9 string is present verbatim on its surface; `grep -ri storyscope` over the manifests, + `pyproject.toml`, SKILL.md, and README lines 1–20 returns nothing; every README command was run + against its CLI's `--help` (GUI routes against the docs) and the ⚠ cells resolved or removed; + the README grounding paragraph scanned with `surface_scan.py` shows no wordlist hits. + +## 9. Positioning copy — research-grounded, every layer, not StoryScope-centred + +Decision (user, 2026-09-14): public copy stops presenting humanize as "grounded in StoryScope" +and presents it as grounded in the body of studies in `references/SOURCES.md` (thirteen today) +and as covering every layer of a text. StoryScope keeps its attribution where it is the source +(base rates, `style-tells.md`/`narrative-tells.md` file descriptions, the license paragraph, +principle 1) — accuracy, not prominence. Copy below was drafted under limits, checked against +each source's `May support:` line, and passed through `surface_scan.py` (no wordlist hits, no +not-but, one tricolon). Strings are verbatim; the plan applies them as-is. + +| Surface | Value | +|---|---| +| `.claude-plugin/marketplace.json` `metadata.description` (≤160) | Measures prose against 13 studies of how AI and human writing differ, from word choice to sentence rhythm to story shape, then rewrites the tells out. | +| ``: `description` in `plugin.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, marketplace `plugins[0]` (≤200) | Audits a draft layer by layer (vocabulary, punctuation, sentence shape, openers, closers, grammar, narrative, per-model habits) against 13 studies, then rewrites what it flags in the author's voice. | +| `` (≤80) | Audit prose for AI tells at every layer, then rewrite it in the author's voice. | +| `` (≤320) | Reads a draft against what 13 studies measured about AI writing: word choice, punctuation, sentence shape, openers, closers, grammar, register, fiction habits, per-model quirks. A dependency-free scanner supplies the counts a model cannot eyeball, and each flag cites the sentence that triggered it. | +| `` (all manifests) | `["humanize", "writing", "prose", "editing", "style", "ai-writing", "rewriting", "linguistics"]` — drops `storyscope` and the detector-adjacent `ai-detection` | +| `pyproject.toml` `description` | Agent skill that audits prose for AI tells and rewrites it in the author's voice, on evidence from 13 studies. | +| `CLAUDE.md` lines 3–4 | `Agent skill that audits prose for AI tells and rewrites it. Evidence base: 13 studies` / `` in `references/SOURCES.md`. Spec and plan: `docs/design/`. Changelog: `CHANGELOG.md`. `` | + +**SKILL.md lines 10–11** (line 9 unchanged; two lines, 71 and 70 chars): + +``` +mark it as AI-generated. Grounded in thirteen studies of measured style +differences: AI converges on shared defaults; human writing disperses. +``` + +**README.** The H1 stays `# humanize`. The paragraph under it becomes: + +> An agent skill that audits prose for the tells of AI writing and rewrites them out, without +> flattening the author's voice. Runs in Claude Code, Codex, Cursor, Hermes Agent, Claude +> Desktop. + +The StoryScope paragraph (current lines 7–17) becomes: + +> The reference entries rest on thirteen studies. StoryScope (2026) measured 304 narrative and +> stylistic features over 61,575 stories, and its widest human-vs-AI gaps supply the base rates. +> Reinhart et al. (2025) put participial modifiers at 5.3 times the human rate and nominalization +> at 2.1 times. Jakesch et al. (2023) found repeated phrasing the strongest true signal of a +> text's source. Herbold et al. (2023) recorded a lexical-diversity reversal between model +> generations, a reminder that findings expire. humanize turns that work into an audit checklist +> plus a dependency-free scanner for the counts a model cannot eyeball; every flag quotes the line +> it came from. One pattern organizes the whole checklist: AI converges on shared defaults while +> human writing disperses. Every cited number resolves in references/SOURCES.md. + +README tree lines for `style-tells.md` / `narrative-tells.md` read "20 style features with +StoryScope base rates" / "57 narrative features (fiction only), StoryScope base rates". The +license/credit paragraph is unchanged (attribution is required there). `.cursor/BUGBOT.md` line +3 mirrors the CLAUDE.md sentence. `principles.md` is unchanged: principle 1 attributes the +convergence result to StoryScope, which is where it comes from. + +Guard: §3 test 7. Wording rule (CLAUDE.md invariant): public copy names StoryScope only where it +is the specific source of a number or a file; the tool is described as grounded in the registry. + +## 10. Risks and fallbacks + +- **Self-referential marketplace `source: "./"`** — read by Codex 0.154.0 (verified) and used by + superpowers for Claude Code, but proven for our install only by acceptance 6. Fallback if Claude + Code or Codex rejects it: move the plugin into `plugin/` (agentcairn's layout) with `source: + "./plugin"`, keeping root `plugin.json` and `skills/` as symlinks or a documented copy. +- **Manifest precedence on Codex** when `.codex-plugin/`, `.claude-plugin/`, and root `plugin.json` + coexist — expected `.codex-plugin` wins; acceptance 3. +- **Agent Plugins 1.0 schema strictness** on the extra fields — the manifest uses only fields + shared with the Claude/Codex manifests; if Cursor rejects any, drop to `{$schema, name, version, + description}`. +- **skills.sh CLI** (`npx skills add`) discovery of root `skills/` — acceptance 7; the row goes if + it fails. +- **Cursor `agent` CLI** here is 2026.01.23 and has no `plugin` subcommand; the smoke script + updates it first and pins flags from `--help`. +- **Cowork custom-marketplace route** — acceptance 5a; zip fallback regardless. +- **claude.ai description length** — acceptance 5b. +- **Cowork validation of plugin-bundled frontmatter** against the six fields is unverified; the + spec removes the only non-spec key, so it no longer matters. + +## Post-review amendments (2026-09-14) + +- §2B — Bugbot (PR #8): "run it from this skill's folder" implied a `cd` that broke project-relative + `` arguments. The command is now `python3 /scripts/surface_scan.py --text + `, with the working directory kept on the user's project; `` is the + directory holding SKILL.md, which every harness shows the model when the skill loads. +- §2 / step 3 — the Codex smoke run (gpt-6-astra) omitted a scanner-reported grammar block while + paraphrasing tell names. Step 3 now says every fired scanner block gets its own row. Budget + unchanged (150). +- §5 — transcripts have three sections (`## Tool calls`, `## Scanner output`, `## Output`) and the + judge checks each rule against the right one; rows match by concept (participial, repetition, + container or a scanner-quoted container hit, disclaimer), not by reference title; an + unauthenticated harness prints `SKIP` with the reason rather than `FAIL`. +- §8 acceptance 2 — Cursor and Hermes were SKIP (not authenticated on this machine) at the + pre-merge run; they are re-run post-login and recorded in `docs/acceptance/v0.3/`. +- §7/§8 — at the pre-merge run Cursor and Hermes were SKIP (not authenticated on this machine); their + smoke runs move to phase 2 (post-merge, pre-tag), re-run after login and committed to + `docs/acceptance/v0.3/`. Acceptance 3 and 7 outcomes are recorded in `docs/acceptance/v0.3/README.md`. +- §8 acceptance 7 — the `grep -ri storyscope` scope is the manifests, `pyproject.toml`, and SKILL.md; + README lines 1–20 contain the one sourced StoryScope sentence §9 prescribes, so the README check is + "StoryScope appears only at sourced positions" instead. +- §5 — the Claude transcript parser keeps only the scanner invocation and its result (as the Codex + parser already did); row concepts match the Tell cell of distinct table rows; traps clean up every + side effect on interrupt; the raw Claude stream is written to a temp directory. +- §2B — if a harness does not show the skill's path, the model searches for `scripts/surface_scan.py` + and, failing that, skips the scan and says so. diff --git a/docs/design/2026-09-14-humanize-v0.3-plan.md b/docs/design/2026-09-14-humanize-v0.3-plan.md new file mode 100644 index 0000000..57cc3df --- /dev/null +++ b/docs/design/2026-09-14-humanize-v0.3-plan.md @@ -0,0 +1,1003 @@ +# humanize v0.3 — portable skill: implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the same `humanize` skill to Claude Code, Codex CLI, Cursor, Hermes Agent, and Claude Desktop through each harness's own installer, with compliance tests, a smoke matrix, and public copy that presents humanize as research-grounded across every text layer. + +**Architecture:** Flatten the plugin to the repository root (`skills/humanize/`, root `plugin.json`, `.claude-plugin/`, `.codex-plugin/`), make SKILL.md harness-agnostic (six-field frontmatter, relative script path, plain-words options), add `tests/test_portability.py` as the mechanical guard, a stdlib zip packager for claude.ai, and a local smoke script whose PASS rule needs evidence the scanner ran. No scanner or method change. + +**Tech Stack:** Python 3.9+ stdlib (tests, packager), bash (smoke script), pytest, ruff, uv, `claude`/`codex`/`agent`/`hermes` CLIs for acceptance only. + +**Spec:** `docs/design/2026-09-14-humanize-v0.3-design.md` (revision 2; binding). Section numbers below refer to it. + +## Global Constraints + +- `skills/humanize/scripts/surface_scan.py` is untouched by this plan. Any new Python (tests, `tools/package_skill_zip.py`) is standard-library only and Python 3.9-compatible: `from __future__ import annotations`; no `match`; no runtime `X | Y`; no `removeprefix`; no f-string reusing its own quote type inside `{}`. +- No network or LLM calls in `tests/` or `skills/**/scripts/` or `tools/package_skill_zip.py`. `tools/smoke_harnesses.sh` is the only thing that calls a harness, and it is never run by CI or pytest. +- ruff line length 100: no source line, comment, or string literal in Python may exceed it. +- SKILL.md: `wc -l` ≤ 150 after Task 2 (expected exactly 150). Frontmatter keys after Task 2 are exactly `name` and `description`. No `${CLAUDE_`, `CLAUDE_PLUGIN_ROOT`, `HERMES_SKILL_DIR`, `CURSOR_`, `CODEX_`, `/plugin `, or `npx skills` anywhere under `skills/`. +- Never write that output is "undetectable", passes a detector, or is "certified human" — anywhere, including README and manifests. +- Curly characters in existing literals (’ “ ” — –) must survive; verify SKILL.md and README edits with `grep -c` before/after where a block contains them. Use heredocs for Python probes. +- Version `0.3.0` appears in exactly these places and nowhere else new: `plugin.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, `.claude-plugin/marketplace.json` (both fields), `pyproject.toml`, `CHANGELOG.md` heading. (Ruling: `pyproject.toml` joins the sync set — it is 0.1.0 today, which is drift.) Bumping `pyproject.toml` makes the pre-commit `pytest` hook (`uv run`) rewrite `uv.lock`'s embedded project version; stage `uv.lock` in the same commit or the hook rolls the commit back. +- The dry run confirmed `claude plugin validate --strict .` (CLI 2.1.270) passes cleanly at the new layout with no warning; do not expect or record one. +- `grep -r` here is `ugrep`, which prints paths without a `./` prefix; exclusion patterns use `^(\./)?(…)`. +- Public copy strings in Tasks 3 and 5 are the spec §9 strings verbatim — copy, never retype. +- Tooling: `uv run pytest -q -W error`; `uv run --python 3.9 pytest -q -W error` once per task; before committing `uv run ruff format && uv run ruff check ` on the Python files you touched (never bare `.`); hooks run on commit; never `--no-verify`. Branch `feat/v0.3`, repo `/Users/ccf/git/humanize`. +- Every commit message ends with: + ``` + Co-Authored-By: Claude Fable 5.1 + Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh + ``` + +## File structure + +Create: +- `plugin.json` — Agent Plugins 1.0 manifest (Task 3) +- `.codex-plugin/plugin.json` — Codex manifest with `interface` (Task 3) +- `tests/test_portability.py` — the seven compliance tests (Tasks 2, 3, 4, 5) +- `tools/package_skill_zip.py` — claude.ai skill zip builder (Task 4) +- `tools/smoke_harnesses.sh` — local acceptance matrix (Task 7) +- `docs/acceptance/v0.3/.md` — smoke transcripts (Task 7) + +Move: `plugins/humanize/skills/` → `skills/`; `plugins/humanize/.claude-plugin/plugin.json` → `.claude-plugin/plugin.json` (Task 1). + +Modify: `tests/conftest.py`, `tests/test_manifests.py`, `.claude-plugin/marketplace.json`, `CLAUDE.md`, `README.md`, `.cursor/BUGBOT.md`, `pyproject.toml`, `CHANGELOG.md`, `.gitignore`, `skills/humanize/SKILL.md`, the spec's status line. + +--- + +### Task 1: Move the plugin to the repository root + +**Files:** +- Move: `plugins/humanize/skills/` → `skills/`; `plugins/humanize/.claude-plugin/plugin.json` → `.claude-plugin/plugin.json` +- Modify: `tests/conftest.py:4`, `tests/test_manifests.py:38,43,53`, `.claude-plugin/marketplace.json:16`, `CLAUDE.md:13`, `README.md:103,128`, `.cursor/BUGBOT.md:9,14` + +**Interfaces:** +- Produces: the layout every later task assumes — `skills/humanize/{SKILL.md,references/,scripts/surface_scan.py}` at the repo root; `ROOT / ".claude-plugin/plugin.json"`. + +- [ ] **Step 1: Move the files** + +```bash +cd /Users/ccf/git/humanize +git mv plugins/humanize/skills skills +git mv plugins/humanize/.claude-plugin/plugin.json .claude-plugin/plugin.json +rmdir plugins/humanize/.claude-plugin plugins/humanize plugins +test -f skills/humanize/SKILL.md && test -f .claude-plugin/plugin.json && echo moved +``` + +- [ ] **Step 2: Run the suite to see the expected failure** + +Run: `uv run pytest -q -W error 2>&1 | tail -3` +Expected: collection error `ModuleNotFoundError: No module named 'surface_scan'` (from `tests/conftest.py`). + +- [ ] **Step 3: Repoint every path** + +`tests/conftest.py` line 4: +```python +SCRIPTS = Path(__file__).resolve().parents[1] / "skills/humanize/scripts" +``` + +`tests/test_manifests.py`: line 38 `ROOT / "plugins/humanize/.claude-plugin/plugin.json"` → `ROOT / ".claude-plugin/plugin.json"`; line 43 `SKILL_MD = ROOT / "plugins/humanize/skills/humanize/SKILL.md"` → `ROOT / "skills/humanize/SKILL.md"`; line 53 `REFS = ROOT / "plugins/humanize/skills/humanize/references"` → `ROOT / "skills/humanize/references"`. + +`.claude-plugin/marketplace.json` line 16: `"source": "./plugins/humanize",` → `"source": "./",` + +`CLAUDE.md` line 13: `uv run python plugins/humanize/skills/humanize/scripts/surface_scan.py --text ` → `uv run python skills/humanize/scripts/surface_scan.py --text ` + +`README.md` line 103 (the tree's first two lines `plugins/humanize/` / ` skills/humanize/`) → one line `skills/humanize/`, and dedent the tree's children by two spaces so the columns stay aligned; line 128: `uv run python plugins/humanize/skills/humanize/scripts/surface_scan.py --text some.txt` → `uv run python skills/humanize/scripts/surface_scan.py --text some.txt`. + +`.cursor/BUGBOT.md` line 9: `` `plugins/humanize/skills/humanize/scripts/surface_scan.py` `` → `` `skills/humanize/scripts/surface_scan.py` ``; line 13 `plugins/**/scripts/` → `skills/**/scripts/`; line 14 `` `plugins/humanize/skills/humanize/references/*.md` `` → `` `skills/humanize/references/*.md` ``. + +Then: `grep -rn 'plugins/humanize' --include='*.md' --include='*.py' --include='*.json' --include='*.toml' --include='*.yml' --include='*.yaml' . | grep -v -E '^(\./)?(docs/design|CHANGELOG\.md|\.superpowers)'` must print nothing. + +- [ ] **Step 4: Verify** + +Run: `uv run pytest -q -W error 2>&1 | tail -1` → `100 passed`. +Run: `uv run --python 3.9 pytest -q -W error 2>&1 | tail -1` → `100 passed`. +Run: `claude plugin validate --strict . 2>&1 | tail -3` → `Validation passed`, no warnings. +Run: `python3 skills/humanize/scripts/surface_scan.py --text tests/fixtures/ai_report.txt | head -1` → the words/sentences line. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "Move the plugin to the repository root + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh" +``` + +--- + +### Task 2: SKILL.md portable wording and the first three portability tests + +**Files:** +- Create: `tests/test_portability.py` +- Modify: `skills/humanize/SKILL.md` (lines 4, 10–11, 27–28, 70, 73–75, 77–84), `tests/test_manifests.py:23` + +**Interfaces:** +- Produces: `tests/test_portability.py` with module constants `ROOT`, `SKILL_DIR = ROOT / "skills/humanize"`, `SKILL_MD`, helper `frontmatter(text) -> dict[str, str]`; later tasks append tests to this file. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_portability.py`: + +```python +"""Portability guards: the skill must be a plain Agent Skill every harness can load.""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILL_DIR = ROOT / "skills/humanize" +SKILL_MD = SKILL_DIR / "SKILL.md" + +# The Agent Skills specification fields (agentskills.io; Codex quick_validate.py; +# claude.ai upload validator). Anything else is a hard error outside Claude Code. +SPEC_FIELDS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"} +NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + +# Harness-specific tokens that must never appear under skills/ (spec §3 test 2). +HARNESS_TOKENS = ( + "${CLAUDE_", + "CLAUDE_PLUGIN_ROOT", + "HERMES_SKILL_DIR", + "CURSOR_", + "CODEX_", + "/plugin ", + "npx skills", +) + +RUNTIME_REFERENCES = ( + "references/principles.md", + "references/surface-tells.md", + "references/style-tells.md", + "references/narrative-tells.md", + "references/model-fingerprints.md", +) + + +def frontmatter(text: str) -> dict[str, str]: + """Return the YAML frontmatter as a flat key -> raw-value map (one line per key).""" + assert text.startswith("---\n"), "SKILL.md must start with frontmatter" + block = text.split("\n---\n", 1)[0][4:] + out: dict[str, str] = {} + for line in block.splitlines(): + if not line.strip() or line.startswith(" "): + continue + key, _, value = line.partition(":") + out[key.strip()] = value.strip() + return out + + +def test_frontmatter_is_spec_only(): + fm = frontmatter(SKILL_MD.read_text(encoding="utf-8")) + extra = set(fm) - SPEC_FIELDS + assert not extra, f"non-spec frontmatter keys: {sorted(extra)}" + name = fm["name"] + assert NAME_RE.match(name) and len(name) <= 64, name + assert "claude" not in name and "anthropic" not in name + assert name == SKILL_DIR.name, "Cursor requires the folder name to equal `name`" + desc = fm["description"] + assert len(desc) <= 1024, len(desc) + assert "<" not in desc and ">" not in desc + assert not desc.startswith("[TODO:") + + +def test_no_harness_specific_content_under_skills(): + # conftest.py's sys.path insert makes pytest write scripts/__pycache__/*.pyc; skip binaries. + for path in SKILL_DIR.rglob("*"): + if not path.is_file() or "__pycache__" in path.parts: + continue + text = path.read_text(encoding="utf-8") + for token in HARNESS_TOKENS: + assert token not in text, f"{path.relative_to(ROOT)} contains {token!r}" + collapsed = " ".join(SKILL_MD.read_text(encoding="utf-8").split()) + assert "reads literally as `$ARGUMENTS`" in collapsed + + +def test_runtime_references_and_script_are_named_in_skill(): + text = SKILL_MD.read_text(encoding="utf-8") + for ref in RUNTIME_REFERENCES: + assert ref in text, ref + assert (SKILL_DIR / ref).is_file(), ref + assert "scripts/surface_scan.py" in text + # SOURCES.md is a maintainer registry, never loaded at runtime, so it is not named. +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `uv run pytest -q tests/test_portability.py 2>&1 | tail -4` +Expected: 2 failed (`argument-hint` is a non-spec key; `CLAUDE_PLUGIN_ROOT` and `/plugin ` present in SKILL.md), 1 passed. + +- [ ] **Step 3: Edit SKILL.md — five exact replacements** + +(a) Delete line 4 entirely: `argument-hint: "[path | text] [--audit-only] [--fiction | --prose]"`. Frontmatter is then `name:` and `description:` only. + +(b) Lines 10–11 (now 9–10 after (a)). Replace +``` +mark it as AI-generated. Grounded in StoryScope (Russell et al., 2026) and +register studies: AI converges on shared defaults; human writing disperses. +``` +with +``` +mark it as AI-generated. Grounded in thirteen studies of measured style +differences: AI converges on shared defaults; human writing disperses. +``` + +(c) Invocation paragraph. Replace +``` +Applies only when the user typed `/humanize …`. On auto-invoke (drafting +mode or a natural-language request) there are no arguments: skip this section. +``` +with +``` +Applies only when the user typed `/humanize …` (Codex: `$humanize …`). On +auto-invoke there are no arguments: skip this section. Options below also +work as plain words in a request ("audit only", "this is fiction"). +``` + +(d) Scan block. Replace the fenced command line +``` +python3 "${CLAUDE_PLUGIN_ROOT}/skills/humanize/scripts/surface_scan.py" --text +``` +with +``` +python3 scripts/surface_scan.py --text +``` +and replace the three-line paragraph under the fence +``` +Write pasted text to a temp file first. For the full JSON, drop `--text`. Under +80 words, skip this step; the statistics are noise. The scanner strips code, +links, URLs, and heading markers from Markdown before measuring. +``` +with +``` +Run it from this skill's folder (the directory holding this SKILL.md). Write +pasted text to a temp file first. For the full JSON, drop `--text`. Under 80 +words, skip this step; the statistics are noise. The scanner strips code, links, +URLs, and heading markers from Markdown before measuring. +``` + +(e) Non-text sources. Replace the eight lines beginning `**Non-text sources**` and ending `as Markdown; if the user wants a Word file back, hand off to the `docx` skill.` with +``` +**Non-text sources** (`.docx`, `.pdf`, `.pptx`, `.odt`, `.rtf`): the scanner reads +plain text only. Extract first with the harness's document skills (Anthropic's +`docx`/`pdf` skills ship with Claude Code's `document-skills` plugin and with +Claude Desktop) to write a temp `.md`, then scan and audit that. In claude.ai the +file is an attachment: convert it in the sandbox (pandoc, pdfplumber). Without +document skills, read the file yourself if you can and write the text to a temp +file; otherwise ask for a text export. Deliver Markdown; a Word file via `docx`. +``` + +Then `wc -l skills/humanize/SKILL.md` → 150 (−1 +0 +1 +1 −1). If it prints 151, one of (c)/(d) wrapped differently — re-check the exact text above; never trim another rule. + +(f) `tests/test_manifests.py`: delete line 23, `assert "argument-hint:" in (src / "skills/humanize/SKILL.md").read_text()`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest -q -W error 2>&1 | tail -1` → `103 passed`. +Run: `grep -c '[’“”—–]' skills/humanize/SKILL.md` before and after Step 3 — the count drops by exactly one (11 → 10): edit (e)'s old text carried one em dash and its replacement carries none. Any other change means a curly literal was lost. +Run: `claude plugin validate --strict . 2>&1 | tail -1` → passes. +Run: `sed -n 1,5p skills/humanize/SKILL.md` → `---`, `name: humanize`, `description: …`, `---`, blank. + +- [ ] **Step 5: Commit** + +```bash +uv run ruff format tests/test_portability.py && uv run ruff check tests/test_portability.py +git add skills/humanize/SKILL.md tests/test_portability.py tests/test_manifests.py +git commit -m "Make SKILL.md harness-agnostic; add frontmatter, path, and reference guards + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh" +``` + +--- + +### Task 3: Manifests, version 0.3.0, CHANGELOG entry, and the manifest-agreement test + +**Files:** +- Create: `plugin.json`, `.codex-plugin/plugin.json` +- Modify: `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `pyproject.toml:3-4`, `CHANGELOG.md`, `tests/test_portability.py` (append) + +**Interfaces:** +- Consumes: `frontmatter`, `ROOT` from Task 2. +- Produces: four manifests + `pyproject.toml` + CHANGELOG at 0.3.0; `MANIFEST_VERSION_PATHS` constant in the test module used by Task 4's packager test. + +- [ ] **Step 1: Write the failing test** (append to `tests/test_portability.py`) + +```python +import json # noqa: E402 (keep imports at the top of the file in the real edit) + +AGENT_PLUGINS_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +CHANGELOG_HEADING_RE = re.compile(r"^## \[(\d+\.\d+\.\d+)\]", re.M) + + +def _json(rel: str) -> dict: + return json.loads((ROOT / rel).read_text(encoding="utf-8")) + + +def _pyproject_version() -> str: + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + return re.search(r'^version = "([^"]+)"', text, re.M).group(1) + + +def test_manifests_agree(): + root = _json("plugin.json") + claude = _json(".claude-plugin/plugin.json") + codex = _json(".codex-plugin/plugin.json") + market = _json(".claude-plugin/marketplace.json") + entry = market["plugins"][0] + for m in (root, claude, codex, entry): + assert m["name"] == "humanize" + versions = { + "plugin.json": root["version"], + ".claude-plugin/plugin.json": claude["version"], + ".codex-plugin/plugin.json": codex["version"], + "marketplace.metadata": market["metadata"]["version"], + "marketplace.plugins[0]": entry["version"], + "pyproject.toml": _pyproject_version(), + } + assert len(set(versions.values())) == 1, versions + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + newest = CHANGELOG_HEADING_RE.search(changelog).group(1) # [Unreleased] has no digits + assert newest == root["version"], (newest, root["version"]) + assert root["$schema"] == AGENT_PLUGINS_SCHEMA + assert (ROOT / codex["skills"] / "humanize/SKILL.md").is_file() + assert (ROOT / entry["source"] / ".claude-plugin/plugin.json").is_file() + assert entry["strict"] is True + for m in (root, claude, codex, entry): + assert "storyscope" not in json.dumps(m).lower() +``` + +Move `import json` to the top of the module with the other imports. + +- [ ] **Step 2: Run it to verify it fails** + +Run: `uv run pytest -q tests/test_portability.py::test_manifests_agree 2>&1 | tail -2` +Expected: FAIL — `FileNotFoundError` for `plugin.json`. + +- [ ] **Step 3: Write the manifests and bump the version** + +`plugin.json` (new, repo root): +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "humanize", + "version": "0.3.0", + "description": "Audits a draft layer by layer (vocabulary, punctuation, sentence shape, openers, closers, grammar, narrative, per-model habits) against 13 studies, then rewrites what it flags in the author's voice.", + "author": { "name": "ccf" }, + "license": "MIT", + "homepage": "https://github.com/ccf/humanize", + "repository": "https://github.com/ccf/humanize", + "keywords": ["humanize", "writing", "prose", "editing", "style", "ai-writing", "rewriting", "linguistics"] +} +``` + +`.codex-plugin/plugin.json` (new): +```json +{ + "name": "humanize", + "version": "0.3.0", + "description": "Audits a draft layer by layer (vocabulary, punctuation, sentence shape, openers, closers, grammar, narrative, per-model habits) against 13 studies, then rewrites what it flags in the author's voice.", + "author": { "name": "ccf" }, + "homepage": "https://github.com/ccf/humanize", + "repository": "https://github.com/ccf/humanize", + "license": "MIT", + "keywords": ["humanize", "writing", "prose", "editing", "style", "ai-writing", "rewriting", "linguistics"], + "skills": "./skills/", + "interface": { + "displayName": "humanize", + "shortDescription": "Audit prose for AI tells at every layer, then rewrite it in the author's voice.", + "longDescription": "Reads a draft against what 13 studies measured about AI writing: word choice, punctuation, sentence shape, openers, closers, grammar, register, fiction habits, per-model quirks. A dependency-free scanner supplies the counts a model cannot eyeball, and each flag cites the sentence that triggered it.", + "developerName": "ccf", + "category": "Developer Tools", + "capabilities": ["Read", "Write"], + "defaultPrompt": ["Humanize this draft.", "Audit this text for AI tells, don't rewrite it."], + "websiteURL": "https://github.com/ccf/humanize" + } +} +``` + +`.claude-plugin/plugin.json`: `"version": "0.3.0"`; `"description"` → the same `` string as above; `"keywords"` → the same eight. + +`.claude-plugin/marketplace.json`: `metadata.description` → `Measures prose against 13 studies of how AI and human writing differ, from word choice to sentence rhythm to story shape, then rewrites the tells out.`; `metadata.version` and `plugins[0].version` → `0.3.0`; `plugins[0].description` → ``; `plugins[0].keywords` → the eight. `source` stays `"./"`, `strict` stays `true`. + +`pyproject.toml` lines 3–4: +```toml +version = "0.3.0" +description = "Agent skill that audits prose for AI tells and rewrites it in the author's voice, on evidence from 13 studies." +``` + +`CHANGELOG.md`: under `## [Unreleased]` insert +```markdown +## [0.3.0] - 2026-09-14 + +### Added +- Agent Plugins 1.0 manifest (`plugin.json`) and Codex manifest (`.codex-plugin/plugin.json`), + so the plugin installs into Codex, Cursor, and Hermes with their own tooling. +- `tests/test_portability.py`: spec-only frontmatter, no harness-specific content under + `skills/`, runtime references named, guard-clean scripts, manifests and CHANGELOG in + version lockstep, zip packager contract, study count matches `SOURCES.md`. +- `tools/package_skill_zip.py`: builds the claude.ai skill upload from the plugin. +- `tools/smoke_harnesses.sh`: local acceptance matrix with scanner-evidence PASS rule; + transcripts under `docs/acceptance/v0.3/`. +- README install table for Claude Code, Codex CLI, Cursor, Hermes Agent, Claude Desktop. + +### Changed +- Repository layout: the plugin now lives at the root (`skills/humanize/`); the marketplace + `source` is `./`. +- `SKILL.md` is harness-agnostic: relative `scripts/surface_scan.py` path, plain-words options, + document extraction described generically; `argument-hint` removed (a hard error outside + Claude Code). The `/plugin` install lines moved to the README. +- Public copy describes humanize as grounded in the thirteen studies in + `references/SOURCES.md` and covering every layer of a text; StoryScope is named where it is + the source of a number. +- `pyproject.toml` version now tracks the plugin version. + +### Migration +- Existing Claude Code installs: `claude plugin marketplace update humanize && claude plugin + update humanize@humanize`. +``` +and add `[0.3.0]: https://github.com/ccf/humanize/compare/v0.2.0...v0.3.0` above the `[0.2.0]:` link; edit the `[Unreleased]:` link to `…/compare/v0.3.0...HEAD`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest -q -W error 2>&1 | tail -1` → `104 passed`. +Run: `claude plugin validate --strict . 2>&1 | tail -1` → passes. +Run: `python3 -c "import json;[json.load(open(p)) for p in ('plugin.json','.codex-plugin/plugin.json','.claude-plugin/plugin.json','.claude-plugin/marketplace.json')];print('json ok')"`. +Run: `grep -rn '0\.2\.0' plugin.json .claude-plugin .codex-plugin pyproject.toml` → nothing. + +- [ ] **Step 5: Commit** + +```bash +uv run ruff format tests/test_portability.py && uv run ruff check tests/test_portability.py +git add plugin.json .codex-plugin/plugin.json .claude-plugin pyproject.toml uv.lock CHANGELOG.md tests/test_portability.py +git commit -m "Add Agent Plugins and Codex manifests; version 0.3.0 in lockstep + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh" +``` + +--- + +### Task 4: Guard-pattern test and the claude.ai zip packager + +**Files:** +- Create: `tools/package_skill_zip.py` +- Modify: `tests/test_portability.py` (append), `.gitignore` (add `dist/`) + +**Interfaces:** +- Produces: `package_skill_zip.build_zip(repo_root: Path, out_dir: Path) -> Path`; `package_skill_zip.SPEC_FIELDS`; CLI `python3 tools/package_skill_zip.py [--out dist]`. + +- [ ] **Step 1: Write the failing tests** (append) + +```python +import importlib.util # top of module (no `import sys` — ruff F401) +import zipfile + +GUARD_TOKENS = ( + "subprocess", + "os.system", + "os.popen", + "os.environ", + "getenv", + "shutil.rmtree", + "eval(", + "exec(", + "compile(", + "getattr(", + "codecs", + "socket", + "urllib", + "requests", + "# /// script", +) + + +def _guard_hits(text: str) -> list[str]: + hits = [t for t in GUARD_TOKENS if t != "compile(" and t in text] + # Bare compile() is a guard pattern; the scanner's 22 re.compile() calls are not. + if re.search(r"(?.zip whose root is the skill folder (named exactly +`name`), the layout Anthropic's uploader and package_skill.py expect. Standard library only. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import zipfile +from pathlib import Path + +SKILL = "humanize" +SPEC_FIELDS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"} + + +class PackagingError(Exception): + pass + + +def _frontmatter_keys(skill_md: Path) -> set[str]: + text = skill_md.read_text(encoding="utf-8") + if not text.startswith("---\n"): + raise PackagingError("SKILL.md has no frontmatter") + block = text.split("\n---\n", 1)[0][4:] + keys = set() + for line in block.splitlines(): + if line.strip() and not line.startswith(" "): + keys.add(line.partition(":")[0].strip()) + return keys + + +def build_zip(repo_root: Path, out_dir: Path) -> Path: + skill_dir = repo_root / "skills" / SKILL + skill_md = skill_dir / "SKILL.md" + extra = _frontmatter_keys(skill_md) - SPEC_FIELDS + if extra: + raise PackagingError( + "non-spec frontmatter keys would fail the upload: " + ", ".join(sorted(extra)) + ) + version = json.loads((repo_root / "plugin.json").read_text(encoding="utf-8"))["version"] + members = [skill_md, skill_dir / "scripts" / "surface_scan.py"] + members += sorted(p for p in (skill_dir / "references").iterdir() if p.is_file()) + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / f"{SKILL}-skill-{version}.zip" + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in members: + zf.write(path, f"{SKILL}/{path.relative_to(skill_dir).as_posix()}") + return out + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", default="dist", help="output directory (default: dist)") + args = parser.parse_args(argv) + root = Path(__file__).resolve().parents[1] + try: + out = build_zip(root, root / args.out) + except PackagingError as e: + print(f"error: {e}", file=sys.stderr) + return 1 + print(out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +Add `dist/` to `.gitignore`. + +- [ ] **Step 4: Verify** + +Run: `uv run pytest -q -W error 2>&1 | tail -1` → `107 passed`. +Run: `uv run --python 3.9 pytest -q -W error 2>&1 | tail -1` → `107 passed` (the packager's `list[str] | None` annotation is fine under `from __future__ import annotations`). +Run: `python3 tools/package_skill_zip.py && unzip -l dist/humanize-skill-0.3.0.zip | tail -12` → 8 members, all under `humanize/`. + +- [ ] **Step 5: Commit** + +```bash +uv run ruff format tools/package_skill_zip.py tests/test_portability.py && uv run ruff check tools/package_skill_zip.py tests/test_portability.py +git add tools/package_skill_zip.py tests/test_portability.py .gitignore +git commit -m "Add guard-clean script test and the claude.ai skill zip packager + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh" +``` + +--- + +### Task 5: Positioning copy in README, CLAUDE.md, BUGBOT.md, and the study-count test + +**Files:** +- Modify: `README.md` (lines 3–17, tree lines for style/narrative tells), `CLAUDE.md:3-4`, `.cursor/BUGBOT.md:3-5`, `tests/test_portability.py` (append) + +- [ ] **Step 1: Write the failing test** (append) + +```python +NUMBER_WORDS = { + "eleven": 11, + "twelve": 12, + "thirteen": 13, + "fourteen": 14, + "fifteen": 15, + "sixteen": 16, + "seventeen": 17, + "eighteen": 18, + "nineteen": 19, + "twenty": 20, +} +STUDY_COUNT_SURFACES = { + "plugin.json": r"against (\d+) studies", + ".claude-plugin/plugin.json": r"against (\d+) studies", + ".codex-plugin/plugin.json": r"(\d+) studies", + ".claude-plugin/marketplace.json": r"(\d+) studies", + "pyproject.toml": r"from (\d+) studies", + "CLAUDE.md": r"Evidence base: (\d+) studies", + "README.md": r"rest on (\w+) studies", + "skills/humanize/SKILL.md": r"Grounded in (\w+) studies", +} + + +def test_study_count_matches_sources(): + sources = (SKILL_DIR / "references/SOURCES.md").read_text(encoding="utf-8") + n = len(re.findall(r"^## `[a-z0-9-]+`", sources, re.M)) + assert n >= 10 + for rel, pattern in STUDY_COUNT_SURFACES.items(): + text = (ROOT / rel).read_text(encoding="utf-8") + found = re.findall(pattern, text) + assert found, f"{rel}: no study count matching {pattern!r}" + for token in found: + value = int(token) if token.isdigit() else NUMBER_WORDS[token.lower()] + assert value == n, f"{rel} says {token}, SOURCES.md has {n}" +``` + +- [ ] **Step 2: Run to verify it fails** — `uv run pytest -q tests/test_portability.py::test_study_count_matches_sources 2>&1 | tail -2` → FAIL on `CLAUDE.md` (no "Evidence base: 13 studies" yet) or `README.md`. + +- [ ] **Step 3: Apply the copy** + +`README.md` lines 3–5 (the paragraph under `# humanize`): +``` +An agent skill that audits prose for the tells of AI writing and rewrites them +out, without flattening the author's voice. Runs in Claude Code, Codex, Cursor, +Hermes Agent, Claude Desktop. +``` + +`README.md` lines 7–17 (the paragraph beginning `It is grounded in [StoryScope]` and ending `every cited number resolves in `references/SOURCES.md`.`): +``` +The reference entries rest on thirteen studies. StoryScope (2026) measured 304 +narrative and stylistic features over 61,575 stories, and its widest human-vs-AI +gaps supply the base rates. Reinhart et al. (2025) put participial modifiers at +5.3 times the human rate and nominalization at 2.1 times. Jakesch et al. (2023) +found repeated phrasing the strongest true signal of a text's source. Herbold et +al. (2023) recorded a lexical-diversity reversal between model generations, a +reminder that findings expire. humanize turns that work into an audit checklist +plus a dependency-free scanner for the counts a model cannot eyeball; every flag +quotes the line it came from. One pattern organizes the whole checklist: AI +converges on shared defaults while human writing disperses. Every cited number +resolves in `references/SOURCES.md`. +``` + +`README.md` tree: `20 StoryScope style features with base rates` → `20 style features with StoryScope base rates`; `57 StoryScope narrative features (fiction only)` → `57 narrative features (fiction only), StoryScope base rates`. + +`CLAUDE.md` lines 3–4: +``` +Agent skill that audits prose for AI tells and rewrites it. Evidence base: 13 studies +in `references/SOURCES.md`. Spec and plan: `docs/design/`. Changelog: `CHANGELOG.md`. +``` + +`.cursor/BUGBOT.md` lines 3–5: +``` +This repo is an agent skill that audits prose for AI tells and rewrites it, on +evidence from the studies in `skills/humanize/references/SOURCES.md`. Design specs: +`docs/design/2026-09-1{3,4}-humanize-*-design.md`. +``` + +- [ ] **Step 4: Verify** + +Run: `uv run pytest -q -W error 2>&1 | tail -1` → `108 passed`. +Run: `grep -in storyscope plugin.json .codex-plugin/plugin.json .claude-plugin/*.json pyproject.toml skills/humanize/SKILL.md; sed -n 1,20p README.md | grep -ic storyscope` → nothing, then `1` (the single attributed mention in the grounding paragraph is expected; the tagline has none). +Run the copy through our own scanner: +```bash +sed -n 3,17p README.md > /tmp/humanize-readme-copy.md +python3 skills/humanize/scripts/surface_scan.py --text /tmp/humanize-readme-copy.md | grep -E 'wordlist|structures' +``` +→ `wordlist: 0.0/1k — none` and `not-but 0`. + +- [ ] **Step 5: Commit** + +```bash +uv run ruff format tests/test_portability.py && uv run ruff check tests/test_portability.py +git add README.md CLAUDE.md .cursor/BUGBOT.md tests/test_portability.py +git commit -m "Reposition humanize as research-grounded across every text layer + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh" +``` + +--- + +### Task 6: README install table and the CLAUDE.md / BUGBOT.md invariants and workflow + +**Files:** +- Modify: `README.md` (the `## Install` section, anchored by heading — line numbers shift after Task 5), `CLAUDE.md` (Commands, Invariants, Workflow), `.cursor/BUGBOT.md` (Invariants) + +- [ ] **Step 1: README Install section** + +Replace everything from `## Install` through the closing fence before `## Use` with: + +````markdown +## Install + +Requires Python 3.9+ on `PATH` for the scanner. No other dependencies. Every +command below was checked against its CLI's `--help` for this release. + +| Harness | Install | Verify | Invoke | +|---|---|---|---| +| Claude Code | `/plugin marketplace add ccf/humanize` then `/plugin install humanize@humanize` | `claude plugin list` | `/humanize …` or automatically | +| Codex CLI | `codex plugin marketplace add ccf/humanize` then `codex plugin add humanize@humanize` — or `cp -R skills/humanize ~/.agents/skills/` | `codex debug prompt-input "hi"` lists the skill | `$humanize …` or automatically | +| Cursor | `cp -R skills/humanize ~/.cursor/skills/` (every project) or `.cursor/skills/` (this project) | the skill appears in the `/` menu | `/humanize …` or automatically | +| Hermes Agent | `hermes skills install ccf/humanize/humanize --category writing`; in a running session, `/reload-skills` | `hermes skills list` | `/humanize …` or automatically | +| Any Agent-Skills harness | `npx skills add ccf/humanize` or `cp -R skills/humanize ~/.agents/skills/` | harness-specific | harness-specific | +| Claude Desktop — Cowork | Customize → Plugins → Add from repository `ccf/humanize` → install `humanize` | listed under Customize → Plugins | `/` or `+` picker, or automatically | +| Claude Desktop — chat (claude.ai) | Customize → Skills → upload `humanize-skill-.zip` from the [latest release](https://github.com/ccf/humanize/releases); "Code execution and file creation" must be on | listed under Customize → Skills | automatically, or the sidebar `/` menu | + +Notes: + +- **Codex** reads `/.agents/skills/` and `~/.agents/skills/` but not `.claude/skills/`, and + caps the injected skills catalog at 2% of the context window; a long catalog drops skills. +- **Cursor** syncs only `~/.cursor/skills/` to Cloud Agents, and only with **Sync Skills for Cloud + Agents** on (Settings → Agents). The Cursor marketplace is reviewed by hand and has no CLI. +- **Hermes** scans installed scripts (`surface_scan.py` is standard-library and passes) and runs + the scanner on the host, so on a remote terminal backend (docker, modal, ssh) the scan step is + skipped and the audit proceeds from reading alone. `hermes plugins install` also works on + recent builds but installs the package disabled and read-only; prefer `hermes skills install`. + Installing from a raw `SKILL.md` URL fetches one file and is not supported. +- **Claude Desktop** picks up a Cowork plugin update only when `version` changes. In claude.ai + chat the skill triggers by description; picking it from the `/` menu passes no arguments. +- **Word, PDF, PowerPoint inputs.** humanize delegates extraction to the harness's document + skills. In Claude Code that is Anthropic's `document-skills` plugin (`/plugin marketplace add + anthropics/skills`, `/plugin install document-skills@anthropic-agent-skills`; its `docx` read + path uses `pandoc` — `brew install pandoc` on macOS); Claude Desktop ships the same skills. +```` + +- [ ] **Step 2: CLAUDE.md** + +Commands block: `uv run pytest -q # must be warning-free` (drop the count); `claude plugin validate --strict . # marketplace JSON only; does not inspect plugin or skill contents`. + +Invariants: replace `- Nothing under `tests/` or `plugins/**/scripts/` touches the network.` with `- Nothing under `tests/`, `skills/**/scripts/`, or `tools/package_skill_zip.py` touches the network.`; replace the marketplace bullet's `Bump `version` in both when shipping.` with `Version lives in `plugin.json`, `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, both `marketplace.json` fields, `pyproject.toml`, and the newest CHANGELOG heading — `tests/test_portability.py` fails on drift.`; add: +``` +- `skills/` is harness-agnostic: no harness variables (`${CLAUDE_…}`, `HERMES_SKILL_DIR`, + `CURSOR_…`, `CODEX_…`) and no install commands inside it; frontmatter is exactly the Agent + Skills fields (`argument-hint` is a hard error outside Claude Code). `tests/test_portability.py` + enforces both. +- Public copy names StoryScope only where it is the specific source of a number or a file; the + tool is described as grounded in the `SOURCES.md` registry (the study count is tested). +``` +Workflow: after the merge sentence add: `Release: `tools/smoke_harnesses.sh` must PASS on every harness installed here (transcripts under `docs/acceptance/`), then merge, then post-merge acceptance from `main` (Codex `plugin marketplace add ccf/humanize` without a ref, Hermes `skills install`, Claude plugin upgrade, Cowork marketplace add), then tag and `gh release create vX.Y.Z dist/humanize-skill-X.Y.Z.zip` (built by `python3 tools/package_skill_zip.py`), then verify the zip uploads and triggers in claude.ai chat. Anything found after the merge is fixed forward as a patch release.` + +- [ ] **Step 3: BUGBOT.md** — mirror the harness-agnostic invariant and the version-lockstep invariant as two bullets under "Invariants to enforce"; extend the network bullet to `Nothing under `tests/`, `skills/**/scripts/`, or `tools/package_skill_zip.py` makes network or LLM calls.` (the glob itself was fixed in Task 1). + +- [ ] **Step 4: Verify** — `uv run pytest -q -W error | tail -1` → `108 passed`; `grep -in 'plugins/' README.md CLAUDE.md .cursor/BUGBOT.md` → empty (the table's "Customize → Plugins" has no slash); `grep -c 'undetectable\|certified human' README.md` → `1` (both phrases sit on one principle line); `grep -c '[’“”—–]' README.md` before and after Step 1 — the count must not decrease (the new table adds an em dash). + +- [ ] **Step 5: Commit** + +```bash +git add README.md CLAUDE.md .cursor/BUGBOT.md +git commit -m "Document installation per harness; portability and version invariants + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh" +``` + +--- + +### Task 7: Smoke matrix script and pre-merge acceptance runs + +**Files:** +- Create: `tools/smoke_harnesses.sh`, `docs/acceptance/v0.3/README.md`, `docs/acceptance/v0.3/.md` per run + +**Side effects (read before running):** Codex runs use a temp project with `--ephemeral` (nothing under `$HOME`). Cursor runs use a temp project; the script never updates the CLI — if `agent --help` lacks `-p`/`--print`, it prints SKIP with the installed version. Hermes has no project scope on v0.11.0: the script copies into `~/.hermes/skills/writing/humanize`, aborts if that directory already exists, and removes only what it created. Acceptance 3 (`codex plugin marketplace add ccf/humanize@feat/v0.3`) adds a marketplace to the user's Codex config; remove it afterwards with `codex plugin remove humanize@humanize` and `codex plugin marketplace remove humanize` (check `codex plugin marketplace --help` for the exact verb). + +- [ ] **Step 1: Write the script** + +`tools/smoke_harnesses.sh`: + +```bash +#!/usr/bin/env bash +# Local acceptance matrix for the humanize skill. Not run by CI or pytest. +# PASS needs evidence the skill AND the scanner ran (spec §5), not row names alone. +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FIXTURE="tests/fixtures/ai_report.txt" +OUT="$ROOT/docs/acceptance/v0.3" +REQUEST="Use the humanize skill on $FIXTURE. Audit only — do not rewrite." +mkdir -p "$OUT" + +# Expected rows (case-insensitive regex) and two scanner numbers that must appear verbatim. +ROWS=('trailing participial clause' 'verbatim repetition' 'container-noun phrase' 'safety disclaimer opener') +SCAN="$(python3 "$ROOT/skills/humanize/scripts/surface_scan.py" --text "$ROOT/$FIXTURE")" +NUM_TAILS="$(printf '%s\n' "$SCAN" | sed -n 's/^grammar: participial tails \([0-9]*\).*/\1/p')" +NUM_REP="$(printf '%s\n' "$SCAN" | sed -n 's/^repetition: \([0-9.]*\)\/1k.*/\1/p')" + +judge() { # judge + local h="$1" f="$2" tool_re="$3" ok=1 + for r in "${ROWS[@]}"; do grep -qiE "$r" "$f" || { echo " missing row: $r"; ok=0; }; done + grep -iE '^\|' "$f" | grep -qi 'nominalization' && { echo " nominalization row present"; ok=0; } + grep -qE "participial tails[^0-9]{0,20}${NUM_TAILS}([^0-9]|$)" "$f" || { echo " scanner count $NUM_TAILS absent"; ok=0; } + grep -qF "${NUM_REP}/1k" "$f" || { echo " scanner rate ${NUM_REP}/1k absent"; ok=0; } + [ -n "$tool_re" ] && { grep -qE "$tool_re" "$f" || { echo " no tool event naming surface_scan.py"; ok=0; }; } + grep -qiE 'Choices you may want to reverse|^## Rewrite' "$f" && { echo " rewrite section present"; ok=0; } + [ "$ok" = 1 ] && echo "PASS $h" || echo "FAIL $h" +} + +run_claude() { + command -v claude >/dev/null || { echo "SKIP claude (not installed)"; return; } + local f="$OUT/claude-code.md" + claude plugin disable humanize@humanize >/dev/null 2>&1 || true + (cd "$ROOT" && claude -p "$REQUEST" --plugin-dir . --output-format stream-json --verbose \ + --allowedTools "Bash,Read,Glob,Grep" > "$f.jsonl" 2>&1) + claude plugin enable humanize@humanize >/dev/null 2>&1 || true + python3 - "$f.jsonl" "$f" <<'EOF' +import json, sys +src, dst = sys.argv[1], sys.argv[2] +texts, tools = [], [] +for line in open(src, encoding="utf-8"): + try: ev = json.loads(line) + except ValueError: continue + for blk in (ev.get("message") or {}).get("content", []) if isinstance(ev, dict) else []: + if blk.get("type") == "text": texts.append(blk["text"]) + if blk.get("type") == "tool_use": tools.append(json.dumps(blk.get("input"))[:300]) + if ev.get("type") == "result" and ev.get("result"): texts.append(ev["result"]) +open(dst, "w", encoding="utf-8").write("# Claude Code\n\n## Tool calls\n" + "\n".join(tools) + "\n\n## Output\n" + "\n".join(texts)) +EOF + judge "claude-code" "$f" 'surface_scan\.py' +} + +run_codex() { + command -v codex >/dev/null || { echo "SKIP codex (not installed)"; return; } + local tmp f="$OUT/codex.md"; tmp="$(mktemp -d)" + mkdir -p "$tmp/.agents/skills" && cp -R "$ROOT/skills/humanize" "$tmp/.agents/skills/" && cp "$ROOT/$FIXTURE" "$tmp/" + (cd "$tmp" && codex debug prompt-input "hi" 2>/dev/null | grep -qi humanize) || echo " warning: skill not listed by codex debug prompt-input" + (cd "$tmp" && codex exec -C "$tmp" --skip-git-repo-check --ephemeral -s read-only --json \ + -o "$tmp/out.md" "Use the humanize skill on ai_report.txt. Audit only — do not rewrite." "$tmp/events.jsonl" 2>&1) + { echo "# Codex"; echo; echo "## Tool events"; grep -i 'surface_scan' "$tmp/events.jsonl" | head -5; echo; echo "## Output"; cat "$tmp/out.md" 2>/dev/null; } > "$f" + judge "codex" "$f" 'surface_scan\.py' + rm -rf "$tmp" +} + +run_cursor() { + command -v agent >/dev/null || { echo "SKIP cursor (agent CLI not installed)"; return; } + agent --help 2>/dev/null | grep -qE -- '(^| )-p[ ,]|--print' || { echo "SKIP cursor (agent $(agent --version 2>/dev/null | head -1) has no headless flag)"; return; } + local tmp f="$OUT/cursor.md"; tmp="$(mktemp -d)" + mkdir -p "$tmp/.cursor/skills" && cp -R "$ROOT/skills/humanize" "$tmp/.cursor/skills/" && cp "$ROOT/$FIXTURE" "$tmp/" + (cd "$tmp" && agent -p "Use the humanize skill on ai_report.txt. Audit only — do not rewrite." > "$f" 2>&1) + judge "cursor" "$f" '' + rm -rf "$tmp" +} + +run_hermes() { + command -v hermes >/dev/null || { echo "SKIP hermes (not installed)"; return; } + local dest="$HOME/.hermes/skills/writing/humanize" f="$OUT/hermes.md" + [ -e "$dest" ] && { echo "SKIP hermes ($dest already exists; not touching it)"; return; } + mkdir -p "$(dirname "$dest")" && cp -R "$ROOT/skills/humanize" "$dest" + if hermes --help 2>/dev/null | grep -q -- ' -z'; then + hermes -z "$REQUEST" > "$f" 2>&1 + else + hermes chat -q "$REQUEST" -Q > "$f" 2>&1 + fi + rm -rf "$dest" + judge "hermes" "$f" '' +} + +HARNESSES=("$@") +[ "${#HARNESSES[@]}" -eq 0 ] && HARNESSES=(claude codex cursor hermes) +for h in "${HARNESSES[@]}"; do "run_$h"; done +``` + +`chmod +x tools/smoke_harnesses.sh`. Write `docs/acceptance/v0.3/README.md`: what the transcripts are, the PASS rule, the date, and the CLI versions (`claude --version`, `codex --version`, `agent --version`, `hermes --version`). + +- [ ] **Step 2: Pin the flags before running** + +Run `codex exec --help`, `agent --help`, `hermes --help`, `hermes chat --help` and confirm every flag the script uses exists on the installed versions; adjust the script where a flag differs and note it in the report. The Hermes non-interactive flag is the one most likely to differ. + +- [ ] **Step 3: Run** + +`tools/smoke_harnesses.sh claude codex` first (no side effects beyond a temp dir), then `tools/smoke_harnesses.sh cursor hermes`. Paste each PASS/FAIL line into the report with the reason lines. Two attempts per harness; a FAIL after two attempts is reported, not fixed here. + +- [ ] **Step 4: Acceptance 3 (pre-merge, Codex from GitHub)** + +```bash +git push origin feat/v0.3 +codex plugin marketplace add ccf/humanize@feat/v0.3 +codex plugin add humanize@humanize +codex debug prompt-input "hi" | grep -i humanize +codex plugin remove humanize@humanize +codex plugin marketplace remove humanize # verb per `codex plugin marketplace --help` +``` +Record the exact output lines; `codex plugin marketplace list` afterwards must not show `humanize`. If `add` reports a manifest problem, that is a finding for the controller, not something to patch silently. + +- [ ] **Step 5: Commit** + +```bash +git add tools/smoke_harnesses.sh docs/acceptance/v0.3 +git commit -m "Add the harness smoke matrix and v0.3 acceptance transcripts + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh" +``` + +--- + +### Task 8: Spec status and final gate + +**Files:** +- Modify: `docs/design/2026-09-14-humanize-v0.3-design.md:4` (status line) + +- [ ] **Step 1:** Change `Status: draft for review (revision 2).` to `Status: implemented on feat/v0.3 as 0.3.0 (revision 2); phased acceptance per §7 — pre-merge items recorded in docs/acceptance/v0.3/.` + +- [ ] **Step 2: Gate** + +`uv run pytest -q -W error` → `108 passed`; `uv run --python 3.9 pytest -q -W error` → `108 passed`; `uv run ruff check tests tools skills && uv run ruff format --check tests tools skills`; `claude plugin validate --strict .` → `Validation passed`; `wc -l skills/humanize/SKILL.md` ≤ 150; `python3 tools/package_skill_zip.py`; `grep -rn 'plugins/humanize' . --include='*.md' --include='*.py' --include='*.json' --include='*.toml' | grep -v -E '^(\./)?(docs/design|CHANGELOG\.md|\.superpowers)'` → nothing. + +- [ ] **Step 3: Commit** + +```bash +git add docs/design/2026-09-14-humanize-v0.3-design.md +git commit -m "Mark the v0.3 design implemented + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh" +``` + +--- + +## Self-review notes + +- Spec coverage: §1 → T1, T3; §2 → T2; §3 tests 1–3 → T2, 5 → T3, 4 and 6 → T4, 7 → T5; §4 → T5 (copy), T6 (install/invariants); §5 → T7; §6 → T4 (packager) + T7/§7 phases (Cowork and zip upload are post-merge/post-release, controller-run); §7 → T3 (CHANGELOG) + T6 (workflow) + controller release steps; §8 acceptance 1, 2, 3, 7 → T7/T8, 4–6 → post-merge; §9 → T3, T5; §10 → T7 records outcomes. +- Test ladder: 100 → T2 103 → T3 104 → T4 107 → T5 108 — confirmed by a dry run of Tasks 1–5 in an isolated worktree (3.11 and 3.9), which also found and fixed in this revision: the `__pycache__` read in test 2, the `uv.lock` staging in Task 3, the `compile(` false positive in test 4, an unused `import sys`, the missing marketplace-remove in Task 7, the broken default-argument loop in the smoke script, the non-existent validate warning, and the `ugrep` path-prefix difference. +- Type consistency: `frontmatter()`, `SKILL_DIR`, `_json()` defined in T2/T3 and used in T4/T5 in the same module; `PackagingError` and `build_zip(repo_root, out_dir)` match between T4's test and implementation. +- Rulings recorded here for the ledger: `pyproject.toml` joins the version-lockstep set; `.cursor/BUGBOT.md` line 3 sentence is the plan's own wording (spec said "mirrors"). diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..b04fef9 --- /dev/null +++ b/plugin.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "humanize", + "version": "0.3.0", + "description": "Audits a draft layer by layer (vocabulary, punctuation, sentence shape, openers, closers, grammar, narrative, per-model habits) against 13 studies, then rewrites what it flags in the author's voice.", + "author": { "name": "ccf" }, + "license": "MIT", + "homepage": "https://github.com/ccf/humanize", + "repository": "https://github.com/ccf/humanize", + "keywords": ["humanize", "writing", "prose", "editing", "style", "ai-writing", "rewriting", "linguistics"] +} diff --git a/plugins/humanize/.claude-plugin/plugin.json b/plugins/humanize/.claude-plugin/plugin.json deleted file mode 100644 index d3383ea..0000000 --- a/plugins/humanize/.claude-plugin/plugin.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "humanize", - "version": "0.2.0", - "description": "Audit prose for AI tells and rewrite it to read as natural human writing, grounded in StoryScope's measured human-vs-AI feature gaps", - "author": { "name": "ccf" }, - "license": "MIT", - "keywords": ["writing", "prose", "editing", "ai-detection", "storyscope", "humanize"] -} diff --git a/pyproject.toml b/pyproject.toml index 7bcaaf6..33962b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "humanize-plugin" -version = "0.1.0" -description = "Claude Code plugin that removes AI tells from prose" +version = "0.3.0" +description = "Agent skill that audits prose for AI tells and rewrites it in the author's voice, on evidence from 13 studies." requires-python = ">=3.9" license = "MIT" dependencies = [] diff --git a/plugins/humanize/skills/humanize/SKILL.md b/skills/humanize/SKILL.md similarity index 78% rename from plugins/humanize/skills/humanize/SKILL.md rename to skills/humanize/SKILL.md index 5c44682..3b1377b 100644 --- a/plugins/humanize/skills/humanize/SKILL.md +++ b/skills/humanize/SKILL.md @@ -1,14 +1,13 @@ --- name: humanize description: Use when drafting or editing any prose — email, essay, documentation, blog post, story, chat reply — or when asked to "humanize" text, make it "sound less like AI", "more natural", "less robotic", or remove AI tells. Also use when reviewing prose someone else wrote. Not for code, config, or commit messages. -argument-hint: "[path | text] [--audit-only] [--fiction | --prose]" --- # Humanize Make prose read as natural human writing by finding and removing the tells that -mark it as AI-generated. Grounded in StoryScope (Russell et al., 2026) and -register studies: AI converges on shared defaults; human writing disperses. +mark it as AI-generated. Grounded in thirteen studies of measured style +differences: AI converges on shared defaults; human writing disperses. Read `references/principles.md` first, every time. @@ -22,10 +21,11 @@ the text. No audit table, no commentary about tells. **Audit mode** — the user asks you to humanize existing text, or `/humanize` was invoked. Follow all six steps below. -## Invocation (`/humanize` only) +## Invocation (explicit only) -Applies only when the user typed `/humanize …`. On auto-invoke (drafting -mode or a natural-language request) there are no arguments: skip this section. +Applies only when the user typed `/humanize …` (Codex: `$humanize …`). On +auto-invoke there are no arguments: skip this section. Options below also +work as plain words in a request ("audit only", "this is fiction"). Arguments: $ARGUMENTS @@ -67,21 +67,20 @@ the length. If the text is 80 words or longer, run the scanner and keep the output: ```bash -python3 "${CLAUDE_PLUGIN_ROOT}/skills/humanize/scripts/surface_scan.py" --text +python3 /scripts/surface_scan.py --text ``` -Write pasted text to a temp file first. For the full JSON, drop `--text`. Under -80 words, skip this step; the statistics are noise. The scanner strips code, -links, URLs, and heading markers from Markdown before measuring. +`` is the directory holding this SKILL.md; if its path was not +shown, search for `scripts/surface_scan.py`, and if it is not found skip the +scan and say so. Keep the working directory on the user's project so `` +resolves. Pasted text goes to a temp file first. Under 80 words, skip this step. **Non-text sources** (`.docx`, `.pdf`, `.pptx`, `.odt`, `.rtf`): the scanner reads -plain text only. Extract first — use the `docx` or `pdf` skill if installed -(Anthropic's `document-skills` plugin) to write a temp `.md`, then scan and -audit that. If the skill is not installed, say so and give the two commands: -`/plugin marketplace add anthropics/skills` and -`/plugin install document-skills@anthropic-agent-skills`; for a PDF you can -still Read it yourself and write the text to a temp file. Deliver the rewrite -as Markdown; if the user wants a Word file back, hand off to the `docx` skill. +plain text only. Extract first with the harness's document skills (`docx`, `pdf`) +to write a temp `.md`, then scan and audit that; in claude.ai convert the +attachment in the sandbox. Without document skills, read the file yourself if +you can and write the text to a temp file; otherwise ask for a text export. +Deliver Markdown; a Word file via `docx`. ### 3. Audit @@ -92,9 +91,10 @@ Walk every loaded tell list. For each tell you judge present, record: - the base rate line from the reference, or the scan number Rank by strength of evidence. Report **at most ten**. For texts under ~300 -words, quote raw counts from `punct.counts`, not per-1k rates. Quote -`repetition.phrases` and `grammar.*.hits` verbatim. `nominalization.hits` -never become a row. Other studies' ratios never go in the base-rate column. +words, quote raw counts from `punct.counts`, not per-1k rates. Every scanner +block that fired (`repetition.phrases`, each `grammar.*` with hits, the +disclaimer opener) gets its own row quoting the hits verbatim. `nominalization` +hits never become a row; other studies' ratios never go in the base-rate column. Format: ``` diff --git a/plugins/humanize/skills/humanize/references/SOURCES.md b/skills/humanize/references/SOURCES.md similarity index 100% rename from plugins/humanize/skills/humanize/references/SOURCES.md rename to skills/humanize/references/SOURCES.md diff --git a/plugins/humanize/skills/humanize/references/model-fingerprints.md b/skills/humanize/references/model-fingerprints.md similarity index 100% rename from plugins/humanize/skills/humanize/references/model-fingerprints.md rename to skills/humanize/references/model-fingerprints.md diff --git a/plugins/humanize/skills/humanize/references/narrative-tells.md b/skills/humanize/references/narrative-tells.md similarity index 100% rename from plugins/humanize/skills/humanize/references/narrative-tells.md rename to skills/humanize/references/narrative-tells.md diff --git a/plugins/humanize/skills/humanize/references/principles.md b/skills/humanize/references/principles.md similarity index 100% rename from plugins/humanize/skills/humanize/references/principles.md rename to skills/humanize/references/principles.md diff --git a/plugins/humanize/skills/humanize/references/style-tells.md b/skills/humanize/references/style-tells.md similarity index 100% rename from plugins/humanize/skills/humanize/references/style-tells.md rename to skills/humanize/references/style-tells.md diff --git a/plugins/humanize/skills/humanize/references/surface-tells.md b/skills/humanize/references/surface-tells.md similarity index 100% rename from plugins/humanize/skills/humanize/references/surface-tells.md rename to skills/humanize/references/surface-tells.md diff --git a/plugins/humanize/skills/humanize/scripts/surface_scan.py b/skills/humanize/scripts/surface_scan.py similarity index 100% rename from plugins/humanize/skills/humanize/scripts/surface_scan.py rename to skills/humanize/scripts/surface_scan.py diff --git a/tests/conftest.py b/tests/conftest.py index 9a1ed1f..f0ffa15 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import sys from pathlib import Path -SCRIPTS = Path(__file__).resolve().parents[1] / "plugins/humanize/skills/humanize/scripts" +SCRIPTS = Path(__file__).resolve().parents[1] / "skills/humanize/scripts" sys.path.insert(0, str(SCRIPTS)) diff --git a/tests/test_manifests.py b/tests/test_manifests.py index 590c4c3..2c33c93 100644 --- a/tests/test_manifests.py +++ b/tests/test_manifests.py @@ -20,7 +20,6 @@ def test_marketplace_points_at_existing_plugin_components(): # /humanize is the skill itself; a separate command file would register a duplicate name. assert not (src / "commands").exists() assert (src / "skills/humanize/SKILL.md").is_file() - assert "argument-hint:" in (src / "skills/humanize/SKILL.md").read_text() assert (src / "skills/humanize/scripts/surface_scan.py").is_file() for doc in ( "principles", @@ -35,12 +34,12 @@ def test_marketplace_points_at_existing_plugin_components(): def test_plugin_manifest_matches_marketplace_entry(): m = json.loads((ROOT / ".claude-plugin/marketplace.json").read_text())["plugins"][0] - p = json.loads((ROOT / "plugins/humanize/.claude-plugin/plugin.json").read_text()) + p = json.loads((ROOT / ".claude-plugin/plugin.json").read_text()) assert p["name"] == m["name"] == "humanize" assert p["version"] == m["version"] -SKILL_MD = ROOT / "plugins/humanize/skills/humanize/SKILL.md" +SKILL_MD = ROOT / "skills/humanize/SKILL.md" def test_skill_line_budget(): @@ -50,7 +49,7 @@ def test_skill_line_budget(): assert len(lines) <= 150, len(lines) -REFS = ROOT / "plugins/humanize/skills/humanize/references" +REFS = ROOT / "skills/humanize/references" KEY_RE = re.compile(r"\[([a-z-]+-\d{4})\]") EXPECTED_KEYS = { "storyscope-2026", diff --git a/tests/test_portability.py b/tests/test_portability.py new file mode 100644 index 0000000..4621389 --- /dev/null +++ b/tests/test_portability.py @@ -0,0 +1,239 @@ +"""Portability guards: the skill must be a plain Agent Skill every harness can load.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILL_DIR = ROOT / "skills/humanize" +SKILL_MD = SKILL_DIR / "SKILL.md" + +# The Agent Skills specification fields (agentskills.io; Codex quick_validate.py; +# claude.ai upload validator). Anything else is a hard error outside Claude Code. +SPEC_FIELDS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"} +NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + +# Harness-specific tokens that must never appear under skills/ (spec §3 test 2). +HARNESS_TOKENS = ( + "${CLAUDE_", + "CLAUDE_PLUGIN_ROOT", + "HERMES_SKILL_DIR", + "CURSOR_", + "CODEX_", + "/plugin ", + "npx skills", +) + +RUNTIME_REFERENCES = ( + "references/principles.md", + "references/surface-tells.md", + "references/style-tells.md", + "references/narrative-tells.md", + "references/model-fingerprints.md", +) + + +def frontmatter(text: str) -> dict[str, str]: + """Return the YAML frontmatter as a flat key -> raw-value map (one line per key).""" + assert text.startswith("---\n"), "SKILL.md must start with frontmatter" + block = text.split("\n---\n", 1)[0][4:] + out: dict[str, str] = {} + for line in block.splitlines(): + if not line.strip() or line.startswith(" "): + continue + key, _, value = line.partition(":") + out[key.strip()] = value.strip() + return out + + +def test_frontmatter_is_spec_only(): + fm = frontmatter(SKILL_MD.read_text(encoding="utf-8")) + extra = set(fm) - SPEC_FIELDS + assert not extra, f"non-spec frontmatter keys: {sorted(extra)}" + name = fm["name"] + assert NAME_RE.match(name) and len(name) <= 64, name + assert "claude" not in name and "anthropic" not in name + assert name == SKILL_DIR.name, "Cursor requires the folder name to equal `name`" + desc = fm["description"] + assert len(desc) <= 1024, len(desc) + assert "<" not in desc and ">" not in desc + assert not desc.startswith("[TODO:") + + +def test_no_harness_specific_content_under_skills(): + # conftest.py's sys.path insert makes pytest write scripts/__pycache__/*.pyc; skip binaries. + for path in SKILL_DIR.rglob("*"): + if not path.is_file() or "__pycache__" in path.parts: + continue + text = path.read_text(encoding="utf-8") + for token in HARNESS_TOKENS: + assert token not in text, f"{path.relative_to(ROOT)} contains {token!r}" + collapsed = " ".join(SKILL_MD.read_text(encoding="utf-8").split()) + assert "reads literally as `$ARGUMENTS`" in collapsed + + +def test_runtime_references_and_script_are_named_in_skill(): + text = SKILL_MD.read_text(encoding="utf-8") + for ref in RUNTIME_REFERENCES: + assert ref in text, ref + assert (SKILL_DIR / ref).is_file(), ref + assert "scripts/surface_scan.py" in text + # SOURCES.md is a maintainer registry, never loaded at runtime, so it is not named. + + +AGENT_PLUGINS_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +CHANGELOG_HEADING_RE = re.compile(r"^## \[(\d+\.\d+\.\d+)\]", re.M) + + +def _json(rel: str) -> dict: + return json.loads((ROOT / rel).read_text(encoding="utf-8")) + + +def _pyproject_version() -> str: + text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + return re.search(r'^version = "([^"]+)"', text, re.M).group(1) + + +def test_manifests_agree(): + root = _json("plugin.json") + claude = _json(".claude-plugin/plugin.json") + codex = _json(".codex-plugin/plugin.json") + market = _json(".claude-plugin/marketplace.json") + entry = market["plugins"][0] + for m in (root, claude, codex, entry): + assert m["name"] == "humanize" + versions = { + "plugin.json": root["version"], + ".claude-plugin/plugin.json": claude["version"], + ".codex-plugin/plugin.json": codex["version"], + "marketplace.metadata": market["metadata"]["version"], + "marketplace.plugins[0]": entry["version"], + "pyproject.toml": _pyproject_version(), + } + assert len(set(versions.values())) == 1, versions + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + newest = CHANGELOG_HEADING_RE.search(changelog).group(1) # [Unreleased] has no digits + assert newest == root["version"], (newest, root["version"]) + assert root["$schema"] == AGENT_PLUGINS_SCHEMA + assert (ROOT / codex["skills"] / "humanize/SKILL.md").is_file() + assert (ROOT / entry["source"] / ".claude-plugin/plugin.json").is_file() + assert entry["strict"] is True + for m in (root, claude, codex, entry): + assert "storyscope" not in json.dumps(m).lower() + + +GUARD_TOKENS = ( + "subprocess", + "os.system", + "os.popen", + "os.environ", + "getenv", + "shutil.rmtree", + "eval(", + "exec(", + "compile(", + "getattr(", + "codecs", + "socket", + "urllib", + "requests", + "# /// script", +) + + +def _guard_hits(text: str) -> list[str]: + hits = [t for t in GUARD_TOKENS if t != "compile(" and t in text] + # Bare compile() is a guard pattern; the scanner's 22 re.compile() calls are not. + if re.search(r"(?= 10 + for rel, pattern in STUDY_COUNT_SURFACES.items(): + text = (ROOT / rel).read_text(encoding="utf-8") + found = re.findall(pattern, text) + assert found, f"{rel}: no study count matching {pattern!r}" + for token in found: + value = int(token) if token.isdigit() else NUMBER_WORDS[token.lower()] + assert value == n, f"{rel} says {token}, SOURCES.md has {n}" diff --git a/tools/package_skill_zip.py b/tools/package_skill_zip.py new file mode 100644 index 0000000..4f38bbb --- /dev/null +++ b/tools/package_skill_zip.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Build the claude.ai / Claude Desktop skill upload for humanize. + +Produces dist/humanize-skill-.zip whose root is the skill folder (named exactly +`name`), the layout Anthropic's uploader and package_skill.py expect. Standard library only. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import zipfile +from pathlib import Path + +SKILL = "humanize" +SPEC_FIELDS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"} + + +class PackagingError(Exception): + pass + + +def _frontmatter_keys(skill_md: Path) -> set[str]: + text = skill_md.read_text(encoding="utf-8") + if not text.startswith("---\n"): + raise PackagingError("SKILL.md has no frontmatter") + block = text.split("\n---\n", 1)[0][4:] + keys = set() + for line in block.splitlines(): + if line.strip() and not line.startswith(" "): + keys.add(line.partition(":")[0].strip()) + return keys + + +def build_zip(repo_root: Path, out_dir: Path) -> Path: + skill_dir = repo_root / "skills" / SKILL + skill_md = skill_dir / "SKILL.md" + extra = _frontmatter_keys(skill_md) - SPEC_FIELDS + if extra: + raise PackagingError( + "non-spec frontmatter keys would fail the upload: " + ", ".join(sorted(extra)) + ) + version = json.loads((repo_root / "plugin.json").read_text(encoding="utf-8"))["version"] + members = [skill_md, skill_dir / "scripts" / "surface_scan.py"] + members += sorted(p for p in (skill_dir / "references").iterdir() if p.is_file()) + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / f"{SKILL}-skill-{version}.zip" + with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in members: + zf.write(path, f"{SKILL}/{path.relative_to(skill_dir).as_posix()}") + return out + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", default="dist", help="output directory (default: dist)") + args = parser.parse_args(argv) + root = Path(__file__).resolve().parents[1] + try: + out = build_zip(root, root / args.out) + except PackagingError as e: + print(f"error: {e}", file=sys.stderr) + return 1 + print(out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/smoke_harnesses.sh b/tools/smoke_harnesses.sh new file mode 100755 index 0000000..8dd6b90 --- /dev/null +++ b/tools/smoke_harnesses.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# Local acceptance matrix for the humanize skill. Not run by CI or pytest. +# PASS needs evidence the skill AND the scanner ran (spec §5), not row names alone. +# +# Every transcript has three H2 sections, in this order: "## Tool calls" (the +# commands/tool inputs the agent issued), "## Scanner output" (raw output of any +# scanner invocation), "## Output" (the agent's own final text only). The judge +# below is section-aware: rows, the nominalization negative, and the rewrite +# check run against ## Output only; the two scanner numbers may match in either +# ## Scanner output or ## Output; the tool-event regex runs against ## Tool +# calls only. +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FIXTURE="tests/fixtures/ai_report.txt" +OUT="$ROOT/docs/acceptance/v0.3" +REQUEST="Use the humanize skill on $FIXTURE. Audit only — do not rewrite." +mkdir -p "$OUT" + +# Two scanner numbers that must appear verbatim. Row concepts (matched against the +# third |-separated field — the Tell cell — of each distinct table row under +# "## Output", rather than by our own reference titles) are checked in judge()'s +# row_check awk block below. +SCAN="$(python3 "$ROOT/skills/humanize/scripts/surface_scan.py" --text "$ROOT/$FIXTURE")" +NUM_TAILS="$(printf '%s\n' "$SCAN" | sed -n 's/^grammar: participial tails \([0-9]*\).*/\1/p')" +NUM_REP="$(printf '%s\n' "$SCAN" | sed -n 's/^repetition: \([0-9.]*\)\/1k.*/\1/p')" +[ -n "$NUM_TAILS" ] && [ -n "$NUM_REP" ] || { echo "FAIL all (scanner summary format changed; cannot derive expected numbers)"; exit 1; } + +# check_auth reads a transcript (or raw CLI output) on stdin and prints the first +# line matching a known authentication-failure signature, or nothing. +check_auth() { + grep -m1 -E "^(Error: )?Authentication required|^\S*AuthError|token refresh failed|Please run 'agent login'" +} + +# section_* extract one H2 section's body from a transcript file, bounded by the +# next known heading (or EOF for the last section) so embedded "## " lines inside +# an agent's own output (e.g. a real "## Rewrite" heading) never get treated as a +# stray section boundary. +section_tool_calls() { awk '/^## Tool calls/{f=1;next} /^## Scanner output/{exit} f' "$1"; } +section_scanner_output() { awk '/^## Scanner output/{f=1;next} /^## Output/{exit} f' "$1"; } +section_output() { awk '/^## Output/{f=1;next} f' "$1"; } + +judge() { # judge + local h="$1" f="$2" tool_re="$3" ok=1 + local out scan calls + out="$(section_output "$f")" + scan="$(section_scanner_output "$f")" + calls="$(section_tool_calls "$f")" + local row_check + # Match each concept against the third |-separated field (the Tell cell) of every + # distinct table row under ## Output, taking the first matching row per concept. + # Header/separator rows (" Tell ", "------") match none of the four regexes, so + # they need no special case. All four concepts must match, on four distinct rows. + row_check="$(printf '%s\n' "$out" | awk -F'|' ' + /^\|/ { + n++ + tell = tolower($3) + if (!l1 && tell ~ /participial/) l1 = n + if (!l2 && tell ~ /repetit/) l2 = n + if (!l3 && tell ~ /container/) l3 = n + if (!l4 && tell ~ /disclaimer/) l4 = n + } + END { + if (!l1 || !l2 || !l3 || !l4) { print "missing concept row (participial/repetition/container/disclaimer)"; exit } + if (l1 == l2 || l1 == l3 || l1 == l4 || l2 == l3 || l2 == l4 || l3 == l4) print "concept rows not distinct" + } + ')" + [ -n "$row_check" ] && { echo " $row_check"; ok=0; } + printf '%s\n' "$out" | grep -iE '^\|' | grep -qi 'nominalization' && { echo " nominalization row present"; ok=0; } + printf '%s\n%s\n' "$scan" "$out" | grep -qE "participial tails[^0-9]{0,20}${NUM_TAILS}([^0-9]|$)" || { echo " scanner count $NUM_TAILS absent"; ok=0; } + printf '%s\n%s\n' "$scan" "$out" | grep -qF "${NUM_REP}/1k" || { echo " scanner rate ${NUM_REP}/1k absent"; ok=0; } + [ -n "$tool_re" ] && { printf '%s\n' "$calls" | grep -qE "$tool_re" || { echo " no tool event naming surface_scan.py"; ok=0; }; } + printf '%s\n' "$out" | grep -qiE 'Choices you may want to reverse|^## Rewrite' && { echo " rewrite section present"; ok=0; } + [ "$ok" = 1 ] && echo "PASS $h" || echo "FAIL $h" +} + +run_claude() { + command -v claude >/dev/null || { echo "SKIP claude (not installed)"; return; } + local f="$OUT/claude-code.md" tmp raw + tmp="$(mktemp -d)" + [ -n "$tmp" ] && [ -d "$tmp" ] || { echo "FAIL claude-code (mktemp failed)"; return; } + raw="$tmp/claude-code.md.jsonl" + claude plugin disable humanize@humanize >/dev/null 2>&1 || true + # A ^C or kill mid-run must not leave the user's installed plugin disabled or + # the raw-stream temp directory behind. + trap 'claude plugin enable humanize@humanize >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT + trap 'claude plugin enable humanize@humanize >/dev/null 2>&1 || true; rm -rf "$tmp"; trap - EXIT INT TERM; exit 130' INT TERM + (cd "$ROOT" && claude -p "$REQUEST" --plugin-dir . --output-format stream-json --verbose \ + --allowedTools "Bash,Read,Glob,Grep" > "$raw" 2>&1) + claude plugin enable humanize@humanize >/dev/null 2>&1 || true + trap - EXIT INT TERM + local auth_line + auth_line="$(check_auth < "$raw")" + if [ -n "$auth_line" ]; then + cp "$raw" "$f" + echo "SKIP claude-code (not authenticated: $auth_line)" + rm -rf "$tmp" + return + fi + python3 - "$raw" "$f" <<'EOF' +import json, sys +src, dst = sys.argv[1], sys.argv[2] +calls, scanner, output = [], [], [] +scan_ids = set() +for line in open(src, encoding="utf-8"): + line = line.strip() + if not line: + continue + try: + ev = json.loads(line) + except ValueError: + continue + if not isinstance(ev, dict): + continue + msg = ev.get("message") or {} + role = msg.get("role") + content = msg.get("content") + if isinstance(content, list): + for blk in content: + if not isinstance(blk, dict): + continue + btype = blk.get("type") + if btype == "tool_use": + # Only a genuine scanner invocation becomes tool-call/scanner-output + # evidence: a Bash call whose command names both python and + # surface_scan.py, mirroring the Codex parser's rule. A Read/Glob/Grep + # search that merely names surface_scan.py — invited by SKILL.md's + # scan-path fallback — must not satisfy the tool-evidence rule. + inp = blk.get("input") + cmd = inp.get("command", "") if isinstance(inp, dict) else "" + if blk.get("name") == "Bash" and "python" in cmd and "surface_scan.py" in cmd: + calls.append(json.dumps(inp, indent=2)) + tool_id = blk.get("id") + if tool_id: + scan_ids.add(tool_id) + elif btype == "tool_result": + if blk.get("tool_use_id") not in scan_ids: + continue + c = blk.get("content") + if isinstance(c, str): + scanner.append(c) + elif isinstance(c, list): + for sub in c: + if isinstance(sub, dict) and sub.get("type") == "text": + scanner.append(sub.get("text", "")) + elif btype == "text" and role == "assistant": + # Only the model's own text becomes ## Output. The Skill launch's + # injected SKILL.md dump arrives as role "user", type "text" and + # is deliberately dropped here — it must land nowhere. + output.append(blk.get("text", "")) + if ev.get("type") == "result" and ev.get("result"): + # stream-json's final top-level "result" event often repeats the last assistant + # text block already collected above; only append it when it actually differs, + # or the final table gets duplicated in ## Output. + if not output or output[-1] != ev["result"]: + output.append(ev["result"]) +open(dst, "w", encoding="utf-8").write( + "# Claude Code\n\n## Tool calls\n" + "\n".join(calls) + + "\n\n## Scanner output\n" + "\n".join(scanner) + + "\n\n## Output\n" + "\n".join(output) +) +EOF + rm -rf "$tmp" + judge "claude-code" "$f" 'surface_scan\.py' +} + +run_codex() { + command -v codex >/dev/null || { echo "SKIP codex (not installed)"; return; } + local tmp f="$OUT/codex.md"; tmp="$(mktemp -d)" + [ -n "$tmp" ] && [ -d "$tmp" ] || { echo "FAIL codex (mktemp failed)"; return; } + trap 'rm -rf "$tmp"' EXIT + trap 'rm -rf "$tmp"; trap - EXIT INT TERM; exit 130' INT TERM + mkdir -p "$tmp/.agents/skills" && cp -R "$ROOT/skills/humanize" "$tmp/.agents/skills/" && cp "$ROOT/$FIXTURE" "$tmp/" + find "$tmp/.agents/skills/humanize" -name __pycache__ -type d -exec rm -rf {} + + (cd "$tmp" && codex debug prompt-input "hi" 2>/dev/null | grep -qi humanize) || echo " warning: skill not listed by codex debug prompt-input" + (cd "$tmp" && codex exec -C "$tmp" --skip-git-repo-check --ephemeral -s read-only --json \ + -o "$tmp/out.md" "Use the humanize skill on ai_report.txt. Audit only — do not rewrite." "$tmp/events.jsonl" 2>&1) + local auth_line + auth_line="$(check_auth < "$tmp/events.jsonl")" + if [ -n "$auth_line" ]; then + cp "$tmp/events.jsonl" "$f" + echo "SKIP codex (not authenticated: $auth_line)" + rm -rf "$tmp" + trap - EXIT INT TERM + return + fi + python3 - "$tmp/events.jsonl" "$tmp/out.md" "$f" <<'EOF' +import json, sys +events_path, out_md_path, dst = sys.argv[1], sys.argv[2], sys.argv[3] +calls, scanner, output = [], [], [] +for line in open(events_path, encoding="utf-8"): + line = line.strip() + if not line: + continue + try: + ev = json.loads(line) + except ValueError: + continue + if not isinstance(ev, dict) or ev.get("type") != "item.completed": + continue + item = ev.get("item") + if not isinstance(item, dict): + continue + itype = item.get("type") + if itype == "command_execution": + cmd = item.get("command") or "" + # Only the scanner invocation itself becomes tool-call/scanner-output + # evidence. Reads of SKILL.md (cat/sed/head) must not appear here, even + # though SKILL.md's own text happens to mention surface_scan.py. + if "python" in cmd and "surface_scan.py" in cmd: + calls.append(cmd) + scanner.append(item.get("aggregated_output") or "") + elif itype == "agent_message": + text = item.get("text") or "" + if text: + output.append(text) +try: + with open(out_md_path, encoding="utf-8") as fh: + out_md_text = fh.read() + # out.md commonly repeats the last agent_message text verbatim; only append + # it when it actually differs, or ## Output carries the audit table twice. + if not output or output[-1] != out_md_text: + output.append(out_md_text) +except OSError: + pass +open(dst, "w", encoding="utf-8").write( + "# Codex\n\n## Tool calls\n" + "\n".join(calls) + + "\n\n## Scanner output\n" + "\n".join(scanner) + + "\n\n## Output\n" + "\n".join(output) +) +EOF + judge "codex" "$f" 'surface_scan\.py' + rm -rf "$tmp" + trap - EXIT INT TERM +} + +run_cursor() { + command -v agent >/dev/null || { echo "SKIP cursor (agent CLI not installed)"; return; } + agent --help 2>/dev/null | grep -qE -- '(^| )-p[ ,]|--print' || { echo "SKIP cursor (agent $(agent --version 2>/dev/null | head -1) has no headless flag)"; return; } + local tmp f="$OUT/cursor.md" raw; tmp="$(mktemp -d)" + [ -n "$tmp" ] && [ -d "$tmp" ] || { echo "FAIL cursor (mktemp failed)"; return; } + trap 'rm -rf "$tmp"' EXIT + trap 'rm -rf "$tmp"; trap - EXIT INT TERM; exit 130' INT TERM + mkdir -p "$tmp/.cursor/skills" && cp -R "$ROOT/skills/humanize" "$tmp/.cursor/skills/" && cp "$ROOT/$FIXTURE" "$tmp/" + find "$tmp/.cursor/skills/humanize" -name __pycache__ -type d -exec rm -rf {} + + # Deviation from the brief: the installed `agent` CLI's approvalMode is `allowlist` + # with only Shell(ls) allowed, so a headless run cannot execute the scanner + # without --force. + raw="$(cd "$tmp" && agent -p --force --output-format text "Use the humanize skill on ai_report.txt. Audit only — do not rewrite." 2>&1)" + rm -rf "$tmp" + trap - EXIT INT TERM + local auth_line + auth_line="$(printf '%s\n' "$raw" | check_auth)" + if [ -n "$auth_line" ]; then + printf '%s\n' "$raw" > "$f" + echo "SKIP cursor (not authenticated: $auth_line)" + return + fi + { + echo "## Tool calls" + echo "(not separately observable — agent --output-format text does not expose tool-call events)" + echo + echo "## Scanner output" + echo "(not separately observable — see ## Output)" + echo + echo "## Output" + printf '%s\n' "$raw" + } > "$f" + judge "cursor" "$f" '' +} + +run_hermes() { + command -v hermes >/dev/null || { echo "SKIP hermes (not installed)"; return; } + local dest="$HOME/.hermes/skills/writing/humanize" f="$OUT/hermes.md" raw + [ -e "$dest" ] && { echo "SKIP hermes ($dest already exists; not touching it)"; return; } + # A ^C or kill mid-run must not leave a copy installed in the user's home + # directory — a leftover copy makes every later run print SKIP hermes forever. + # Armed before the copy starts: rm -rf on a $dest that doesn't exist yet, or + # exists only partially, is safe. + trap 'rm -rf "$dest"' EXIT + trap 'rm -rf "$dest"; trap - EXIT INT TERM; exit 130' INT TERM + mkdir -p "$(dirname "$dest")" && cp -R "$ROOT/skills/humanize" "$dest" + find "$dest" -name __pycache__ -type d -exec rm -rf {} + + # $REQUEST names the fixture by a project-relative path; run from $ROOT so it resolves + # no matter which directory this script itself is invoked from. + if hermes --help 2>/dev/null | grep -q -- ' -z'; then + raw="$(cd "$ROOT" && hermes -z "$REQUEST" 2>&1)" + else + raw="$(cd "$ROOT" && hermes chat -q "$REQUEST" -Q 2>&1)" + fi + rm -rf "$dest" + trap - EXIT INT TERM + local auth_line + auth_line="$(printf '%s\n' "$raw" | check_auth)" + if [ -n "$auth_line" ]; then + printf '%s\n' "$raw" > "$f" + echo "SKIP hermes (not authenticated: $auth_line)" + return + fi + { + echo "## Tool calls" + echo "(not separately observable — hermes oneshot/quiet mode does not expose tool-call events)" + echo + echo "## Scanner output" + echo "(not separately observable — see ## Output)" + echo + echo "## Output" + printf '%s\n' "$raw" + } > "$f" + judge "hermes" "$f" '' +} + +HARNESSES=("$@") +[ "${#HARNESSES[@]}" -eq 0 ] && HARNESSES=(claude codex cursor hermes) +for h in "${HARNESSES[@]}"; do + type "run_$h" >/dev/null 2>&1 || { echo "SKIP $h (unknown harness)"; continue; } + "run_$h" +done diff --git a/uv.lock b/uv.lock index aaf90b7..42e39f8 100644 --- a/uv.lock +++ b/uv.lock @@ -86,7 +86,7 @@ wheels = [ [[package]] name = "humanize-plugin" -version = "0.1.0" +version = "0.3.0" source = { virtual = "." } [package.dev-dependencies]