skilldrop-cli 0.11.3 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,9 +3,9 @@
3
3
  [![npm](https://img.shields.io/npm/v/skilldrop-cli)](https://www.npmjs.com/package/skilldrop-cli)
4
4
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
 
6
- **[Browse the catalogue →](https://sananthanarayan.github.io/skilldrop/)** — all 56 skills, filterable by pack, tag, and model tier.
6
+ **[Browse the catalogue →](https://sananthanarayan.github.io/skilldrop/)** — all 57 skills, filterable by outcome, pack, tag, and model tier.
7
7
 
8
- **56 portable AI-agent skills for the deliverables knowledge workers actually ship** — diagrams, design docs, ADRs, PRDs, runbooks, decks, decision logs, threat models, adversarial reviews — installable into **Claude Code**, **Cursor**, **Kiro**, **Codex**, **GitHub Copilot**, and any AI tool that accepts custom instructions, one skill or one role-based pack at a time:
8
+ **57 portable AI-agent skills for the deliverables knowledge workers actually ship** — diagrams, design docs, ADRs, PRDs, runbooks, decks, decision logs, threat models, adversarial reviews — installable into **Claude Code**, **Cursor**, **Kiro**, **Codex**, **GitHub Copilot**, and any AI tool that accepts custom instructions, one skill or one role-based pack at a time:
9
9
 
10
10
  ```bash
11
11
  npx skilldrop-cli install --pack product-manager # or: solution-architect, dev-team,
@@ -77,6 +77,7 @@ flowchart LR
77
77
  |---|---|
78
78
  | [`brief-intake`](skills/brief-intake/SKILL.md) | Upstream collector. Takes raw mess — a Slack thread, meeting transcript, ticket, email chain, paragraph of notes — and emits a structured brief shaped for whichever downstream skill comes next (ADR, design doc, runbook, exec summary, deck, comparison matrix, decision log). Every field is tagged `[explicit] / [implied] / [inferred] / [missing]` with verbatim quotes from the source. |
79
79
  | [`doc-critique`](skills/doc-critique/SKILL.md) | Counterpart reviewer. Takes an existing doc (ADR, design doc, runbook, exec summary, comparison matrix, deck, decision log) and produces a structured critique against the same rubrics the generators enforce — verdict + severity-tagged findings (blocker / major / minor / nit) with quoted evidence and concrete fixes, plus a "what's working" section. |
80
+ | [`output-hygiene`](skills/output-hygiene/SKILL.md) | Pre-ship surface pass. Runs a stdlib-only script over agent-written text to find what the machine put there — invisible Unicode, non-breaking spaces, homoglyphs, harness-added provenance trailers, trailing chat closers — reporting each with its codepoint and line, then sweeps the prose for the tells a script cannot see and quotes every one with a rewrite. Separates what is safe to auto-apply from what needs a decision, and holds the provenance class where an academic, employer, or contribution policy requires disclosure. |
80
81
 
81
82
  ### Planning & delivery
82
83
 
@@ -235,12 +236,12 @@ Categories (above) say what a skill *is*; packs say *who needs it*. [`packs.json
235
236
 
236
237
  | Pack | Skills | For |
237
238
  |---|---|---|
238
- | `solution-architect` | 15 | Design-phase artifacts: diagrams, ADRs, design docs, contracts, schemas, threat models, and the reviews that gate them |
239
- | `product-manager` | 12 | Direction to requirements: PR/FAQs, strategy frameworks, OKRs, PRDs, journey maps, story splitting, success measurement |
240
- | `dev-team` | 11 | Build-and-ship loop: implementation with adversarial review, test plans, triage, migrations, release notes, quality gates |
241
- | `stakeholder-comms` | 8 | Non-technical audiences: audience profiling, deck outlines and real `.pptx` decks, exec summaries, decision logs, guides |
239
+ | `solution-architect` | 17 | Design-phase artifacts: diagrams, ADRs, design docs, contracts, schemas, threat models, and the reviews that gate them |
240
+ | `dev-team` | 14 | Build-and-ship loop: implementation with adversarial review, test plans, triage, migrations, release notes, quality gates, pre-ship hygiene |
241
+ | `product-manager` | 13 | Direction to requirements: PR/FAQs, strategy frameworks, OKRs, PRDs, journey maps, story splitting, success measurement |
242
+ | `ai-engineering` | 12 | Build and run AI systems: agent loop design, subagent orchestration, spend budgets, eval harnesses, usage reporting, data contracts |
243
+ | `stakeholder-comms` | 9 | Non-technical audiences: audience profiling, deck outlines and real `.pptx` decks, exec summaries, decision logs, guides |
242
244
  | `sre-oncall` | 5 | Operate the service: runbooks, observability design, incident comms, postmortems, capacity/cost models |
243
- | `ai-engineering` | 6 | Build and run AI systems: agent loop design, subagent orchestration, spend budgets, eval harnesses, usage reporting, data contracts |
244
245
 
245
246
  ```bash
246
247
  python3 pack.py # list packs
@@ -252,6 +253,26 @@ python3 pack.py product-manager --install --dest .cursor/skills # any dir, for
252
253
 
253
254
  For non-Claude IDEs, `--dest` drops the folders where your tool expects them; the per-IDE wiring steps below still apply. Packs are metadata only — skills stay in flat `skills/<name>` folders, so per-skill `cp -R` installs keep working unchanged ([RFC-0001](docs/rfcs/0001-skill-packs.md) records the design decision).
254
255
 
256
+ ## Outcomes — browse by why you're here
257
+
258
+ Packs answer *who am I*. Outcomes answer *why did I open this*. The seven below are the nine
259
+ categories above, restated as the things people actually arrive wanting to do, and they live in
260
+ the `outcomes` block of [`packs.json`](packs.json) so the [catalogue site](https://sananthanarayan.github.io/skilldrop/)
261
+ can filter on them. Every skill belongs to at least one; `validate.py` enforces it.
262
+
263
+ | Outcome | Skills | What it covers |
264
+ |---|---|---|
265
+ | `design-the-system` | 15 | Decision records, design docs, diagrams, contracts, schemas, migration and threat models, agentic design |
266
+ | `decide-what-to-build` | 11 | Press releases, strategy frameworks, OKRs, business cases, requirements, PRDs, success metrics |
267
+ | `build-and-review-software` | 11 | Supervised implementation, review panels, test plans, triage, accessibility and static-analysis passes |
268
+ | `explain-it-to-decision-makers` | 6 | Audience profiling, deck outlines and real decks, exec summaries, decision logs, guides |
269
+ | `govern-ai-use` | 6 | Readiness, use-case triage, rollout, acceptable-use policy, adoption staging, usage reporting |
270
+ | `run-and-recover-the-service` | 5 | Runbooks, observability design, release notes, incident comms, postmortems |
271
+ | `get-a-draft-ready-to-ship` | 3 | Collect a messy input, critique the argument, strip the machine artifacts |
272
+
273
+ Outcomes are a browse aid, not an install unit — there is no `--outcome` flag. Install by skill
274
+ or by pack.
275
+
255
276
  ## Installing a skill into your IDE
256
277
 
257
278
  ### Quickest: the skilldrop CLI
@@ -276,14 +297,29 @@ npx skilldrop-cli list --json # machine-readable: lis
276
297
 
277
298
  ### Or: the Claude Code plugin marketplace
278
299
 
279
- skilldrop is also a **Claude Code plugin marketplace** — install the whole catalogue as one plugin, no npm step:
300
+ skilldrop is also a **Claude Code plugin marketplace** — one marketplace, seven plugins, no npm step. Add it once:
280
301
 
281
302
  ```text
282
303
  /plugin marketplace add sananthanarayan/skilldrop
283
- /plugin install skilldrop@skilldrop
284
304
  ```
285
305
 
286
- Every skill then invokes as `/skilldrop:<name>` (e.g. `/skilldrop:prfaq`), and the reviewer subagents come with it. The marketplace ships the flat `skills/` and `agents/` trees at the repo root unchanged — same copy-install premise, expressed in Claude's own plugin format. `.claude-plugin/{marketplace,plugin}.json` are generated from `package.json` by [`build_marketplace.py`](build_marketplace.py) (`--check` guards drift in CI). Use the CLI above when you want per-pack or per-skill granularity, another IDE, or hooks; use the marketplace when you're in Claude Code and want everything in two lines. Rationale and the deferred per-pack-plugin step: [RFC-0014](docs/rfcs/0014-agentbundle-interop.md).
306
+ Then take the whole catalogue, or just your role's pack:
307
+
308
+ ```text
309
+ /plugin install skilldrop@skilldrop # all 57 skills + 3 reviewer subagents
310
+ /plugin install solution-architect@skilldrop # 17 skills
311
+ /plugin install dev-team@skilldrop # 14 skills + the 3 reviewer subagents
312
+ /plugin install product-manager@skilldrop # 13 skills
313
+ /plugin install ai-engineering@skilldrop # 12 skills
314
+ /plugin install stakeholder-comms@skilldrop # 9 skills
315
+ /plugin install sre-oncall@skilldrop # 5 skills
316
+ ```
317
+
318
+ Every skill then invokes as `/<plugin>:<name>` (e.g. `/skilldrop:prfaq`). The whole-catalogue plugin ships the flat `skills/` and `agents/` trees at the repo root unchanged — same copy-install premise, expressed in Claude's own plugin format.
319
+
320
+ The six **pack plugins** work differently, because a plugin's skills have to sit in a `skills/` folder inside the plugin. Rather than duplicate every skill into six directories on `main` — physical packs, which [RFC-0001](docs/rfcs/0001-skill-packs.md) rejected — the marketplace entries use Claude's `git-subdir` source to point at `packs/<name>/` on a generated [`plugins`](https://github.com/sananthanarayan/skilldrop/tree/plugins) branch, rebuilt by CI on every push to main. Nothing moves in the source tree, and you still only ever type the one `marketplace add`. A pack carries the reviewer subagents its own skills delegate to, so `dev-team` brings the review panel and `sre-oncall` does not.
321
+
322
+ `.claude-plugin/{marketplace,plugin}.json` are generated from `package.json` + `packs.json` by [`build_marketplace.py`](build_marketplace.py) (`--check` guards drift in CI; `--dist` renders the branch). Use the CLI above when you want per-skill granularity, another IDE, or hooks; use the marketplace when you're in Claude Code. Rationale: [RFC-0027](docs/rfcs/0027-retire-agentbundle-export.md).
287
323
 
288
324
  ### Hooks (opt-in) — wire a skill to an event
289
325
 
@@ -336,7 +372,7 @@ npx skilldrop-cli install --pack starter --from ../local-catalog
336
372
  npx skilldrop-cli update # updates bundled and third-party skills side by side — the ledger remembers each skill's source
337
373
  ```
338
374
 
339
- The CLI also reads **agentbundle-shaped catalogs** ([agent-ready-repo](https://github.com/eugenelim/agent-ready-repo)) — `packs/<pack>/.apm/skills/<name>/SKILL.md` with a `pack.toml` per pack — so you can install *its* packs through the same command ([RFC-0014](docs/rfcs/0014-agentbundle-interop.md)). Both shapes share the agentskills.io `SKILL.md`, so the reader just maps his packs onto the accessors above:
375
+ The CLI also reads **agentbundle-shaped catalogs** ([agent-ready-repo](https://github.com/eugenelim/agent-ready-repo)) — `packs/<pack>/.apm/skills/<name>/SKILL.md` with a `pack.toml` per pack — so you can install *its* packs through the same command ([RFC-0014](docs/rfcs/0014-agentbundle-interop.md)). This is one-directional by design: skilldrop reads his shape, and no longer publishes a generated catalogue back into it ([RFC-0027](docs/rfcs/0027-retire-agentbundle-export.md)). Both shapes share the agentskills.io `SKILL.md`, so the reader just maps his packs onto the accessors above:
340
376
 
341
377
  ```bash
342
378
  npx skilldrop-cli packs --from https://github.com/eugenelim/agent-ready-repo
@@ -527,8 +563,9 @@ Full contributor guide — the three lanes, the PR gates, and the release flow
527
563
  4. If your skill needs scripts, drop them in `scripts/` and reference them with a path relative to the skill folder — **avoid hard-coding `${CLAUDE_SKILL_DIR}` only**; show both paths so non–Claude-Code users aren't stuck.
528
564
  5. Add an `evals/` folder: `evals.json` (at least one realistic prompt with a list of assertions the output must satisfy) and `eval_queries.json` (phrases that should and should **not** trigger the skill). These double as the checklist for the manual test pass and keep the `description` honest about when the skill fires.
529
565
  6. Add an entry to the **Skills in this repo** table above and to the **Installing dependencies** table.
530
- 7. Add the skill to at least one pack in `packs.json`.
531
- 8. Run `python3 validate.py` from the repo root — it checks name consistency, the tier sync with `model-routing.json`, the `related`↔SKILL.md reference sync, description sync, pack membership, and eval file shape.
566
+ 7. Add the skill to at least one pack **and** at least one outcome in `packs.json`.
567
+ 8. If the change is going out in a release, add a bullet to [`CHANGELOG.md`](CHANGELOG.md) under the new version — the site build fails without one.
568
+ 9. Run `python3 validate.py` from the repo root — it checks name consistency, the tier sync with `model-routing.json`, the `related`↔SKILL.md reference sync, description sync, pack and outcome membership, and eval file shape.
532
569
 
533
570
  ## License
534
571
 
package/bin/skilldrop.js CHANGED
@@ -204,6 +204,16 @@ function skillDir(cat, s) {
204
204
  if (cat.shape === "apm") return cat.skillIndex[s] && cat.skillIndex[s].dir;
205
205
  return path.join(cat.skillsDir, s);
206
206
  }
207
+ // Skill names reach RegExp when unwiring hooks. skillExists() proves a directory exists; it
208
+ // does not prove the name is regex-safe, and a third-party catalog (RFC-0003) may name a
209
+ // skill anything a filesystem accepts. Escape before interpolating.
210
+ // Read a file if it is there. Checking existsSync first then reading is a race: the file can
211
+ // vanish in between and the read throws. Handle the absence, do not predict it.
212
+ function readIfPresent(p, absent = "") {
213
+ try { return fs.readFileSync(p, "utf8"); }
214
+ catch (e) { if (e.code === "ENOENT") return absent; throw e; }
215
+ }
216
+ function reEscape(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
207
217
  function skillExists(cat, s) { const d = skillDir(cat, s); return !!d && fs.existsSync(d); }
208
218
  function manifestOf(cat, s) {
209
219
  if (cat.shape === "apm") {
@@ -591,7 +601,7 @@ function gitRoot(startDir) {
591
601
  function writeGitPreCommitHook(root, skill, hook) {
592
602
  const p = path.join(root, ".git", "hooks", "pre-commit");
593
603
  const marker = `skilldrop-hook:${skill}:pre-commit-review`;
594
- let body = fs.existsSync(p) ? fs.readFileSync(p, "utf8") : "";
604
+ let body = readIfPresent(p); // read-and-handle, not check-then-read
595
605
  if (!body.startsWith("#!")) body = "#!/bin/sh\n" + body;
596
606
  if (body.includes(marker)) return p; // already wired
597
607
  const line = `echo "skilldrop: run /${hook.action} on your staged changes before committing — ${hook.description}"`;
@@ -606,8 +616,9 @@ function writeGitPreCommitHook(root, skill, hook) {
606
616
  // or null if the file exists but is malformed (we never clobber unparseable JSON).
607
617
  function writeClaudeSessionHook(settingsPath, skill, hook) {
608
618
  let data = {};
609
- if (fs.existsSync(settingsPath)) {
610
- try { data = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
619
+ const raw = readIfPresent(settingsPath, null);
620
+ if (raw !== null) {
621
+ try { data = JSON.parse(raw); }
611
622
  catch (e) { return null; }
612
623
  }
613
624
  data.hooks = data.hooks || {};
@@ -653,18 +664,20 @@ function removeHooksFor(skill, dest, ide) {
653
664
  const root = gitRoot(process.cwd());
654
665
  if (root) {
655
666
  const p = path.join(root, ".git", "hooks", "pre-commit");
656
- if (fs.existsSync(p)) {
657
- const body = fs.readFileSync(p, "utf8");
658
- const re = new RegExp(`\\n?# >>> skilldrop-hook:${skill}:[^\\n]* >>>[\\s\\S]*?# <<< skilldrop-hook:${skill}:[^\\n]* <<<\\n?`, "g");
667
+ const body = readIfPresent(p, null);
668
+ if (body !== null) {
669
+ const q = reEscape(skill);
670
+ const re = new RegExp(`\\n?# >>> skilldrop-hook:${q}:[^\\n]* >>>[\\s\\S]*?# <<< skilldrop-hook:${q}:[^\\n]* <<<\\n?`, "g");
659
671
  const next = body.replace(re, "\n");
660
672
  if (next !== body) fs.writeFileSync(p, next);
661
673
  }
662
674
  }
663
675
  if (ide === "claude") {
664
676
  const sp = path.join(path.dirname(dest), "settings.json");
665
- if (fs.existsSync(sp)) {
677
+ const raw = readIfPresent(sp, null);
678
+ if (raw !== null) {
666
679
  try {
667
- const data = JSON.parse(fs.readFileSync(sp, "utf8"));
680
+ const data = JSON.parse(raw);
668
681
  const arr = data.hooks && data.hooks.SessionStart;
669
682
  if (Array.isArray(arr)) {
670
683
  const kept = arr.filter((e) => !JSON.stringify(e).includes(`skilldrop-hook:${skill}:`));
@@ -142,6 +142,7 @@
142
142
  "observability-plan": { "tier": "standard", "rationale": "Methodology synthesis against fixed SRE practices (golden signals, burn-rate alerting, pillar split). SLO calibration needs judgment but is bounded; escalate via ambiguous-input on contested reliability targets." },
143
143
  "council-review": { "tier": "heavy", "rationale": "Multi-perspective deliberation + weighted reconciliation across distinct expert seats. Cheaper models collapse the seats into one voice and either manufacture or miss the real cruxes. Never downgrade." },
144
144
  "devils-advocate": { "tier": "heavy", "rationale": "Adversarial code review across four lenses. Cheaper models miss edge cases and under-call severity — the whole value is the hard thinking. Never downgrade." },
145
+ "output-hygiene": { "tier": "standard", "rationale": "The mechanical pass is a deterministic script; the model does bounded rewriting of flagged prose against a fixed tell list, not adversarial review." },
145
146
  "doc-critique": { "tier": "heavy", "rationale": "Adversarial doc review against rubrics. Same as devils-advocate: the value is catching what a fast pass misses. Never downgrade." },
146
147
  "tech-comparison-matrix": { "tier": "heavy", "rationale": "Weighted multi-criteria judgment with a recommendation. Mis-weighting is the failure mode; heavy is worth it. Never downgrade." },
147
148
  "threat-model": { "tier": "heavy", "rationale": "Adversarial reasoning over a design — cheaper models emit checklist residue, miss authz/insider paths, and inflate severity. Never downgrade." },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilldrop-cli",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "Portable AI-agent skills for the deliverables knowledge workers ship — diagrams, ADRs, PRDs, runbooks, decks, reviews — installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
5
5
  "author": {
6
6
  "name": "Sanjay Ananthanarayan",
package/packs.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "Virtual skill packs — role-based bundles for one-command install. Packs are METADATA ONLY: skills stay in flat skills/<name> folders (golden rule: skills are discovered by path), a skill may belong to several packs, and every skill must belong to at least one (validate.py enforces both directions). Install a pack with `python3 pack.py <pack> --install`; see README. Rationale: docs/rfcs/0001-skill-packs.md.",
2
+ "$comment": "Virtual skill packs — role-based bundles for one-command install. Packs are METADATA ONLY: skills stay in flat skills/<name> folders (golden rule: skills are discovered by path), a skill may belong to several packs, and every skill must belong to at least one (validate.py enforces both directions). Install a pack with `python3 pack.py <pack> --install`; see README. Rationale: docs/rfcs/0001-skill-packs.md. `outcomes` (RFC-0026) is the second browse axis: packs say WHO needs a skill, outcomes say WHY someone opens the catalogue. It is the README's nine categories restated as seven outcomes and made machine-readable so the site cannot drift from them. Outcomes are a browse aid only — not an install unit — and validate.py enforces the same two-way check packs get.",
3
3
  "version": "0.1.0",
4
4
  "packs": {
5
5
  "solution-architect": {
@@ -57,7 +57,8 @@
57
57
  "release-notes",
58
58
  "sonar-onboard",
59
59
  "sonar-review",
60
- "accessibility-audit"
60
+ "accessibility-audit",
61
+ "output-hygiene"
61
62
  ]
62
63
  },
63
64
  "sre-oncall": {
@@ -80,7 +81,8 @@
80
81
  "exec-summary",
81
82
  "decision-log",
82
83
  "guide-builder",
83
- "doc-critique"
84
+ "doc-critique",
85
+ "output-hygiene"
84
86
  ]
85
87
  },
86
88
  "ai-engineering": {
@@ -100,5 +102,99 @@
100
102
  "agent-adoption-stage"
101
103
  ]
102
104
  }
105
+ },
106
+ "outcomes": {
107
+ "decide-what-to-build": {
108
+ "description": "Turn a direction into a committed, measurable plan: press releases, strategy frameworks, OKRs, business cases, requirements, PRDs, and the metrics that will say whether it worked.",
109
+ "skills": [
110
+ "prfaq",
111
+ "strategy-analysis",
112
+ "okr-cascade",
113
+ "business-case",
114
+ "capacity-cost-model",
115
+ "requirements-interview",
116
+ "prd-draft",
117
+ "nfr-spec",
118
+ "success-metrics",
119
+ "user-story-splitter",
120
+ "user-journey-map"
121
+ ]
122
+ },
123
+ "design-the-system": {
124
+ "description": "Commit the shape before the code: decision records, design docs, diagrams, contracts, schemas, migration and threat models, and the design of any agentic parts.",
125
+ "skills": [
126
+ "adr-generator",
127
+ "design-doc",
128
+ "architecture-diagrams",
129
+ "reverse-architecture",
130
+ "figma-diagrams",
131
+ "api-contract-draft",
132
+ "db-schema-design",
133
+ "data-contract",
134
+ "tech-comparison-matrix",
135
+ "threat-model",
136
+ "agent-threat-model",
137
+ "migration-plan",
138
+ "agent-loop-design",
139
+ "subagent-design",
140
+ "agent-budget"
141
+ ]
142
+ },
143
+ "build-and-review-software": {
144
+ "description": "The loop around the code itself: supervised implementation, adversarial review panels, test plans, triage, accessibility and static-analysis passes, and the policy files agents read.",
145
+ "skills": [
146
+ "feature-implement-loop",
147
+ "pre-merge-review",
148
+ "devils-advocate",
149
+ "council-review",
150
+ "accessibility-audit",
151
+ "sonar-onboard",
152
+ "sonar-review",
153
+ "test-plan-generator",
154
+ "bug-triage",
155
+ "agents-md-generator",
156
+ "llm-eval-harness"
157
+ ]
158
+ },
159
+ "run-and-recover-the-service": {
160
+ "description": "Operate what shipped: runbooks, observability design, release notes, incident communication, and the postmortem that closes the loop.",
161
+ "skills": [
162
+ "runbook-generator",
163
+ "observability-plan",
164
+ "release-notes",
165
+ "incident-comms",
166
+ "postmortem-generator"
167
+ ]
168
+ },
169
+ "explain-it-to-decision-makers": {
170
+ "description": "Get a non-technical audience to a decision: audience profiling, deck outlines and real decks, exec summaries, decision logs, and how-to guides.",
171
+ "skills": [
172
+ "audience-profile",
173
+ "slide-outliner",
174
+ "deck-builder",
175
+ "exec-summary",
176
+ "decision-log",
177
+ "guide-builder"
178
+ ]
179
+ },
180
+ "govern-ai-use": {
181
+ "description": "Adopt AI deliberately and prove what it is doing: readiness assessment, use-case triage, rollout plans, acceptable-use policy, adoption staging, and usage reporting.",
182
+ "skills": [
183
+ "ai-readiness-assessment",
184
+ "ai-use-case-triage",
185
+ "ai-adoption-rollout",
186
+ "ai-usage-policy",
187
+ "agent-adoption-stage",
188
+ "ai-usage-report"
189
+ ]
190
+ },
191
+ "get-a-draft-ready-to-ship": {
192
+ "description": "The passes either side of a generator: collect a messy input into a structured brief, critique the argument, and strip the machine artifacts before it leaves the building.",
193
+ "skills": [
194
+ "brief-intake",
195
+ "doc-critique",
196
+ "output-hygiene"
197
+ ]
198
+ }
103
199
  }
104
200
  }
@@ -0,0 +1,95 @@
1
+ ---
2
+ name: output-hygiene
3
+ description: Review agent-written text before it ships and strip the machine artifacts it carries — invisible Unicode, non-breaking spaces, homoglyphs, harness-added provenance trailers, trailing chat closers — then flag the prose tells a script cannot catch, each quoted with a rewrite. Use when the user wants to clean up AI-generated output, asks to remove a Claude watermark, attribution footer, or "Generated with" line, wants a draft that does not read as machine-written, or needs a pre-ship hygiene pass on a document, commit message, or pasted chat answer.
4
+ ---
5
+
6
+ # output-hygiene
7
+
8
+ Surface pass over text an agent wrote. It answers one question — *what in here was put there by the machine rather than by the author* — and separates the half a script can prove from the half that needs judgment. It does not review whether the document makes its case; that is `doc-critique`.
9
+
10
+ There is no cryptographic watermark in Claude's text output, and this skill does not pretend to remove one. What it removes is real and boring: characters the transport inserted, trailers the harness appended, and cadence the drafting picked up.
11
+
12
+ ## How to respond
13
+
14
+ 1. **Name the target medium before reading a word of the text.** Markdown document, plain text, commit message, code comment, web form, CSV. The medium decides whether a class is a defect at all: a curly apostrophe is correct in Markdown prose and a bug in a commit message; an em dash is punctuation in an essay and a tell at four per paragraph. State the medium in the first line of the report.
15
+
16
+ 2. **Run the script first, and read its output before reading the draft.** It localizes every mechanical finding, so the model pass only has to look at prose.
17
+
18
+ ```
19
+ python3 ${CLAUDE_SKILL_DIR}/scripts/scrub.py <path> --medium <medium> --json
20
+ ```
21
+ In non-Claude IDEs the same file is at a plain relative path: `python3 scripts/scrub.py <path> --json`. Pass the medium from step 1 — `markdown` (the default), `html`, `plaintext`, `commit`, `code`, `csv` — because it decides whether typographic punctuation is reported at all. Add `--fix` to apply, `--ascii` to report and flatten typography whatever the medium, `-` to read stdin.
22
+
23
+ 3. **Report before rewriting.** Show what `--fix` would delete, then apply it. The script's two tiers are the report's two tiers and they never merge:
24
+ - **auto** — invisible characters, non-ASCII spacing, provenance trailers, trailing chat closers. Removing these cannot change what the text asserts, so they are applied on request without a per-finding decision.
25
+ - **review** — homoglyphs and typographic punctuation. Each needs a decision, because the correct replacement is a guess (a Cyrillic `а` may be deliberate) or medium-dependent. Never batch-apply this tier.
26
+
27
+ 4. **Check the disclosure boundary before touching the provenance class.** Four contexts where the provenance trailer stays and the skill says so instead of removing it: an academic or journal submission under an AI-disclosure rule; a workplace whose AI-use policy requires declaring assisted work; a repository whose contribution guide requires the co-author trailer; a regulated filing or legal document. The rest of the pass still runs — only the provenance class is held. When the user's own policy is the open question, hand to `ai-usage-policy`, which is where that rule gets written down.
28
+
29
+ 5. **Then do the pass the script cannot: prose tells.** Read [`reference.md`](reference.md) and sweep the draft against the tell list. Every finding quotes the offending span verbatim with its line and proposes a concrete rewrite. A tell is a pattern, not a word — one em dash is punctuation, one bolded lead-in is emphasis. Flag the density, and quote three instances as evidence when claiming one.
30
+
31
+ 6. **Change cadence, never content.** A rewrite that removes a hedge which was carrying real uncertainty has made the document worse and less true. Preserve every number, claim, caveat, and named risk; move only the shape of the sentence.
32
+
33
+ 7. **Emit the report in this shape:**
34
+
35
+ ```
36
+ ## Hygiene report — <target> (<medium>)
37
+
38
+ **Verdict:** ship as-is | clean first | disclosure check needed
39
+
40
+ ### Machine artifacts — N auto, M review
41
+ | line | class | what it is | action |
42
+ |---|---|---|---|
43
+
44
+ ### Prose tells — N
45
+ | line | tell | quoted | rewrite |
46
+ |---|---|---|---|
47
+
48
+ ### Not touched
49
+ - <what was left alone, and why>
50
+ ```
51
+
52
+ The **Not touched** section is mandatory. A cleaning pass that reports only what it changed is unauditable.
53
+
54
+ 8. **Hand off when the problem is not the surface.** If the draft is clean and still bad, the fault is the argument: say so and name `doc-critique` rather than rewriting sentences that were never the issue.
55
+
56
+ **Non-interactive invocation:** with no text and no path, emit `BLOCKED: need the text to clean, or a path to it` — a hygiene pass over an imagined draft is fabrication. With the medium unstated, assume Markdown, tag it `[assumption]` at the top of the report, and continue. Never apply `--fix` unattended to a path the user did not name.
57
+
58
+ ## Useful references in this skill
59
+
60
+ - [`reference.md`](reference.md) — the full tell taxonomy: every mechanical class with its codepoints, and every prose tell with a failing and a passing example.
61
+ - `scripts/scrub.py` — the deterministic pass. Stdlib only, no network, reads a path or stdin, takes `--medium`, and exits non-zero while findings remain, so it also works as a pre-commit gate.
62
+
63
+ ## Quality bar
64
+
65
+ - **Every mechanical finding names a codepoint or quotes the matched line.** "Contains invisible characters" is unverifiable; `3:18 U+00A0 NO-BREAK SPACE` is checkable in one keystroke.
66
+ - **Every prose finding quotes the span it is judging.** The quote is the evidence. A finding that paraphrases is an opinion.
67
+ - **The auto and review tiers stay separate in the report.** Merging them means the user approves a homoglyph guess while approving a zero-width-space deletion.
68
+ - **The medium is stated before the first finding.** Half the classes are medium-dependent; a report that skips it is asserting a default it never disclosed.
69
+ - **Rewrites are paste-able.** "Tighten this" is not a rewrite. The replacement sentence is.
70
+ - **The report says what it left alone.** Including the provenance trailer when disclosure is owed, and why.
71
+
72
+ ## When to use this skill
73
+
74
+ - ✅ A draft written with an agent is about to go to a customer, a board, a journal, or a public repo.
75
+ - ✅ Text pasted out of a chat window carries invisible characters that break a diff, a grep, a CSV import, or a YAML parser.
76
+ - ✅ A commit message or PR body carries a harness-added trailer the repository does not want.
77
+ - ✅ The user says the writing "sounds like AI" and wants the specific sentences named rather than a vibe.
78
+ - ✅ A pre-commit or CI gate needs a deterministic, non-zero-exit check for machine artifacts in committed prose.
79
+
80
+ ## When NOT to use this skill
81
+
82
+ - ❌ The document's argument, structure, or evidence is the problem — that is `doc-critique`.
83
+ - ❌ The goal is to pass work off as unassisted where a policy requires disclosure. The skill stops on the provenance class and names the policy.
84
+ - ❌ The text is source code. Invisible-character scanning is worth running, but prose tells do not apply and the rewrite advice will be wrong.
85
+ - ❌ The user wants the text shortened or re-pitched for a different audience. That is a rewrite, not a hygiene pass.
86
+
87
+ ## Anti-patterns to avoid
88
+
89
+ - ❌ **Asserting "this reads like AI" without quoting the span.** The user cannot act on a verdict they cannot locate. Every claim carries a line number and the text.
90
+ - ❌ **Laundering authorship.** Stripping a co-author trailer from a repository whose contribution guide requires it, or an AI-disclosure line from a journal submission, is not hygiene. Hold the class, name the rule, keep cleaning everything else.
91
+ - ❌ **Killing every em dash because em dashes are "a tell".** ❌ Rewriting *"three phases — each gated on a rollback drill"* to *"three phases, each gated on a rollback drill"* fixes nothing. ✅ The finding is four em dashes in one paragraph, and the fix is varying three of them.
92
+ - ❌ **Removing hedges that were carrying meaning.** ❌ *"p99 latency is 250 ms"* where the draft said *"p99 latency is roughly 250 ms — we have two weeks of data"* deletes a real caveat and makes the document less true. Strip *"it is important to note that"*; keep *"we have two weeks of data"*.
93
+ - ❌ **Auto-replacing a homoglyph with the ASCII letter that looks like it.** The word may be genuinely non-English. Report it, show the codepoint, let the author decide.
94
+ - ❌ **Re-reading the whole draft for characters the script already found.** The script is exhaustive on its classes and cheaper than a model pass. Spend the model on prose.
95
+ - ❌ **Running `--fix` and reporting only the count.** "Removed 6 artifacts" is not auditable. List them.
@@ -0,0 +1,11 @@
1
+ [
2
+ { "query": "Remove the Claude watermark from this doc before I send it", "should_trigger": true },
3
+ { "query": "Strip the 'Generated with Claude Code' footer and the co-author trailer", "should_trigger": true },
4
+ { "query": "This draft sounds like AI — tell me exactly which sentences give it away", "should_trigger": true },
5
+ { "query": "There are invisible characters in this file breaking my diff, find them", "should_trigger": true },
6
+ { "query": "Clean up this text I pasted out of a chat window before it goes to the board", "should_trigger": true },
7
+ { "query": "Review this design doc and tell me where the argument is weak", "should_trigger": false },
8
+ { "query": "Summarize this report for executives", "should_trigger": false },
9
+ { "query": "Write our company policy on acceptable AI use", "should_trigger": false },
10
+ { "query": "Review this pull request for correctness bugs", "should_trigger": false }
11
+ ]
@@ -0,0 +1,30 @@
1
+ {
2
+ "skill_name": "output-hygiene",
3
+ "evals": [
4
+ {
5
+ "id": 1,
6
+ "prompt": "Clean this up before I send it to the board — it came out of a chat window. [a two-page migration summary containing a no-break space, a zero-width space mid-word, a narrow no-break space in '250 ms', a Cyrillic 'а' in 'admin', four em dashes in one paragraph, ten bullets that all open with a bolded lead-in, a closing 'Let me know if you'd like me to expand any of these phases', and a trailing 'Generated with Claude Code' footer]",
7
+ "assertions": [
8
+ "The target medium is named in the first line of the report, before any finding",
9
+ "Every mechanical finding carries a line number and either a U+XXXX codepoint or the verbatim matched line",
10
+ "Auto-tier findings (invisible, spacing, provenance, closer) and review-tier findings (homoglyph, typography) are reported as separate groups and never merged into one list",
11
+ "The Cyrillic 'а' is reported with its codepoint and left unreplaced, not silently swapped for an ASCII 'a'",
12
+ "The em-dash finding is stated as a density across the paragraph with instances quoted, not as a per-dash defect",
13
+ "The bolded-lead-in finding proposes keeping bold on a named subset rather than removing all of it",
14
+ "Every prose tell quotes the offending span verbatim and supplies a paste-able replacement sentence",
15
+ "No number, named risk, or meaning-bearing caveat present in the input is absent from the rewritten text",
16
+ "The report contains a 'Not touched' section listing what was deliberately left alone and why"
17
+ ]
18
+ },
19
+ {
20
+ "id": 2,
21
+ "prompt": "Strip the Claude attribution from these commit messages before I push — this is for a paper's supplementary repo, and the journal requires an AI-use declaration.",
22
+ "assertions": [
23
+ "The provenance class is held rather than removed, and the report names the disclosure rule that holds it",
24
+ "Every other class is still scanned and reported — the disclosure boundary stops one class, not the pass",
25
+ "The response does not moralize; it states the rule in a sentence and offers the cleaning it can do",
26
+ "ai-usage-policy is named as the place the user's own disclosure rule gets written down"
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "output-hygiene",
3
+ "version": "0.1.0",
4
+ "description": "Review agent-written text before it ships and strip the machine artifacts it carries — invisible Unicode, non-breaking spaces, homoglyphs, harness-added provenance trailers, trailing chat closers — then flag the prose tells a script cannot catch, each quoted with a rewrite. Use when the user wants to clean up AI-generated output, asks to remove a Claude watermark, attribution footer, or \"Generated with\" line, wants a draft that does not read as machine-written, or needs a pre-ship hygiene pass on a document, commit message, or pasted chat answer.",
5
+ "entrypoint": "SKILL.md",
6
+ "deps": {
7
+ "npm": [],
8
+ "pip": []
9
+ },
10
+ "env": {
11
+ "required": [],
12
+ "optional": []
13
+ },
14
+ "related": [
15
+ "doc-critique",
16
+ "ai-usage-policy"
17
+ ],
18
+ "tags": [
19
+ "hygiene",
20
+ "cleanup",
21
+ "unicode",
22
+ "provenance",
23
+ "ai-detection",
24
+ "pre-ship"
25
+ ],
26
+ "model": {
27
+ "tier": "standard",
28
+ "rationale": "The mechanical pass is a deterministic script; the model does bounded rewriting of flagged prose against a fixed tell list, not adversarial review."
29
+ }
30
+ }
@@ -0,0 +1,106 @@
1
+ # Tell taxonomy
2
+
3
+ Two halves. The first is decided by `scripts/scrub.py` and needs no judgment. The second is
4
+ what the script cannot see, and is the reason this is a skill rather than a lint rule.
5
+
6
+ ---
7
+
8
+ ## Part 1 — mechanical classes (the script decides)
9
+
10
+ ### auto: safe to remove without reading the sentence
11
+
12
+ | Class | Codepoints | Why it is a defect |
13
+ |---|---|---|
14
+ | invisible | U+200B ZWSP · U+200C ZWNJ · U+200D ZWJ · U+2060 WJ · U+FEFF BOM · U+00AD SHY · U+2028/2029 separators · U+202A–202E and U+2066–2069 bidi controls | Renders as nothing, survives copy-paste, and silently breaks `grep`, `diff`, string equality, and JSON round-trips. The bidi controls additionally reorder displayed text away from what the bytes say. |
15
+ | spacing | U+00A0 NBSP · U+202F NNBSP · U+2007 figure · U+2009 thin · U+200A hair · U+2002 en · U+2003 em · U+3000 ideographic | Looks exactly like a space and is not one. Breaks word-splitting, Markdown table alignment, YAML indentation, and shell arguments. |
16
+ | provenance | `Co-authored-by: Claude …` · a `Generated with … Claude Code` link footer · a trailing `— Claude` signature | Added by the harness, not chosen by the author. Whether it should go is a policy question (see **Where the pass stops**), not a style one. |
17
+ | closer | A final paragraph opening `Let me know if…`, `Hope this helps`, `Would you like me to…`, `Happy to…` | Addresses a chat partner. The reader of a shipped document is not in a conversation. |
18
+
19
+ ### review: reported, never auto-applied
20
+
21
+ | Class | What it catches | Why judgment is required |
22
+ |---|---|---|
23
+ | homoglyph | A non-ASCII letter inside an otherwise-ASCII word — Cyrillic `а` U+0430 for `a`, `е` U+0435 for `e`, `о` U+043E for `o`, Greek `ο` U+03BF, `ι` U+03B9 | The ASCII letter that "looks right" is a guess, and the word may be genuinely foreign. In a URL or an identifier it is also a spoofing signal worth escalating rather than quietly fixing. |
24
+ | typography | U+2018/2019 curly singles · U+201C/201D curly doubles · U+2013 en dash · U+2014 em dash · U+2026 ellipsis · U+2032/2033 primes | Correct in Markdown prose and in anything typeset, so `--medium markdown` (the default) does not report them at all — one row per apostrophe would bury every finding that mattered. A defect in a commit message, a CSV cell, a code identifier, or anything that will re-encode the text: `--medium commit|code|csv|plaintext` reports them, and `--ascii` reports and flattens them whatever the medium. |
25
+
26
+ ### Where the pass stops
27
+
28
+ The provenance class is held — reported, not removed — in four contexts:
29
+
30
+ - An academic or journal submission under an AI-disclosure rule.
31
+ - A workplace whose AI-use policy requires declaring assisted work.
32
+ - A repository whose contribution guide requires the co-author trailer.
33
+ - A regulated filing or a legal document.
34
+
35
+ In all four the report names the rule and cleans every other class. For a repository that does
36
+ *not* want the trailer, the durable fix is prospective and lives in the harness, not in a
37
+ cleaning pass: Claude Code writes it per its own settings, so turning it off there stops it at
38
+ the source and leaves history alone.
39
+
40
+ ---
41
+
42
+ ## Part 2 — prose tells (the model decides)
43
+
44
+ A tell is a **density**, not a word. One instance is a style choice; five in three paragraphs is
45
+ a fingerprint. Never flag an instance — flag the pattern, and quote three instances as evidence.
46
+
47
+ ### The symmetric triple
48
+
49
+ ❌ *"The migration is faster, cheaper, and safer."*
50
+ ✅ *"The migration halves cutover time. It also costs less, though the saving is small enough that it should not drive the decision."*
51
+
52
+ Why: three balanced adjectives is a rhythm, not an argument. The second version ranks them and admits one is weak.
53
+
54
+ ### The not-just-X-but-Y frame
55
+
56
+ ❌ *"This is not just a schema change, it's a change in how the team reasons about ownership."*
57
+ ✅ *"The schema change forces every service to declare an owner, which is the part that will cause arguments."*
58
+
59
+ Why: the frame promises a reveal and delivers a restatement. Say the second clause and drop the first.
60
+
61
+ ### Bolded lead-ins on every bullet
62
+
63
+ ❌ Ten bullets, each opening with two bold words and a colon.
64
+ ✅ Bold the three bullets a skimmer must not miss; leave the other seven plain.
65
+
66
+ Why: when everything is emphasized, nothing is. Uniform bolding is a template, and it reads as one.
67
+
68
+ ### The hedge stack
69
+
70
+ ❌ *"It is important to note that this approach may potentially introduce some additional complexity."*
71
+ ✅ *"This adds a second write path. That is the cost."*
72
+
73
+ Why: four hedges in one sentence assert nothing. Strip hedges that qualify the hedge — keep the
74
+ one that carries a real, named uncertainty (*"we have two weeks of data"* stays).
75
+
76
+ ### Section symmetry
77
+
78
+ ❌ Six sections, each 3–4 sentences, each with a heading of the same grammatical shape.
79
+ ✅ The section that matters runs nine sentences; two others run one line each.
80
+
81
+ Why: real documents are lumpy because real subjects are. Perfect evenness is generated shape.
82
+
83
+ ### The restating conclusion
84
+
85
+ ❌ A closing paragraph that summarizes the four sections above and adds nothing.
86
+ ✅ End on the decision, the owner, and the date — or end where the argument ends.
87
+
88
+ Why: the summary-of-the-summary is filler the reader has already read.
89
+
90
+ ### Vocabulary flags
91
+
92
+ `delve` · `leverage` (as a verb) · `robust` · `seamless` · `underscores the importance of` ·
93
+ `in today's fast-paced` · `it is worth noting that` · `navigate the complexities of` ·
94
+ `a testament to` · `tapestry`.
95
+
96
+ These are flags, not bans. One `robust` in a document about error handling is fine. Three
97
+ different words from this list in one paragraph is the finding.
98
+
99
+ ### What is not a tell
100
+
101
+ - **Em dashes.** Punctuation. Only a finding at four-plus per paragraph.
102
+ - **Bullet lists.** Structure. Humans write them.
103
+ - **Correct grammar.** Consistent spelling is not evidence of anything.
104
+ - **Headings.** A document with headings is a document with headings.
105
+
106
+ Flagging any of these is how a hygiene pass loses the author's trust for the findings that were real.
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic surface-hygiene pass over agent-written text. Stdlib only, no network.
3
+
4
+ python3 scrub.py DRAFT.md # report findings, exit 1 if any
5
+ python3 scrub.py DRAFT.md --fix # rewrite the file, report what changed
6
+ python3 scrub.py - < draft.md # read stdin, report
7
+ python3 scrub.py - --fix < draft.md # read stdin, write cleaned text to stdout
8
+ python3 scrub.py DRAFT.md --json # machine-readable findings (for a hook)
9
+ python3 scrub.py MSG.txt --medium commit # a medium where curly quotes ARE a defect
10
+ python3 scrub.py DRAFT.md --ascii --fix # flatten curly quotes and dashes regardless
11
+
12
+ Two tiers, and the split is the point:
13
+
14
+ AUTO invisible characters, non-ASCII spacing, provenance trailers, chat closers.
15
+ Removing these cannot change what the text asserts, so --fix applies them.
16
+ REVIEW homoglyphs and typographic punctuation. A blind rewrite here corrupts meaning
17
+ (a Cyrillic 'а' may be intentional; an em dash in Markdown prose is correct),
18
+ so these are reported and left alone unless the caller opts in with --ascii.
19
+
20
+ Typography is medium-dependent and the default medium is markdown, where curly quotes and
21
+ em dashes are correct typesetting rather than defects — reporting them there would bury the
22
+ findings that matter under one row per apostrophe. Pass --medium for a target that will
23
+ re-encode the text (commit, code, csv, plaintext) and they become review findings.
24
+
25
+ What this script cannot do is the other half of the job: prose tells — the cadence, the
26
+ bolded lead-in on every bullet, the hedge vocabulary — need a model pass. See SKILL.md.
27
+
28
+ Exit codes: 0 nothing left to fix · 1 findings remain · 2 usage or I/O error.
29
+ """
30
+ import argparse
31
+ import json
32
+ import re
33
+ import sys
34
+ import unicodedata
35
+
36
+ # --- AUTO tier ------------------------------------------------------------------------
37
+
38
+ # Zero-width, directional, and formatting characters. They survive copy-paste, render as
39
+ # nothing, and break grep, diff, and anything that compares strings.
40
+ INVISIBLE = {
41
+ "​": "ZERO WIDTH SPACE",
42
+ "‌": "ZERO WIDTH NON-JOINER",
43
+ "‍": "ZERO WIDTH JOINER",
44
+ "⁠": "WORD JOINER",
45
+ "": "ZERO WIDTH NO-BREAK SPACE (BOM)",
46
+ "­": "SOFT HYPHEN",
47
+ "
": "LINE SEPARATOR",
48
+ "
": "PARAGRAPH SEPARATOR",
49
+ "‪": "LEFT-TO-RIGHT EMBEDDING",
50
+ "‫": "RIGHT-TO-LEFT EMBEDDING",
51
+ "‬": "POP DIRECTIONAL FORMATTING",
52
+ "‭": "LEFT-TO-RIGHT OVERRIDE",
53
+ "‮": "RIGHT-TO-LEFT OVERRIDE",
54
+ "⁦": "LEFT-TO-RIGHT ISOLATE",
55
+ "⁧": "RIGHT-TO-LEFT ISOLATE",
56
+ "⁨": "FIRST STRONG ISOLATE",
57
+ "⁩": "POP DIRECTIONAL ISOLATE",
58
+ }
59
+
60
+ # Spaces that are not U+0020. They look identical and silently break word-splitting,
61
+ # Markdown table alignment, and YAML.
62
+ SPACING = {
63
+ " ": "NO-BREAK SPACE",
64
+ " ": "NARROW NO-BREAK SPACE",
65
+ " ": "FIGURE SPACE",
66
+ " ": "THIN SPACE",
67
+ " ": "HAIR SPACE",
68
+ " ": "EN SPACE",
69
+ " ": "EM SPACE",
70
+ " ": "IDEOGRAPHIC SPACE",
71
+ }
72
+
73
+ # Whole-line provenance trailers added by an agent harness, not by the author.
74
+ PROVENANCE = [
75
+ ("co-authored-by trailer", re.compile(r"^\s*co-authored-by:\s*claude\b.*$", re.I)),
76
+ ("generated-with footer", re.compile(r"^\s*(?:\S\s*)?generated with \[?claude code\]?.*$", re.I)),
77
+ ("generated-with footer", re.compile(r"^\s*(?:\S\s*)?generated (?:with|by) \[[^\]]+\]\(https://claude\.com[^)]*\).*$", re.I)),
78
+ # A signature is a dash, a name, and nothing else. A plain "-" opener is excluded and the
79
+ # tail is capped: "- Claude Code project settings: …" is a bullet, not a sign-off, and a
80
+ # cleaner that eats real list items loses the author's trust for the findings that were real.
81
+ ("assistant signature", re.compile(r"^\s*(?:—|–|--)\s*claude\b[\w.()\s-]{0,24}$", re.I)),
82
+ ]
83
+
84
+ # A trailing paragraph that addresses the reader as a chat partner. Matched only as the
85
+ # final block of the text, and only when it opens with one of these — a conservative list,
86
+ # because a false positive here deletes real content.
87
+ CLOSER_OPENERS = re.compile(
88
+ r"^(?:let me know (?:if|whether)|(?:i )?hope (?:this|that) helps|"
89
+ r"would you like me to|want me to|happy to (?:help|expand|adjust)|"
90
+ r"feel free to (?:ask|reach out|let me know)|"
91
+ r"if you(?:'| a)?d like(?:,| )?\s*i can)\b",
92
+ re.I,
93
+ )
94
+
95
+ # --- REVIEW tier ----------------------------------------------------------------------
96
+
97
+ # Non-ASCII letters sitting inside an otherwise-ASCII word: the classic paste artifact
98
+ # (and, occasionally, a deliberate spoof). Never auto-fixed — the correct ASCII letter is
99
+ # a guess, and the word may be genuinely foreign.
100
+ WORD = re.compile(r"[^\W\d_]+", re.UNICODE)
101
+
102
+ TYPOGRAPHY = {
103
+ "‘": ("'", "LEFT SINGLE QUOTATION MARK"),
104
+ "’": ("'", "RIGHT SINGLE QUOTATION MARK"),
105
+ "“": ('"', "LEFT DOUBLE QUOTATION MARK"),
106
+ "”": ('"', "RIGHT DOUBLE QUOTATION MARK"),
107
+ "–": ("-", "EN DASH"),
108
+ "—": ("--", "EM DASH"),
109
+ "…": ("...", "HORIZONTAL ELLIPSIS"),
110
+ "′": ("'", "PRIME"),
111
+ "″": ('"', "DOUBLE PRIME"),
112
+ }
113
+
114
+
115
+ def _positions(text):
116
+ """Offset -> (line, col), both 1-based. Computed once; the scan is O(n)."""
117
+ line, col, out = 1, 1, []
118
+ for ch in text:
119
+ out.append((line, col))
120
+ if ch == "\n":
121
+ line, col = line + 1, 1
122
+ else:
123
+ col += 1
124
+ return out
125
+
126
+
127
+ def _finding(cls, tier, line, col, detail, excerpt):
128
+ return {"class": cls, "tier": tier, "line": line, "col": col,
129
+ "detail": detail, "excerpt": excerpt}
130
+
131
+
132
+ def _excerpt(text, offset, width=36):
133
+ lo = text.rfind("\n", 0, offset) + 1
134
+ hi = text.find("\n", offset)
135
+ hi = len(text) if hi == -1 else hi
136
+ frag = text[lo:hi]
137
+ rel = offset - lo
138
+ start = max(0, rel - width // 2)
139
+ snippet = frag[start:start + width].replace("\t", " ")
140
+ return ("…" if start > 0 else "") + snippet.strip() + ("…" if start + width < len(frag) else "")
141
+
142
+
143
+ # Media where typographic punctuation survives and is correct. Everywhere else it is a
144
+ # defect waiting for a re-encode.
145
+ TYPOGRAPHY_OK = {"markdown", "html"}
146
+
147
+
148
+ def scan(text, ascii_mode=False, medium="markdown"):
149
+ """Return (findings, cleaned_text). cleaned_text applies the AUTO tier, plus the
150
+ REVIEW typography tier when ascii_mode is on. Findings describe the ORIGINAL text."""
151
+ findings = []
152
+ report_typography = ascii_mode or medium not in TYPOGRAPHY_OK
153
+ pos = _positions(text)
154
+
155
+ # --- character-level classes
156
+ for i, ch in enumerate(text):
157
+ line, col = pos[i]
158
+ if ch in INVISIBLE:
159
+ findings.append(_finding("invisible", "auto", line, col,
160
+ f"U+{ord(ch):04X} {INVISIBLE[ch]} — renders as nothing",
161
+ _excerpt(text, i)))
162
+ elif ch in SPACING:
163
+ findings.append(_finding("spacing", "auto", line, col,
164
+ f"U+{ord(ch):04X} {SPACING[ch]} — not a plain space",
165
+ _excerpt(text, i)))
166
+ elif ch in TYPOGRAPHY and report_typography:
167
+ repl, name = TYPOGRAPHY[ch]
168
+ findings.append(_finding("typography", "auto" if ascii_mode else "review", line, col,
169
+ f"U+{ord(ch):04X} {name} — flatten to {repl!r} for plain-text targets",
170
+ _excerpt(text, i)))
171
+
172
+ # --- homoglyphs: a non-ASCII letter inside a word that is otherwise ASCII
173
+ for m in WORD.finditer(text):
174
+ word = m.group(0)
175
+ # A homoglyph is a LETTER that impersonates another letter. Superscripts and symbols
176
+ # inside a word — the ² in "O(n²)" — are typography, and flagging them is the noise
177
+ # that teaches an author to ignore the findings that were real.
178
+ odd = [(k, c) for k, c in enumerate(word)
179
+ if ord(c) > 127 and unicodedata.category(c).startswith("L")]
180
+ if not odd or len(odd) == len(word):
181
+ continue
182
+ for k, c in odd:
183
+ off = m.start() + k
184
+ line, col = pos[off]
185
+ try:
186
+ name = unicodedata.name(c)
187
+ except ValueError:
188
+ name = "UNNAMED"
189
+ findings.append(_finding("homoglyph", "review", line, col,
190
+ f"U+{ord(c):04X} {name} inside ASCII word {word!r} — verify before replacing",
191
+ _excerpt(text, off)))
192
+
193
+ # --- line-level provenance
194
+ drop_lines = set()
195
+ for n, raw in enumerate(text.split("\n"), start=1):
196
+ for label, pattern in PROVENANCE:
197
+ if pattern.match(raw):
198
+ findings.append(_finding("provenance", "auto", n, 1,
199
+ f"{label} — harness-added, not authored", raw.strip()))
200
+ drop_lines.add(n)
201
+ break
202
+
203
+ cleaned = "\n".join(l for n, l in enumerate(text.split("\n"), start=1) if n not in drop_lines)
204
+ if drop_lines:
205
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
206
+ cleaned = re.sub(r"\n+\Z", "\n" if text.endswith("\n") else "", cleaned)
207
+
208
+ # --- trailing chat closer, measured against the text as it stands after line drops
209
+ trimmed = cleaned.rstrip()
210
+ blocks = re.split(r"\n\s*\n", trimmed)
211
+ if len(blocks) > 1 and CLOSER_OPENERS.match(blocks[-1].strip()):
212
+ tail = blocks[-1]
213
+ idx = trimmed.rfind(tail)
214
+ findings.append(_finding("closer", "auto", trimmed.count("\n", 0, max(idx, 0)) + 1, 1,
215
+ "trailing chat closer — addresses a chat partner, not a reader",
216
+ " ".join(tail.split())[:80]))
217
+ cleaned = "\n\n".join(blocks[:-1]).rstrip() + ("\n" if text.endswith("\n") else "")
218
+
219
+ # --- character substitutions
220
+ for ch in INVISIBLE:
221
+ cleaned = cleaned.replace(ch, "\n" if ch in ("
", "
") else "")
222
+ for ch in SPACING:
223
+ cleaned = cleaned.replace(ch, " ")
224
+ if ascii_mode:
225
+ for ch, (repl, _) in TYPOGRAPHY.items():
226
+ cleaned = cleaned.replace(ch, repl)
227
+
228
+ findings.sort(key=lambda f: (f["line"], f["col"]))
229
+ return findings, cleaned
230
+
231
+
232
+ TIER_ORDER = {"auto": 0, "review": 1}
233
+
234
+
235
+ def report(path, findings, fixed, ascii_mode, medium):
236
+ auto = [f for f in findings if f["tier"] == "auto"]
237
+ review = [f for f in findings if f["tier"] == "review"]
238
+ head = (f"{path} [{medium}]: {len(findings)} finding(s) — "
239
+ f"{len(auto)} auto, {len(review)} review")
240
+ lines = [head, "=" * len(head)]
241
+ for tier, group, note in (
242
+ ("auto", auto, "applied" if fixed else "safe to apply with --fix"),
243
+ ("review", review, "left alone — judgment required" + ("" if ascii_mode else "; --ascii flattens typography")),
244
+ ):
245
+ if not group:
246
+ continue
247
+ lines.append(f"\n[{tier}] {len(group)} — {note}")
248
+ for f in group:
249
+ lines.append(f" {str(f['line']) + ':' + str(f['col']):<9} {f['class']:<11} {f['detail']}")
250
+ lines.append(f" {'':<9} {'':<11} {f['excerpt']}")
251
+ if not findings:
252
+ lines = [f"{path} [{medium}]: clean"]
253
+ return "\n".join(lines)
254
+
255
+
256
+ def main():
257
+ ap = argparse.ArgumentParser(
258
+ description="Report and optionally remove machine artifacts from agent-written text.")
259
+ ap.add_argument("paths", nargs="+", help="files to scan, or - for stdin")
260
+ ap.add_argument("--fix", action="store_true",
261
+ help="apply the auto tier (rewrites files in place; stdin goes to stdout)")
262
+ ap.add_argument("--medium", default="markdown",
263
+ choices=["markdown", "html", "plaintext", "commit", "code", "csv"],
264
+ help="where the text is going; decides whether typographic punctuation "
265
+ "is a defect (default: markdown, where it is not)")
266
+ ap.add_argument("--ascii", action="store_true",
267
+ help="report AND flatten curly quotes, dashes, and ellipses whatever the medium")
268
+ ap.add_argument("--json", action="store_true", help="emit findings as JSON")
269
+ ap.add_argument("--quiet", action="store_true", help="exit code only, no report")
270
+ args = ap.parse_args()
271
+
272
+ if args.json and args.quiet:
273
+ ap.error("--json and --quiet are mutually exclusive")
274
+
275
+ results, remaining = [], 0
276
+ for path in args.paths:
277
+ try:
278
+ text = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
279
+ except OSError as e:
280
+ print(f"scrub.py: cannot read {path}: {e}", file=sys.stderr)
281
+ return 2
282
+ except UnicodeDecodeError:
283
+ print(f"scrub.py: {path} is not UTF-8 text", file=sys.stderr)
284
+ return 2
285
+
286
+ findings, cleaned = scan(text, ascii_mode=args.ascii, medium=args.medium)
287
+ if args.fix and cleaned != text:
288
+ if path == "-":
289
+ sys.stdout.write(cleaned)
290
+ else:
291
+ try:
292
+ with open(path, "w", encoding="utf-8") as f:
293
+ f.write(cleaned)
294
+ except OSError as e:
295
+ print(f"scrub.py: cannot write {path}: {e}", file=sys.stderr)
296
+ return 2
297
+ elif args.fix and path == "-":
298
+ sys.stdout.write(cleaned)
299
+
300
+ left = [f for f in findings if f["tier"] == "review" or not args.fix]
301
+ remaining += len(left)
302
+ results.append({"path": path, "findings": findings,
303
+ "fixed": args.fix and cleaned != text})
304
+
305
+ if args.json:
306
+ print(json.dumps({"results": results, "remaining": remaining}, indent=2))
307
+ elif not args.quiet:
308
+ out = sys.stderr if (args.fix and "-" in args.paths) else sys.stdout
309
+ for r in results:
310
+ print(report(r["path"], r["findings"], r["fixed"], args.ascii, args.medium), file=out)
311
+ return 1 if remaining else 0
312
+
313
+
314
+ if __name__ == "__main__":
315
+ sys.exit(main())