@holdyourvoice/hyv 3.1.0 → 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.
@@ -0,0 +1,94 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { analyzeBatch, analyzeEditorial, parseWritingBrief } from './editorial-packs.js';
4
+ test('uses only the selected format pack and keeps advisory format findings non-blocking', () => {
5
+ const brief = parseWritingBrief({
6
+ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social',
7
+ });
8
+ const report = analyzeEditorial('A pattern I keep seeing in founder posts is vague advice.', brief);
9
+ assert.equal(report.engine, 'editorial');
10
+ assert.equal(report.passed, true);
11
+ assert.deepEqual(report.findings.map((finding) => [finding.id, finding.severity, finding.sentence]), [['editorial.social.generic-opener', 'yellow', 1]]);
12
+ });
13
+ test('enforces explicit local disclosure terms independently from format guidance', () => {
14
+ const brief = parseWritingBrief({
15
+ version: '1', audience: 'operators', intent: 'write an update', format: 'general', prohibitedTerms: ['internal contract value'],
16
+ });
17
+ const report = analyzeEditorial('The internal contract value is $12,000.', brief);
18
+ assert.equal(report.passed, false);
19
+ assert.deepEqual(report.findings.map((finding) => [finding.id, finding.severity]), [['editorial.prohibited-term', 'red']]);
20
+ });
21
+ test('matches prohibited terms as complete normalized words or phrases', () => {
22
+ const brief = parseWritingBrief({ version: '1', audience: 'operators', intent: 'write an update', format: 'general', prohibitedTerms: ['art'] });
23
+ assert.equal(analyzeEditorial('We start with the delivery date.', brief).passed, true);
24
+ assert.equal(analyzeEditorial('The art needs a signed owner.', brief).passed, false);
25
+ });
26
+ test('covers the remaining contextual format checks without applying packs outside their format', () => {
27
+ const social = parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'social' });
28
+ assert.deepEqual(analyzeEditorial('One point.\n\nSecond point.\n\nThird point.', social).findings.map((item) => item.id), ['editorial.social.one-line-run']);
29
+ const deck = parseWritingBrief({ version: '1', audience: 'buyers', intent: 'present', format: 'deck', title: '3 ways to improve delivery' });
30
+ assert.deepEqual(analyzeEditorial('We make delivery simpler.', deck).findings.map((item) => item.id), ['editorial.deck.first-slide-we', 'editorial.deck.numeric-title']);
31
+ const outreach = parseWritingBrief({ version: '1', audience: 'operators', intent: 'start a conversation', format: 'outreach' });
32
+ assert.deepEqual(analyzeEditorial('Does that sound interesting?', outreach).findings.map((item) => item.id), ['editorial.outreach.generic-question-cta']);
33
+ assert.deepEqual(analyzeEditorial('We make delivery simpler.', outreach).findings, []);
34
+ });
35
+ test('detects exact repeated openings and endings across a batch without judging deliberate variation', () => {
36
+ const report = analyzeBatch([
37
+ 'The launch needs a clear owner. Start with the dependency map.',
38
+ 'The launch needs a clear owner. Start with the dependency map.',
39
+ 'The invoice needs a clear owner. Check the due date.',
40
+ ]);
41
+ assert.deepEqual(report.findings.map((finding) => [finding.id, finding.draftIndexes]), [
42
+ ['batch.repeated-opening', [1, 2]],
43
+ ['batch.repeated-ending', [1, 2]],
44
+ ]);
45
+ });
46
+ test('rejects malformed writing briefs before they activate editorial checks', () => {
47
+ assert.throws(() => parseWritingBrief({ version: '1', audience: '', intent: 'write', format: 'social' }), /WritingBrief/);
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'));
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,8 +1,12 @@
1
- import { rules, RULESET_VERSION } from './ai-editor.js';
1
+ import { RULESET_VERSION, serializedRules } from './ai-editor.js';
2
+ import { parseCopySpec } from './copy-spec.js';
3
+ import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
2
4
  import { composeLearning, recordVerifiedCandidate } from './learning.js';
3
- import { analyze, rewritePrompt, verify } from './pipeline.js';
5
+ import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
4
6
  import { parseProfile } from './profile.js';
7
+ import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
5
8
  import { buildProfile } from './voice-dna.js';
9
+ import { finalOutputCheck, inspectHygiene } from './hygiene.js';
6
10
  function profileFromJson(profileJson) {
7
11
  try {
8
12
  return parseProfile(JSON.parse(profileJson));
@@ -11,21 +15,59 @@ function profileFromJson(profileJson) {
11
15
  throw new Error(error instanceof Error ? error.message : 'Profile is not valid JSON.');
12
16
  }
13
17
  }
18
+ function copySpecFromJson(copySpecJson) {
19
+ try {
20
+ return parseCopySpec(JSON.parse(copySpecJson));
21
+ }
22
+ catch (error) {
23
+ throw new Error(error instanceof Error ? error.message : 'CopySpec is not valid JSON.');
24
+ }
25
+ }
26
+ function writingBriefFromJson(writingBriefJson) {
27
+ if (!writingBriefJson)
28
+ return undefined;
29
+ try {
30
+ return parseWritingBrief(JSON.parse(writingBriefJson));
31
+ }
32
+ catch (error) {
33
+ throw new Error(error instanceof Error ? error.message : 'WritingBrief is not valid JSON.');
34
+ }
35
+ }
14
36
  export function buildProfileForMcp(samples, avoid = []) {
15
37
  return buildProfile(samples, avoid);
16
38
  }
17
- export function analyzeForMcp(draft, profileJson) {
18
- return analyze(draft, profileFromJson(profileJson));
39
+ export function analyzeForMcp(draft, profileJson, writingBriefJson) {
40
+ return analyze(draft, profileFromJson(profileJson), writingBriefFromJson(writingBriefJson));
41
+ }
42
+ export function inspectHygieneForMcp(draft) {
43
+ return inspectHygiene(draft);
19
44
  }
20
- export function rewritePromptForMcp(draft, profileJson, options = {}) {
45
+ export function finalOutputCheckForMcp(text) {
46
+ return finalOutputCheck(text);
47
+ }
48
+ export function rewritePromptForMcp(draft, profileJson, options = {}, writingBriefJson) {
21
49
  const profile = profileFromJson(profileJson);
22
- return { prompt: rewritePrompt(draft, profile, composeLearning(profile, options)) };
50
+ return { prompt: rewritePrompt(draft, profile, composeLearning(profile, options), writingBriefFromJson(writingBriefJson)) };
51
+ }
52
+ export function prepareRewriteForMcp(draft, profileJson, copySpecJson, writingBriefJson) {
53
+ return prepareRewriteTask(draft, profileFromJson(profileJson), copySpecJson ? copySpecFromJson(copySpecJson) : undefined, writingBriefFromJson(writingBriefJson));
23
54
  }
24
- export function verifyForMcp(original, candidate, profileJson, options = {}) {
55
+ export function applyRewriteForMcp(taskJson, responseJson, profileJson) {
56
+ return evaluateRewriteResponse(parseRewriteTask(JSON.parse(taskJson)), responseJson, profileFromJson(profileJson));
57
+ }
58
+ export function verifyForMcp(original, candidate, profileJson, options = {}, writingBriefJson) {
25
59
  const profile = profileFromJson(profileJson);
26
- const result = verify(original, candidate, profile);
60
+ const result = verify(original, candidate, profile, writingBriefFromJson(writingBriefJson));
27
61
  return { ...result, learning: recordVerifiedCandidate(profile, result, candidate, options) };
28
62
  }
63
+ export function verifyCopySpecForMcp(original, candidate, profileJson, copySpecJson, options = {}, writingBriefJson) {
64
+ const profile = profileFromJson(profileJson);
65
+ const result = verifyWithCopySpec(original, candidate, profile, copySpecFromJson(copySpecJson), writingBriefFromJson(writingBriefJson));
66
+ return { ...result, learning: result.passed ? recordVerifiedCandidate(profile, result, candidate, options) : 'nothing_to_learn' };
67
+ }
29
68
  export function patternsForMcp() {
30
- return { version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) };
69
+ return { version: RULESET_VERSION, rules: serializedRules() };
70
+ }
71
+ export function analyzeBatchForMcp(drafts) {
72
+ return analyzeBatch(drafts);
31
73
  }
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
- import { analyzeForMcp, buildProfileForMcp, patternsForMcp, rewritePromptForMcp, verifyForMcp } from './mcp-tools.js';
6
+ import { analyzeBatchForMcp, analyzeForMcp, applyRewriteForMcp, buildProfileForMcp, finalOutputCheckForMcp, inspectHygieneForMcp, patternsForMcp, prepareRewriteForMcp, rewritePromptForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
7
7
  const profile = buildProfileForMcp(['I write clearly. I keep the useful detail.', 'I make the call. Then I explain the trade-off.'], ['leverage']);
8
8
  const profileJson = JSON.stringify(profile);
9
9
  test('builds a portable profile for MCP without files', () => {
@@ -11,9 +11,33 @@ test('builds a portable profile for MCP without files', () => {
11
11
  assert.equal(profile.sampleCount, 2);
12
12
  });
13
13
  test('keeps the dual-engine analysis shape through MCP tools', () => {
14
- const result = analyzeForMcp('I leverage a clear plan.', profileJson);
14
+ const result = analyzeForMcp('I leverage a clear plan.\u200B', profileJson);
15
15
  assert.equal(result.voiceDna.engine, 'voice_dna');
16
16
  assert.equal(result.aiEditor.engine, 'ai_editor');
17
+ assert.equal(result.hygiene.suspiciousCount, 1);
18
+ });
19
+ test('inspects Unicode hygiene through MCP without a voice profile', () => {
20
+ const result = inspectHygieneForMcp('one\u200Btwo\u00A0three');
21
+ assert.equal(result.suspiciousCount, 2);
22
+ assert.equal(result.fixableCount, 0);
23
+ });
24
+ test('gates exact final output through MCP without a voice profile', () => {
25
+ const accepted = finalOutputCheckForMcp('exact output');
26
+ assert.equal(accepted.accepted && accepted.output, 'exact output');
27
+ const rejected = finalOutputCheckForMcp('hidden\u200Boutput');
28
+ assert.equal(rejected.accepted, false);
29
+ assert.equal('output' in rejected, false);
30
+ });
31
+ test('accepts optional WritingBrief context and exposes batch findings through MCP helpers', () => {
32
+ const brief = JSON.stringify({
33
+ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social', evidenceStatus: 'unverified',
34
+ argumentMap: { observation: 'Founders repeat vague advice.', mechanism: 'The advice skips the work.', consequence: 'Readers cannot act.', readerValue: 'Avoid a vague post.' },
35
+ });
36
+ const analysis = analyzeForMcp('A pattern I keep seeing in founder posts is vague advice.', profileJson, brief);
37
+ assert.ok(analysis.editorial?.findings.some((item) => item.id === 'editorial.social.generic-opener'));
38
+ assert.ok(analysis.editorial?.findings.some((item) => item.id === 'editorial.evidence.unverified'));
39
+ const batch = analyzeBatchForMcp(['The launch needs a clear owner.', 'The launch needs a clear owner.']);
40
+ assert.equal(batch.findings.length, 2);
17
41
  });
18
42
  test('creates and verifies an editing loop through MCP tools', () => {
19
43
  const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-'));
@@ -30,5 +54,33 @@ test('creates and verifies an editing loop through MCP tools', () => {
30
54
  }
31
55
  });
32
56
  test('exposes the executable pattern IDs through MCP tools', () => {
33
- assert.ok(patternsForMcp().rules.some((rule) => rule.id === 'ai.leverage'));
57
+ const catalog = patternsForMcp();
58
+ assert.ok(catalog.rules.some((rule) => rule.id === 'ai.leverage'));
59
+ });
60
+ test('fails closed on changed CopySpec claims through MCP tools', () => {
61
+ const result = verifyCopySpecForMcp('The launch is on 14 August.', 'The launch is next month.', profileJson, JSON.stringify({
62
+ version: '1', audience: 'operators', intent: 'explain', channel: 'email',
63
+ claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar.' }],
64
+ }));
65
+ assert.equal(result.passed, false);
66
+ assert.equal(result.claims.failures[0]?.code, 'missing_immutable_claim');
67
+ assert.equal(result.learning, 'nothing_to_learn');
68
+ });
69
+ test('allows declared CopySpec atoms to survive a split MCP rewrite', () => {
70
+ const spec = JSON.stringify({
71
+ version: '1', audience: 'operators', intent: 'explain', channel: 'email',
72
+ claims: [{ id: 'model-size', text: 'Kimi K2.6 has 600 GB of INT4 weights.', atoms: ['Kimi K2.6 uses INT4 weights', 'payload is 600 GB'], evidence: 'Technical report.' }],
73
+ });
74
+ const preserved = verifyCopySpecForMcp('Kimi K2.6 has 600 GB of INT4 weights.', 'Kimi K2.6 uses INT4 weights. The payload is 600 GB.', profileJson, spec);
75
+ assert.equal(preserved.claims.passed, true);
76
+ const missing = verifyCopySpecForMcp('Kimi K2.6 has 600 GB of INT4 weights.', 'Kimi K2.6 uses INT4 weights.', profileJson, spec);
77
+ assert.deepEqual(missing.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
78
+ });
79
+ test('prepares and applies the rewrite task through MCP helpers', () => {
80
+ const task = prepareRewriteForMcp('I leverage the answer with useful detail and clear mechanism.', profileJson);
81
+ const result = applyRewriteForMcp(JSON.stringify(task), JSON.stringify({
82
+ version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer with useful detail and clear mechanism.' }],
83
+ }), profileJson);
84
+ assert.equal(result.status, 'needs_semantic_review');
85
+ assert.equal(result.candidate, 'I use the answer with useful detail and clear mechanism.');
34
86
  });
package/dist/mcp.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
- import { analyzeForMcp, buildProfileForMcp, patternsForMcp, rewritePromptForMcp, verifyForMcp } from './mcp-tools.js';
4
+ import { analyzeBatchForMcp, analyzeForMcp, applyRewriteForMcp, buildProfileForMcp, finalOutputCheckForMcp, inspectHygieneForMcp, patternsForMcp, prepareRewriteForMcp, rewritePromptForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
5
+ import { HYV_VERSION } from './version.js';
5
6
  const writing = z.string().min(1).max(100_000);
7
+ const hygieneText = z.string().max(100_000);
6
8
  const profileJson = z.string().min(1).max(50_000);
9
+ const copySpecJson = z.string().min(1).max(250_000);
10
+ const writingBriefJson = z.string().min(1).max(50_000);
7
11
  const samples = z.array(writing).min(2).max(20);
8
12
  const avoid = z.array(z.string().min(1).max(200)).max(50).optional();
9
13
  function json(value) {
@@ -12,7 +16,7 @@ function json(value) {
12
16
  function failure(error) {
13
17
  return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
14
18
  }
15
- const server = new McpServer({ name: 'hold-your-voice', version: '3.1.0' });
19
+ const server = new McpServer({ name: 'hold-your-voice', version: HYV_VERSION });
16
20
  server.registerTool('hyv_build_profile', {
17
21
  description: 'Build a portable VoiceDNA profile from at least two writing samples. The samples stay in memory and are not saved.',
18
22
  inputSchema: { samples, avoid },
@@ -26,24 +30,58 @@ server.registerTool('hyv_build_profile', {
26
30
  }
27
31
  });
28
32
  server.registerTool('hyv_analyze', {
29
- description: 'Run the separate VoiceDNA and AI Editor checks against a draft using a portable profile JSON string.',
30
- inputSchema: { draft: writing, profile_json: profileJson },
33
+ description: 'Run separate VoiceDNA and AI Editor checks plus a non-scoring Unicode hygiene inspection against a draft using a portable profile JSON string.',
34
+ inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
31
35
  annotations: { readOnlyHint: true },
32
- }, async ({ draft, profile_json }) => {
36
+ }, async ({ draft, profile_json, writing_brief_json }) => {
33
37
  try {
34
- return json(analyzeForMcp(draft, profile_json));
38
+ return json(analyzeForMcp(draft, profile_json, writing_brief_json));
35
39
  }
36
40
  catch (error) {
37
41
  return failure(error);
38
42
  }
39
43
  });
44
+ server.registerTool('hyv_hygiene', {
45
+ description: 'Inspect text for zero-width characters, bidirectional controls, Unicode tag characters, and unusual spaces without changing it or requiring a voice profile.',
46
+ inputSchema: { draft: hygieneText },
47
+ annotations: { readOnlyHint: true },
48
+ }, async ({ draft }) => json(inspectHygieneForMcp(draft)));
49
+ server.registerTool('hyv_final_check', {
50
+ description: 'Gate exact user-facing text from any model, tool, or interface. Returns output only when clean or after removing a leading byte-order mark; unresolved hidden characters withhold output.',
51
+ inputSchema: { text: hygieneText },
52
+ annotations: { readOnlyHint: true },
53
+ }, async ({ text }) => json(finalOutputCheckForMcp(text)));
40
54
  server.registerTool('hyv_rewrite_prompt', {
41
55
  description: 'Create a constrained editing brief. It does not rewrite the draft or call a model.',
42
- inputSchema: { draft: writing, profile_json: profileJson },
56
+ inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
57
+ annotations: { readOnlyHint: true },
58
+ }, async ({ draft, profile_json, writing_brief_json }) => {
59
+ try {
60
+ return json(rewritePromptForMcp(draft, profile_json, {}, writing_brief_json));
61
+ }
62
+ catch (error) {
63
+ return failure(error);
64
+ }
65
+ });
66
+ server.registerTool('hyv_prepare_rewrite', {
67
+ description: 'Prepare a local, versioned rewrite task. The caller may forward it to a provider; doing so shares the draft and must be an explicit choice.',
68
+ inputSchema: { draft: writing, profile_json: profileJson, copy_spec_json: copySpecJson.optional(), writing_brief_json: writingBriefJson.optional() },
69
+ annotations: { readOnlyHint: true },
70
+ }, async ({ draft, profile_json, copy_spec_json, writing_brief_json }) => {
71
+ try {
72
+ return json(prepareRewriteForMcp(draft, profile_json, copy_spec_json, writing_brief_json));
73
+ }
74
+ catch (error) {
75
+ return failure(error);
76
+ }
77
+ });
78
+ server.registerTool('hyv_apply_rewrite', {
79
+ description: 'Validate and apply a model response to a prepared task, then run the local gates. It never calls a provider or stores source or candidate text.',
80
+ inputSchema: { task_json: z.string().min(1).max(250_000), response_json: z.string().min(1).max(100_000), profile_json: profileJson },
43
81
  annotations: { readOnlyHint: true },
44
- }, async ({ draft, profile_json }) => {
82
+ }, async ({ task_json, response_json, profile_json }) => {
45
83
  try {
46
- return json(rewritePromptForMcp(draft, profile_json));
84
+ return json(applyRewriteForMcp(task_json, response_json, profile_json));
47
85
  }
48
86
  catch (error) {
49
87
  return failure(error);
@@ -51,11 +89,35 @@ server.registerTool('hyv_rewrite_prompt', {
51
89
  });
52
90
  server.registerTool('hyv_verify', {
53
91
  description: 'Verify a revised candidate against an original draft and portable profile. On a successful check, it stores only resolved finding IDs in local profile-scoped learning state; it never retains either text.',
54
- inputSchema: { original: writing, candidate: writing, profile_json: profileJson },
92
+ inputSchema: { original: writing, candidate: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
93
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
94
+ }, async ({ original, candidate, profile_json, writing_brief_json }) => {
95
+ try {
96
+ return json(verifyForMcp(original, candidate, profile_json, {}, writing_brief_json));
97
+ }
98
+ catch (error) {
99
+ return failure(error);
100
+ }
101
+ });
102
+ server.registerTool('hyv_verify_copy_spec', {
103
+ description: 'Verify a candidate against the existing voice gates and a local CopySpec. Immutable claims remain verbatim unless atoms are supplied; then each declared atom must remain. Prohibited claims fail closed.',
104
+ inputSchema: { original: writing, candidate: writing, profile_json: profileJson, copy_spec_json: copySpecJson, writing_brief_json: writingBriefJson.optional() },
55
105
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
56
- }, async ({ original, candidate, profile_json }) => {
106
+ }, async ({ original, candidate, profile_json, copy_spec_json, writing_brief_json }) => {
107
+ try {
108
+ return json(verifyCopySpecForMcp(original, candidate, profile_json, copy_spec_json, {}, writing_brief_json));
109
+ }
110
+ catch (error) {
111
+ return failure(error);
112
+ }
113
+ });
114
+ server.registerTool('hyv_batch_analyze', {
115
+ description: 'Inspect two to one hundred drafts for repeated opening and closing sentences. It returns advisory batch findings and does not store the drafts.',
116
+ inputSchema: { drafts: z.array(writing).min(2).max(100) },
117
+ annotations: { readOnlyHint: true },
118
+ }, async ({ drafts }) => {
57
119
  try {
58
- return json(verifyForMcp(original, candidate, profile_json));
120
+ return json(analyzeBatchForMcp(drafts));
59
121
  }
60
122
  catch (error) {
61
123
  return failure(error);
package/dist/mcp.test.js CHANGED
@@ -22,9 +22,43 @@ test('serves local Claude tools over stdio', async () => {
22
22
  assert.equal(code, 0);
23
23
  const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
24
24
  const tools = responses.find((response) => response.id === 2)?.result?.tools;
25
- assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_rewrite_prompt', 'hyv_verify', 'hyv_patterns']);
26
- assert.ok(tools?.filter((tool) => tool.name !== 'hyv_verify').every((tool) => tool.annotations?.readOnlyHint));
25
+ assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_hygiene', 'hyv_final_check', 'hyv_rewrite_prompt', 'hyv_prepare_rewrite', 'hyv_apply_rewrite', 'hyv_verify', 'hyv_verify_copy_spec', 'hyv_batch_analyze', 'hyv_patterns']);
26
+ assert.ok(tools?.filter((tool) => tool.name !== 'hyv_verify' && tool.name !== 'hyv_verify_copy_spec').every((tool) => tool.annotations?.readOnlyHint));
27
27
  assert.equal(tools?.find((tool) => tool.name === 'hyv_verify')?.annotations?.readOnlyHint, false);
28
+ assert.equal(tools?.find((tool) => tool.name === 'hyv_verify_copy_spec')?.annotations?.readOnlyHint, false);
29
+ });
30
+ test('accepts empty text for profile-free hygiene inspection', async () => {
31
+ const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
32
+ let stdout = '';
33
+ server.stdout.on('data', (chunk) => { stdout += chunk; });
34
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } })}\n`);
35
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
36
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'hyv_hygiene', arguments: { draft: '' } } })}\n`);
37
+ server.stdin.end();
38
+ const [code] = await once(server, 'close');
39
+ assert.equal(code, 0);
40
+ const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
41
+ const report = JSON.parse(responses.find((response) => response.id === 2)?.result?.content?.[0]?.text ?? '{}');
42
+ assert.equal(report.suspiciousCount, 0);
43
+ });
44
+ test('gates exact final output through the registered profile-free MCP tool', async () => {
45
+ const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
46
+ let stdout = '';
47
+ server.stdout.on('data', (chunk) => { stdout += chunk; });
48
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } })}\n`);
49
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
50
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'hyv_final_check', arguments: { text: 'Exact output.' } } })}\n`);
51
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hyv_final_check', arguments: { text: 'Hidden\u200B output.' } } })}\n`);
52
+ server.stdin.end();
53
+ const [code] = await once(server, 'close');
54
+ assert.equal(code, 0);
55
+ const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
56
+ const accepted = JSON.parse(responses.find((response) => response.id === 2)?.result?.content?.[0]?.text ?? '{}');
57
+ const rejected = JSON.parse(responses.find((response) => response.id === 3)?.result?.content?.[0]?.text ?? '{}');
58
+ assert.equal(accepted.accepted, true);
59
+ assert.equal(accepted.output, 'Exact output.');
60
+ assert.equal(rejected.accepted, false);
61
+ assert.equal('output' in rejected, false);
28
62
  });
29
63
  test('uses default local learning through the registered MCP tools', async () => {
30
64
  const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-server-'));
@@ -42,13 +76,26 @@ test('uses default local learning through the registered MCP tools', async () =>
42
76
  server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
43
77
  server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'hyv_verify', arguments: { original: 'I leverage the answer with useful detail and clear mechanism.', candidate: 'I use the answer with useful detail and clear mechanism.', profile_json: JSON.stringify(profile) } } })}\n`);
44
78
  server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hyv_rewrite_prompt', arguments: { draft: 'I use the answer with useful detail and clear mechanism.', profile_json: JSON.stringify(profile) } } })}\n`);
79
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hyv_analyze', arguments: { draft: 'A pattern I keep seeing in founder posts is vague advice.\u200B', profile_json: JSON.stringify(profile), writing_brief_json: JSON.stringify({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' }) } } })}\n`);
80
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'hyv_batch_analyze', arguments: { drafts: ['The launch needs a clear owner.', 'The launch needs a clear owner.'] } } })}\n`);
81
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'hyv_analyze', arguments: { draft: 'Plain draft.', profile_json: JSON.stringify(profile), writing_brief_json: '{' } } })}\n`);
82
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'hyv_hygiene', arguments: { draft: 'Plain\u200B draft.' } } })}\n`);
45
83
  server.stdin.end();
46
84
  const [code] = await once(server, 'close');
47
85
  assert.equal(stderr, '');
48
86
  assert.equal(code, 0);
49
87
  const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
50
88
  const prompt = JSON.parse(responses.find((response) => response.id === 3)?.result?.content?.[0]?.text ?? '{}').prompt;
89
+ const contextual = JSON.parse(responses.find((response) => response.id === 4)?.result?.content?.[0]?.text ?? '{}');
90
+ const batch = JSON.parse(responses.find((response) => response.id === 5)?.result?.content?.[0]?.text ?? '{}');
91
+ const malformed = responses.find((response) => response.id === 6)?.result;
92
+ const hygiene = JSON.parse(responses.find((response) => response.id === 7)?.result?.content?.[0]?.text ?? '{}');
51
93
  assert.match(prompt, /Learned local preferences/);
94
+ assert.equal(contextual.editorial.findings[0].id, 'editorial.social.generic-opener');
95
+ assert.equal(contextual.hygiene.suspiciousCount, 1);
96
+ assert.deepEqual(batch.findings.map((finding) => finding.id), ['batch.repeated-opening', 'batch.repeated-ending']);
97
+ assert.equal(malformed?.isError, true);
98
+ assert.equal(hygiene.suspiciousCount, 1);
52
99
  const stored = readFileSync(join(root, 'learning', `${profileFingerprint(profile)}.jsonl`), 'utf8');
53
100
  assert.match(stored, /ai\.leverage/);
54
101
  assert.doesNotMatch(stored, /I leverage the answer/);