@holdyourvoice/hyv 3.1.1 → 3.2.0

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/dist/cli.js CHANGED
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync } from 'node:fs';
3
- import { rules, RULESET_VERSION } from './ai-editor.js';
2
+ import { linkSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, extname, join, resolve } from 'node:path';
4
+ import { RULESET_VERSION, serializedRules } from './ai-editor.js';
4
5
  import { parseCopySpec } from './copy-spec.js';
5
6
  import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
6
7
  import { addLearningInstruction, clearLearning, composeLearning, profileFingerprint, recordVerifiedCandidate } from './learning.js';
8
+ import { cleanHygiene, finalOutputCheck, inspectHygiene } from './hygiene.js';
7
9
  import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
8
10
  import { parseProfile } from './profile.js';
9
11
  import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
10
12
  import { buildProfile } from './voice-dna.js';
11
- const usage = 'Commands: profile, analyze, batch-analyze, rewrite-prompt, prepare-rewrite, apply-rewrite, verify, verify-spec, learning, patterns, mcp';
13
+ const usage = 'Commands: profile, analyze, hygiene, final-check, batch-analyze, rewrite-prompt, prepare-rewrite, apply-rewrite, verify, verify-spec, learning, patterns, mcp';
12
14
  function input(path) {
13
15
  return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
14
16
  }
@@ -70,6 +72,58 @@ function profileArguments(args) {
70
72
  throw new Error('Usage: hyv profile profile.json sample-a.md sample-b.md [sample-c.md] [--avoid=phrase]');
71
73
  return { output, samples, avoid };
72
74
  }
75
+ function cleanedPath(path) {
76
+ const extension = extname(path);
77
+ const stem = extension ? path.slice(0, -extension.length) : path;
78
+ return `${stem}.cleaned${extension}`;
79
+ }
80
+ function hygieneArguments(args) {
81
+ const [path, ...options] = args;
82
+ if (!path)
83
+ throw new Error('Usage: hyv hygiene draft.md [--fix] [--output=cleaned.md]');
84
+ let fix = false;
85
+ let output;
86
+ for (const option of options) {
87
+ if (option === '--fix')
88
+ fix = true;
89
+ else if (option.startsWith('--output='))
90
+ output = option.slice('--output='.length).trim();
91
+ else
92
+ throw new Error('Usage: hyv hygiene draft.md [--fix] [--output=cleaned.md]');
93
+ }
94
+ if (output !== undefined && (!output || !fix))
95
+ throw new Error('--output requires --fix and a non-empty path.');
96
+ return { path, fix, ...(output ? { output } : {}) };
97
+ }
98
+ function writeNewFileAtomically(path, text) {
99
+ const temporaryDirectory = mkdtempSync(join(dirname(resolve(path)), '.hyv-hygiene-'));
100
+ const temporaryPath = join(temporaryDirectory, 'cleaned');
101
+ let primaryError;
102
+ try {
103
+ writeFileSync(temporaryPath, text, 'utf8');
104
+ try {
105
+ linkSync(temporaryPath, path);
106
+ }
107
+ catch (error) {
108
+ const code = error.code;
109
+ if (!['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV'].includes(code ?? ''))
110
+ throw error;
111
+ throw new Error(`Atomic hygiene output is not supported by this filesystem: ${path}`);
112
+ }
113
+ }
114
+ catch (error) {
115
+ primaryError = error.code === 'EEXIST' ? new Error(`Hygiene output already exists: ${path}`) : error;
116
+ }
117
+ try {
118
+ rmSync(temporaryDirectory, { recursive: true, force: true });
119
+ }
120
+ catch (error) {
121
+ if (!primaryError)
122
+ console.error(`Warning: output was published, but temporary-file cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
123
+ }
124
+ if (primaryError)
125
+ throw primaryError;
126
+ }
73
127
  export async function runCli(args) {
74
128
  const [command, ...rest] = args;
75
129
  if (command === 'profile') {
@@ -84,6 +138,37 @@ export async function runCli(args) {
84
138
  json(analyze(input(draft), readProfile(profilePath), readBrief(briefPath)));
85
139
  return 0;
86
140
  }
141
+ if (command === 'hygiene') {
142
+ const { path, fix, output } = hygieneArguments(rest);
143
+ if (fix && path === '-')
144
+ throw new Error('hyv hygiene --fix requires a file path so the original can be preserved.');
145
+ const text = input(path);
146
+ if (!fix) {
147
+ json(inspectHygiene(text));
148
+ return 0;
149
+ }
150
+ const outputPath = output ?? cleanedPath(path);
151
+ if (resolve(outputPath) === resolve(path))
152
+ throw new Error('Hygiene output must differ from the input path.');
153
+ const result = cleanHygiene(text);
154
+ writeNewFileAtomically(outputPath, result.cleaned);
155
+ json({ ...result.report, changed: result.changed, changes: result.changes, outputPath });
156
+ return 0;
157
+ }
158
+ if (command === 'final-check') {
159
+ const [path, ...options] = rest;
160
+ if (!path || options.length)
161
+ throw new Error('Usage: hyv final-check <path|->');
162
+ const result = finalOutputCheck(input(path));
163
+ if (!result.accepted) {
164
+ console.error(JSON.stringify(result, null, 2));
165
+ return 2;
166
+ }
167
+ if (result.changed)
168
+ console.error(JSON.stringify({ changed: true, changes: result.changes }, null, 2));
169
+ process.stdout.write(result.output);
170
+ return 0;
171
+ }
87
172
  if (command === 'batch-analyze') {
88
173
  if (rest.length < 2)
89
174
  throw new Error('Usage: hyv batch-analyze draft-a.md draft-b.md [draft-c.md]');
@@ -168,7 +253,7 @@ export async function runCli(args) {
168
253
  throw new Error('Usage: hyv learning <show|add|clear> profile.json [instruction]');
169
254
  }
170
255
  if (command === 'patterns') {
171
- json({ version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) });
256
+ json({ version: RULESET_VERSION, rules: serializedRules() });
172
257
  return 0;
173
258
  }
174
259
  if (command === 'mcp') {
package/dist/cli.test.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import test from 'node:test';
7
+ import { patternsForMcp } from './mcp-tools.js';
7
8
  const cli = new URL('./cli.js', import.meta.url).pathname;
8
- function run(args, env = process.env) {
9
- return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env });
9
+ function run(args, env = process.env, input) {
10
+ return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env, input });
10
11
  }
11
12
  test('creates an explicit local avoid list and exposes the ruleset', () => {
12
13
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
@@ -27,6 +28,15 @@ test('creates an explicit local avoid list and exposes the ruleset', () => {
27
28
  rmSync(directory, { recursive: true, force: true });
28
29
  }
29
30
  });
31
+ test('publishes the same normalized 145-rule catalog and version through CLI and MCP', () => {
32
+ const result = run(['patterns']);
33
+ assert.equal(result.status, 0, result.stderr);
34
+ const cliCatalog = JSON.parse(result.stdout);
35
+ const mcpCatalog = patternsForMcp();
36
+ assert.equal(cliCatalog.version, '2.9.24-static.2');
37
+ assert.equal(cliCatalog.rules.length, 145);
38
+ assert.deepEqual(cliCatalog, mcpCatalog);
39
+ });
30
40
  test('runs contextual analysis and batch analysis without changing the profile contract', () => {
31
41
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
32
42
  try {
@@ -45,6 +55,7 @@ test('runs contextual analysis and batch analysis without changing the profile c
45
55
  assert.equal(run(['profile', profile, first, second]).status, 0);
46
56
  const contextual = JSON.parse(run(['analyze', draft, profile, brief]).stdout);
47
57
  assert.equal(contextual.editorial.findings[0].id, 'editorial.social.generic-opener');
58
+ assert.equal(contextual.hygiene.suspiciousCount, 0);
48
59
  const batch = JSON.parse(run(['batch-analyze', draft, duplicate]).stdout);
49
60
  assert.equal(batch.findings.length, 2);
50
61
  assert.equal(run(['prepare-rewrite', draft, profile, task, brief]).status, 0);
@@ -54,6 +65,66 @@ test('runs contextual analysis and batch analysis without changing the profile c
54
65
  rmSync(directory, { recursive: true, force: true });
55
66
  }
56
67
  });
68
+ test('inspects and conservatively fixes Unicode hygiene without overwriting either file', () => {
69
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
70
+ try {
71
+ const draft = join(directory, 'draft.md');
72
+ const cleaned = join(directory, 'draft.cleaned.md');
73
+ const original = `\uFEFFkeep\u200Bthis\u00A0space\u200D`;
74
+ writeFileSync(draft, original);
75
+ const inspected = run(['hygiene', draft]);
76
+ assert.equal(inspected.status, 0, inspected.stderr);
77
+ const report = JSON.parse(inspected.stdout);
78
+ assert.equal(report.suspiciousCount, 4);
79
+ assert.equal(report.fixableCount, 1);
80
+ const fixed = run(['hygiene', draft, '--fix']);
81
+ assert.equal(fixed.status, 0, fixed.stderr);
82
+ const receipt = JSON.parse(fixed.stdout);
83
+ assert.equal(receipt.outputPath, cleaned);
84
+ assert.equal(receipt.changed, true);
85
+ assert.equal(receipt.changes.length, 1);
86
+ assert.equal(readFileSync(draft, 'utf8'), original);
87
+ assert.equal(readFileSync(cleaned, 'utf8'), `keep\u200Bthis\u00A0space\u200D`);
88
+ const custom = join(directory, 'review-copy.md');
89
+ const customFixed = run(['hygiene', draft, '--fix', `--output=${custom}`]);
90
+ assert.equal(customFixed.status, 0, customFixed.stderr);
91
+ assert.equal(JSON.parse(customFixed.stdout).outputPath, custom);
92
+ assert.equal(readFileSync(custom, 'utf8'), `keep\u200Bthis\u00A0space\u200D`);
93
+ const samePath = run(['hygiene', draft, '--fix', `--output=${draft}`]);
94
+ assert.equal(samePath.status, 1);
95
+ assert.match(samePath.stderr, /must differ from the input path/);
96
+ assert.equal(readFileSync(draft, 'utf8'), original);
97
+ const refused = run(['hygiene', draft, '--fix']);
98
+ assert.equal(refused.status, 1);
99
+ assert.match(refused.stderr, /already exists/);
100
+ assert.equal(readdirSync(directory).some((name) => name.startsWith('.hyv-hygiene-')), false);
101
+ }
102
+ finally {
103
+ rmSync(directory, { recursive: true, force: true });
104
+ }
105
+ });
106
+ test('inspects stdin and refuses to clean it without a preservable input file', () => {
107
+ const inspected = run(['hygiene', '-'], process.env, 'one\u200Btwo');
108
+ assert.equal(inspected.status, 0, inspected.stderr);
109
+ assert.equal(JSON.parse(inspected.stdout).suspiciousCount, 1);
110
+ const refused = run(['hygiene', '-', '--fix'], process.env, 'one\u200Btwo');
111
+ assert.equal(refused.status, 1);
112
+ assert.match(refused.stderr, /requires a file path/);
113
+ });
114
+ test('gates final output from any producer without a voice profile', () => {
115
+ const clean = run(['final-check', '-'], process.env, 'exact output\n');
116
+ assert.equal(clean.status, 0, clean.stderr);
117
+ assert.equal(clean.stdout, 'exact output\n');
118
+ assert.equal(clean.stderr, '');
119
+ const bom = run(['final-check', '-'], process.env, '\uFEFFexact output');
120
+ assert.equal(bom.status, 0, bom.stderr);
121
+ assert.equal(bom.stdout, 'exact output');
122
+ assert.match(bom.stderr, /U\+FEFF/);
123
+ const unresolved = run(['final-check', '-'], process.env, 'Thai\u200Bboundary');
124
+ assert.equal(unresolved.status, 2);
125
+ assert.equal(unresolved.stdout, '');
126
+ assert.match(unresolved.stderr, /U\+200B/);
127
+ });
57
128
  test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
58
129
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
59
130
  try {
package/dist/copy-spec.js CHANGED
@@ -2,6 +2,15 @@ import { sentences } from './text.js';
2
2
  function normalized(value) {
3
3
  return value.toLowerCase().replace(/\s+/g, ' ').trim();
4
4
  }
5
+ function escaped(value) {
6
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ }
8
+ function atomMatches(value, atom) {
9
+ return new RegExp(`(?<![\\p{L}\\p{N}\\p{M}])${escaped(atom)}(?![\\p{L}\\p{N}\\p{M}])`, 'u').test(value);
10
+ }
11
+ function isAtom(value) {
12
+ return isText(value, 500) && /[\p{L}\p{N}]/u.test(normalized(value));
13
+ }
5
14
  function isText(value, limit) {
6
15
  return typeof value === 'string' && value.trim().length > 0 && value.length <= limit;
7
16
  }
@@ -11,7 +20,8 @@ function isClaim(value) {
11
20
  const claim = value;
12
21
  return isText(claim.id, 100) && /^[A-Za-z0-9._-]+$/.test(claim.id)
13
22
  && isText(claim.text, 2_000) && isText(claim.evidence, 4_000)
14
- && (claim.mutable === undefined || typeof claim.mutable === 'boolean');
23
+ && (claim.mutable === undefined || typeof claim.mutable === 'boolean')
24
+ && (claim.atoms === undefined || (Array.isArray(claim.atoms) && claim.atoms.length > 0 && claim.atoms.length <= 20 && claim.atoms.every(isAtom)));
15
25
  }
16
26
  export function parseCopySpec(value) {
17
27
  if (!value || typeof value !== 'object')
@@ -26,17 +36,34 @@ export function parseCopySpec(value) {
26
36
  return spec;
27
37
  }
28
38
  export function verifyClaims(candidate, spec) {
29
- const draftSentences = sentences(candidate);
39
+ const draftSentences = sentences(candidate).map((sentence) => ({ ...sentence, normalizedText: normalized(sentence.text) }));
30
40
  const normalizedCandidate = normalized(candidate);
31
41
  const sentenceClaims = {};
32
42
  const failures = [];
33
43
  for (const claim of spec.claims) {
34
- const claimText = normalized(claim.text);
35
- const matching = draftSentences.filter((sentence) => normalized(sentence.text).includes(claimText));
36
- for (const sentence of matching)
37
- (sentenceClaims[sentence.index] ??= []).push(claim.id);
38
- if (!claim.mutable && matching.length === 0) {
39
- failures.push({ id: claim.id, code: 'missing_immutable_claim', message: `Immutable claim ${claim.id} is absent or changed.`, evidence: claim.evidence });
44
+ if (claim.atoms?.length) {
45
+ const atoms = claim.atoms.map(normalized);
46
+ const presentAtoms = new Set();
47
+ for (const sentence of draftSentences) {
48
+ if (atoms.some((atom) => atomMatches(sentence.normalizedText, atom))) {
49
+ for (const atom of atoms)
50
+ if (atomMatches(sentence.normalizedText, atom))
51
+ presentAtoms.add(atom);
52
+ (sentenceClaims[sentence.index] ??= []).push(claim.id);
53
+ }
54
+ }
55
+ const missingAtoms = atoms.filter((atom) => !presentAtoms.has(atom));
56
+ if (!claim.mutable && missingAtoms.length) {
57
+ failures.push({ id: claim.id, code: 'missing_immutable_atom', message: `Immutable claim ${claim.id} is missing atomic facts: ${missingAtoms.join(', ')}.`, evidence: claim.evidence });
58
+ }
59
+ }
60
+ else {
61
+ const claimText = normalized(claim.text);
62
+ const matching = draftSentences.filter((sentence) => sentence.normalizedText.includes(claimText));
63
+ for (const sentence of matching)
64
+ (sentenceClaims[sentence.index] ??= []).push(claim.id);
65
+ if (!claim.mutable && matching.length === 0)
66
+ failures.push({ id: claim.id, code: 'missing_immutable_claim', message: `Immutable claim ${claim.id} is absent or changed.`, evidence: claim.evidence });
40
67
  }
41
68
  }
42
69
  for (const claim of spec.prohibitedClaims ?? []) {
@@ -1,11 +1,18 @@
1
1
  import { paragraphs, sentences, words } from './text.js';
2
2
  const formats = ['general', 'social', 'deck', 'outreach', 'blog', 'audit', 'website'];
3
+ const evidenceStatuses = ['primary', 'attributed', 'internal', 'unverified'];
3
4
  function isText(value, limit) {
4
5
  return typeof value === 'string' && value.trim().length > 0 && value.length <= limit;
5
6
  }
6
7
  function isTerms(value) {
7
8
  return Array.isArray(value) && value.length <= 100 && value.every((term) => isText(term, 200));
8
9
  }
10
+ function isArgumentMap(value) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value))
12
+ return false;
13
+ const map = value;
14
+ return isText(map.observation, 500) && isText(map.mechanism, 500) && isText(map.consequence, 500) && isText(map.readerValue, 500);
15
+ }
9
16
  export function parseWritingBrief(value) {
10
17
  if (!value || typeof value !== 'object' || Array.isArray(value))
11
18
  throw new Error('WritingBrief must be a JSON object.');
@@ -14,17 +21,34 @@ export function parseWritingBrief(value) {
14
21
  || (brief.readerKnowsAuthor !== undefined && typeof brief.readerKnowsAuthor !== 'boolean')
15
22
  || (brief.vocabulary !== undefined && !isTerms(brief.vocabulary))
16
23
  || (brief.prohibitedTerms !== undefined && !isTerms(brief.prohibitedTerms))
17
- || (brief.title !== undefined && !isText(brief.title, 500))) {
24
+ || (brief.title !== undefined && !isText(brief.title, 500))
25
+ || (brief.evidenceStatus !== undefined && !evidenceStatuses.includes(brief.evidenceStatus))
26
+ || (brief.argumentMap !== undefined && !isArgumentMap(brief.argumentMap))) {
18
27
  throw new Error('WritingBrief needs version "1", audience, intent, a known format, and optional bounded context fields.');
19
28
  }
20
29
  return brief;
21
30
  }
31
+ function meaningfulTerms(value) {
32
+ return [...new Set(words(value.toLowerCase()).filter((word) => word.length > 3 && !['that', 'this', 'with', 'from', 'your', 'when', 'what', 'into', 'their'].includes(word)))];
33
+ }
22
34
  function finding(id, severity, sentence, excerpt, reason, suggestion) {
23
35
  return { engine: 'editorial', id, severity, sentence, excerpt, reason, suggestion };
24
36
  }
25
37
  function formatFindings(text, draftSentences, brief) {
26
38
  const findings = [];
27
39
  const first = draftSentences[0];
40
+ if (brief.evidenceStatus === 'unverified' && first) {
41
+ findings.push(finding('editorial.evidence.unverified', 'yellow', first.index, first.text, 'The brief marks the source state as unverified.', 'Keep attribution explicit and verify the source before treating the claim as established.'));
42
+ }
43
+ if (brief.argumentMap && first) {
44
+ const expected = meaningfulTerms(brief.argumentMap.readerValue);
45
+ const draftWords = new Set(words(text.toLowerCase()));
46
+ const matchedTerms = expected.filter((term) => draftWords.has(term)).length;
47
+ const requiredTerms = expected.length === 1 ? 1 : Math.min(2, expected.length);
48
+ if (expected.length && matchedTerms < requiredTerms) {
49
+ findings.push(finding('editorial.argument-map.reader-value-missing', 'yellow', first.index, first.text, 'The draft does not carry a concrete reader-value cue from the brief.', 'Connect the observation to the operational consequence the reader can act on.'));
50
+ }
51
+ }
28
52
  if (brief.format === 'social') {
29
53
  for (const sentence of draftSentences) {
30
54
  if (/^(a pattern|a theme|something) i (keep )?(seeing|noticing)\b/i.test(sentence.text)) {
@@ -46,4 +46,49 @@ test('detects exact repeated openings and endings across a batch without judging
46
46
  test('rejects malformed writing briefs before they activate editorial checks', () => {
47
47
  assert.throws(() => parseWritingBrief({ version: '1', audience: '', intent: 'write', format: 'social' }), /WritingBrief/);
48
48
  assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'unknown' }), /WritingBrief/);
49
+ assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'social', evidenceStatus: 'unknown' }), /WritingBrief/);
50
+ assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'social', argumentMap: { observation: 'A', mechanism: 'B', consequence: 'C' } }), /WritingBrief/);
51
+ });
52
+ test('adds opt-in evidence and reader-value review cues without blocking publication', () => {
53
+ const brief = parseWritingBrief({
54
+ version: '1',
55
+ audience: 'operators',
56
+ intent: 'explain a reliability cost',
57
+ format: 'social',
58
+ evidenceStatus: 'unverified',
59
+ argumentMap: {
60
+ observation: 'A worker failed.',
61
+ mechanism: 'The cache was lost.',
62
+ consequence: 'The request restarts.',
63
+ readerValue: 'Avoid the cold restart cost.',
64
+ },
65
+ });
66
+ const report = analyzeEditorial('A worker failed. The request restarts.', brief);
67
+ assert.equal(report.passed, true);
68
+ assert.deepEqual(report.findings.map((finding) => finding.id), [
69
+ 'editorial.evidence.unverified',
70
+ 'editorial.argument-map.reader-value-missing',
71
+ ]);
72
+ });
73
+ test('does not flag an argument map when the draft carries the reader value', () => {
74
+ const brief = parseWritingBrief({
75
+ version: '1',
76
+ audience: 'operators',
77
+ intent: 'explain a reliability cost',
78
+ format: 'social',
79
+ argumentMap: {
80
+ observation: 'A worker failed.',
81
+ mechanism: 'The cache was lost.',
82
+ consequence: 'The request restarts.',
83
+ readerValue: 'Avoid the cold restart cost.',
84
+ },
85
+ });
86
+ assert.deepEqual(analyzeEditorial('A worker failed. Avoid the cold restart cost.', brief).findings, []);
87
+ });
88
+ test('keeps the reader-value cue when only one generic term overlaps', () => {
89
+ const brief = parseWritingBrief({
90
+ version: '1', audience: 'operators', intent: 'explain a reliability cost', format: 'social',
91
+ argumentMap: { observation: 'A worker failed.', mechanism: 'The cache was lost.', consequence: 'The request restarts.', readerValue: 'Avoid the cold restart cost.' },
92
+ });
93
+ assert.ok(analyzeEditorial('A worker failed. We avoid a delay.', brief).findings.some((item) => item.id === 'editorial.argument-map.reader-value-missing'));
49
94
  });
@@ -0,0 +1,85 @@
1
+ const CHARACTER_POLICIES = new Map([
2
+ [0x180e, { kind: 'zero_width', label: 'Mongolian vowel separator', fix: 'none' }],
3
+ [0x200b, { kind: 'zero_width', label: 'Zero width space', fix: 'none' }],
4
+ [0x200c, { kind: 'zero_width', label: 'Zero width non-joiner', fix: 'none' }],
5
+ [0x200d, { kind: 'zero_width', label: 'Zero width joiner', fix: 'none' }],
6
+ [0x2060, { kind: 'zero_width', label: 'Word joiner', fix: 'none' }],
7
+ [0xfeff, { kind: 'zero_width', label: 'Byte order mark / zero width no-break space', fix: 'none' }],
8
+ ]);
9
+ for (const [codepoint, label] of [
10
+ [0x061c, 'Arabic letter mark'], [0x200e, 'Left-to-right mark'], [0x200f, 'Right-to-left mark'],
11
+ [0x202a, 'Left-to-right embedding'], [0x202b, 'Right-to-left embedding'], [0x202c, 'Pop directional formatting'],
12
+ [0x202d, 'Left-to-right override'], [0x202e, 'Right-to-left override'], [0x2066, 'Left-to-right isolate'],
13
+ [0x2067, 'Right-to-left isolate'], [0x2068, 'First strong isolate'], [0x2069, 'Pop directional isolate'],
14
+ ])
15
+ CHARACTER_POLICIES.set(codepoint, { kind: 'bidi', label, fix: 'none' });
16
+ for (const [codepoint, label] of [
17
+ [0x00a0, 'No-break space'], [0x1680, 'Ogham space mark'], [0x2000, 'En quad'], [0x2001, 'Em quad'],
18
+ [0x2002, 'En space'], [0x2003, 'Em space'], [0x2004, 'Three-per-em space'], [0x2005, 'Four-per-em space'],
19
+ [0x2006, 'Six-per-em space'], [0x2007, 'Figure space'], [0x2008, 'Punctuation space'], [0x2009, 'Thin space'],
20
+ [0x200a, 'Hair space'], [0x202f, 'Narrow no-break space'], [0x205f, 'Medium mathematical space'], [0x3000, 'Ideographic space'],
21
+ ])
22
+ CHARACTER_POLICIES.set(codepoint, { kind: 'unusual_space', label, fix: 'none' });
23
+ function formattedCodepoint(codepoint) {
24
+ return `U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`;
25
+ }
26
+ function classification(codepoint) {
27
+ return CHARACTER_POLICIES.get(codepoint) ?? (codepoint >= 0xe0001 && codepoint <= 0xe007f
28
+ ? { kind: 'tag', label: 'Unicode tag character', fix: 'none' }
29
+ : undefined);
30
+ }
31
+ function policyAt(codepoint, offset) {
32
+ const policy = classification(codepoint);
33
+ return codepoint === 0xfeff && offset === 0 && policy ? { ...policy, fix: 'remove' } : policy;
34
+ }
35
+ function scanHygiene(text, clean) {
36
+ const grouped = new Map();
37
+ const cleanedParts = [];
38
+ const changes = [];
39
+ let unchangedStart = 0;
40
+ for (let offset = 0; offset < text.length;) {
41
+ const codepoint = text.codePointAt(offset);
42
+ const character = String.fromCodePoint(codepoint);
43
+ const found = policyAt(codepoint, offset);
44
+ if (found) {
45
+ const key = `${codepoint}:${found.fix}`;
46
+ const hit = grouped.get(key) ?? { ...found, codepoint, offsets: [] };
47
+ hit.offsets.push(offset);
48
+ grouped.set(key, hit);
49
+ if (clean && found.fix !== 'none') {
50
+ cleanedParts.push(text.slice(unchangedStart, offset));
51
+ changes.push({ offset, codepoint: formattedCodepoint(codepoint), action: 'removed' });
52
+ unchangedStart = offset + character.length;
53
+ }
54
+ }
55
+ offset += character.length;
56
+ }
57
+ const hits = [...grouped.values()].sort((left, right) => left.codepoint - right.codepoint || left.offsets[0] - right.offsets[0]).map((hit) => {
58
+ const { codepoint } = hit;
59
+ const base = { codepoint: formattedCodepoint(codepoint), label: hit.label, kind: hit.kind, count: hit.offsets.length, offsets: hit.offsets };
60
+ return { ...base, fix: hit.fix };
61
+ });
62
+ const report = {
63
+ version: '1',
64
+ length: text.length,
65
+ suspiciousCount: hits.reduce((total, hit) => total + hit.count, 0),
66
+ fixableCount: hits.filter((hit) => hit.fix !== 'none').reduce((total, hit) => total + hit.count, 0),
67
+ hits,
68
+ };
69
+ if (cleanedParts.length)
70
+ cleanedParts.push(text.slice(unchangedStart));
71
+ return { report, cleaned: cleanedParts.length ? cleanedParts.join('') : text, changes };
72
+ }
73
+ export function inspectHygiene(text) {
74
+ return scanHygiene(text, false).report;
75
+ }
76
+ export function cleanHygiene(text) {
77
+ const result = scanHygiene(text, true);
78
+ return { ...result, changed: result.changes.length > 0 };
79
+ }
80
+ export function finalOutputCheck(text) {
81
+ const cleaned = cleanHygiene(text);
82
+ const remaining = inspectHygiene(cleaned.cleaned);
83
+ const base = { version: '1', changed: cleaned.changed, changes: cleaned.changes, input: cleaned.report, remaining };
84
+ return remaining.suspiciousCount === 0 ? { ...base, accepted: true, output: cleaned.cleaned } : { ...base, accepted: false };
85
+ }
@@ -0,0 +1,67 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { cleanHygiene, finalOutputCheck, inspectHygiene } from './hygiene.js';
4
+ test('reports zero-width, bidi, tag, and unusual-space characters with exact offsets', () => {
5
+ const text = `one\u200Btwo\u202Ethree\u{E0001}\u00A0four`;
6
+ const report = inspectHygiene(text);
7
+ assert.equal(report.suspiciousCount, 4);
8
+ assert.equal(report.fixableCount, 0);
9
+ assert.deepEqual(report.hits.map((hit) => [hit.codepoint, hit.kind, hit.count]), [
10
+ ['U+00A0', 'unusual_space', 1],
11
+ ['U+200B', 'zero_width', 1],
12
+ ['U+202E', 'bidi', 1],
13
+ ['U+E0001', 'tag', 1],
14
+ ]);
15
+ assert.deepEqual(report.hits.find((hit) => hit.codepoint === 'U+E0001')?.offsets, [13]);
16
+ });
17
+ test('removes only a leading byte-order mark and preserves language, spacing, bidi, and tag controls', () => {
18
+ const text = `\uFEFFa\u200Bb\uFEFFc\u00A0d\u200Ce\u200Df\u202Eg\u{E0001}`;
19
+ const result = cleanHygiene(text);
20
+ assert.equal(result.cleaned, `a\u200Bb\uFEFFc\u00A0d\u200Ce\u200Df\u202Eg\u{E0001}`);
21
+ assert.equal(result.changed, true);
22
+ assert.deepEqual(result.changes.map((change) => [change.codepoint, change.action]), [['U+FEFF', 'removed']]);
23
+ assert.equal(result.report.suspiciousCount, 8);
24
+ assert.equal(result.report.fixableCount, 1);
25
+ });
26
+ test('leaves clean text byte-for-byte unchanged', () => {
27
+ const text = 'plain text\nwith normal spaces.';
28
+ const result = cleanHygiene(text);
29
+ assert.equal(result.cleaned, text);
30
+ assert.equal(result.changed, false);
31
+ assert.deepEqual(result.changes, []);
32
+ assert.deepEqual(result.report.hits, []);
33
+ });
34
+ test('groups repeated report-only hits and preserves supplementary characters', () => {
35
+ const text = `😀\u200Bword\u200B`;
36
+ const result = cleanHygiene(text);
37
+ assert.equal(result.cleaned, text);
38
+ assert.equal(result.report.suspiciousCount, 2);
39
+ assert.equal(result.report.hits.length, 1);
40
+ assert.equal(result.report.hits[0]?.count, 2);
41
+ assert.deepEqual(result.report.hits[0]?.offsets, [2, 7]);
42
+ });
43
+ test('preserves multilingual spacing and word-boundary controls byte-for-byte', () => {
44
+ const text = `ไทย\u200Bภาษา 10\u00A0kg 日本語\u3000本文 ᠮ\u180Eᠣ a\u2060b`;
45
+ const result = cleanHygiene(text);
46
+ assert.equal(result.cleaned, text);
47
+ assert.equal(result.changed, false);
48
+ assert.equal(result.report.suspiciousCount, 5);
49
+ assert.equal(result.report.fixableCount, 0);
50
+ });
51
+ test('accepts exact clean output and minimally removes only a leading BOM', () => {
52
+ const clean = finalOutputCheck('exact output\n');
53
+ assert.equal(clean.accepted, true);
54
+ assert.equal(clean.accepted && clean.output, 'exact output\n');
55
+ assert.equal(clean.changed, false);
56
+ const bom = finalOutputCheck('\uFEFFexact output');
57
+ assert.equal(bom.accepted, true);
58
+ assert.equal(bom.accepted && bom.output, 'exact output');
59
+ assert.deepEqual(bom.changes, [{ offset: 0, codepoint: 'U+FEFF', action: 'removed' }]);
60
+ });
61
+ test('withholds output when hidden characters remain unresolved', () => {
62
+ const result = finalOutputCheck('Thai\u200Bboundary 👩\u200D💻');
63
+ assert.equal(result.accepted, false);
64
+ assert.equal('output' in result, false);
65
+ assert.equal(result.changed, false);
66
+ assert.deepEqual(result.remaining.hits.map((hit) => hit.codepoint), ['U+200B', 'U+200D']);
67
+ });
package/dist/mcp-tools.js CHANGED
@@ -1,4 +1,4 @@
1
- import { rules, RULESET_VERSION } from './ai-editor.js';
1
+ import { RULESET_VERSION, serializedRules } from './ai-editor.js';
2
2
  import { parseCopySpec } from './copy-spec.js';
3
3
  import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
4
4
  import { composeLearning, recordVerifiedCandidate } from './learning.js';
@@ -6,6 +6,7 @@ import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.j
6
6
  import { parseProfile } from './profile.js';
7
7
  import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
8
8
  import { buildProfile } from './voice-dna.js';
9
+ import { finalOutputCheck, inspectHygiene } from './hygiene.js';
9
10
  function profileFromJson(profileJson) {
10
11
  try {
11
12
  return parseProfile(JSON.parse(profileJson));
@@ -38,6 +39,12 @@ export function buildProfileForMcp(samples, avoid = []) {
38
39
  export function analyzeForMcp(draft, profileJson, writingBriefJson) {
39
40
  return analyze(draft, profileFromJson(profileJson), writingBriefFromJson(writingBriefJson));
40
41
  }
42
+ export function inspectHygieneForMcp(draft) {
43
+ return inspectHygiene(draft);
44
+ }
45
+ export function finalOutputCheckForMcp(text) {
46
+ return finalOutputCheck(text);
47
+ }
41
48
  export function rewritePromptForMcp(draft, profileJson, options = {}, writingBriefJson) {
42
49
  const profile = profileFromJson(profileJson);
43
50
  return { prompt: rewritePrompt(draft, profile, composeLearning(profile, options), writingBriefFromJson(writingBriefJson)) };
@@ -59,7 +66,7 @@ export function verifyCopySpecForMcp(original, candidate, profileJson, copySpecJ
59
66
  return { ...result, learning: result.passed ? recordVerifiedCandidate(profile, result, candidate, options) : 'nothing_to_learn' };
60
67
  }
61
68
  export function patternsForMcp() {
62
- return { version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) };
69
+ return { version: RULESET_VERSION, rules: serializedRules() };
63
70
  }
64
71
  export function analyzeBatchForMcp(drafts) {
65
72
  return analyzeBatch(drafts);