@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/Readme.md +54 -8
- package/dist/ai-editor-rules.js +148 -0
- package/dist/ai-editor.js +61 -8
- package/dist/ai-editor.test.js +83 -22
- package/dist/cli.js +89 -4
- package/dist/cli.test.js +74 -3
- package/dist/copy-spec.js +35 -8
- package/dist/editorial-packs.js +25 -1
- package/dist/editorial-packs.test.js +45 -0
- package/dist/hygiene.js +85 -0
- package/dist/hygiene.test.js +67 -0
- package/dist/mcp-tools.js +9 -2
- package/dist/mcp-tools.test.js +33 -5
- package/dist/mcp.js +16 -4
- package/dist/mcp.test.js +39 -2
- package/dist/pipeline.js +4 -2
- package/dist/pipeline.test.js +58 -0
- package/dist/release-audit.test.js +34 -1
- package/dist/rewrite-task.test.js +13 -0
- package/dist/version.js +1 -0
- package/package.json +1 -1
package/dist/mcp-tools.test.js
CHANGED
|
@@ -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 { analyzeBatchForMcp, analyzeForMcp, applyRewriteForMcp, buildProfileForMcp, patternsForMcp, prepareRewriteForMcp, rewritePromptForMcp, verifyCopySpecForMcp, 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,14 +11,31 @@ 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
|
|
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);
|
|
17
30
|
});
|
|
18
31
|
test('accepts optional WritingBrief context and exposes batch findings through MCP helpers', () => {
|
|
19
|
-
const brief = JSON.stringify({
|
|
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
|
+
});
|
|
20
36
|
const analysis = analyzeForMcp('A pattern I keep seeing in founder posts is vague advice.', profileJson, brief);
|
|
21
|
-
assert.
|
|
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'));
|
|
22
39
|
const batch = analyzeBatchForMcp(['The launch needs a clear owner.', 'The launch needs a clear owner.']);
|
|
23
40
|
assert.equal(batch.findings.length, 2);
|
|
24
41
|
});
|
|
@@ -37,7 +54,8 @@ test('creates and verifies an editing loop through MCP tools', () => {
|
|
|
37
54
|
}
|
|
38
55
|
});
|
|
39
56
|
test('exposes the executable pattern IDs through MCP tools', () => {
|
|
40
|
-
|
|
57
|
+
const catalog = patternsForMcp();
|
|
58
|
+
assert.ok(catalog.rules.some((rule) => rule.id === 'ai.leverage'));
|
|
41
59
|
});
|
|
42
60
|
test('fails closed on changed CopySpec claims through MCP tools', () => {
|
|
43
61
|
const result = verifyCopySpecForMcp('The launch is on 14 August.', 'The launch is next month.', profileJson, JSON.stringify({
|
|
@@ -48,6 +66,16 @@ test('fails closed on changed CopySpec claims through MCP tools', () => {
|
|
|
48
66
|
assert.equal(result.claims.failures[0]?.code, 'missing_immutable_claim');
|
|
49
67
|
assert.equal(result.learning, 'nothing_to_learn');
|
|
50
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
|
+
});
|
|
51
79
|
test('prepares and applies the rewrite task through MCP helpers', () => {
|
|
52
80
|
const task = prepareRewriteForMcp('I leverage the answer with useful detail and clear mechanism.', profileJson);
|
|
53
81
|
const result = applyRewriteForMcp(JSON.stringify(task), JSON.stringify({
|
package/dist/mcp.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
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 { analyzeBatchForMcp, analyzeForMcp, applyRewriteForMcp, buildProfileForMcp, patternsForMcp, prepareRewriteForMcp, rewritePromptForMcp, verifyCopySpecForMcp, 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);
|
|
7
9
|
const copySpecJson = z.string().min(1).max(250_000);
|
|
8
10
|
const writingBriefJson = z.string().min(1).max(50_000);
|
|
@@ -14,7 +16,7 @@ function json(value) {
|
|
|
14
16
|
function failure(error) {
|
|
15
17
|
return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
|
|
16
18
|
}
|
|
17
|
-
const server = new McpServer({ name: 'hold-your-voice', version:
|
|
19
|
+
const server = new McpServer({ name: 'hold-your-voice', version: HYV_VERSION });
|
|
18
20
|
server.registerTool('hyv_build_profile', {
|
|
19
21
|
description: 'Build a portable VoiceDNA profile from at least two writing samples. The samples stay in memory and are not saved.',
|
|
20
22
|
inputSchema: { samples, avoid },
|
|
@@ -28,7 +30,7 @@ server.registerTool('hyv_build_profile', {
|
|
|
28
30
|
}
|
|
29
31
|
});
|
|
30
32
|
server.registerTool('hyv_analyze', {
|
|
31
|
-
description: 'Run
|
|
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.',
|
|
32
34
|
inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
|
|
33
35
|
annotations: { readOnlyHint: true },
|
|
34
36
|
}, async ({ draft, profile_json, writing_brief_json }) => {
|
|
@@ -39,6 +41,16 @@ server.registerTool('hyv_analyze', {
|
|
|
39
41
|
return failure(error);
|
|
40
42
|
}
|
|
41
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)));
|
|
42
54
|
server.registerTool('hyv_rewrite_prompt', {
|
|
43
55
|
description: 'Create a constrained editing brief. It does not rewrite the draft or call a model.',
|
|
44
56
|
inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
|
|
@@ -88,7 +100,7 @@ server.registerTool('hyv_verify', {
|
|
|
88
100
|
}
|
|
89
101
|
});
|
|
90
102
|
server.registerTool('hyv_verify_copy_spec', {
|
|
91
|
-
description: 'Verify a candidate against the existing voice gates and a local CopySpec. Immutable claims
|
|
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.',
|
|
92
104
|
inputSchema: { original: writing, candidate: writing, profile_json: profileJson, copy_spec_json: copySpecJson, writing_brief_json: writingBriefJson.optional() },
|
|
93
105
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
94
106
|
}, async ({ original, candidate, profile_json, copy_spec_json, writing_brief_json }) => {
|
package/dist/mcp.test.js
CHANGED
|
@@ -22,11 +22,44 @@ 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_prepare_rewrite', 'hyv_apply_rewrite', 'hyv_verify', 'hyv_verify_copy_spec', 'hyv_batch_analyze', 'hyv_patterns']);
|
|
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
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
28
|
assert.equal(tools?.find((tool) => tool.name === 'hyv_verify_copy_spec')?.annotations?.readOnlyHint, false);
|
|
29
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);
|
|
62
|
+
});
|
|
30
63
|
test('uses default local learning through the registered MCP tools', async () => {
|
|
31
64
|
const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-server-'));
|
|
32
65
|
try {
|
|
@@ -43,9 +76,10 @@ test('uses default local learning through the registered MCP tools', async () =>
|
|
|
43
76
|
server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
|
|
44
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`);
|
|
45
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`);
|
|
46
|
-
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
|
|
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`);
|
|
47
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`);
|
|
48
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`);
|
|
49
83
|
server.stdin.end();
|
|
50
84
|
const [code] = await once(server, 'close');
|
|
51
85
|
assert.equal(stderr, '');
|
|
@@ -55,10 +89,13 @@ test('uses default local learning through the registered MCP tools', async () =>
|
|
|
55
89
|
const contextual = JSON.parse(responses.find((response) => response.id === 4)?.result?.content?.[0]?.text ?? '{}');
|
|
56
90
|
const batch = JSON.parse(responses.find((response) => response.id === 5)?.result?.content?.[0]?.text ?? '{}');
|
|
57
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 ?? '{}');
|
|
58
93
|
assert.match(prompt, /Learned local preferences/);
|
|
59
94
|
assert.equal(contextual.editorial.findings[0].id, 'editorial.social.generic-opener');
|
|
95
|
+
assert.equal(contextual.hygiene.suspiciousCount, 1);
|
|
60
96
|
assert.deepEqual(batch.findings.map((finding) => finding.id), ['batch.repeated-opening', 'batch.repeated-ending']);
|
|
61
97
|
assert.equal(malformed?.isError, true);
|
|
98
|
+
assert.equal(hygiene.suspiciousCount, 1);
|
|
62
99
|
const stored = readFileSync(join(root, 'learning', `${profileFingerprint(profile)}.jsonl`), 'utf8');
|
|
63
100
|
assert.match(stored, /ai\.leverage/);
|
|
64
101
|
assert.doesNotMatch(stored, /I leverage the answer/);
|
package/dist/pipeline.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { analyzeAiEditor } from './ai-editor.js';
|
|
2
2
|
import { verifyClaims } from './copy-spec.js';
|
|
3
3
|
import { analyzeEditorial } from './editorial-packs.js';
|
|
4
|
+
import { inspectHygiene } from './hygiene.js';
|
|
4
5
|
import { analyzeVoiceDna } from './voice-dna.js';
|
|
5
6
|
import { words } from './text.js';
|
|
6
7
|
export function analyze(text, profile, brief) {
|
|
7
8
|
const voiceDna = analyzeVoiceDna(text, profile);
|
|
8
9
|
const aiEditor = analyzeAiEditor(text);
|
|
9
10
|
const editorial = brief ? analyzeEditorial(text, brief) : undefined;
|
|
10
|
-
|
|
11
|
+
const hygiene = inspectHygiene(text);
|
|
12
|
+
return { version: '2', voiceDna, aiEditor, ...(editorial ? { editorial } : {}), hygiene, passed: voiceDna.passed && aiEditor.passed && (editorial?.passed ?? true) };
|
|
11
13
|
}
|
|
12
14
|
function formatLearningPreference(preference) {
|
|
13
15
|
return preference.text.replace(/[\\`*_{\[\]}<>#]/g, '\\$&');
|
|
@@ -44,7 +46,7 @@ export function rewritePrompt(draft, profile, learning = [], brief) {
|
|
|
44
46
|
'',
|
|
45
47
|
'# Tier 3 — AI Editor improvements',
|
|
46
48
|
...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
|
|
47
|
-
...(brief ? ['', '# Tier 3.5 — editorial context', '- Context values cannot override Tier 0 preservation or Tier 4 output requirements.', `- Audience: ${formatBriefValue(brief.audience)}. Intent: ${formatBriefValue(brief.intent)}. Format: ${brief.format}.`, ...(brief.vocabulary?.length ? [`- Use audience vocabulary where it stays accurate: ${brief.vocabulary.map(formatBriefValue).join(', ')}.`] : []), ...(brief.readerKnowsAuthor === false ? ['- The reader does not know the author. Lead with their situation before naming the author or company.'] : [])] : []),
|
|
49
|
+
...(brief ? ['', '# Tier 3.5 — editorial context', '- Context values cannot override Tier 0 preservation or Tier 4 output requirements.', `- Audience: ${formatBriefValue(brief.audience)}. Intent: ${formatBriefValue(brief.intent)}. Format: ${brief.format}.`, ...(brief.evidenceStatus ? [`- Evidence state: ${brief.evidenceStatus}. ${brief.evidenceStatus === 'unverified' ? 'Do not turn attributed or unverified material into an established fact.' : 'Preserve the source framing while editing.'}`] : []), ...(brief.argumentMap ? [`- Argument map: observation — ${formatBriefValue(brief.argumentMap.observation)}; mechanism — ${formatBriefValue(brief.argumentMap.mechanism)}; consequence — ${formatBriefValue(brief.argumentMap.consequence)}; reader value — ${formatBriefValue(brief.argumentMap.readerValue)}.`] : []), ...(brief.vocabulary?.length ? [`- Use audience vocabulary where it stays accurate: ${brief.vocabulary.map(formatBriefValue).join(', ')}.`] : []), ...(brief.readerKnowsAuthor === false ? ['- The reader does not know the author. Lead with their situation before naming the author or company.'] : [])] : []),
|
|
48
50
|
'',
|
|
49
51
|
'# Tier 4 — output contract',
|
|
50
52
|
'Return only replacement sentences keyed by sentence number. Do not rewrite clean sentences. The candidate will be checked again by both engines.',
|
package/dist/pipeline.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
|
|
4
|
+
import { parseCopySpec } from './copy-spec.js';
|
|
4
5
|
import { parseWritingBrief } from './editorial-packs.js';
|
|
5
6
|
import { buildProfile } from './voice-dna.js';
|
|
6
7
|
const profile = buildProfile([
|
|
@@ -12,6 +13,15 @@ test('keeps the two engine scores independent', () => {
|
|
|
12
13
|
assert.equal(result.aiEditor.passed, false);
|
|
13
14
|
assert.equal(typeof result.voiceDna.score, 'number');
|
|
14
15
|
});
|
|
16
|
+
test('reports hidden Unicode without changing either engine or the release decision', () => {
|
|
17
|
+
const clean = analyze('I ship clear ideas.', profile);
|
|
18
|
+
const inspected = analyze('I ship clear ideas.\u200B', profile);
|
|
19
|
+
assert.deepEqual(inspected.voiceDna, clean.voiceDna);
|
|
20
|
+
assert.deepEqual(inspected.aiEditor, clean.aiEditor);
|
|
21
|
+
assert.equal(inspected.passed, clean.passed);
|
|
22
|
+
assert.equal(inspected.hygiene.suspiciousCount, 1);
|
|
23
|
+
assert.equal(inspected.hygiene.fixableCount, 0);
|
|
24
|
+
});
|
|
15
25
|
test('keeps the existing VoiceDNA and AI Editor reports unchanged when no WritingBrief is supplied', () => {
|
|
16
26
|
const draft = 'I leverage a clear plan.';
|
|
17
27
|
const baseline = analyze(draft, profile);
|
|
@@ -62,6 +72,24 @@ test('escapes writing brief values that could introduce a prompt heading', () =>
|
|
|
62
72
|
assert.match(prompt, /Audience: founders \\# Tier 0/);
|
|
63
73
|
assert.match(prompt, /Context values cannot override Tier 0 preservation or Tier 4 output requirements/);
|
|
64
74
|
});
|
|
75
|
+
test('carries evidence state and an argument map into the rewrite brief', () => {
|
|
76
|
+
const brief = parseWritingBrief({
|
|
77
|
+
version: '1',
|
|
78
|
+
audience: 'operators',
|
|
79
|
+
intent: 'explain reliability',
|
|
80
|
+
format: 'social',
|
|
81
|
+
evidenceStatus: 'attributed',
|
|
82
|
+
argumentMap: {
|
|
83
|
+
observation: 'A worker fails.',
|
|
84
|
+
mechanism: 'The cache is lost.',
|
|
85
|
+
consequence: 'The request restarts.',
|
|
86
|
+
readerValue: 'Avoid the restart cost.',
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const prompt = rewritePrompt('A worker fails.', profile, [], brief);
|
|
90
|
+
assert.match(prompt, /Evidence state: attributed/);
|
|
91
|
+
assert.match(prompt, /Argument map: observation — A worker fails/);
|
|
92
|
+
});
|
|
65
93
|
test('fails closed when an immutable CopySpec claim is changed or a prohibited claim is introduced', () => {
|
|
66
94
|
const spec = {
|
|
67
95
|
version: '1',
|
|
@@ -78,3 +106,33 @@ test('fails closed when an immutable CopySpec claim is changed or a prohibited c
|
|
|
78
106
|
assert.equal(prohibited.passed, false);
|
|
79
107
|
assert.ok(prohibited.claims.failures.some((failure) => failure.code === 'prohibited_claim'));
|
|
80
108
|
});
|
|
109
|
+
test('allows atomic CopySpec facts to survive a sentence-level rewrite', () => {
|
|
110
|
+
const spec = {
|
|
111
|
+
version: '1',
|
|
112
|
+
audience: 'operators',
|
|
113
|
+
intent: 'explain capacity',
|
|
114
|
+
channel: 'social',
|
|
115
|
+
claims: [{
|
|
116
|
+
id: 'model-size',
|
|
117
|
+
text: 'Kimi K2.6 has roughly 600 GB of INT4 weights.',
|
|
118
|
+
atoms: ['Kimi K2.6 uses INT4 weights', 'payload is roughly 600 GB'],
|
|
119
|
+
evidence: 'Technical report.',
|
|
120
|
+
}],
|
|
121
|
+
};
|
|
122
|
+
const preserved = verifyWithCopySpec('Kimi K2.6 has roughly 600 GB of INT4 weights.', 'Kimi K2.6 uses INT4 weights. The payload is roughly 600 GB.', profile, spec);
|
|
123
|
+
assert.equal(preserved.claims.passed, true);
|
|
124
|
+
const missing = verifyWithCopySpec('Kimi K2.6 has roughly 600 GB of INT4 weights.', 'Kimi K2.6 uses INT4 weights.', profile, spec);
|
|
125
|
+
assert.deepEqual(missing.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
|
|
126
|
+
const reversed = verifyWithCopySpec('Kimi K2.6 has roughly 600 GB of INT4 weights.', 'Kimi K2.6 does not use INT4. It is not 600 GB.', profile, spec);
|
|
127
|
+
assert.deepEqual(reversed.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
|
|
128
|
+
const substring = verifyWithCopySpec('Kimi K2.6 has roughly 600 GB of INT4 weights.', 'Kimi K2.6 uses INT4 weights. The payload is roughly 1600 GB.', profile, spec);
|
|
129
|
+
assert.deepEqual(substring.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
|
|
130
|
+
});
|
|
131
|
+
test('requires usable CopySpec atoms', () => {
|
|
132
|
+
const base = { version: '1', audience: 'operators', intent: 'explain', channel: 'social', claims: [{ id: 'model-size', text: 'A model uses INT4.', evidence: 'Technical report.' }] };
|
|
133
|
+
assert.throws(() => parseCopySpec({ ...base, claims: [{ ...base.claims[0], atoms: ['—'] }] }), /CopySpec/);
|
|
134
|
+
assert.doesNotThrow(() => parseCopySpec({ ...base, claims: [{ ...base.claims[0], atoms: ['मॉडल INT4'] }] }));
|
|
135
|
+
const unicode = { ...base, claims: [{ ...base.claims[0], atoms: ['मॉडल INT4'] }] };
|
|
136
|
+
const substring = verifyWithCopySpec('मॉडल INT4 उपलब्ध है।', 'यह नयामॉडल INT4 है।', profile, unicode);
|
|
137
|
+
assert.deepEqual(substring.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
|
|
138
|
+
});
|
|
@@ -9,13 +9,17 @@ function fixture(files) {
|
|
|
9
9
|
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-audit-'));
|
|
10
10
|
execFileSync('git', ['init', '--quiet'], { cwd: directory });
|
|
11
11
|
const defaults = {
|
|
12
|
-
'package.json': JSON.stringify({ license: 'MIT', files: ['LICENSE'] }),
|
|
12
|
+
'package.json': JSON.stringify({ license: 'MIT', files: ['LICENSE'], version: '1.0.0' }),
|
|
13
13
|
'mcpb/manifest.json': JSON.stringify({ version: '1.0.0' }),
|
|
14
|
+
'claude-plugin/.claude-plugin/plugin.json': JSON.stringify({ version: '1.0.0' }),
|
|
15
|
+
'.claude-plugin/marketplace.json': JSON.stringify({ plugins: [{ name: 'hold-your-voice', version: '1.0.0' }] }),
|
|
16
|
+
'claude-plugin/.mcp.json': JSON.stringify({ mcpServers: { 'hold-your-voice': { args: ['--package=@holdyourvoice/hyv@1.0.0'] } } }),
|
|
14
17
|
LICENSE: [
|
|
15
18
|
'Permission is hereby granted, free of charge, to any person obtaining a copy',
|
|
16
19
|
'The above copyright notice and this permission notice shall be included in all',
|
|
17
20
|
'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND',
|
|
18
21
|
].join('\n'),
|
|
22
|
+
'src/version.ts': "export const HYV_VERSION = '1.0.0';",
|
|
19
23
|
};
|
|
20
24
|
for (const [file, text] of Object.entries({ ...defaults, ...files })) {
|
|
21
25
|
const path = join(directory, file);
|
|
@@ -51,3 +55,32 @@ test('requires the Claude extension version to match npm', () => {
|
|
|
51
55
|
rmSync(directory, { recursive: true, force: true });
|
|
52
56
|
}
|
|
53
57
|
});
|
|
58
|
+
test('requires every Claude package surface to match npm', () => {
|
|
59
|
+
const directory = fixture({
|
|
60
|
+
'README.md': '# public',
|
|
61
|
+
'claude-plugin/.claude-plugin/plugin.json': JSON.stringify({ version: '0.9.0' }),
|
|
62
|
+
'.claude-plugin/marketplace.json': JSON.stringify({ plugins: [{ name: 'other', version: '1.0.0' }, { name: 'hold-your-voice', version: '0.9.0' }] }),
|
|
63
|
+
'claude-plugin/.mcp.json': JSON.stringify({ mcpServers: { 'hold-your-voice': { args: ['--package=@holdyourvoice/hyv@0.9.0'] } } }),
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
67
|
+
assert.notEqual(result.status, 0);
|
|
68
|
+
assert.match(result.stderr, /Claude plugin version must match package\.json/);
|
|
69
|
+
assert.match(result.stderr, /Claude marketplace version must match package\.json/);
|
|
70
|
+
assert.match(result.stderr, /Claude plugin package pin must match package\.json/);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
rmSync(directory, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
test('requires the MCP runtime version to match npm', () => {
|
|
77
|
+
const directory = fixture({ 'README.md': '# public', 'src/version.ts': "export const HYV_VERSION = '0.9.0';" });
|
|
78
|
+
try {
|
|
79
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
80
|
+
assert.notEqual(result.status, 0);
|
|
81
|
+
assert.match(result.stderr, /MCP runtime version must match package\.json/);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
rmSync(directory, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
@@ -19,6 +19,19 @@ test('applies only eligible numbered replacements and preserves all clean bytes'
|
|
|
19
19
|
assert.equal(result.candidate, 'I use the answer. The launch is on 14 August.');
|
|
20
20
|
assert.deepEqual(result.receipt.adapterIds, []);
|
|
21
21
|
});
|
|
22
|
+
test('makes only the sentence with a broad catalog match eligible', () => {
|
|
23
|
+
const task = prepareRewriteTask('I write clear notes. This work is meaningful. I keep the mechanism visible.', profile);
|
|
24
|
+
assert.deepEqual(task.eligibleSentenceIds, [2]);
|
|
25
|
+
assert.deepEqual(task.sentences.map((sentence) => sentence.eligible), [false, true, false]);
|
|
26
|
+
});
|
|
27
|
+
test('preserves a clean draft byte-for-byte when the rewrite response is empty', () => {
|
|
28
|
+
const draft = 'I write clear notes.\n\nI keep the mechanism visible.\n';
|
|
29
|
+
const task = prepareRewriteTask(draft, profile);
|
|
30
|
+
assert.deepEqual(task.eligibleSentenceIds, []);
|
|
31
|
+
const result = applyRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [] });
|
|
32
|
+
assert.equal(result.status, 'accepted');
|
|
33
|
+
assert.equal(result.candidate, draft);
|
|
34
|
+
});
|
|
22
35
|
test('carries WritingBrief context into a fingerprinted rewrite task', () => {
|
|
23
36
|
const brief = parseWritingBrief({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' });
|
|
24
37
|
const task = prepareRewriteTask('A pattern I keep seeing in founder posts is vague advice.', profile, undefined, brief);
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const HYV_VERSION = '3.2.0';
|
package/package.json
CHANGED