@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
@@ -1,9 +1,11 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
2
3
  import { mkdtempSync, rmSync } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
5
6
  import test from 'node:test';
6
- import { analyzeBatchForMcp, analyzeForMcp, applyRewriteForMcp, buildProfileForMcp, patternsForMcp, prepareRewriteForMcp, rewritePromptForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
7
+ import { analyzeBatchForMcp, analyzeForMcp, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, recordApprovedLearningForMcp, recordLearningForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
8
+ import { canonicalJson } from './canonical-json.js';
7
9
  const profile = buildProfileForMcp(['I write clearly. I keep the useful detail.', 'I make the call. Then I explain the trade-off.'], ['leverage']);
8
10
  const profileJson = JSON.stringify(profile);
9
11
  test('builds a portable profile for MCP without files', () => {
@@ -11,33 +13,154 @@ test('builds a portable profile for MCP without files', () => {
11
13
  assert.equal(profile.sampleCount, 2);
12
14
  });
13
15
  test('keeps the dual-engine analysis shape through MCP tools', () => {
14
- const result = analyzeForMcp('I leverage a clear plan.', profileJson);
16
+ const result = analyzeForMcp('I leverage a clear plan.\u200B', profileJson);
15
17
  assert.equal(result.voiceDna.engine, 'voice_dna');
16
18
  assert.equal(result.aiEditor.engine, 'ai_editor');
19
+ assert.equal(result.hygiene.suspiciousCount, 1);
20
+ });
21
+ test('inspects Unicode hygiene through MCP without a voice profile', () => {
22
+ const result = inspectHygieneForMcp('one\u200Btwo\u00A0three');
23
+ assert.equal(result.suspiciousCount, 2);
24
+ assert.equal(result.fixableCount, 0);
25
+ });
26
+ test('gates exact final output through MCP without a voice profile', () => {
27
+ const accepted = finalOutputCheckForMcp('exact output');
28
+ assert.equal(accepted.accepted && accepted.output, 'exact output');
29
+ const rejected = finalOutputCheckForMcp('hidden\u200Boutput');
30
+ assert.equal(rejected.accepted, false);
31
+ assert.equal('output' in rejected, false);
17
32
  });
18
33
  test('accepts optional WritingBrief context and exposes batch findings through MCP helpers', () => {
19
- const brief = JSON.stringify({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' });
34
+ const brief = JSON.stringify({
35
+ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social', evidenceStatus: 'unverified',
36
+ argumentMap: { observation: 'Founders repeat vague advice.', mechanism: 'The advice skips the work.', consequence: 'Readers cannot act.', readerValue: 'Avoid a vague post.' },
37
+ });
20
38
  const analysis = analyzeForMcp('A pattern I keep seeing in founder posts is vague advice.', profileJson, brief);
21
- assert.equal(analysis.editorial?.findings[0]?.id, 'editorial.social.generic-opener');
39
+ assert.ok(analysis.editorial?.findings.some((item) => item.id === 'editorial.social.generic-opener'));
40
+ assert.ok(analysis.editorial?.findings.some((item) => item.id === 'editorial.evidence.unverified'));
22
41
  const batch = analyzeBatchForMcp(['The launch needs a clear owner.', 'The launch needs a clear owner.']);
23
42
  assert.equal(batch.findings.length, 2);
24
43
  });
25
- test('creates and verifies an editing loop through MCP tools', () => {
44
+ test('keeps MCP verification read-only', () => {
26
45
  const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-'));
27
46
  try {
28
- const result = verifyForMcp('I leverage the answer with useful detail and clear mechanism.', 'I use the answer with useful detail and clear mechanism.', profileJson, { root });
47
+ const result = verifyForMcp('I leverage the answer with useful detail and clear mechanism.', 'I use the answer with useful detail and clear mechanism.', profileJson);
29
48
  const brief = rewritePromptForMcp('I leverage a clear plan.', profileJson, { root });
30
49
  assert.match(brief.prompt, /Tier 0/);
31
- assert.match(brief.prompt, /Learned local preferences/);
50
+ assert.doesNotMatch(brief.prompt, /Learned local preferences/);
32
51
  assert.equal(result.preservationScore >= 70, true);
33
- assert.equal(result.learning, 'recorded');
52
+ assert.equal('learning' in result, false);
53
+ assert.deepEqual(inspectLearningForMcp(profileJson, { root }), []);
54
+ }
55
+ finally {
56
+ rmSync(root, { recursive: true, force: true });
57
+ }
58
+ });
59
+ test('prepares, submits, and inspects the normal lifecycle through MCP helpers', () => {
60
+ const deterministicBase = { 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: [] };
61
+ const deterministic = { ...deterministicBase, artifactFingerprint: createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(deterministicBase)}`).digest('hex') };
62
+ 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' };
63
+ const receipt = { version: '1', taskFingerprint: binding.rewriteTaskFingerprint, responseFingerprint: binding.rewriteResponseFingerprint, adapterIds: [], replacementSentenceIds: [1] };
64
+ const prepared = prepareLifecycleForMcp(JSON.stringify(deterministic), JSON.stringify(binding), JSON.stringify(receipt), 'normal', ['action_change']);
65
+ const context = { now: 0, trustStore: { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 1, keys: [] }, authorizedSemanticEvaluatorIds: { normal: ['reviewer-1'], highAssurance: [] }, authorizedHumanFinalizerIds: [] };
66
+ const submitted = submitSemanticVerdictForMcp(JSON.stringify(prepared.artifact), JSON.stringify(prepared.task), 'reviewer-1', JSON.stringify({ approved: true, violations: [] }), context);
67
+ assert.equal(submitted.ok && submitted.artifact.status, 'ready_for_human_review');
68
+ assert.equal(inspectLifecycleForMcp(JSON.stringify(submitted.ok && submitted.artifact)).status, 'ready_for_human_review');
69
+ assert.throws(() => prepareLifecycleForMcp(JSON.stringify(deterministic), JSON.stringify(binding), JSON.stringify(receipt), 'high_assurance', ['action_change']), /trusted embedding/);
70
+ const forgedTask = { ...prepared.task, allowedViolations: ['unsupported_claim'] };
71
+ assert.throws(() => submitSemanticVerdictForMcp(JSON.stringify(prepared.artifact), JSON.stringify(forgedTask), 'reviewer-1', JSON.stringify({ approved: false, violations: ['unsupported_claim'] }), context));
72
+ });
73
+ test('validates and finalizes signed approval with helper/core parity and no bearer output', () => {
74
+ const deterministicBase = { 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: [] };
75
+ const deterministic = { ...deterministicBase, artifactFingerprint: createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(deterministicBase)}`).digest('hex') };
76
+ 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' };
77
+ const receipt = { version: '1', taskFingerprint: binding.rewriteTaskFingerprint, responseFingerprint: binding.rewriteResponseFingerprint, adapterIds: [], replacementSentenceIds: [1] };
78
+ const prepared = prepareLifecycleForMcp(JSON.stringify(deterministic), JSON.stringify(binding), JSON.stringify(receipt), 'normal', ['action_change']);
79
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
80
+ const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'host', keyId: 'k1', publicKeySpki: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), status: 'active' }] };
81
+ const context = { now: 150, trustStore, authorizedSemanticEvaluatorIds: { normal: ['reviewer-1'], highAssurance: [] }, authorizedHumanFinalizerIds: ['human-1'] };
82
+ const submitted = submitSemanticVerdictForMcp(JSON.stringify(prepared.artifact), JSON.stringify(prepared.task), 'reviewer-1', JSON.stringify({ approved: true, violations: [] }), context);
83
+ assert.equal(submitted.ok, true);
84
+ if (!submitted.ok)
85
+ return;
86
+ const claims = { version: '1', purpose: 'hyv.final-approval', issuer: 'host', audience: '@holdyourvoice/hyv', subjectArtifactFingerprint: submitted.artifact.artifactFingerprint, sourceHash: binding.sourceHash, candidateHash: binding.candidateHash, profileId: binding.profileId, profileRevisionDigest: binding.profileRevisionDigest, keyId: 'k1', issuedAt: 100, notBefore: 100, expiresAt: 200, nonce: 'mcp-secret-nonce' };
87
+ const payload = Buffer.from(canonicalJson(claims));
88
+ const capability = { payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') };
89
+ const validated = validateFinalApprovalForMcp(JSON.stringify(submitted.artifact), canonicalJson(capability), context);
90
+ assert.equal(validated.ok, true);
91
+ const finalized = finalizeLifecycleForMcp(JSON.stringify(submitted.artifact), JSON.stringify({ evaluatorId: 'human-1', decision: 'approve' }), context, canonicalJson(capability));
92
+ assert.equal(finalized.ok && finalized.artifact.status, 'approved');
93
+ assert.throws(() => finalizeLifecycleForMcp(JSON.stringify(submitted.artifact), JSON.stringify({ evaluatorId: 'human-1', decision: 'reject' }), context, canonicalJson(capability)), /does not accept/);
94
+ assert.doesNotMatch(JSON.stringify({ validated, finalized }), /mcp-secret-nonce|payload|signature/);
95
+ });
96
+ test('records approved learning once and treats an exact replay as a no-op', () => {
97
+ const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-approved-'));
98
+ const previousHome = process.env.HYV_HOME;
99
+ process.env.HYV_HOME = root;
100
+ try {
101
+ const source = 'I leverage the answer with useful detail and clear mechanism.';
102
+ const candidate = 'I use the answer with useful detail and clear mechanism.';
103
+ const rewriteTask = prepareRewriteForMcp(source, profileJson);
104
+ const evaluation = applyRewriteForMcp(JSON.stringify(rewriteTask), JSON.stringify({
105
+ version: '1', taskFingerprint: rewriteTask.fingerprint, replacements: [{ sentenceId: 1, text: candidate }],
106
+ }), profileJson);
107
+ assert.equal(evaluation.status, 'needs_semantic_review');
108
+ const prepared = prepareLifecycleForMcp(JSON.stringify(evaluation.deterministicArtifact), JSON.stringify(evaluation.lifecycleBinding), JSON.stringify(evaluation.receipt), 'normal', []);
109
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
110
+ const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'host', keyId: 'k1', publicKeySpki: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), status: 'active' }] };
111
+ const context = { now: 150, trustStore, authorizedSemanticEvaluatorIds: { normal: ['reviewer-1'], highAssurance: [] }, authorizedHumanFinalizerIds: ['human-1'] };
112
+ const submitted = submitSemanticVerdictForMcp(JSON.stringify(prepared.artifact), JSON.stringify(prepared.task), 'reviewer-1', JSON.stringify({ approved: true, violations: [] }), context);
113
+ assert.equal(submitted.ok, true);
114
+ if (!submitted.ok)
115
+ return;
116
+ const binding = submitted.artifact.binding;
117
+ const claims = { version: '1', purpose: 'hyv.final-approval', issuer: 'host', audience: '@holdyourvoice/hyv', subjectArtifactFingerprint: submitted.artifact.artifactFingerprint, sourceHash: binding.sourceHash, candidateHash: binding.candidateHash, profileId: binding.profileId, profileRevisionDigest: binding.profileRevisionDigest, keyId: 'k1', issuedAt: 100, notBefore: 100, expiresAt: 200, nonce: 'approved-learning-secret' };
118
+ const payload = Buffer.from(canonicalJson(claims));
119
+ const capability = canonicalJson({ payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') });
120
+ const decision = JSON.stringify({ evaluatorId: 'human-1', decision: 'approve' });
121
+ const finalized = finalizeLifecycleForMcp(JSON.stringify(submitted.artifact), decision, context, capability);
122
+ assert.equal(finalized.ok, true);
123
+ if (!finalized.ok)
124
+ return;
125
+ const request = { readyJson: JSON.stringify(submitted.artifact), approvedJson: JSON.stringify(finalized.artifact), source, candidate, profileJson, decisionJson: decision, capabilityJson: capability, context };
126
+ assert.equal(recordApprovedLearningForMcp(request), 'recorded');
127
+ assert.equal(recordApprovedLearningForMcp(request), 'nothing_to_learn');
128
+ const stored = JSON.stringify(inspectLearningForMcp(profileJson, { root }));
129
+ assert.equal(JSON.parse(stored).length, 1);
130
+ assert.doesNotMatch(stored, /approved-learning-secret|payload|signature|I leverage|I use/);
131
+ }
132
+ finally {
133
+ if (previousHome === undefined)
134
+ delete process.env.HYV_HOME;
135
+ else
136
+ process.env.HYV_HOME = previousHome;
137
+ rmSync(root, { recursive: true, force: true });
138
+ }
139
+ });
140
+ test('exposes text-free learning inspection and explicit mutations through MCP helpers', () => {
141
+ const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-learning-'));
142
+ 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'] } };
143
+ 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);
144
+ const profileV3 = { ...unsigned, revisionDigest: createHash('sha256').update(canonical(unsigned)).digest('hex') };
145
+ const v3Json = JSON.stringify(profileV3);
146
+ try {
147
+ const recorded = recordLearningForMcp(v3Json, 'Keep the mechanism concrete.', { root, mutationId: 'm1', authority: 'founder', provenance: 'editor-review', weight: 2, compatibility: 'exact' });
148
+ assert.equal(recorded.status, 'recorded');
149
+ assert.equal(recordLearningForMcp(v3Json, 'Different instruction.', { root, mutationId: 'm1' }).status, 'conflict');
150
+ const inspection = inspectLearningForMcp(v3Json, { root });
151
+ assert.equal(inspection[0]?.eventType, 'instruction');
152
+ assert.equal(JSON.stringify(inspection).includes('Keep the mechanism concrete.'), false);
153
+ assert.equal(ratifyLearningForMcp(v3Json, recorded.eventId, { root, mutationId: 'm2', authority: 'founder' }).status, 'recorded');
154
+ assert.equal(supersedeLearningForMcp(v3Json, recorded.eventId, { root, mutationId: 'm3', authority: 'founder' }).status, 'recorded');
155
+ assert.equal(clearLearningForMcp(v3Json, { root }).cleared, true);
34
156
  }
35
157
  finally {
36
158
  rmSync(root, { recursive: true, force: true });
37
159
  }
38
160
  });
39
161
  test('exposes the executable pattern IDs through MCP tools', () => {
40
- assert.ok(patternsForMcp().rules.some((rule) => rule.id === 'ai.leverage'));
162
+ const catalog = patternsForMcp();
163
+ assert.ok(catalog.rules.some((rule) => rule.id === 'ai.leverage'));
41
164
  });
42
165
  test('fails closed on changed CopySpec claims through MCP tools', () => {
43
166
  const result = verifyCopySpecForMcp('The launch is on 14 August.', 'The launch is next month.', profileJson, JSON.stringify({
@@ -46,7 +169,17 @@ test('fails closed on changed CopySpec claims through MCP tools', () => {
46
169
  }));
47
170
  assert.equal(result.passed, false);
48
171
  assert.equal(result.claims.failures[0]?.code, 'missing_immutable_claim');
49
- assert.equal(result.learning, 'nothing_to_learn');
172
+ assert.equal('learning' in result, false);
173
+ });
174
+ test('allows declared CopySpec atoms to survive a split MCP rewrite', () => {
175
+ const spec = JSON.stringify({
176
+ version: '1', audience: 'operators', intent: 'explain', channel: 'email',
177
+ 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.' }],
178
+ });
179
+ 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);
180
+ assert.equal(preserved.claims.passed, true);
181
+ const missing = verifyCopySpecForMcp('Kimi K2.6 has 600 GB of INT4 weights.', 'Kimi K2.6 uses INT4 weights.', profileJson, spec);
182
+ assert.deepEqual(missing.claims.failures.map((failure) => failure.code), ['missing_immutable_atom']);
50
183
  });
51
184
  test('prepares and applies the rewrite task through MCP helpers', () => {
52
185
  const task = prepareRewriteForMcp('I leverage the answer with useful detail and clear mechanism.', profileJson);
@@ -56,3 +189,48 @@ test('prepares and applies the rewrite task through MCP helpers', () => {
56
189
  assert.equal(result.status, 'needs_semantic_review');
57
190
  assert.equal(result.candidate, 'I use the answer with useful detail and clear mechanism.');
58
191
  });
192
+ test('prepares and reduces judgment envelopes through MCP helpers', () => {
193
+ const draft = 'I leverage the answer.';
194
+ const envelopes = ['triage', 'argument', 'form'].map((kind) => {
195
+ const task = prepareJudgmentForMcp('pre-edit', kind, draft, profileJson);
196
+ return {
197
+ version: '1', stage: 'pre-edit', judgmentType: kind, taskFingerprint: task.taskFingerprint,
198
+ bindings: { ...task.bindings, evaluatorId: 'writer.1' }, findings: [], decision: 'SHIP',
199
+ };
200
+ });
201
+ assert.equal(reduceJudgmentForMcp(JSON.stringify(envelopes)).decision, 'SHIP');
202
+ });
203
+ test('prepares and evaluates authorized rebuild through MCP helpers', () => {
204
+ const draft = 'I leverage the answer. The launch is on 14 August.';
205
+ const envelopes = ['triage', 'argument', 'form'].map((kind) => {
206
+ const task = prepareJudgmentForMcp('pre-edit', kind, draft, profileJson);
207
+ return {
208
+ version: '1', stage: 'pre-edit', judgmentType: kind, taskFingerprint: task.taskFingerprint,
209
+ bindings: { ...task.bindings, evaluatorId: 'writer.1' }, findings: kind === 'argument' ? [] : [], decision: kind === 'argument' ? 'REBUILD' : 'SHIP',
210
+ };
211
+ });
212
+ const reduction = reduceJudgmentForMcp(JSON.stringify(envelopes));
213
+ assert.equal(reduction.decision, 'REBUILD');
214
+ if (!('recommendationFingerprint' in reduction))
215
+ throw new Error('expected a pre-edit rebuild recommendation');
216
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
217
+ const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'host.example', keyId: 'key-1', publicKeySpki: publicKey.export({ format: 'der', type: 'spki' }).toString('base64url'), status: 'active' }] };
218
+ const sourceHash = createHash('sha256').update(draft).digest('hex');
219
+ const identity = `legacy-v2:${createHash('sha256').update(canonicalJson(profile)).digest('hex')}`;
220
+ const claims = {
221
+ version: '1', purpose: 'hyv.rebuild-authorization', issuer: 'host.example', audience: '@holdyourvoice/hyv',
222
+ subjectArtifactFingerprint: reduction.recommendationFingerprint, sourceHash, candidateHash: sourceHash,
223
+ profileId: identity, profileRevisionDigest: identity, keyId: 'key-1', issuedAt: 100, notBefore: 100, expiresAt: 200, nonce: 'mcp-rebuild',
224
+ };
225
+ const payload = Buffer.from(canonicalJson(claims));
226
+ const capability = { payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') };
227
+ const copySpec = JSON.stringify({ version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar, 7 August.' }] });
228
+ const context = { now: 150, trustStore, authorizedSemanticEvaluatorIds: { normal: [], highAssurance: [] }, authorizedHumanFinalizerIds: [] };
229
+ const task = prepareRebuildForMcp(draft, profileJson, JSON.stringify(reduction), copySpec, JSON.stringify(capability), context);
230
+ const result = applyRebuildForMcp(JSON.stringify(task), JSON.stringify({
231
+ version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint,
232
+ candidate: 'Ship planning now treats one calendar fact as fixed. The launch is on 14 August. Every other sentence in this note is new operational language for the release desk.',
233
+ }), profileJson, JSON.stringify(capability), context);
234
+ assert.equal(result.status, 'needs_semantic_review');
235
+ assert.equal(result.receipt.mode, 'REBUILD');
236
+ });
package/dist/mcp.js CHANGED
@@ -1,20 +1,41 @@
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, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, finalizeRejectionForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, migrateLearningForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, recordApprovedLearningForMcp, recordLearningForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
5
+ import { HYV_VERSION } from './version.js';
6
+ import { loadApprovalContext } from './approval-context.js';
5
7
  const writing = z.string().min(1).max(100_000);
8
+ const hygieneText = z.string().max(100_000);
6
9
  const profileJson = z.string().min(1).max(50_000);
7
10
  const copySpecJson = z.string().min(1).max(250_000);
8
11
  const writingBriefJson = z.string().min(1).max(50_000);
9
12
  const samples = z.array(writing).min(2).max(20);
10
13
  const avoid = z.array(z.string().min(1).max(200)).max(50).optional();
14
+ const lifecycleJson = z.string().min(1).max(1_048_576);
15
+ const approvedLearningText = z.string().min(1).max(1_048_576);
16
+ const evaluatorId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/);
17
+ const semanticViolation = z.enum(['action_change', 'dropped_object', 'unsupported_claim', 'constraint_weakened', 'clarity_regression']);
18
+ const redactsSensitiveInputs = process.env.HYV_MCP_SENSITIVE_INPUT_REDACTION === '1';
19
+ const learningOptions = {
20
+ mutation_id: z.string().min(1).max(200).optional(),
21
+ authority: z.enum(['founder', 'team', 'system']).optional(),
22
+ provenance: z.string().min(1).max(500).optional(),
23
+ weight: z.number().positive().finite().optional(),
24
+ compatibility: z.enum(['same-or-newer', 'exact']).optional(),
25
+ };
26
+ function learningArgs(value) {
27
+ return { mutationId: value.mutation_id, authority: value.authority, provenance: value.provenance, weight: value.weight, compatibility: value.compatibility };
28
+ }
11
29
  function json(value) {
12
30
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
13
31
  }
14
32
  function failure(error) {
15
33
  return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
16
34
  }
17
- const server = new McpServer({ name: 'hold-your-voice', version: '3.1.1' });
35
+ function lifecycleResult(result) {
36
+ return json(result.ok ? result.artifact : { error: result.error });
37
+ }
38
+ const server = new McpServer({ name: 'hold-your-voice', version: HYV_VERSION });
18
39
  server.registerTool('hyv_build_profile', {
19
40
  description: 'Build a portable VoiceDNA profile from at least two writing samples. The samples stay in memory and are not saved.',
20
41
  inputSchema: { samples, avoid },
@@ -28,7 +49,7 @@ server.registerTool('hyv_build_profile', {
28
49
  }
29
50
  });
30
51
  server.registerTool('hyv_analyze', {
31
- description: 'Run the separate VoiceDNA and AI Editor checks against a draft using a portable profile JSON string.',
52
+ 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
53
  inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
33
54
  annotations: { readOnlyHint: true },
34
55
  }, async ({ draft, profile_json, writing_brief_json }) => {
@@ -39,6 +60,16 @@ server.registerTool('hyv_analyze', {
39
60
  return failure(error);
40
61
  }
41
62
  });
63
+ server.registerTool('hyv_hygiene', {
64
+ description: 'Inspect text for zero-width characters, bidirectional controls, Unicode tag characters, and unusual spaces without changing it or requiring a voice profile.',
65
+ inputSchema: { draft: hygieneText },
66
+ annotations: { readOnlyHint: true },
67
+ }, async ({ draft }) => json(inspectHygieneForMcp(draft)));
68
+ server.registerTool('hyv_final_check', {
69
+ 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.',
70
+ inputSchema: { text: hygieneText },
71
+ annotations: { readOnlyHint: true },
72
+ }, async ({ text }) => json(finalOutputCheckForMcp(text)));
42
73
  server.registerTool('hyv_rewrite_prompt', {
43
74
  description: 'Create a constrained editing brief. It does not rewrite the draft or call a model.',
44
75
  inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
@@ -75,25 +106,55 @@ server.registerTool('hyv_apply_rewrite', {
75
106
  return failure(error);
76
107
  }
77
108
  });
109
+ server.registerTool('hyv_prepare_judgment', {
110
+ description: 'Prepare a versioned pre-edit or post-candidate judgment task. It does not call a model.',
111
+ inputSchema: {
112
+ stage: z.enum(['pre-edit', 'post-candidate']),
113
+ kind: z.enum(['triage', 'argument', 'form', 'polarity', 'flatness', 'semantic']),
114
+ draft: writing,
115
+ profile_json: profileJson,
116
+ candidate: writing.optional(),
117
+ },
118
+ annotations: { readOnlyHint: true },
119
+ }, async ({ stage, kind, draft, profile_json, candidate }) => {
120
+ try {
121
+ return json(prepareJudgmentForMcp(stage, kind, draft, profile_json, candidate));
122
+ }
123
+ catch (error) {
124
+ return failure(error);
125
+ }
126
+ });
127
+ server.registerTool('hyv_reduce_judgment', {
128
+ description: 'Reduce bound judgment envelopes into SHIP, EDIT, REBUILD, CLEAR, or ESCALATE. It does not call a model.',
129
+ inputSchema: { envelopes_json: z.string().min(1).max(250_000) },
130
+ annotations: { readOnlyHint: true },
131
+ }, async ({ envelopes_json }) => {
132
+ try {
133
+ return json(reduceJudgmentForMcp(envelopes_json));
134
+ }
135
+ catch (error) {
136
+ return failure(error);
137
+ }
138
+ });
78
139
  server.registerTool('hyv_verify', {
79
- 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.',
140
+ description: 'Verify a revised candidate against an original draft and portable profile without changing learning state.',
80
141
  inputSchema: { original: writing, candidate: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
81
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
142
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
82
143
  }, async ({ original, candidate, profile_json, writing_brief_json }) => {
83
144
  try {
84
- return json(verifyForMcp(original, candidate, profile_json, {}, writing_brief_json));
145
+ return json(verifyForMcp(original, candidate, profile_json, writing_brief_json));
85
146
  }
86
147
  catch (error) {
87
148
  return failure(error);
88
149
  }
89
150
  });
90
151
  server.registerTool('hyv_verify_copy_spec', {
91
- description: 'Verify a candidate against the existing voice gates and a local CopySpec. Immutable claims must remain verbatim and each carries local evidence; prohibited claims fail closed.',
152
+ 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
153
  inputSchema: { original: writing, candidate: writing, profile_json: profileJson, copy_spec_json: copySpecJson, writing_brief_json: writingBriefJson.optional() },
93
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
154
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
94
155
  }, async ({ original, candidate, profile_json, copy_spec_json, writing_brief_json }) => {
95
156
  try {
96
- return json(verifyCopySpecForMcp(original, candidate, profile_json, copy_spec_json, {}, writing_brief_json));
157
+ return json(verifyCopySpecForMcp(original, candidate, profile_json, copy_spec_json, writing_brief_json));
97
158
  }
98
159
  catch (error) {
99
160
  return failure(error);
@@ -116,4 +177,162 @@ server.registerTool('hyv_patterns', {
116
177
  inputSchema: {},
117
178
  annotations: { readOnlyHint: true },
118
179
  }, async () => json(patternsForMcp()));
180
+ server.registerTool('hyv_learning_inspect', {
181
+ description: 'Inspect profile-scoped learning receipts without returning stored instruction or draft text.',
182
+ inputSchema: { profile_json: profileJson }, annotations: { readOnlyHint: true },
183
+ }, async ({ profile_json }) => { try {
184
+ return json(inspectLearningForMcp(profile_json));
185
+ }
186
+ catch (error) {
187
+ return failure(error);
188
+ } });
189
+ server.registerTool('hyv_learning_record', {
190
+ description: 'Record an explicit profile-scoped learning instruction with authority and provenance metadata.',
191
+ inputSchema: { profile_json: profileJson, instruction: z.string().min(1).max(240), ...learningOptions }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
192
+ }, async (args) => { try {
193
+ return json(recordLearningForMcp(args.profile_json, args.instruction, learningArgs(args)));
194
+ }
195
+ catch (error) {
196
+ return failure(error);
197
+ } });
198
+ server.registerTool('hyv_learning_ratify', {
199
+ description: 'Ratify a learning event for a Profile v3 revision.',
200
+ inputSchema: { profile_json: profileJson, event_id: z.string().min(1).max(200), ...learningOptions }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
201
+ }, async (args) => { try {
202
+ return json(ratifyLearningForMcp(args.profile_json, args.event_id, learningArgs(args)));
203
+ }
204
+ catch (error) {
205
+ return failure(error);
206
+ } });
207
+ server.registerTool('hyv_learning_supersede', {
208
+ description: 'Supersede a learning event for a Profile v3 revision.',
209
+ inputSchema: { profile_json: profileJson, event_id: z.string().min(1).max(200), ...learningOptions }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
210
+ }, async (args) => { try {
211
+ return json(supersedeLearningForMcp(args.profile_json, args.event_id, learningArgs(args)));
212
+ }
213
+ catch (error) {
214
+ return failure(error);
215
+ } });
216
+ server.registerTool('hyv_learning_migrate', {
217
+ description: 'Migrate Profile v2 learning into a Profile v3 identity.',
218
+ inputSchema: { source_profile_json: profileJson, target_profile_json: profileJson, ...learningOptions }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
219
+ }, async (args) => { try {
220
+ return json(migrateLearningForMcp(args.source_profile_json, args.target_profile_json, learningArgs(args)));
221
+ }
222
+ catch (error) {
223
+ return failure(error);
224
+ } });
225
+ server.registerTool('hyv_learning_clear', {
226
+ description: 'Delete all local learning state for a profile.',
227
+ inputSchema: { profile_json: profileJson }, annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
228
+ }, async ({ profile_json }) => { try {
229
+ return json(clearLearningForMcp(profile_json));
230
+ }
231
+ catch (error) {
232
+ return failure(error);
233
+ } });
234
+ server.registerTool('hyv_lifecycle_prepare_semantic', {
235
+ description: 'Prepare a normal semantic-review task and its initial immutable lifecycle artifact.',
236
+ inputSchema: { deterministic_json: lifecycleJson, binding_json: lifecycleJson, receipt_json: lifecycleJson, policy: z.literal('normal'), allowed_violations: z.array(semanticViolation).max(5) },
237
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
238
+ }, async (args) => { try {
239
+ return json(prepareLifecycleForMcp(args.deterministic_json, args.binding_json, args.receipt_json, args.policy, args.allowed_violations));
240
+ }
241
+ catch (error) {
242
+ return failure(error);
243
+ } });
244
+ server.registerTool('hyv_lifecycle_submit_verdict', {
245
+ description: 'Submit one normal-policy semantic verdict using the server-installed evaluator authorization context.',
246
+ inputSchema: { artifact_json: lifecycleJson, task_json: lifecycleJson, evaluator_id: evaluatorId, verdict_json: lifecycleJson },
247
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
248
+ }, async (args) => { try {
249
+ return lifecycleResult(submitSemanticVerdictForMcp(args.artifact_json, args.task_json, args.evaluator_id, args.verdict_json, loadApprovalContext()));
250
+ }
251
+ catch (error) {
252
+ return failure(error);
253
+ } });
254
+ server.registerTool('hyv_lifecycle_inspect', {
255
+ description: 'Validate and inspect an immutable lifecycle artifact without exposing bound source or candidate hashes.',
256
+ inputSchema: { artifact_json: lifecycleJson }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
257
+ }, async ({ artifact_json }) => { try {
258
+ return json(inspectLifecycleForMcp(artifact_json));
259
+ }
260
+ catch (error) {
261
+ return failure(error);
262
+ } });
263
+ if (redactsSensitiveInputs) {
264
+ server.registerTool('hyv_lifecycle_finalize', {
265
+ description: 'Finalize an authorized human approval or rejection. Capability input requires host-guaranteed sensitive-input redaction.',
266
+ inputSchema: { artifact_json: lifecycleJson, decision_json: lifecycleJson, capability_json: lifecycleJson.optional() },
267
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
268
+ }, async (args) => { try {
269
+ return lifecycleResult(finalizeLifecycleForMcp(args.artifact_json, args.decision_json, loadApprovalContext(), args.capability_json));
270
+ }
271
+ catch {
272
+ return failure(new Error('Lifecycle finalization failed.'));
273
+ } });
274
+ server.registerTool('hyv_lifecycle_validate_final_approval', {
275
+ description: 'Validate a final-approval capability against the server-installed trust context.',
276
+ inputSchema: { artifact_json: lifecycleJson, capability_json: lifecycleJson }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
277
+ }, async (args) => { try {
278
+ return json(validateFinalApprovalForMcp(args.artifact_json, args.capability_json, loadApprovalContext()));
279
+ }
280
+ catch {
281
+ return failure(new Error('Capability validation failed.'));
282
+ } });
283
+ server.registerTool('hyv_learning_record_approved', {
284
+ description: 'Record one approval-revalidated, deterministic, text-free learning event.',
285
+ inputSchema: { ready_json: lifecycleJson, approved_json: lifecycleJson, original: approvedLearningText, candidate: approvedLearningText, profile_json: profileJson, decision_json: lifecycleJson, capability_json: lifecycleJson, copy_spec_json: copySpecJson.optional(), writing_brief_json: writingBriefJson.optional() },
286
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
287
+ }, async (args) => { try {
288
+ return json({ status: recordApprovedLearningForMcp({ readyJson: args.ready_json, approvedJson: args.approved_json, source: args.original, candidate: args.candidate, profileJson: args.profile_json, decisionJson: args.decision_json, capabilityJson: args.capability_json, context: loadApprovalContext(), copySpecJson: args.copy_spec_json, writingBriefJson: args.writing_brief_json }) });
289
+ }
290
+ catch {
291
+ return failure(new Error('Approved learning was not authorized.'));
292
+ } });
293
+ server.registerTool('hyv_prepare_rebuild', {
294
+ description: 'Prepare a rebuild task only after an upstream REBUILD recommendation, CopySpec, and signed rebuild-authorization capability. Capability input requires host-guaranteed sensitive-input redaction.',
295
+ inputSchema: {
296
+ draft: writing,
297
+ profile_json: profileJson,
298
+ reduction_json: lifecycleJson,
299
+ copy_spec_json: copySpecJson,
300
+ capability_json: lifecycleJson,
301
+ writing_brief_json: writingBriefJson.optional(),
302
+ },
303
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
304
+ }, async (args) => {
305
+ try {
306
+ return json(prepareRebuildForMcp(args.draft, args.profile_json, args.reduction_json, args.copy_spec_json, args.capability_json, loadApprovalContext(), args.writing_brief_json));
307
+ }
308
+ catch {
309
+ return failure(new Error('Rebuild preparation failed.'));
310
+ }
311
+ });
312
+ server.registerTool('hyv_apply_rebuild', {
313
+ description: 'Validate and evaluate a whole-document rebuild response against a prepared authorized rebuild task. Capability input requires host-guaranteed sensitive-input redaction. It never calls a provider.',
314
+ inputSchema: { task_json: lifecycleJson, response_json: z.string().min(1).max(100_000), profile_json: profileJson, capability_json: lifecycleJson },
315
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
316
+ }, async (args) => {
317
+ try {
318
+ return json(applyRebuildForMcp(args.task_json, args.response_json, args.profile_json, args.capability_json, loadApprovalContext()));
319
+ }
320
+ catch {
321
+ return failure(new Error('Rebuild application failed.'));
322
+ }
323
+ });
324
+ }
325
+ else {
326
+ server.registerTool('hyv_lifecycle_finalize', {
327
+ description: 'Record an authorized human rejection. Approval is unavailable because this host does not guarantee sensitive-input redaction.',
328
+ inputSchema: { artifact_json: lifecycleJson, decision_json: lifecycleJson }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
329
+ }, async (args) => {
330
+ try {
331
+ return lifecycleResult(finalizeRejectionForMcp(args.artifact_json, args.decision_json, loadApprovalContext()));
332
+ }
333
+ catch {
334
+ return failure(new Error('Only rejection is available without sensitive-input redaction.'));
335
+ }
336
+ });
337
+ }
119
338
  await server.connect(new StdioServerTransport());