app
Prose Lint
Public Made by Adomby adom
Deterministic house-style linter for Adom copy. Flags em-dashes and the tics that make AI-written text read as AI-written (tell-words, boilerplate cadence, filler), with line:col, plain-language suggestions, --fix for safe swaps, and a --json envelope. Markdown/code-aware: never flags a --flag in a code span, a number range, or a URL.
← Commit history
Publish 0.1.0
8 files changed
+563
SKILL.mdadded+54@@ -0,0 +1,54 @@+---+name: prose-lint+description: >+ Deterministic house-style linter. Run before shipping ANY user-facing copy+ (READMEs, wiki pages, release notes, chat announcements, UI strings) to catch+ em-dashes and the tics that make AI-written text read as AI-written. Trigger+ words: prose lint, lint this copy, remove em-dashes, de-AI this, does this+ sound AI-written, check my prose, make it read less generated.+---++# prose-lint++`prose-lint` is the deterministic enforcement side of the Adom house style. The+judgment lives in the `prose-style` skill; this CLI is the fast, mechanical check.++## Use it++```+prose-lint README.md # lint one or more files+prose-lint docs/*.md # globs are fine+cat page.md | prose-lint # or stdin+prose-lint --fix README.md # apply the safe 1:1 word swaps in place+prose-lint --json page.md # { status, data: {counts, files}, hints }+prose-lint --strict page.md # exit non-zero on ANY finding, not just em-dashes+```++Default exit codes: `0` clean or soft-only, `1` hard findings (em-dashes / spaced+`--`), `2` usage error. Under `--strict`, any finding fails.++## What it catches++<!-- prose-lint-disable (this list names the banned words on purpose) -->++- **Hard (error):** em-dashes, en-dashes in prose, and spaced double-hyphens.+- **Tells (warn):** AI-favorite words (delve, seamless, robust, leverage, utilize,+ showcase, in-depth, cutting-edge, myriad, ...) and boilerplate cadence+ ("it's not just X, it's Y", "in today's ... world", "when it comes to", "whether+ you're X or Y", "plays a pivotal role", ...).+- **Filler (info):** "in order to", "a variety of", empty intensifiers.++<!-- prose-lint-enable -->++It is markdown/code-aware: it will **not** flag a `--flag` inside a code span, a+number range like `2-5 V`, a URL, or YAML front matter. So it is safe to run over+mixed prose+code files.++## Where to run it++- Before `adom-wiki page publish` / `pkg publish` on any page with prose.+- In component-page / README generators, over the generated copy.+- On release notes and Google Chat announcements before they go out.++`--fix` only performs the unambiguous word-for-word swaps. Em-dashes and cadence+need a human rewrite. See the `prose-style` skill for how.
bin/prose-lintadded+385@@ -0,0 +1,385 @@+#!/usr/bin/env node+'use strict';+/*+ * prose-lint — Adom house-style linter.+ *+ * Flags em-dashes and the tics that make AI-written copy read as AI-written.+ * Deterministic, zero-dependency, markdown/code-aware (it will not flag a+ * `--flag` in a code span, a number range in a table, or a URL).+ *+ * Contract (adom-cli-design): human output is line-oriented and ends with an+ * OK:/ERROR: verdict + Hint: lines; `--json` emits { status, data, hints }.+ * Exit 0 = clean or soft-only; exit 1 = hard findings (em-dashes) — or any+ * finding under --strict; exit 2 = usage error.+ */++const fs = require('fs');+const path = require('path');++// ----------------------------------------------------------------------------+// Rules. severity: 'error' (hard — em-dashes), 'warn' (AI tells), 'info' (filler)+// A rule is { id, severity, re, msg, fix? }. `re` must be a global regex.+// `msg(m)` receives the RegExp match array. `fix` (optional) is the literal+// replacement for a safe 1:1 swap used by --fix.+// ----------------------------------------------------------------------------++// tell-words → plain-language suggestion (also drives --fix for 1:1 swaps)+const TELL_WORDS = {+ 'delve': 'dig in / look at',+ 'seamless': 'smooth / simple',+ 'seamlessly': 'smoothly / cleanly',+ 'in-depth': 'detailed / thorough',+ 'robust': 'solid / reliable',+ 'leverage': 'use',+ 'leverages': 'uses',+ 'leveraging': 'using',+ 'utilize': 'use',+ 'utilizes': 'uses',+ 'utilizing': 'using',+ 'utilise': 'use',+ 'boasts': 'has',+ 'cutting-edge': 'new / modern',+ 'state-of-the-art': 'modern',+ 'unlock': 'enable / get',+ 'unleash': 'release / enable',+ 'elevate': 'improve / raise',+ 'empower': 'let / help',+ 'empowers': 'lets / helps',+ 'realm': 'area / world',+ 'landscape': 'field / market',+ 'tapestry': 'mix',+ 'testament': 'proof / sign',+ 'foster': 'build / support',+ 'fosters': 'builds / supports',+ 'underscore': 'show / stress',+ 'underscores': 'shows / stresses',+ 'showcase': 'show',+ 'showcases': 'shows',+ 'showcasing': 'showing',+ 'embark': 'start',+ 'ever-evolving': 'changing',+ 'ever-changing': 'changing',+ 'fast-paced': 'fast',+ 'game-changer': 'big deal',+ 'game-changing': 'major',+ 'revolutionize': 'change / improve',+ 'revolutionizes': 'changes / improves',+ 'revolutionizing': 'changing / improving',+ 'harness': 'use',+ 'harnesses': 'uses',+ 'streamline': 'simplify / speed up',+ 'streamlines': 'simplifies',+ 'holistic': 'whole / overall',+ 'myriad': 'many',+ 'plethora': 'plenty / many',+ 'bustling': 'busy',+ 'meticulous': 'careful',+ 'meticulously': 'carefully',+ 'vibrant': 'lively',+ 'pivotal': 'key',+ 'paramount': 'critical / top',+ 'nestled': 'set / located',+ 'endeavor': 'effort / try',+ 'facilitate': 'help / enable',+ 'facilitates': 'helps / enables',+ 'commence': 'start',+ 'commences': 'starts',+ 'plethora of': 'many',+};+// 1:1 lowercase swaps safe for --fix (no "/" alternatives in value)+const SAFE_FIX = {+ 'utilize': 'use', 'utilizes': 'uses', 'utilizing': 'using', 'utilise': 'use',+ 'leverage': 'use', 'leverages': 'uses', 'leveraging': 'using',+ 'commence': 'start', 'commences': 'starts',+ 'facilitate': 'help', 'facilitates': 'helps',+ 'boasts': 'has', 'myriad': 'many', 'plethora': 'plenty',+};++function tellWordRule() {+ const words = Object.keys(TELL_WORDS).filter((w) => !w.includes(' '));+ // longest first so "in-depth" wins over "depth"; escape hyphens+ const alt = words+ .sort((a, b) => b.length - a.length)+ .map((w) => w.replace(/[-]/g, '\\-'))+ .join('|');+ return {+ id: 'tell-word',+ severity: 'warn',+ re: new RegExp('\\b(' + alt + ')\\b', 'gi'),+ msg: (m) => {+ const key = m[1].toLowerCase();+ const sug = TELL_WORDS[key];+ return `AI-tell word "${m[1]}"` + (sug ? ` — prefer plain language (e.g. ${sug})` : '');+ },+ };+}++const RULES = [+ // --- hard: em/en-dash used as an em-dash in prose ---+ {+ id: 'em-dash',+ severity: 'error',+ re: /—/g,+ msg: () => 'Em-dash (—) in prose — replace with a comma, colon, period, or parentheses',+ },+ {+ id: 'ascii-em-dash',+ severity: 'error',+ re: / -- /g,+ msg: () => 'Spaced double-hyphen ( -- ) reads as an em-dash — use a comma, colon, or period',+ },+ {+ id: 'en-dash',+ severity: 'warn',+ // en-dash NOT between digits (a real number range like 2–5 is fine)+ re: /(?<!\d)–(?!\d)|(?<=\s)–(?=\s)/g,+ msg: () => 'En-dash (–) in prose — use a hyphen for compounds or rewrite',+ },+ tellWordRule(),+ // --- warn: AI cadence / boilerplate structures ---+ { id: 'cadence-not-just', severity: 'warn', re: /\bit['’]?s not just\b[^.?!\n]{0,60}?,\s*it['’]?s\b/gi,+ msg: () => '"it\'s not just X, it\'s Y" — classic AI cadence; state the point plainly' },+ { id: 'cadence-not-only', severity: 'warn', re: /\bnot only\b[^.?!\n]{0,60}?\bbut also\b/gi,+ msg: () => '"not only X but also Y" — tighten to a plain list or sentence' },+ { id: 'cadence-in-today', severity: 'warn', re: /\bin today['’]?s\b[^.?!\n]{0,40}?\b(world|landscape|era|market|age|environment)\b/gi,+ msg: () => '"In today\'s [adjective] world" opener — cut it and start with the point' },+ { id: 'cadence-when-it-comes', severity: 'warn', re: /\bwhen it comes to\b/gi,+ msg: () => '"When it comes to X" — usually deletable filler' },+ { id: 'cadence-thats-where', severity: 'warn', re: /\bthat['’]?s where\b[^.?!\n]{0,40}?\bcomes? in\b/gi,+ msg: () => '"That\'s where X comes in" — marketing cadence; say what it does' },+ { id: 'cadence-whether', severity: 'warn', re: /\bwhether you['’]?re\b[^.?!\n]{0,60}?\bor\b/gi,+ msg: () => '"Whether you\'re X or Y" opener — reads as landing-page copy' },+ { id: 'cadence-goodbye', severity: 'warn', re: /\bsay goodbye to\b/gi,+ msg: () => '"Say goodbye to X" — ad copy; describe the change directly' },+ { id: 'cadence-look-no-further', severity: 'warn', re: /\blook no further\b/gi,+ msg: () => '"Look no further" — ad cliché; delete' },+ { id: 'cadence-heart-of', severity: 'warn', re: /\bat the heart of\b/gi,+ msg: () => '"At the heart of X" — vague; name the actual relationship' },+ { id: 'cadence-plays-role', severity: 'warn', re: /\bplays? an? (crucial|pivotal|vital|key|significant|important) role\b/gi,+ msg: () => '"plays a [crucial] role" — say what it actually does' },+ { id: 'cadence-navigate', severity: 'warn', re: /\bnavigat(e|ing) the complexit/gi,+ msg: () => '"navigate the complexities of X" — filler; be concrete' },+ { id: 'cadence-result-q', severity: 'warn', re: /\bThe result\?/g,+ msg: () => 'Rhetorical "The result?" fragment — write a full sentence' },+ // --- info: low-signal filler / intensifiers ---+ { id: 'filler-in-order-to', severity: 'info', re: /\bin order to\b/gi, fix: 'to',+ msg: () => '"in order to" → "to"' },+ { id: 'filler-a-variety-of', severity: 'info', re: /\ba (variety|number) of\b/gi,+ msg: () => '"a variety/number of" — prefer "several", "some", or a count' },+ { id: 'filler-intensifier', severity: 'info', re: /\b(very|really|simply|basically|actually|just literally|truly)\b/gi,+ msg: (m) => `filler intensifier "${m[1]}" — usually deletable` },+];++// ----------------------------------------------------------------------------+// Masking: build skip-ranges so we never lint inside code / URLs / front matter.+// Ranges are [start,end) offsets in the original text; offsets are preserved so+// line:col stays accurate.+// ----------------------------------------------------------------------------+function skipRanges(text) {+ const ranges = [];+ // whole-file opt-out+ if (/prose-lint-disable-file/.test(text)) return [[0, text.length]];+ const add = (re) => { let m; re.lastIndex = 0; while ((m = re.exec(text))) { ranges.push([m.index, m.index + m[0].length]); if (m[0].length === 0) re.lastIndex++; } };+ // opt-out regions: prose-lint-disable ... prose-lint-enable (any comment syntax).+ // A region with no matching enable runs to EOF. Lets reference docs name the+ // very words we ban without tripping the linter.+ {+ const marker = /prose-lint-(disable|enable)/g;+ let m, open = -1;+ while ((m = marker.exec(text))) {+ if (m[1] === 'disable' && open < 0) open = m.index;+ else if (m[1] === 'enable' && open >= 0) { ranges.push([open, m.index + m[0].length]); open = -1; }+ }+ if (open >= 0) ranges.push([open, text.length]);+ }+ // leading YAML front matter+ const fm = /^---\n[\s\S]*?\n---/;+ const fmm = fm.exec(text); if (fmm) ranges.push([0, fmm[0].length]);+ add(/```[\s\S]*?```/g); // fenced code+ add(/~~~[\s\S]*?~~~/g);+ add(/<pre[\s\S]*?<\/pre>/gi);+ add(/<code[\s\S]*?<\/code>/gi);+ add(/`[^`\n]*`/g); // inline code+ add(/<[^>\n]+>/g); // html tags (attributes, not prose)+ add(/\]\(([^)\s]+)/g); // markdown link/image target (url only)+ add(/^\[[^\]]+\]:\s.*$/gm); // reference-style link defs+ add(/https?:\/\/[^\s)]+/g); // bare URLs+ add(/[\w.+-]+@[\w.-]+\.\w+/g); // emails+ add(/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/gm); // hr / thematic break (--- *** ___)+ return ranges;+}+function inSkip(idx, ranges) { for (const [s, e] of ranges) if (idx >= s && idx < e) return true; return false; }++function offsetToLineCol(text, idx) {+ let line = 1, col = 1;+ for (let i = 0; i < idx; i++) { if (text[i] === '\n') { line++; col = 1; } else col++; }+ return [line, col];+}++function lint(text) {+ const ranges = skipRanges(text);+ const findings = [];+ for (const rule of RULES) {+ let m; rule.re.lastIndex = 0;+ while ((m = rule.re.exec(text))) {+ if (m[0].length === 0) { rule.re.lastIndex++; continue; }+ if (inSkip(m.index, ranges)) continue;+ const [line, col] = offsetToLineCol(text, m.index);+ findings.push({ rule: rule.id, severity: rule.severity, line, col, match: m[0].trim(), message: rule.msg(m) });+ }+ }+ findings.sort((a, b) => a.line - b.line || a.col - b.col);+ return findings;+}++// deterministic 1:1 autofix (safe swaps + "in order to" only)+function applyFix(text) {+ const ranges = skipRanges(text);+ const edits = [];+ const push = (re, repl, keepCase) => {+ let m; re.lastIndex = 0;+ while ((m = re.exec(text))) {+ if (inSkip(m.index, ranges)) continue;+ let r = repl;+ if (keepCase && /^[A-Z]/.test(m[0])) r = r[0].toUpperCase() + r.slice(1);+ edits.push([m.index, m.index + m[0].length, r]);+ }+ };+ for (const [w, r] of Object.entries(SAFE_FIX)) push(new RegExp('\\b' + w.replace(/-/g, '\\-') + '\\b', 'gi'), r, true);+ push(/\bin order to\b/gi, 'to', true);+ edits.sort((a, b) => a[0] - b[0]);+ let out = '', last = 0;+ for (const [s, e, r] of edits) { if (s < last) continue; out += text.slice(last, s) + r; last = e; }+ out += text.slice(last);+ return { text: out, count: edits.length };+}++// ----------------------------------------------------------------------------+// CLI+// ----------------------------------------------------------------------------+function parseArgs(argv) {+ const o = { files: [], json: false, strict: false, fix: false, quiet: false };+ for (let i = 0; i < argv.length; i++) {+ const a = argv[i];+ if (a === '--json') o.json = true;+ else if (a === '--strict') o.strict = true;+ else if (a === '--fix') o.fix = true;+ else if (a === '--quiet' || a === '-q') o.quiet = true;+ else if (a === '--stdin' || a === '-') o.stdin = true;+ else if (a === '--help' || a === '-h') o.help = true;+ else if (a === '--version' || a === '-V') o.version = true;+ else if (a.startsWith('-')) { o.badFlag = a; }+ else o.files.push(a);+ }+ return o;+}++const HELP = `prose-lint — Adom house-style linter (no em-dashes, no AI slop)++USAGE+ prose-lint [FILE...] lint files (markdown/html/txt)+ prose-lint --stdin < file lint stdin+ echo "text" | prose-lint lint stdin (auto when no files)++OPTIONS+ --fix apply safe 1:1 word swaps in place (utilize→use, "in order to"→to)+ --strict exit non-zero on ANY finding (default: only em-dashes fail)+ --json machine-readable { status, data, hints[] }+ --quiet, -q suppress the per-finding lines (summary + verdict only)+ --version print version+ --help this help++EXIT 0 clean/soft-only · 1 hard findings (or any under --strict) · 2 usage`;++const SEV_ORDER = { error: 0, warn: 1, info: 2 };+const SEV_LABEL = { error: 'HARD ', warn: 'warn ', info: 'info ' };++function main() {+ const o = parseArgs(process.argv.slice(2));+ if (o.help) { process.stdout.write(HELP + '\n'); process.exit(0); }+ if (o.version) {+ let v = '0.0.0';+ try { v = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; } catch (e) {}+ process.stdout.write('prose-lint ' + v + '\n'); process.exit(0);+ }+ if (o.badFlag) { process.stderr.write('ERROR: unknown flag ' + o.badFlag + '\nHint: run `prose-lint --help`\n'); process.exit(2); }++ // gather inputs+ const inputs = []; // { name, text }+ if (o.files.length) {+ for (const f of o.files) {+ try { inputs.push({ name: f, text: fs.readFileSync(f, 'utf8') }); }+ catch (e) { process.stderr.write('ERROR: cannot read ' + f + ' (' + e.code + ')\n'); process.exit(2); }+ }+ } else {+ let text = '';+ try { text = fs.readFileSync(0, 'utf8'); } catch (e) {}+ if (!text && process.stdin.isTTY) { process.stdout.write(HELP + '\n'); process.exit(0); }+ inputs.push({ name: '<stdin>', text });+ }++ // --fix mode+ if (o.fix) {+ const results = [];+ for (const inp of inputs) {+ const { text, count } = applyFix(inp.text);+ if (inp.name !== '<stdin>' && count > 0) fs.writeFileSync(inp.name, text);+ else if (inp.name === '<stdin>') process.stdout.write(text);+ results.push({ file: inp.name, fixed: count });+ }+ const total = results.reduce((n, r) => n + r.fixed, 0);+ if (o.json) { process.stdout.write(JSON.stringify({ status: 'ok', data: { fixed: total, files: results }, hints: [] }) + '\n'); }+ else {+ for (const r of results) if (r.file !== '<stdin>') process.stdout.write(`fixed ${r.fixed} in ${r.file}\n`);+ process.stdout.write((total ? 'OK: applied ' + total + ' safe swap(s)' : 'OK: nothing to auto-fix') ++ '\nHint: --fix only handles 1:1 word swaps; em-dashes and cadence need a human rewrite. Re-run without --fix to see what remains.\n');+ }+ process.exit(0);+ }++ // lint mode+ let all = [];+ const perFile = [];+ for (const inp of inputs) {+ const f = lint(inp.text);+ perFile.push({ file: inp.name, findings: f });+ all = all.concat(f.map((x) => ({ file: inp.name, ...x })));+ }+ const counts = { error: 0, warn: 0, info: 0 };+ for (const f of all) counts[f.severity]++;++ if (o.json) {+ const hard = counts.error;+ const status = (o.strict ? all.length : hard) ? 'error' : 'ok';+ process.stdout.write(JSON.stringify({+ status,+ data: { counts, total: all.length, files: perFile },+ hints: hard ? [{ level: 'error', code: 'EM_DASH', message: `${hard} hard finding(s) — em-dashes/AI-tells must go before shipping copy` }] : [],+ }) + '\n');+ process.exit((o.strict ? all.length : hard) ? 1 : 0);+ }++ if (!o.quiet) {+ for (const f of all.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.line - b.line || a.col - b.col)) {+ process.stdout.write(`${f.file}:${f.line}:${f.col} ${SEV_LABEL[f.severity]} [${f.rule}] ${f.message}\n`);+ }+ if (all.length) process.stdout.write('\n');+ }++ if (all.length === 0) {+ process.stdout.write('OK: prose is clean — no em-dashes or AI-tells found\n');+ process.exit(0);+ }+ const parts = [];+ if (counts.error) parts.push(counts.error + ' hard');+ if (counts.warn) parts.push(counts.warn + ' tell');+ if (counts.info) parts.push(counts.info + ' filler');+ const verdict = counts.error ? 'ERROR' : 'OK';+ process.stdout.write(`${verdict}: ${parts.join(', ')} issue(s) across ${inputs.length} file(s)\n`);+ if (counts.error) process.stdout.write('Hint: em-dashes and " -- " are the hard rule — swap for a comma, colon, or period, then re-run.\n');+ if (counts.warn) process.stdout.write('Hint: "tell" lines are advisory; rewrite the ones that make the copy sound generated.\n');+ process.stdout.write('Hint: `prose-lint --fix` applies the safe word swaps automatically.\n');+ process.exit((o.strict ? all.length : counts.error) ? 1 : 0);+}++main();
docs/HERO.mdadded+1@@ -0,0 +1 @@+Put a 1200x630 `hero.png` in this folder. It is the billboard reused on the page header, the homepage, and the screensaver. Until it exists, `adom-wiki pkg lint` reports the hero as the one outstanding item.
docs/hero.htmladded+47@@ -0,0 +1,47 @@+<!doctype html><html><head><meta charset="utf-8"><style>+ * { margin:0; padding:0; box-sizing:border-box; }+ html,body { width:1200px; height:750px; }+ body { background:#0a0e13; color:#e9eef5; font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;+ overflow:hidden; position:relative; }+ .glow { position:absolute; inset:0;+ background:radial-gradient(900px 500px at 82% 18%, rgba(0,230,220,.16), transparent 60%),+ radial-gradient(760px 520px at 12% 92%, rgba(188,140,255,.16), transparent 60%); }+ .wrap { position:absolute; inset:0; display:flex; flex-direction:column; justify-content:center; padding:76px 84px; }+ .kicker { font-size:19px; letter-spacing:.32em; text-transform:uppercase; font-weight:700;+ background:linear-gradient(90deg,#00e6dc,#58a6ff 55%,#bc8cff); -webkit-background-clip:text; background-clip:text; color:transparent; }+ h1 { font-size:104px; font-weight:800; letter-spacing:-.03em; margin:14px 0 6px; line-height:.98; }+ h1 .dot { color:#00e6dc; }+ .sub { font-size:34px; font-weight:600; color:#aeb9c6; }+ .accent { height:7px; width:230px; border-radius:6px; margin:26px 0 34px;+ background:linear-gradient(90deg,#00e6dc,#58a6ff 55%,#bc8cff); }+ .card { position:absolute; right:70px; bottom:70px; width:640px; background:#0f151d; border:1px solid #1e2a37;+ border-radius:16px; box-shadow:0 40px 90px rgba(0,0,0,.55); overflow:hidden; font-family:'SF Mono',ui-monospace,Menlo,Consolas,monospace; }+ .bar { height:38px; background:#131b25; border-bottom:1px solid #1e2a37; display:flex; align-items:center; gap:8px; padding:0 14px; }+ .bar i { width:12px; height:12px; border-radius:50%; display:inline-block; }+ .bar .r{background:#ff5f57}.bar .y{background:#febc2e}.bar .g{background:#28c840}+ .bar span{margin-left:10px; font-size:14px; color:#7d8b9a}+ .body { padding:20px 22px; font-size:19px; line-height:1.85; }+ .body .path{color:#7d8b9a}+ .hard{color:#ff6b6b; font-weight:700}+ .warn{color:#febc2e}+ .ok{color:#00e6dc; font-weight:700}+ .rule{color:#8aa0b4}+</style></head><body>+ <div class="glow"></div>+ <div class="wrap">+ <div class="kicker">Adom House Style</div>+ <h1>prose<span class="dot">-</span>lint</h1>+ <div class="accent"></div>+ <div class="sub">No em-dashes.<br>No AI slop.<br>Deterministic.</div>+ </div>+ <div class="card">+ <div class="bar"><i class="r"></i><i class="y"></i><i class="g"></i><span>prose-lint README.md</span></div>+ <div class="body">+ <div><span class="path">README:1:48</span> <span class="warn">warn</span> <span class="rule">[tell-word]</span> "game-changer"</div>+ <div><span class="path">README:1:61</span> <span class="hard">HARD</span> <span class="rule">[em-dash]</span> em-dash in prose</div>+ <div><span class="path">README:3:12</span> <span class="warn">warn</span> <span class="rule">[cadence]</span> "in today's world"</div>+ <div style="margin-top:10px"> </div>+ <div class="ok">OK: 1 hard, 2 tell. Fix before shipping.</div>+ </div>+ </div>+</body></html>
docs/hero.pngadded⋯ 1 unchanged line ⋯
install.shadded+16@@ -0,0 +1,16 @@+#!/bin/sh+set -e++# Symlink the CLI into ~/.local/bin so `prose-lint` is on PATH. Symlinking (not+# copying) means a reinstall/update never leaves a stale binary behind — the link+# always points at the current module dir.+BIN_DIR="${HOME}/.local/bin"+mkdir -p "$BIN_DIR"+chmod +x "$PWD/bin/prose-lint" 2>/dev/null || true+ln -sfn "$PWD/bin/prose-lint" "$BIN_DIR/prose-lint"++if ! command -v node >/dev/null 2>&1; then+ echo "prose-lint installed, but 'node' was not found on PATH — the CLI needs Node 16+ to run."+else+ echo "prose-lint installed -> $BIN_DIR/prose-lint"+fi
package.jsonadded+54@@ -0,0 +1,54 @@+{+ "slug": "prose-lint",+ "title": "Prose Lint",+ "type": "app",+ "version": "0.1.0",+ "description": "Deterministic house-style linter for Adom copy. Flags em-dashes and the tics that make AI-written text read as AI-written (tell-words, boilerplate cadence, filler), with line:col, plain-language suggestions, --fix for safe swaps, and a --json envelope. Markdown/code-aware: never flags a --flag in a code span, a number range, or a URL.",+ "tags": [+ "cli",+ "lint",+ "writing",+ "house-style"+ ],+ "dependencies": {},+ "discovery_triggers": [+ "lint this copy",+ "remove em-dashes",+ "does this sound AI-written",+ "check my prose",+ "make this read less AI",+ "prose lint"+ ],+ "discovery_pitch": "Reach for prose-lint before shipping any user-facing copy — READMEs, wiki pages, release notes, UI strings — to catch em-dashes and AI-tells deterministically.",+ "sample_prompts": [+ {+ "label": "Lint a wiki page",+ "prompt": "run prose-lint on readme.md before I publish it"+ },+ {+ "label": "De-AI some copy",+ "prompt": "this paragraph sounds AI-written, check it with prose-lint"+ }+ ],+ "metadata": {+ "activate": {+ "kind": "bridge",+ "skill": "prose-lint",+ "requires": [+ "node"+ ],+ "verbPrefixes": [+ "prose-lint"+ ]+ }+ },+ "scripts": {+ "install": "./install.sh",+ "uninstall": "./uninstall.sh"+ },+ "hero": {+ "headline": "Prose Lint",+ "subhead": "No em-dashes. No AI slop. Deterministic.",+ "screenshot": "docs/hero.png"+ }+}
uninstall.shadded+6@@ -0,0 +1,6 @@+#!/bin/sh+set -e++BIN_DIR="${HOME}/.local/bin"+[ -L "$BIN_DIR/prose-lint" ] && rm -f "$BIN_DIR/prose-lint"+echo "prose-lint uninstalled"