Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to this project are documented here.

## [Unreleased]

### Added

- The bundled `ai-writing-detector` script accepts `--source-mode <plain|rendered-markdown>`, so the published plugin can reach rendered-Markdown scoring instead of flagging YAML frontmatter as the author's prose. It also accepts the `marketing` and `personal` contexts the root CLI and the detector already support, which it previously rejected. Blank input reports the selected context and source mode instead of an empty `stats` object, matching the root CLI. A bad argument now prints the usage message and exits 2 instead of throwing an uncaught stack trace (#244).

### Changed

- Cover two phrasings flagged in #325 as judgment-only examples: cold-outreach flattery asks ("I'd value your take on this") under sycophantic tone, and the teaser form of the crowd contrast ("the call most leaders still won't make"). No detector change and no new category.
Expand Down
131 changes: 131 additions & 0 deletions scripts/detect-parity.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"use strict";

// Parity between the bundled ai-writing-detector script and the root CLI
// (#244). Both load detector/patterns.js, but detect.js had drifted: it could
// not reach rendered-Markdown mode, so a Markdown file with YAML frontmatter
// scored "Minimal AI signals" through detect.js and "Clean" through the root
// CLI, and passing the flag threw an uncaught "unknown argument" stack trace.

const assert = require("assert");
const { spawnSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");

const ROOT_CLI = path.join(__dirname, "..", "bin", "avoid-ai-writing.js");
const DETECT = path.join(__dirname, "..", "skills", "ai-writing-detector", "scripts", "detect.js");

function run(cli, args) {
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8" });
}

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aaw-detect-"));
const draft = path.join(tmp, "draft.md");
fs.writeFileSync(
draft,
[
"---",
"title: Delve into the vibrant landscape",
"description: A testament to seamless synergy",
"tags: [draft]",
"---",
"",
"The team met on Tuesday and agreed the next steps for the release.",
"",
].join("\n"),
"utf8",
);

// The same file and the same flags produce the same analysis through both entry
// points. The root CLI takes a positional path, detect.js takes --file; that
// difference is deliberate and stays.
//
// Every context the shared detector accepts is exercised here, because the
// bundled parser is a second copy of the root CLI's validation: a context the
// root CLI accepts but detect.js rejects is a parity break that only shows up
// on the invocation that uses it.
const CONTEXTS = ["general", "technical", "marketing", "personal"];

for (const context of CONTEXTS) {
const rootOut = run(ROOT_CLI, ["--context", context, "--source-mode", "rendered-markdown", draft]);
assert.strictEqual(rootOut.status, 0, `root CLI --context ${context}: ${rootOut.stderr}`);
const detectOut = run(DETECT, ["--file", draft, "--context", context, "--source-mode", "rendered-markdown"]);
assert.strictEqual(detectOut.status, 0, `detect.js --context ${context}: ${detectOut.stderr}`);
assert.deepStrictEqual(
JSON.parse(detectOut.stdout),
JSON.parse(rootOut.stdout),
`--context ${context} must agree between the two entry points`,
);
assert.strictEqual(JSON.parse(detectOut.stdout).stats.contextMode, context);
}

const detectOut = run(DETECT, ["--file", draft, "--context", "general", "--source-mode", "rendered-markdown"]);
assert.strictEqual(detectOut.status, 0, detectOut.stderr);
assert.strictEqual(JSON.parse(detectOut.stdout).stats.sourceMode, "rendered-markdown");

// The flag is not a no-op: plain mode still scores the YAML frontmatter as
// prose, which is the false positive rendered-markdown removes.
const plainOut = run(DETECT, ["--file", draft, "--context", "general"]);
assert.strictEqual(plainOut.status, 0, plainOut.stderr);
const plain = JSON.parse(plainOut.stdout);
const rendered = JSON.parse(detectOut.stdout);
assert.strictEqual(plain.stats.sourceMode, "plain");
assert.strictEqual(rendered.issues.length, 0, "rendered-markdown must not score the frontmatter");
assert.strictEqual(rendered.label, "Clean");
assert.ok(
plain.issues.length > 0,
"plain mode must still score the frontmatter, or this flag has nothing to remove",
);

// Blank input still reports the selected modes. analyzeText() returns an empty
// stats object for it, and the root CLI fills in contextMode and sourceMode;
// without the same normalization the bundled script returned stats: {} and the
// two entry points disagreed on an explicitly selected mode.
const blank = path.join(tmp, "blank.md");
fs.writeFileSync(blank, "", "utf8");
const whitespace = path.join(tmp, "whitespace.md");
fs.writeFileSync(whitespace, " \n\t\n", "utf8");

for (const [label, blankFile] of [["empty", blank], ["whitespace-only", whitespace]]) {
for (const sourceMode of ["plain", "rendered-markdown"]) {
const rootOut = run(ROOT_CLI, ["--context", "marketing", "--source-mode", sourceMode, blankFile]);
assert.strictEqual(rootOut.status, 0, `root CLI ${label} ${sourceMode}: ${rootOut.stderr}`);
const detectOut = run(DETECT, ["--file", blankFile, "--context", "marketing", "--source-mode", sourceMode]);
assert.strictEqual(detectOut.status, 0, `detect.js ${label} ${sourceMode}: ${detectOut.stderr}`);
assert.deepStrictEqual(
JSON.parse(detectOut.stdout),
JSON.parse(rootOut.stdout),
`${label} input with --source-mode ${sourceMode} must agree between the two entry points`,
);
const stats = JSON.parse(detectOut.stdout).stats;
assert.strictEqual(stats.contextMode, "marketing", `${label}: selected context must stay visible`);
assert.strictEqual(stats.sourceMode, sourceMode, `${label}: selected source mode must stay visible`);
}
}

// Usage errors exit 2 with the usage message and no stack trace.
const errorCases = [
["unknown argument", ["--nope"]],
["missing --file value", ["--file"]],
["missing --context value", ["--context"]],
["invalid context", ["--context", "nope"]],
["missing --source-mode value", ["--source-mode"]],
["invalid source mode", ["--source-mode", "nope"]],
];

for (const [label, args] of errorCases) {
const res = run(DETECT, args);
assert.strictEqual(res.status, 2, `${label}: expected exit 2`);
assert.strictEqual(res.stdout, "", `${label}: expected no stdout`);
assert.ok(res.stderr.includes("Usage: detect.js"), `${label}: expected the usage message`);
assert.ok(!/\n\s+at /.test(res.stderr), `${label}: expected no stack trace`);
}

// --help prints the usage and exits 0.
const help = run(DETECT, ["--help"]);
assert.strictEqual(help.status, 0);
assert.ok(help.stdout.includes("Usage: detect.js"));
assert.ok(help.stdout.includes("--source-mode <plain|rendered-markdown>"));

fs.rmSync(tmp, { recursive: true, force: true });
console.log("ai-writing-detector bundled script: ok");
1 change: 1 addition & 0 deletions scripts/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const SUITES = [
'scripts/test-canonical-skill-package.js',
'bin/avoid-ai-writing.test.js',
'bin/avoid-ai-writing-gate.test.js',
'scripts/detect-parity.test.js',
'scripts/rewrite-demo.test.js',
'scripts/rewrite-eval.test.js',
'scripts/rewrite-eval-opencode.test.js',
Expand Down
13 changes: 10 additions & 3 deletions skills/ai-writing-detector/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ When the current host can execute Node safely:

1. Pass the supplied text to `scripts/detect.js`.
2. Use `--context technical` for code-adjacent or technical prose when appropriate. Otherwise use `general`.
3. Report the detector's score, label, issue types, severity, matched text, and suggestions.
4. Separate deterministic candidate matches from justified editorial findings and observations that only exist in the full rulebook.
5. Never claim execution unless the command actually ran.
3. Use `--source-mode rendered-markdown` for Markdown that carries YAML frontmatter or HTML comments, so unedited metadata is not scored as the author's prose. Otherwise leave the default `plain`.
4. Report the detector's score, label, issue types, severity, matched text, and suggestions.
5. Separate deterministic candidate matches from justified editorial findings and observations that only exist in the full rulebook.
6. Never claim execution unless the command actually ran.

Example:

Expand All @@ -79,6 +80,12 @@ For a file:
node scripts/detect.js --file path/to/draft.md --context general
```

For Markdown with frontmatter or HTML comments:

```bash
node scripts/detect.js --file path/to/draft.md --source-mode rendered-markdown
```

If Node or shell execution is unavailable, perform the detect-only workflow from the canonical `avoid-ai-writing` Skill and explicitly say the deterministic detector was not run.

## Stop conditions
Expand Down
128 changes: 109 additions & 19 deletions skills/ai-writing-detector/scripts/detect.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,117 @@ const fs = require("fs");
const path = require("path");
const AIDetector = require("./patterns.js");

const args = process.argv.slice(2);
let file = null;
let contextMode = "general";

for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--file") {
if (!args[i + 1]) throw new Error("--file requires a path");
file = args[++i];
} else if (arg === "--context") {
if (!args[i + 1]) throw new Error("--context requires general or technical");
contextMode = args[++i];
} else {
throw new Error(`unknown argument: ${arg}`);
const USAGE = `Usage: detect.js [options]

Scores UTF-8 text from --file or stdin and prints the complete analyzeText()
result as JSON to stdout. Read-only: nothing is modified.
Exits 0 after a successful analysis, 2 on usage or I/O errors.

Options:
--file <path> Read the text from a file (default: stdin)
--context <general|technical|marketing|personal>
Analysis context (default: general)
--source-mode <plain|rendered-markdown>
Plain text (default) or rendered
Markdown, which excludes YAML
frontmatter and HTML comments from
the score
-h, --help Show this help

Examples:
printf '%s' "$TEXT" | node scripts/detect.js --context general
node scripts/detect.js --file path/to/draft.md --source-mode rendered-markdown
`;

// Must stay in step with VALID_CONTEXT_MODES in patterns.js and CONTEXTS in
// bin/avoid-ai-writing.js: the bundled script has to accept every context the
// root CLI accepts, or equivalent invocations stop agreeing.
const CONTEXTS = ["general", "technical", "marketing", "personal"];
const SOURCE_MODES = ["plain", "rendered-markdown"];

function parseArgs(argv) {
const options = { help: false, file: null, context: "general", sourceMode: "plain" };

for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];

if (arg === "-h" || arg === "--help") {
options.help = true;
continue;
}

if (arg === "--file" || arg === "--context" || arg === "--source-mode") {
const value = argv[i + 1];
if (value === undefined) {
return { error: `${arg} requires a value` };
}
i += 1;
if (arg === "--file") {
options.file = value;
} else if (arg === "--context") {
if (!CONTEXTS.includes(value)) {
return { error: `invalid --context value: ${value}` };
}
options.context = value;
} else {
if (!SOURCE_MODES.includes(value)) {
return { error: `invalid --source-mode value: ${value}` };
}
options.sourceMode = value;
}
continue;
}

return { error: `unknown argument: ${arg}` };
}

return options;
}

if (!["general", "technical"].includes(contextMode)) {
throw new Error("--context must be general or technical");
function readInput(file) {
const source = file === null ? "stdin" : file;
try {
return { text: fs.readFileSync(file === null ? 0 : path.resolve(file), "utf8") };
} catch (error) {
return { error: `cannot read ${source}: ${error.message}` };
}
}

function main(argv) {
const parsed = parseArgs(argv);

if (parsed.error) {
process.stderr.write(`detect.js: ${parsed.error}\n\n${USAGE}`);
return 2;
}

if (parsed.help) {
process.stdout.write(USAGE);
return 0;
}

const input = readInput(parsed.file);
if (input.error) {
process.stderr.write(`detect.js: ${input.error}\n\n${USAGE}`);
return 2;
}

const result = AIDetector.analyzeText(input.text, {
contextMode: parsed.context,
sourceMode: parsed.sourceMode,
});
Comment thread
conorbronsdon marked this conversation as resolved.

// analyzeText() returns an empty stats object for empty input. Surface the
// selected modes anyway, exactly as the root CLI does, so the option
// contract holds in every case and blank input does not diverge between the
// two entry points.
if (result.stats && Object.keys(result.stats).length === 0) {
result.stats.contextMode = parsed.context;
result.stats.sourceMode = parsed.sourceMode;
}

process.stdout.write(JSON.stringify(result, null, 2) + "\n");
return 0;
}

const text = file ? fs.readFileSync(path.resolve(file), "utf8") : fs.readFileSync(0, "utf8");
const result = AIDetector.analyzeText(text, { contextMode });
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
process.exitCode = main(process.argv.slice(2));
Loading