@holdyourvoice/hyv 3.1.1 → 3.3.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.
Files changed (53) hide show
  1. package/Readme.md +76 -17
  2. package/dist/ai-editor-rules.js +151 -0
  3. package/dist/ai-editor.js +104 -8
  4. package/dist/ai-editor.test.js +135 -22
  5. package/dist/approval-capability.js +111 -0
  6. package/dist/approval-capability.test.js +52 -0
  7. package/dist/approval-context.js +54 -0
  8. package/dist/approval-context.test.js +38 -0
  9. package/dist/benchmark.js +232 -0
  10. package/dist/benchmark.test.js +328 -0
  11. package/dist/canonical-json.js +123 -0
  12. package/dist/canonical-json.test.js +24 -0
  13. package/dist/cli.js +359 -21
  14. package/dist/cli.test.js +275 -7
  15. package/dist/copy-spec.js +35 -8
  16. package/dist/editorial-packs.js +25 -1
  17. package/dist/editorial-packs.test.js +45 -0
  18. package/dist/hygiene.js +91 -0
  19. package/dist/hygiene.test.js +73 -0
  20. package/dist/judgment-task.js +171 -0
  21. package/dist/judgment-task.test.js +162 -0
  22. package/dist/learning.js +240 -100
  23. package/dist/learning.test.js +203 -3
  24. package/dist/lifecycle-adapter.js +75 -0
  25. package/dist/lifecycle-adapter.test.js +56 -0
  26. package/dist/mcp-tools.js +110 -9
  27. package/dist/mcp-tools.test.js +188 -10
  28. package/dist/mcp.js +228 -9
  29. package/dist/mcp.test.js +248 -12
  30. package/dist/pipeline.js +81 -15
  31. package/dist/pipeline.test.js +94 -2
  32. package/dist/preservation.js +89 -0
  33. package/dist/preservation.test.js +22 -0
  34. package/dist/profile.js +87 -0
  35. package/dist/profile.test.js +114 -0
  36. package/dist/rebuild-task.js +226 -0
  37. package/dist/rebuild-task.test.js +179 -0
  38. package/dist/release-audit.test.js +144 -2
  39. package/dist/rewrite-task.js +136 -16
  40. package/dist/rewrite-task.test.js +72 -4
  41. package/dist/rule-reconciliation.test.js +50 -0
  42. package/dist/semantic-review.js +176 -7
  43. package/dist/semantic-review.test.js +98 -14
  44. package/dist/stage1-dry-run.test.js +39 -0
  45. package/dist/stage1-evaluation.js +579 -0
  46. package/dist/stage1-evaluation.test.js +184 -0
  47. package/dist/stage1-human-packet.test.js +102 -0
  48. package/dist/stage1-schema-contract.test.js +95 -0
  49. package/dist/stage2-human-packet.test.js +81 -0
  50. package/dist/version.js +1 -0
  51. package/dist/voice-dna.js +53 -1
  52. package/dist/voice-dna.test.js +79 -1
  53. package/package.json +2 -2
package/dist/mcp.test.js CHANGED
@@ -1,12 +1,54 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
2
3
  import { spawn } from 'node:child_process';
3
4
  import { once } from 'node:events';
4
- import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
5
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
6
  import { tmpdir } from 'node:os';
6
7
  import { join } from 'node:path';
7
8
  import test from 'node:test';
8
9
  import { profileFingerprint } from './learning.js';
9
10
  import { buildProfile } from './voice-dna.js';
11
+ import { canonicalJson } from './canonical-json.js';
12
+ import { applyRewriteForMcp, prepareLifecycleForMcp, prepareRewriteForMcp } from './mcp-tools.js';
13
+ function profileV3Json() {
14
+ const profile = buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'], ['leverage']);
15
+ const unsigned = { ...profile, version: '3', id: 'founder.test', revision: 1, provenance: { source: 'test', rights: 'test', createdAt: '2026-08-13T00:00:00.000Z' }, rulePolicy: {}, fingerprint: { contractionRate: 0, sentenceLengthDistribution: { short: 1, medium: 0, long: 0 }, bulletRate: 0, enDashRate: 0 }, tolerances: { contractionRate: { absolute: 0.1, calibrated: false }, sentenceLengthDistribution: { absolute: 0.1, calibrated: false }, bulletRate: { absolute: 0.1, calibrated: false }, enDashRate: { absolute: 0.1, calibrated: false } }, metricFixtures: { contractionRate: ['test'], sentenceLengthDistribution: ['test'], bulletRate: ['test'], enDashRate: ['test'] } };
16
+ const canonical = (value) => Array.isArray(value) ? `[${value.map(canonical).join(',')}]` : value && typeof value === 'object' ? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}` : JSON.stringify(value);
17
+ return JSON.stringify({ ...unsigned, revisionDigest: createHash('sha256').update(canonical(unsigned)).digest('hex') });
18
+ }
19
+ async function callMcp(tools, env = process.env) {
20
+ const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'], env });
21
+ let stdout = '';
22
+ let stderr = '';
23
+ server.stdout.on('data', (chunk) => { stdout += chunk; });
24
+ server.stderr.on('data', (chunk) => { stderr += chunk; });
25
+ 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`);
26
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
27
+ tools.forEach((tool, index) => server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: index + 2, method: 'tools/call', params: tool })}\n`));
28
+ server.stdin.end();
29
+ const [code] = await once(server, 'close');
30
+ assert.equal(code, 0);
31
+ assert.equal(stderr, '');
32
+ return stdout.trim().split('\n').map((line) => JSON.parse(line)).filter((response) => response.id >= 2).sort((a, b) => a.id - b.id);
33
+ }
34
+ function toolPayload(response) {
35
+ return JSON.parse(response.result?.content?.[0]?.text ?? '{}');
36
+ }
37
+ function installedContextEnvironment(root, context) {
38
+ const home = join(root, 'home');
39
+ const config = join(home, '.config', 'holdyourvoice');
40
+ mkdirSync(config, { recursive: true, mode: 0o700 });
41
+ const contextPath = join(config, 'approval-context.json');
42
+ writeFileSync(contextPath, canonicalJson(context), { mode: 0o600 });
43
+ chmodSync(contextPath, 0o600);
44
+ const fakeOs = join(root, 'fake-os.mjs');
45
+ const hooks = join(root, 'hooks.mjs');
46
+ const register = join(root, 'register.mjs');
47
+ writeFileSync(fakeOs, `import * as actual from 'node:os'; export const userInfo = () => ({ ...actual.userInfo(), homedir: process.env.HYV_TEST_HOME });\n`);
48
+ writeFileSync(hooks, `export async function resolve(specifier, context, nextResolve) { if (specifier === 'node:os' && context.parentURL?.endsWith('/approval-context.js')) return { url: new URL('./fake-os.mjs', import.meta.url).href, shortCircuit: true }; return nextResolve(specifier, context); }\n`);
49
+ writeFileSync(register, `import { register } from 'node:module'; register(new URL('./hooks.mjs', import.meta.url));\n`);
50
+ return { ...process.env, NODE_NO_WARNINGS: '1', NODE_OPTIONS: `--import=${register}`, HYV_TEST_HOME: home };
51
+ }
10
52
  test('serves local Claude tools over stdio', async () => {
11
53
  const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
12
54
  let stdout = '';
@@ -22,12 +64,205 @@ test('serves local Claude tools over stdio', async () => {
22
64
  assert.equal(code, 0);
23
65
  const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
24
66
  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']);
26
- assert.ok(tools?.filter((tool) => tool.name !== 'hyv_verify' && tool.name !== 'hyv_verify_copy_spec').every((tool) => tool.annotations?.readOnlyHint));
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);
67
+ 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_prepare_judgment', 'hyv_reduce_judgment', 'hyv_verify', 'hyv_verify_copy_spec', 'hyv_batch_analyze', 'hyv_patterns', 'hyv_learning_inspect', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear', 'hyv_lifecycle_prepare_semantic', 'hyv_lifecycle_submit_verdict', 'hyv_lifecycle_inspect', 'hyv_lifecycle_finalize']);
68
+ assert.ok(tools?.filter((tool) => !['hyv_verify', 'hyv_verify_copy_spec', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear'].includes(tool.name)).every((tool) => tool.annotations?.readOnlyHint));
69
+ assert.equal(tools?.find((tool) => tool.name === 'hyv_verify')?.annotations?.readOnlyHint, true);
70
+ assert.equal(tools?.find((tool) => tool.name === 'hyv_verify_copy_spec')?.annotations?.readOnlyHint, true);
71
+ assert.equal(tools?.some((tool) => tool.name === 'hyv_lifecycle_validate_final_approval'), false);
72
+ assert.equal(tools?.some((tool) => tool.name === 'hyv_learning_record_approved'), false);
73
+ assert.equal(tools?.some((tool) => tool.name === 'hyv_prepare_rebuild'), false);
74
+ assert.equal(tools?.some((tool) => tool.name === 'hyv_apply_rebuild'), false);
75
+ assert.equal('capability_json' in (tools?.find((tool) => tool.name === 'hyv_lifecycle_finalize')?.inputSchema?.properties ?? {}), false);
76
+ assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_inspect')?.annotations?.readOnlyHint, true);
77
+ assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_clear')?.annotations?.readOnlyHint, false);
78
+ assert.deepEqual(tools?.filter((tool) => tool.name.startsWith('hyv_learning_')).map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.annotations?.destructiveHint]), [
79
+ ['hyv_learning_inspect', true, undefined], ['hyv_learning_record', false, false], ['hyv_learning_ratify', false, false],
80
+ ['hyv_learning_supersede', false, true], ['hyv_learning_migrate', false, false], ['hyv_learning_clear', false, true],
81
+ ]);
82
+ });
83
+ test('registers capability tools only with host redaction attestation', async () => {
84
+ const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' } });
85
+ let stdout = '';
86
+ let stderr = '';
87
+ server.stdout.on('data', (chunk) => { stdout += chunk; });
88
+ server.stderr.on('data', (chunk) => { stderr += chunk; });
89
+ 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`);
90
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
91
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`);
92
+ server.stdin.end();
93
+ const [code] = await once(server, 'close');
94
+ assert.equal(code, 0);
95
+ assert.equal(stderr, '');
96
+ const response = stdout.trim().split('\n').map((line) => JSON.parse(line)).find((item) => item.id === 2);
97
+ const names = response.result.tools.map((tool) => tool.name);
98
+ assert.equal(names.length, 27);
99
+ assert.ok(names.includes('hyv_lifecycle_validate_final_approval'));
100
+ assert.ok(names.includes('hyv_learning_record_approved'));
101
+ assert.ok(names.includes('hyv_prepare_rebuild'));
102
+ assert.ok(names.includes('hyv_apply_rebuild'));
103
+ const finalize = response.result.tools.find((tool) => tool.name === 'hyv_lifecycle_finalize');
104
+ assert.equal('capability_json' in finalize.inputSchema.properties, true);
105
+ });
106
+ test('redacts rebuild prepare transport failures', async () => {
107
+ const secret = 'rebuild-prepare-secret-nonce';
108
+ const responses = await callMcp([{ name: 'hyv_prepare_rebuild', arguments: { draft: 'x', profile_json: '{}', reduction_json: '{}', copy_spec_json: '{}', capability_json: JSON.stringify({ payload: secret, signature: secret }) } }], { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' });
109
+ const serialized = JSON.stringify(responses[0]);
110
+ assert.equal(responses[0]?.result?.isError, true);
111
+ assert.doesNotMatch(serialized, new RegExp(secret));
112
+ assert.match(serialized, /Rebuild preparation failed/);
113
+ });
114
+ test('redacts rebuild apply transport failures', async () => {
115
+ const secret = 'rebuild-apply-secret-nonce';
116
+ const responses = await callMcp([{ name: 'hyv_apply_rebuild', arguments: { task_json: '{}', response_json: '{}', profile_json: '{}', capability_json: JSON.stringify({ payload: secret, signature: secret }) } }], { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' });
117
+ const serialized = JSON.stringify(responses[0]);
118
+ assert.equal(responses[0]?.result?.isError, true);
119
+ assert.doesNotMatch(serialized, new RegExp(secret));
120
+ assert.match(serialized, /Rebuild application failed/);
121
+ });
122
+ test('redacts capability-bearing transport failures', async () => {
123
+ const secret = 'transport-secret-nonce';
124
+ const responses = await callMcp([{ name: 'hyv_lifecycle_validate_final_approval', arguments: { artifact_json: '{}', capability_json: JSON.stringify({ payload: secret, signature: secret }) } }], { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' });
125
+ const serialized = JSON.stringify(responses[0]);
126
+ assert.equal(responses[0]?.result?.isError, true);
127
+ assert.doesNotMatch(serialized, new RegExp(secret));
128
+ assert.match(serialized, /Capability validation failed/);
129
+ });
130
+ test('runs registered lifecycle preparation and inspection over stdio', async () => {
131
+ const base = { version: '1', verificationKind: 'standard', passed: true, analysisVersion: '2', rulesetVersion: '3.2.0', preservationMetricVersion: 'legacy-set-v1', preservationScore: 100, sourceHash: '4'.repeat(64), candidateHash: '5'.repeat(64), profileId: 'founder.primary', profileRevisionDigest: '6'.repeat(64), regressionKeys: [] };
132
+ const deterministic = { ...base, artifactFingerprint: createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(base)}`).digest('hex') };
133
+ const binding = { rewriteTaskFingerprint: '1'.repeat(64), rewriteResponseFingerprint: '2'.repeat(64), deterministicArtifactFingerprint: deterministic.artifactFingerprint, sourceHash: deterministic.sourceHash, candidateHash: deterministic.candidateHash, profileId: deterministic.profileId, profileRevisionDigest: deterministic.profileRevisionDigest, rulesetVersion: deterministic.rulesetVersion, schemaVersion: '1' };
134
+ const receipt = { version: '1', taskFingerprint: binding.rewriteTaskFingerprint, responseFingerprint: binding.rewriteResponseFingerprint, adapterIds: [], replacementSentenceIds: [1] };
135
+ const preparedResponse = await callMcp([{ name: 'hyv_lifecycle_prepare_semantic', arguments: { deterministic_json: JSON.stringify(deterministic), binding_json: JSON.stringify(binding), receipt_json: JSON.stringify(receipt), policy: 'normal', allowed_violations: ['action_change'] } }], { ...process.env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '' });
136
+ const prepared = toolPayload(preparedResponse[0]);
137
+ assert.equal(prepared.artifact.status, 'needs_semantic_review');
138
+ const inspectedResponse = await callMcp([{ name: 'hyv_lifecycle_inspect', arguments: { artifact_json: JSON.stringify(prepared.artifact) } }]);
139
+ const inspected = toolPayload(inspectedResponse[0]);
140
+ assert.equal(inspected.artifactFingerprint, prepared.artifact.artifactFingerprint);
141
+ assert.doesNotMatch(JSON.stringify(inspected), /sourceHash|candidateHash|payload|signature|nonce/);
142
+ });
143
+ test('runs signed lifecycle and approved-learning replay through registered stdio tools', async () => {
144
+ const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-approved-'));
145
+ try {
146
+ const source = 'I leverage the answer with useful detail and clear mechanism.';
147
+ const candidate = 'I use the answer with useful detail and clear mechanism.';
148
+ const profile = buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'], ['leverage']);
149
+ const profileJson = JSON.stringify(profile);
150
+ const rewriteTask = prepareRewriteForMcp(source, profileJson);
151
+ const evaluation = applyRewriteForMcp(JSON.stringify(rewriteTask), JSON.stringify({ version: '1', taskFingerprint: rewriteTask.fingerprint, replacements: [{ sentenceId: 1, text: candidate }] }), profileJson);
152
+ assert.equal(evaluation.status, 'needs_semantic_review');
153
+ const prepared = prepareLifecycleForMcp(JSON.stringify(evaluation.deterministicArtifact), JSON.stringify(evaluation.lifecycleBinding), JSON.stringify(evaluation.receipt), 'normal', []);
154
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
155
+ const now = Math.floor(Date.now() / 1000);
156
+ const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'test-host', keyId: 'test-key', publicKeySpki: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), status: 'active' }] };
157
+ const context = { now: 0, trustStore, authorizedSemanticEvaluatorIds: { normal: ['test-reviewer'], highAssurance: [] }, authorizedHumanFinalizerIds: ['test-human'] };
158
+ const env = { ...installedContextEnvironment(root, context), HYV_HOME: join(root, 'state'), HYV_MCP_SENSITIVE_INPUT_REDACTION: '1' };
159
+ const submittedResponse = await callMcp([{ name: 'hyv_lifecycle_submit_verdict', arguments: { artifact_json: JSON.stringify(prepared.artifact), task_json: JSON.stringify(prepared.task), evaluator_id: 'test-reviewer', verdict_json: JSON.stringify({ approved: true, violations: [] }) } }], env);
160
+ const ready = toolPayload(submittedResponse[0]);
161
+ assert.equal(ready.status, 'ready_for_human_review');
162
+ const rejectedResponse = await callMcp([{ name: 'hyv_lifecycle_finalize', arguments: { artifact_json: JSON.stringify(ready), decision_json: JSON.stringify({ evaluatorId: 'test-human', decision: 'reject' }) } }], { ...env, HYV_MCP_SENSITIVE_INPUT_REDACTION: '' });
163
+ const rejected = toolPayload(rejectedResponse[0]);
164
+ assert.equal(rejected.status, 'needs_escalation');
165
+ assert.equal(rejected.reason, 'human_rejection');
166
+ const binding = ready.binding;
167
+ const claims = { version: '1', purpose: 'hyv.final-approval', issuer: 'test-host', audience: '@holdyourvoice/hyv', subjectArtifactFingerprint: ready.artifactFingerprint, sourceHash: binding.sourceHash, candidateHash: binding.candidateHash, profileId: binding.profileId, profileRevisionDigest: binding.profileRevisionDigest, keyId: 'test-key', issuedAt: now - 1, notBefore: now - 1, expiresAt: now + 120, nonce: 'registered-secret-nonce' };
168
+ const payload = Buffer.from(canonicalJson(claims));
169
+ const capability = canonicalJson({ payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') });
170
+ const decision = JSON.stringify({ evaluatorId: 'test-human', decision: 'approve' });
171
+ const approvalResponses = await callMcp([
172
+ { name: 'hyv_lifecycle_validate_final_approval', arguments: { artifact_json: JSON.stringify(ready), capability_json: capability } },
173
+ { name: 'hyv_lifecycle_finalize', arguments: { artifact_json: JSON.stringify(ready), decision_json: decision, capability_json: capability } },
174
+ ], env);
175
+ assert.equal(toolPayload(approvalResponses[0]).ok, true);
176
+ const approved = toolPayload(approvalResponses[1]);
177
+ assert.equal(approved.status, 'approved');
178
+ const learningArguments = { ready_json: JSON.stringify(ready), approved_json: JSON.stringify(approved), original: source, candidate, profile_json: profileJson, decision_json: decision, capability_json: capability };
179
+ const learned = await callMcp([
180
+ { name: 'hyv_learning_record_approved', arguments: learningArguments },
181
+ { name: 'hyv_learning_record_approved', arguments: learningArguments },
182
+ ], env);
183
+ assert.equal(toolPayload(learned[0]).status, 'recorded');
184
+ assert.equal(toolPayload(learned[1]).status, 'nothing_to_learn');
185
+ const serialized = JSON.stringify({ submittedResponse, rejectedResponse, approvalResponses, learned });
186
+ assert.doesNotMatch(serialized, /registered-secret-nonce|payload|signature|I leverage|I use/);
187
+ }
188
+ finally {
189
+ rmSync(root, { recursive: true, force: true });
190
+ }
191
+ });
192
+ test('runs the registered MCP learning lifecycle and rejects invalid profile versions', async () => {
193
+ const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-lifecycle-'));
194
+ try {
195
+ const env = { ...process.env, HYV_HOME: root };
196
+ const v2Json = JSON.stringify(buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'], ['leverage']));
197
+ const v3Json = profileV3Json();
198
+ const first = await callMcp([
199
+ { name: 'hyv_learning_record', arguments: { profile_json: v3Json, instruction: 'Keep the mechanism concrete.', mutation_id: 'm1', authority: 'founder', provenance: 'review', weight: 2, compatibility: 'exact' } },
200
+ { name: 'hyv_learning_inspect', arguments: { profile_json: v3Json } },
201
+ { name: 'hyv_learning_record', arguments: { profile_json: v2Json, instruction: 'Keep the evidence named.', mutation_id: 'legacy-1' } },
202
+ { name: 'hyv_learning_migrate', arguments: { source_profile_json: v2Json, target_profile_json: v3Json, mutation_id: 'migration-1' } },
203
+ { name: 'hyv_learning_ratify', arguments: { profile_json: v2Json, event_id: 'invalid' } },
204
+ { name: 'hyv_learning_migrate', arguments: { source_profile_json: v3Json, target_profile_json: v2Json } },
205
+ { name: 'hyv_learning_record', arguments: { profile_json: v3Json, instruction: 'Invalid schema.', mutation_id: 'm'.repeat(201) } },
206
+ ], env);
207
+ const recorded = toolPayload(first[0]);
208
+ assert.equal(recorded.status, 'recorded');
209
+ assert.equal(toolPayload(first[1])[0].eventType, 'instruction');
210
+ assert.equal(toolPayload(first[2]).status, 'recorded');
211
+ assert.equal(toolPayload(first[3]).status, 'recorded');
212
+ assert.equal(first[4]?.result?.isError, true);
213
+ assert.equal(first[5]?.result?.isError, true);
214
+ assert.equal(first[6]?.result?.isError, true);
215
+ const second = await callMcp([
216
+ { name: 'hyv_learning_record', arguments: { profile_json: v3Json, instruction: 'Different instruction.', mutation_id: 'm1' } },
217
+ { name: 'hyv_learning_ratify', arguments: { profile_json: v3Json, event_id: recorded.eventId, mutation_id: 'm2', authority: 'founder' } },
218
+ { name: 'hyv_learning_supersede', arguments: { profile_json: v3Json, event_id: recorded.eventId, mutation_id: 'm3', authority: 'founder' } },
219
+ { name: 'hyv_learning_clear', arguments: { profile_json: v3Json } },
220
+ { name: 'hyv_learning_inspect', arguments: { profile_json: v3Json } },
221
+ ], env);
222
+ assert.equal(toolPayload(second[0]).status, 'conflict');
223
+ assert.equal(toolPayload(second[1]).status, 'recorded');
224
+ assert.equal(toolPayload(second[2]).status, 'recorded');
225
+ assert.equal(toolPayload(second[3]).cleared, true);
226
+ assert.deepEqual(toolPayload(second[4]), []);
227
+ }
228
+ finally {
229
+ rmSync(root, { recursive: true, force: true });
230
+ }
231
+ });
232
+ test('accepts empty text for profile-free hygiene inspection', async () => {
233
+ const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
234
+ let stdout = '';
235
+ server.stdout.on('data', (chunk) => { stdout += chunk; });
236
+ 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`);
237
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
238
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'hyv_hygiene', arguments: { draft: '' } } })}\n`);
239
+ server.stdin.end();
240
+ const [code] = await once(server, 'close');
241
+ assert.equal(code, 0);
242
+ const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
243
+ const report = JSON.parse(responses.find((response) => response.id === 2)?.result?.content?.[0]?.text ?? '{}');
244
+ assert.equal(report.suspiciousCount, 0);
245
+ });
246
+ test('gates exact final output through the registered profile-free MCP tool', async () => {
247
+ const server = spawn(process.execPath, [new URL('./cli.js', import.meta.url).pathname, 'mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
248
+ let stdout = '';
249
+ server.stdout.on('data', (chunk) => { stdout += chunk; });
250
+ 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`);
251
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
252
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'hyv_final_check', arguments: { text: 'Exact output.' } } })}\n`);
253
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hyv_final_check', arguments: { text: 'Hidden\u200B output.' } } })}\n`);
254
+ server.stdin.end();
255
+ const [code] = await once(server, 'close');
256
+ assert.equal(code, 0);
257
+ const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
258
+ const accepted = JSON.parse(responses.find((response) => response.id === 2)?.result?.content?.[0]?.text ?? '{}');
259
+ const rejected = JSON.parse(responses.find((response) => response.id === 3)?.result?.content?.[0]?.text ?? '{}');
260
+ assert.equal(accepted.accepted, true);
261
+ assert.equal(accepted.output, 'Exact output.');
262
+ assert.equal(rejected.accepted, false);
263
+ assert.equal('output' in rejected, false);
29
264
  });
30
- test('uses default local learning through the registered MCP tools', async () => {
265
+ test('keeps registered MCP verification read-only', async () => {
31
266
  const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-server-'));
32
267
  try {
33
268
  const profile = buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'], ['leverage']);
@@ -43,9 +278,10 @@ test('uses default local learning through the registered MCP tools', async () =>
43
278
  server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} })}\n`);
44
279
  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
280
  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.', profile_json: JSON.stringify(profile), writing_brief_json: JSON.stringify({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' }) } } })}\n`);
281
+ 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
282
  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
283
  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`);
284
+ server.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'hyv_hygiene', arguments: { draft: 'Plain\u200B draft.' } } })}\n`);
49
285
  server.stdin.end();
50
286
  const [code] = await once(server, 'close');
51
287
  assert.equal(stderr, '');
@@ -55,14 +291,14 @@ test('uses default local learning through the registered MCP tools', async () =>
55
291
  const contextual = JSON.parse(responses.find((response) => response.id === 4)?.result?.content?.[0]?.text ?? '{}');
56
292
  const batch = JSON.parse(responses.find((response) => response.id === 5)?.result?.content?.[0]?.text ?? '{}');
57
293
  const malformed = responses.find((response) => response.id === 6)?.result;
58
- assert.match(prompt, /Learned local preferences/);
294
+ const hygiene = JSON.parse(responses.find((response) => response.id === 7)?.result?.content?.[0]?.text ?? '{}');
295
+ assert.doesNotMatch(prompt, /Learned local preferences/);
59
296
  assert.equal(contextual.editorial.findings[0].id, 'editorial.social.generic-opener');
297
+ assert.equal(contextual.hygiene.suspiciousCount, 1);
60
298
  assert.deepEqual(batch.findings.map((finding) => finding.id), ['batch.repeated-opening', 'batch.repeated-ending']);
61
299
  assert.equal(malformed?.isError, true);
62
- const stored = readFileSync(join(root, 'learning', `${profileFingerprint(profile)}.jsonl`), 'utf8');
63
- assert.match(stored, /ai\.leverage/);
64
- assert.doesNotMatch(stored, /I leverage the answer/);
65
- assert.doesNotMatch(stored, /I use the answer/);
300
+ assert.equal(hygiene.suspiciousCount, 1);
301
+ assert.equal(existsSync(join(root, 'learning', `${profileFingerprint(profile)}.jsonl`)), false);
66
302
  }
67
303
  finally {
68
304
  rmSync(root, { recursive: true, force: true });
package/dist/pipeline.js CHANGED
@@ -1,13 +1,18 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from './canonical-json.js';
3
+ import { HYV_VERSION } from './version.js';
1
4
  import { analyzeAiEditor } from './ai-editor.js';
2
5
  import { verifyClaims } from './copy-spec.js';
3
6
  import { analyzeEditorial } from './editorial-packs.js';
7
+ import { finalOutputCheck, inspectHygiene } from './hygiene.js';
4
8
  import { analyzeVoiceDna } from './voice-dna.js';
5
- import { words } from './text.js';
9
+ import { legacySetPreservation } from './preservation.js';
6
10
  export function analyze(text, profile, brief) {
7
11
  const voiceDna = analyzeVoiceDna(text, profile);
8
- const aiEditor = analyzeAiEditor(text);
12
+ const aiEditor = analyzeAiEditor(text, profile);
9
13
  const editorial = brief ? analyzeEditorial(text, brief) : undefined;
10
- return { version: '2', voiceDna, aiEditor, ...(editorial ? { editorial } : {}), passed: voiceDna.passed && aiEditor.passed && (editorial?.passed ?? true) };
14
+ const hygiene = inspectHygiene(text);
15
+ return { version: '2', voiceDna, aiEditor, ...(editorial ? { editorial } : {}), hygiene, passed: voiceDna.passed && aiEditor.passed && (editorial?.passed ?? true) };
11
16
  }
12
17
  function formatLearningPreference(preference) {
13
18
  return preference.text.replace(/[\\`*_{\[\]}<>#]/g, '\\$&');
@@ -18,11 +23,24 @@ function formatBriefValue(value) {
18
23
  function formatFindings(findings) {
19
24
  return findings.map((finding) => `- Sentence ${finding.sentence} [${finding.engine}/${finding.id}]: ${formatBriefValue(finding.reason)} Repair: ${formatBriefValue(finding.suggestion)}`);
20
25
  }
21
- export function rewritePrompt(draft, profile, learning = [], brief) {
22
- const result = analyze(draft, profile, brief);
26
+ export function isBlockingFinding(finding) {
27
+ return finding.engine === 'ai_editor' ? finding.appliedPolicy === 'blocking' : finding.severity === 'red';
28
+ }
29
+ export function deriveEditScope(result) {
30
+ const findings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
31
+ const blocking = findings.filter(isBlockingFinding);
32
+ const pendingJudgment = findings.filter((finding) => finding.appliedPolicy === 'judgment-required');
33
+ return {
34
+ eligibleSentenceIds: [...new Set(blocking.map((finding) => finding.sentence))].sort((left, right) => left - right),
35
+ blocking,
36
+ pendingJudgment,
37
+ };
38
+ }
39
+ export function renderRewritePrompt(draft, profile, result, learning = [], brief) {
23
40
  const allFindings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
24
- const redFindings = allFindings.filter((finding) => finding.severity === 'red');
25
- const yellowFindings = allFindings.filter((finding) => finding.severity === 'yellow');
41
+ const scope = deriveEditScope(result);
42
+ const redFindings = scope.blocking;
43
+ const yellowFindings = allFindings.filter((finding) => !isBlockingFinding(finding) && finding.appliedPolicy !== 'judgment-required');
26
44
  const metrics = profile.metrics;
27
45
  return [
28
46
  '# Tier 0 — non-negotiable preservation',
@@ -44,7 +62,10 @@ export function rewritePrompt(draft, profile, learning = [], brief) {
44
62
  '',
45
63
  '# Tier 3 — AI Editor improvements',
46
64
  ...(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.'] : [])] : []),
65
+ '',
66
+ '## Pending judgment — no edit permission in this task',
67
+ ...(scope.pendingJudgment.length ? formatFindings(scope.pendingJudgment) : ['- None.']),
68
+ ...(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
69
  '',
49
70
  '# Tier 4 — output contract',
50
71
  'Return only replacement sentences keyed by sentence number. Do not rewrite clean sentences. The candidate will be checked again by both engines.',
@@ -53,26 +74,28 @@ export function rewritePrompt(draft, profile, learning = [], brief) {
53
74
  draft,
54
75
  ].join('\n');
55
76
  }
56
- function preservationScore(original, candidate) {
57
- const baseline = new Set(words(original.toLowerCase()).filter((word) => word.length > 4));
58
- const rewritten = new Set(words(candidate.toLowerCase()));
59
- return baseline.size ? Math.round([...baseline].filter((word) => rewritten.has(word)).length / baseline.size * 100) : 100;
77
+ export function rewritePrompt(draft, profile, learning = [], brief) {
78
+ return renderRewritePrompt(draft, profile, analyze(draft, profile, brief), learning, brief);
60
79
  }
61
- export function verify(original, candidate, profile, brief) {
80
+ function compareCandidates(original, candidate, profile, brief) {
62
81
  const baseline = analyze(original, profile, brief);
63
82
  const checked = analyze(candidate, profile, brief);
64
83
  const baselineFindings = [...baseline.voiceDna.findings, ...baseline.aiEditor.findings, ...(baseline.editorial?.findings ?? [])];
65
84
  const checkedFindings = [...checked.voiceDna.findings, ...checked.aiEditor.findings, ...(checked.editorial?.findings ?? [])];
66
85
  const known = new Set(baselineFindings.map((finding) => `${finding.engine}:${finding.id}:${finding.sentence}`));
67
86
  const regressions = checkedFindings.filter((finding) => !known.has(`${finding.engine}:${finding.id}:${finding.sentence}`));
68
- const preservation = preservationScore(original, candidate);
87
+ const preservation = legacySetPreservation(original, candidate).score;
88
+ return { baseline, checked, regressions, preservation };
89
+ }
90
+ export function verify(original, candidate, profile, brief) {
91
+ const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
69
92
  return {
70
93
  version: '2',
71
94
  original: baseline,
72
95
  candidate: checked,
73
96
  preservationScore: preservation,
74
97
  regressions,
75
- passed: checked.passed && !regressions.some((finding) => finding.severity === 'red') && preservation >= 70,
98
+ passed: checked.passed && !regressions.some(isBlockingFinding) && preservation >= 70,
76
99
  };
77
100
  }
78
101
  export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
@@ -80,3 +103,46 @@ export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
80
103
  const claims = verifyClaims(candidate, spec);
81
104
  return { ...verification, claims, passed: verification.passed && claims.passed };
82
105
  }
106
+ export function verifyRebuildWithCopySpec(original, candidate, profile, spec, brief) {
107
+ const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
108
+ const claims = verifyClaims(candidate, spec);
109
+ const hygiene = inspectHygiene(candidate);
110
+ const finalCheck = finalOutputCheck(candidate);
111
+ return {
112
+ version: '2',
113
+ original: baseline,
114
+ candidate: checked,
115
+ preservationScore: preservation,
116
+ regressions,
117
+ claims,
118
+ passed: checked.passed && !regressions.some(isBlockingFinding) && claims.passed && hygiene.suspiciousCount === 0 && finalCheck.accepted,
119
+ };
120
+ }
121
+ function digest(value) { return createHash('sha256').update(value).digest('hex'); }
122
+ function digestCanonical(value) { return digest(canonicalJson(value)); }
123
+ function profileIdentity(profile) {
124
+ if (profile.version === '3')
125
+ return { profileId: profile.id, profileRevisionDigest: profile.revisionDigest };
126
+ const legacy = `legacy-v2:${digestCanonical(profile)}`;
127
+ return { profileId: legacy, profileRevisionDigest: legacy };
128
+ }
129
+ function projectDeterministicVerificationArtifact(source, candidate, profile, verification, copySpec, writingBrief, verificationKind = 'claims' in verification ? 'copy_spec' : 'standard') {
130
+ const identity = profileIdentity(profile);
131
+ const base = {
132
+ version: '1', verificationKind, passed: verification.passed, analysisVersion: verification.candidate.version,
133
+ rulesetVersion: HYV_VERSION, preservationMetricVersion: 'legacy-set-v1', preservationScore: verification.preservationScore,
134
+ sourceHash: digest(source), candidateHash: digest(candidate), ...identity,
135
+ ...(copySpec ? { copySpecHash: digestCanonical(copySpec) } : {}), ...(writingBrief ? { writingBriefHash: digestCanonical(writingBrief) } : {}),
136
+ regressionKeys: verification.regressions.map((finding) => `${finding.engine}:${finding.id}:${finding.sentence}`).sort(),
137
+ ...('claims' in verification ? { claimFailureKeys: verification.claims.failures.map((failure) => `${failure.id}:${failure.code}`).sort() } : {}),
138
+ };
139
+ return { ...base, artifactFingerprint: digest(`hyv:deterministic-verification:v1\0${canonicalJson(base)}`) };
140
+ }
141
+ export function verifyDeterministically(source, candidate, profile, copySpec, writingBrief) {
142
+ const verification = copySpec ? verifyWithCopySpec(source, candidate, profile, copySpec, writingBrief) : verify(source, candidate, profile, writingBrief);
143
+ return { verification, artifact: projectDeterministicVerificationArtifact(source, candidate, profile, verification, copySpec, writingBrief) };
144
+ }
145
+ export function verifyRebuildDeterministically(source, candidate, profile, copySpec, writingBrief) {
146
+ const verification = verifyRebuildWithCopySpec(source, candidate, profile, copySpec, writingBrief);
147
+ return { verification, artifact: projectDeterministicVerificationArtifact(source, candidate, profile, verification, copySpec, writingBrief, 'rebuild') };
148
+ }
@@ -1,17 +1,29 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
3
+ import { analyze, rewritePrompt, verify, verifyDeterministically, 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';
7
+ import { comparePreservation } from './preservation.js';
6
8
  const profile = buildProfile([
7
9
  'I ship clear ideas. The details stay concrete. I explain the mechanism without fuss.',
8
10
  'I write short sentences. Then I explain the mechanism. My work stays plain and specific.',
9
11
  ], ['leverage']);
10
12
  test('keeps the two engine scores independent', () => {
11
13
  const result = analyze('Firstly, we leverage a holistic strategy.', profile);
12
- assert.equal(result.aiEditor.passed, false);
14
+ assert.equal(result.aiEditor.passed, true);
15
+ assert.equal(result.voiceDna.passed, false);
13
16
  assert.equal(typeof result.voiceDna.score, 'number');
14
17
  });
18
+ test('reports hidden Unicode without changing either engine or the release decision', () => {
19
+ const clean = analyze('I ship clear ideas.', profile);
20
+ const inspected = analyze('I ship clear ideas.\u200B', profile);
21
+ assert.deepEqual(inspected.voiceDna, clean.voiceDna);
22
+ assert.deepEqual(inspected.aiEditor, clean.aiEditor);
23
+ assert.equal(inspected.passed, clean.passed);
24
+ assert.equal(inspected.hygiene.suspiciousCount, 1);
25
+ assert.equal(inspected.hygiene.fixableCount, 0);
26
+ });
15
27
  test('keeps the existing VoiceDNA and AI Editor reports unchanged when no WritingBrief is supplied', () => {
16
28
  const draft = 'I leverage a clear plan.';
17
29
  const baseline = analyze(draft, profile);
@@ -37,6 +49,28 @@ test('post gate reports a new AI regression', () => {
37
49
  assert.equal(result.original.aiEditor.passed, true);
38
50
  assert.equal(result.candidate.aiEditor.passed, false);
39
51
  });
52
+ test('advisory and pending-judgment findings pass while blocking findings fail', () => {
53
+ const advisory = analyze('Firstly, check the invoice.', profile);
54
+ assert.equal(advisory.passed, true);
55
+ const neutralProfile = buildProfile(['I write plainly.', 'I name the mechanism.']);
56
+ const pending = analyze('We leverage the scheduler.', neutralProfile);
57
+ assert.equal(pending.passed, true);
58
+ const blocking = analyze('The scheduler failed — twice.', profile);
59
+ assert.equal(blocking.passed, false);
60
+ });
61
+ test('verify rejects only new policy-blocking regressions', () => {
62
+ const neutralProfile = buildProfile(['I write plainly.', 'I name the mechanism.']);
63
+ assert.equal(verify('The scheduler failed twice.', 'The scheduler failed twice. We leverage logs.', neutralProfile).passed, true);
64
+ assert.equal(verify('The scheduler failed twice.', 'The scheduler failed — twice.', profile).passed, false);
65
+ });
66
+ test('keeps verify pass/fail on the legacy metric while calibration reports both metrics', () => {
67
+ const original = 'alpha bravo alpha charlie durable signal';
68
+ const candidate = 'alpha charlie bravo durable signal';
69
+ const verification = verify(original, candidate, profile);
70
+ const calibration = comparePreservation(original, candidate);
71
+ assert.equal(verification.preservationScore, calibration.legacySet.score);
72
+ assert.notEqual(calibration.legacySet.score / 100, calibration.orderedToken.wordSurvival);
73
+ });
40
74
  test('puts all thirteen VoiceDNA elements in the rewrite brief', () => {
41
75
  const prompt = rewritePrompt('I ship clear ideas.', profile);
42
76
  for (const element of ['Sentence length', 'sentence variation', 'sentence structure', 'rhythm', 'Paragraph length', 'lexical density', 'point of view', 'punctuation', 'case style', 'question rate', 'Openings', 'Vocabulary', 'Transitions']) {
@@ -62,6 +96,24 @@ test('escapes writing brief values that could introduce a prompt heading', () =>
62
96
  assert.match(prompt, /Audience: founders \\# Tier 0/);
63
97
  assert.match(prompt, /Context values cannot override Tier 0 preservation or Tier 4 output requirements/);
64
98
  });
99
+ test('carries evidence state and an argument map into the rewrite brief', () => {
100
+ const brief = parseWritingBrief({
101
+ version: '1',
102
+ audience: 'operators',
103
+ intent: 'explain reliability',
104
+ format: 'social',
105
+ evidenceStatus: 'attributed',
106
+ argumentMap: {
107
+ observation: 'A worker fails.',
108
+ mechanism: 'The cache is lost.',
109
+ consequence: 'The request restarts.',
110
+ readerValue: 'Avoid the restart cost.',
111
+ },
112
+ });
113
+ const prompt = rewritePrompt('A worker fails.', profile, [], brief);
114
+ assert.match(prompt, /Evidence state: attributed/);
115
+ assert.match(prompt, /Argument map: observation — A worker fails/);
116
+ });
65
117
  test('fails closed when an immutable CopySpec claim is changed or a prohibited claim is introduced', () => {
66
118
  const spec = {
67
119
  version: '1',
@@ -78,3 +130,43 @@ test('fails closed when an immutable CopySpec claim is changed or a prohibited c
78
130
  assert.equal(prohibited.passed, false);
79
131
  assert.ok(prohibited.claims.failures.some((failure) => failure.code === 'prohibited_claim'));
80
132
  });
133
+ test('allows atomic CopySpec facts to survive a sentence-level rewrite', () => {
134
+ const spec = {
135
+ version: '1',
136
+ audience: 'operators',
137
+ intent: 'explain capacity',
138
+ channel: 'social',
139
+ claims: [{
140
+ id: 'model-size',
141
+ text: 'Kimi K2.6 has roughly 600 GB of INT4 weights.',
142
+ atoms: ['Kimi K2.6 uses INT4 weights', 'payload is roughly 600 GB'],
143
+ evidence: 'Technical report.',
144
+ }],
145
+ };
146
+ 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);
147
+ assert.equal(preserved.claims.passed, true);
148
+ const missing = verifyWithCopySpec('Kimi K2.6 has roughly 600 GB of INT4 weights.', 'Kimi K2.6 uses INT4 weights.', profile, spec);
149
+ assert.deepEqual(missing.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
150
+ 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);
151
+ assert.deepEqual(reversed.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
152
+ 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);
153
+ assert.deepEqual(substring.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
154
+ });
155
+ test('requires usable CopySpec atoms', () => {
156
+ const base = { version: '1', audience: 'operators', intent: 'explain', channel: 'social', claims: [{ id: 'model-size', text: 'A model uses INT4.', evidence: 'Technical report.' }] };
157
+ assert.throws(() => parseCopySpec({ ...base, claims: [{ ...base.claims[0], atoms: ['—'] }] }), /CopySpec/);
158
+ assert.doesNotThrow(() => parseCopySpec({ ...base, claims: [{ ...base.claims[0], atoms: ['मॉडल INT4'] }] }));
159
+ const unicode = { ...base, claims: [{ ...base.claims[0], atoms: ['मॉडल INT4'] }] };
160
+ const substring = verifyWithCopySpec('मॉडल INT4 उपलब्ध है।', 'यह नयामॉडल INT4 है।', profile, unicode);
161
+ assert.deepEqual(substring.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
162
+ });
163
+ test('projects a stable text-free deterministic verification artifact', () => {
164
+ const original = 'I write clear notes.';
165
+ const candidate = 'I write clear notes.';
166
+ const left = verifyDeterministically(original, candidate, profile).artifact;
167
+ const right = verifyDeterministically(original, candidate, profile).artifact;
168
+ assert.deepEqual(left, right);
169
+ assert.equal(left.passed, true);
170
+ assert.doesNotMatch(JSON.stringify(left), /I write clear notes/);
171
+ assert.notEqual(verifyDeterministically(original, `${candidate} Changed.`, profile).artifact.artifactFingerprint, left.artifactFingerprint);
172
+ });