@holdyourvoice/hyv 3.2.0 → 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 (50) hide show
  1. package/Readme.md +23 -10
  2. package/dist/ai-editor-rules.js +5 -2
  3. package/dist/ai-editor.js +52 -9
  4. package/dist/ai-editor.test.js +62 -10
  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 +272 -19
  14. package/dist/cli.test.js +205 -8
  15. package/dist/hygiene.js +6 -0
  16. package/dist/hygiene.test.js +7 -1
  17. package/dist/judgment-task.js +171 -0
  18. package/dist/judgment-task.test.js +162 -0
  19. package/dist/learning.js +240 -100
  20. package/dist/learning.test.js +203 -3
  21. package/dist/lifecycle-adapter.js +75 -0
  22. package/dist/lifecycle-adapter.test.js +56 -0
  23. package/dist/mcp-tools.js +101 -7
  24. package/dist/mcp-tools.test.js +156 -6
  25. package/dist/mcp.js +213 -6
  26. package/dist/mcp.test.js +210 -11
  27. package/dist/pipeline.js +78 -14
  28. package/dist/pipeline.test.js +36 -2
  29. package/dist/preservation.js +89 -0
  30. package/dist/preservation.test.js +22 -0
  31. package/dist/profile.js +87 -0
  32. package/dist/profile.test.js +114 -0
  33. package/dist/rebuild-task.js +226 -0
  34. package/dist/rebuild-task.test.js +179 -0
  35. package/dist/release-audit.test.js +111 -2
  36. package/dist/rewrite-task.js +136 -16
  37. package/dist/rewrite-task.test.js +62 -7
  38. package/dist/rule-reconciliation.test.js +50 -0
  39. package/dist/semantic-review.js +176 -7
  40. package/dist/semantic-review.test.js +98 -14
  41. package/dist/stage1-dry-run.test.js +39 -0
  42. package/dist/stage1-evaluation.js +579 -0
  43. package/dist/stage1-evaluation.test.js +184 -0
  44. package/dist/stage1-human-packet.test.js +102 -0
  45. package/dist/stage1-schema-contract.test.js +95 -0
  46. package/dist/stage2-human-packet.test.js +81 -0
  47. package/dist/version.js +1 -1
  48. package/dist/voice-dna.js +53 -1
  49. package/dist/voice-dna.test.js +79 -1
  50. 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, finalOutputCheckForMcp, inspectHygieneForMcp, 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', () => {
@@ -39,15 +41,118 @@ test('accepts optional WritingBrief context and exposes batch findings through M
39
41
  const batch = analyzeBatchForMcp(['The launch needs a clear owner.', 'The launch needs a clear owner.']);
40
42
  assert.equal(batch.findings.length, 2);
41
43
  });
42
- test('creates and verifies an editing loop through MCP tools', () => {
44
+ test('keeps MCP verification read-only', () => {
43
45
  const root = mkdtempSync(join(tmpdir(), 'holdyourvoice-mcp-'));
44
46
  try {
45
- 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);
46
48
  const brief = rewritePromptForMcp('I leverage a clear plan.', profileJson, { root });
47
49
  assert.match(brief.prompt, /Tier 0/);
48
- assert.match(brief.prompt, /Learned local preferences/);
50
+ assert.doesNotMatch(brief.prompt, /Learned local preferences/);
49
51
  assert.equal(result.preservationScore >= 70, true);
50
- 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);
51
156
  }
52
157
  finally {
53
158
  rmSync(root, { recursive: true, force: true });
@@ -64,7 +169,7 @@ test('fails closed on changed CopySpec claims through MCP tools', () => {
64
169
  }));
65
170
  assert.equal(result.passed, false);
66
171
  assert.equal(result.claims.failures[0]?.code, 'missing_immutable_claim');
67
- assert.equal(result.learning, 'nothing_to_learn');
172
+ assert.equal('learning' in result, false);
68
173
  });
69
174
  test('allows declared CopySpec atoms to survive a split MCP rewrite', () => {
70
175
  const spec = JSON.stringify({
@@ -84,3 +189,48 @@ test('prepares and applies the rewrite task through MCP helpers', () => {
84
189
  assert.equal(result.status, 'needs_semantic_review');
85
190
  assert.equal(result.candidate, 'I use the answer with useful detail and clear mechanism.');
86
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,8 +1,9 @@
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, finalOutputCheckForMcp, inspectHygieneForMcp, 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
5
  import { HYV_VERSION } from './version.js';
6
+ import { loadApprovalContext } from './approval-context.js';
6
7
  const writing = z.string().min(1).max(100_000);
7
8
  const hygieneText = z.string().max(100_000);
8
9
  const profileJson = z.string().min(1).max(50_000);
@@ -10,12 +11,30 @@ const copySpecJson = z.string().min(1).max(250_000);
10
11
  const writingBriefJson = z.string().min(1).max(50_000);
11
12
  const samples = z.array(writing).min(2).max(20);
12
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
+ }
13
29
  function json(value) {
14
30
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
15
31
  }
16
32
  function failure(error) {
17
33
  return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
18
34
  }
35
+ function lifecycleResult(result) {
36
+ return json(result.ok ? result.artifact : { error: result.error });
37
+ }
19
38
  const server = new McpServer({ name: 'hold-your-voice', version: HYV_VERSION });
20
39
  server.registerTool('hyv_build_profile', {
21
40
  description: 'Build a portable VoiceDNA profile from at least two writing samples. The samples stay in memory and are not saved.',
@@ -87,13 +106,43 @@ server.registerTool('hyv_apply_rewrite', {
87
106
  return failure(error);
88
107
  }
89
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
+ });
90
139
  server.registerTool('hyv_verify', {
91
- description: 'Verify a revised candidate against an original draft and portable profile. On a successful check, it stores only resolved finding IDs in local profile-scoped learning state; it never retains either text.',
140
+ description: 'Verify a revised candidate against an original draft and portable profile without changing learning state.',
92
141
  inputSchema: { original: writing, candidate: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
93
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
142
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
94
143
  }, async ({ original, candidate, profile_json, writing_brief_json }) => {
95
144
  try {
96
- return json(verifyForMcp(original, candidate, profile_json, {}, writing_brief_json));
145
+ return json(verifyForMcp(original, candidate, profile_json, writing_brief_json));
97
146
  }
98
147
  catch (error) {
99
148
  return failure(error);
@@ -102,10 +151,10 @@ server.registerTool('hyv_verify', {
102
151
  server.registerTool('hyv_verify_copy_spec', {
103
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.',
104
153
  inputSchema: { original: writing, candidate: writing, profile_json: profileJson, copy_spec_json: copySpecJson, writing_brief_json: writingBriefJson.optional() },
105
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
154
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
106
155
  }, async ({ original, candidate, profile_json, copy_spec_json, writing_brief_json }) => {
107
156
  try {
108
- 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));
109
158
  }
110
159
  catch (error) {
111
160
  return failure(error);
@@ -128,4 +177,162 @@ server.registerTool('hyv_patterns', {
128
177
  inputSchema: {},
129
178
  annotations: { readOnlyHint: true },
130
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
+ }
131
338
  await server.connect(new StdioServerTransport());