@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
@@ -3,7 +3,7 @@ import { appendFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'n
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
- import { addLearningInstruction, clearLearning, composeLearning, profileFingerprint, recordVerifiedCandidate } from './learning.js';
6
+ import { addLearningInstruction, clearLearning, composeLearning, inspectLearning, migrateLearningV2ToV3, profileFingerprint, ratifyLearningEvent, recordLearningInstruction, recordVerifiedCandidate, supersedeLearningEvent } from './learning.js';
7
7
  import { verify } from './pipeline.js';
8
8
  import { buildProfile } from './voice-dna.js';
9
9
  const profile = buildProfile([
@@ -13,6 +13,206 @@ const profile = buildProfile([
13
13
  function directory() {
14
14
  return mkdtempSync(join(tmpdir(), 'holdyourvoice-learning-'));
15
15
  }
16
+ function version3(id, revision) {
17
+ return {
18
+ ...profile,
19
+ version: '3', id, revision, revisionDigest: String(revision).padStart(64, '0'),
20
+ provenance: { source: 'test', rights: 'test', createdAt: '2026-08-13T00:00:00.000Z' },
21
+ rulePolicy: {},
22
+ fingerprint: { contractionRate: 0, sentenceLengthDistribution: { short: 1, medium: 0, long: 0 }, bulletRate: 0, enDashRate: 0 },
23
+ tolerances: {
24
+ contractionRate: { absolute: 0.1, calibrated: false }, sentenceLengthDistribution: { absolute: 0.1, calibrated: false },
25
+ bulletRate: { absolute: 0.1, calibrated: false }, enDashRate: { absolute: 0.1, calibrated: false },
26
+ },
27
+ metricFixtures: { contractionRate: ['test'], sentenceLengthDistribution: ['test'], bulletRate: ['test'], enDashRate: ['test'] },
28
+ };
29
+ }
30
+ test('keeps compatible learning across Profile v3 revisions by stable identity', () => {
31
+ const root = directory();
32
+ try {
33
+ assert.equal(addLearningInstruction(version3('founder.jane', 1), 'Keep the mechanism.', { root }), true);
34
+ assert.deepEqual(composeLearning(version3('founder.jane', 2), { root }), [{ text: 'Keep the mechanism.', count: 1 }]);
35
+ assert.deepEqual(composeLearning(version3('founder.other', 2), { root }), []);
36
+ }
37
+ finally {
38
+ rmSync(root, { recursive: true, force: true });
39
+ }
40
+ });
41
+ test('migrates v2 learning explicitly and idempotently without crossing identities', () => {
42
+ const root = directory();
43
+ try {
44
+ addLearningInstruction(profile, 'Keep it direct.', { root });
45
+ const first = migrateLearningV2ToV3(profile, version3('founder.jane', 1), { root, mutationId: 'migration-1' });
46
+ const replay = migrateLearningV2ToV3(profile, version3('founder.jane', 1), { root, mutationId: 'migration-1' });
47
+ assert.equal(first.status, 'recorded');
48
+ assert.equal(replay.status, 'already_recorded');
49
+ assert.deepEqual(composeLearning(version3('founder.jane', 1), { root }), [{ text: 'Keep it direct.', count: 1 }]);
50
+ assert.deepEqual(composeLearning(version3('founder.other', 1), { root }), []);
51
+ }
52
+ finally {
53
+ rmSync(root, { recursive: true, force: true });
54
+ }
55
+ });
56
+ test('imports legacy v1 JSONL and makes default migration replay idempotent', () => {
57
+ const root = directory();
58
+ try {
59
+ const legacyFile = join(root, 'learning', `${profileFingerprint(profile)}.jsonl`);
60
+ addLearningInstruction(profile, 'bootstrap', { root });
61
+ const legacy = { version: '1', timestamp: '2026-08-13T00:00:00.000Z', kind: 'instruction', instruction: 'Keep the legacy cadence.' };
62
+ appendFileSync(legacyFile, `${JSON.stringify(legacy)}\n`);
63
+ const target = version3('founder.jane', 1);
64
+ const first = migrateLearningV2ToV3(profile, target, { root });
65
+ const replay = migrateLearningV2ToV3(profile, target, { root });
66
+ assert.equal(first.status, 'recorded');
67
+ assert.equal(replay.status, 'already_recorded');
68
+ assert.ok(composeLearning(target, { root }).some((item) => item.text === 'Keep the legacy cadence.'));
69
+ }
70
+ finally {
71
+ rmSync(root, { recursive: true, force: true });
72
+ }
73
+ });
74
+ test('inspects bounded learning metadata without leaking prose', () => {
75
+ const root = directory();
76
+ try {
77
+ const current = version3('founder.jane', 1);
78
+ recordLearningInstruction(current, 'Never expose this instruction.', { root, mutationId: 'inspect-1', authority: 'founder', provenance: 'author-interview' });
79
+ const inspection = inspectLearning(current, { root });
80
+ assert.equal(inspection.length, 1);
81
+ assert.equal(inspection[0]?.status, 'active');
82
+ assert.equal(inspection[0]?.authority, 'founder');
83
+ const serialized = JSON.stringify(inspection);
84
+ assert.doesNotMatch(serialized, /Never expose this instruction|candidate text|source text/);
85
+ assert.ok(inspection.every((item) => !('text' in item) && !('instruction' in item) && !('candidate' in item)));
86
+ }
87
+ finally {
88
+ rmSync(root, { recursive: true, force: true });
89
+ }
90
+ });
91
+ test('makes mutation replay atomic and returns a text-free receipt', () => {
92
+ const root = directory();
93
+ try {
94
+ const first = recordLearningInstruction(version3('founder.jane', 1), 'Keep it direct.', { root, mutationId: 'instruction-1', authority: 'founder' });
95
+ const replay = recordLearningInstruction(version3('founder.jane', 1), 'Keep it direct.', { root, mutationId: 'instruction-1', authority: 'founder' });
96
+ assert.equal(first.status, 'recorded');
97
+ assert.equal(replay.status, 'already_recorded');
98
+ assert.equal(JSON.stringify(first).includes('Keep it direct.'), false);
99
+ assert.deepEqual(composeLearning(version3('founder.jane', 1), { root }), [{ text: 'Keep it direct.', count: 1 }]);
100
+ }
101
+ finally {
102
+ rmSync(root, { recursive: true, force: true });
103
+ }
104
+ });
105
+ test('rejects conflicting mutation-id reuse without changing stored learning', () => {
106
+ const root = directory();
107
+ try {
108
+ const current = version3('founder.jane', 1);
109
+ assert.equal(recordLearningInstruction(current, 'First instruction.', { root, mutationId: 'same' }).status, 'recorded');
110
+ assert.equal(recordLearningInstruction(current, 'Conflicting instruction.', { root, mutationId: 'same' }).status, 'conflict');
111
+ assert.deepEqual(composeLearning(current, { root }), [{ text: 'First instruction.', count: 1 }]);
112
+ }
113
+ finally {
114
+ rmSync(root, { recursive: true, force: true });
115
+ }
116
+ });
117
+ test('fails closed on corrupt learning without rewriting the original', () => {
118
+ const root = directory();
119
+ try {
120
+ const current = version3('founder.jane', 1);
121
+ recordLearningInstruction(current, 'Valid.', { root, mutationId: 'valid' });
122
+ const file = join(root, 'learning', 'founder.jane.jsonl');
123
+ appendFileSync(file, '{corrupt}\n');
124
+ const before = readFileSync(file, 'utf8');
125
+ assert.throws(() => composeLearning(current, { root }), /corrupt/i);
126
+ assert.equal(recordLearningInstruction(current, 'Must not write.', { root, mutationId: 'blocked' }).status, 'corrupt');
127
+ assert.equal(readFileSync(file, 'utf8'), before);
128
+ }
129
+ finally {
130
+ rmSync(root, { recursive: true, force: true });
131
+ }
132
+ });
133
+ test('rejects lower-authority ratification and supersession', () => {
134
+ const root = directory();
135
+ try {
136
+ const current = version3('founder.jane', 1);
137
+ const founder = recordLearningInstruction(current, 'Founder ruling.', { root, mutationId: 'founder-rule', authority: 'founder' });
138
+ assert.equal(ratifyLearningEvent(current, founder.eventId, { root, mutationId: 'team-ratify', authority: 'team' }).status, 'unauthorized');
139
+ assert.equal(supersedeLearningEvent(current, founder.eventId, { root, mutationId: 'team-supersede', authority: 'team' }).status, 'unauthorized');
140
+ assert.deepEqual(composeLearning(current, { root }), [{ text: 'Founder ruling.', count: 1 }]);
141
+ }
142
+ finally {
143
+ rmSync(root, { recursive: true, force: true });
144
+ }
145
+ });
146
+ test('inspection identifiers and digests do not depend on secret prose or provenance', () => {
147
+ const firstRoot = directory();
148
+ const secondRoot = directory();
149
+ try {
150
+ const current = version3('founder.jane', 1);
151
+ recordLearningInstruction(current, 'Secret alpha.', { root: firstRoot, mutationId: 'public-id', provenance: 'secret provenance alpha' });
152
+ recordLearningInstruction(current, 'Secret beta.', { root: secondRoot, mutationId: 'public-id', provenance: 'secret provenance beta' });
153
+ const publicFields = (root) => inspectLearning(current, { root }).map(({ timestamp: _timestamp, ...item }) => item);
154
+ assert.deepEqual(publicFields(firstRoot), publicFields(secondRoot));
155
+ }
156
+ finally {
157
+ rmSync(firstRoot, { recursive: true, force: true });
158
+ rmSync(secondRoot, { recursive: true, force: true });
159
+ }
160
+ });
161
+ test('migration fails atomically when all intended imports cannot fit', () => {
162
+ const root = directory();
163
+ try {
164
+ for (let index = 0; index < 40; index += 1)
165
+ recordLearningInstruction(profile, `Legacy ${index}.`, { root, mutationId: `legacy-${index}` });
166
+ const target = version3('founder.capacity', 1);
167
+ assert.equal(migrateLearningV2ToV3(profile, target, { root }).status, 'capacity_exceeded');
168
+ assert.deepEqual(composeLearning(target, { root }), []);
169
+ }
170
+ finally {
171
+ rmSync(root, { recursive: true, force: true });
172
+ }
173
+ });
174
+ test('ratifies incompatible events and supersedes them without deleting audit history', () => {
175
+ const root = directory();
176
+ try {
177
+ const recorded = recordLearningInstruction(version3('founder.jane', 1), 'Keep the old cadence.', { root, mutationId: 'old', compatibility: 'exact' });
178
+ assert.deepEqual(composeLearning(version3('founder.jane', 2), { root }), []);
179
+ assert.equal(ratifyLearningEvent(version3('founder.jane', 2), recorded.eventId, { root, mutationId: 'ratify-old' }).status, 'recorded');
180
+ assert.deepEqual(composeLearning(version3('founder.jane', 2), { root }), [{ text: 'Keep the old cadence.', count: 1 }]);
181
+ assert.equal(supersedeLearningEvent(version3('founder.jane', 2), recorded.eventId, { root, mutationId: 'supersede-old' }).status, 'recorded');
182
+ assert.deepEqual(composeLearning(version3('founder.jane', 2), { root }), []);
183
+ }
184
+ finally {
185
+ rmSync(root, { recursive: true, force: true });
186
+ }
187
+ });
188
+ test('compaction retains higher-authority learning before lower-authority observations', () => {
189
+ const root = directory();
190
+ try {
191
+ const current = version3('founder.jane', 1);
192
+ recordLearningInstruction(current, 'Founder ruling.', { root, mutationId: 'founder', authority: 'founder', weight: 1 });
193
+ for (let index = 0; index < 45; index += 1)
194
+ recordLearningInstruction(current, `Team observation ${index}.`, { root, mutationId: `team-${index}`, authority: 'team', weight: 100 });
195
+ assert.ok(composeLearning(current, { root }).some((item) => item.text === 'Founder ruling.'));
196
+ }
197
+ finally {
198
+ rmSync(root, { recursive: true, force: true });
199
+ }
200
+ });
201
+ test('compaction preserves a retained target with its ratification control', () => {
202
+ const root = directory();
203
+ try {
204
+ const first = version3('founder.controls', 1);
205
+ const second = version3('founder.controls', 2);
206
+ const old = recordLearningInstruction(first, 'Ratified founder ruling.', { root, mutationId: 'old-founder', authority: 'founder', compatibility: 'exact' });
207
+ ratifyLearningEvent(second, old.eventId, { root, mutationId: 'ratify-founder', authority: 'founder' });
208
+ for (let index = 0; index < 45; index += 1)
209
+ recordLearningInstruction(second, `Team churn ${index}.`, { root, mutationId: `churn-${index}`, authority: 'team' });
210
+ assert.ok(composeLearning(second, { root }).some((item) => item.text === 'Ratified founder ruling.'));
211
+ }
212
+ finally {
213
+ rmSync(root, { recursive: true, force: true });
214
+ }
215
+ });
16
216
  test('records only resolved findings from successful verification without draft text', () => {
17
217
  const root = directory();
18
218
  try {
@@ -75,12 +275,12 @@ test('bounds retained events and local instruction size', () => {
75
275
  rmSync(root, { recursive: true, force: true });
76
276
  }
77
277
  });
78
- test('skips malformed local events without breaking composition', () => {
278
+ test('fails closed on malformed local events', () => {
79
279
  const root = directory();
80
280
  try {
81
281
  addLearningInstruction(profile, 'Keep it direct.', { root });
82
282
  appendFileSync(join(root, 'learning', `${profileFingerprint(profile)}.jsonl`), '{"version":"1","timestamp":"now","kind":"verified_candidate","resolved":{}}\n');
83
- assert.deepEqual(composeLearning(profile, { root }), [{ text: 'Keep it direct.', count: 1 }]);
283
+ assert.throws(() => composeLearning(profile, { root }), /corrupt/);
84
284
  }
85
285
  finally {
86
286
  rmSync(root, { recursive: true, force: true });
@@ -0,0 +1,75 @@
1
+ import { canonicalJson } from './canonical-json.js';
2
+ import { verifyApprovalCapability } from './approval-capability.js';
3
+ import { createInitialLifecycleArtifact, isValidLifecycleArtifact, parseSemanticVerdict, prepareSemanticReviewTask, reduceRewriteLifecycle } from './semantic-review.js';
4
+ import { verifyDeterministically } from './pipeline.js';
5
+ import { recordVerifiedCandidate } from './learning.js';
6
+ export function prepareLifecycle(deterministic, binding, receipt, policy, allowedViolations) {
7
+ const task = prepareSemanticReviewTask(binding, policy, receipt, allowedViolations);
8
+ return { task, artifact: createInitialLifecycleArtifact(task, deterministic) };
9
+ }
10
+ export function submitSemanticVerdict(artifact, task, evaluatorId, verdict, context) {
11
+ try {
12
+ inspectLifecycle(artifact);
13
+ }
14
+ catch {
15
+ return { ok: false, error: 'invalid_action' };
16
+ }
17
+ const parsed = parseSemanticVerdict(task, evaluatorId, verdict);
18
+ return reduceRewriteLifecycle(artifact, {
19
+ version: '1', type: 'semantic_submission', parentArtifactFingerprint: artifact.artifactFingerprint,
20
+ taskFingerprint: task.taskFingerprint, verdicts: [parsed],
21
+ }, context);
22
+ }
23
+ export function inspectLifecycle(artifact) {
24
+ if (!artifact || artifact.version !== '1' || !isValidLifecycleArtifact(artifact))
25
+ throw new Error('Invalid lifecycle artifact.');
26
+ return {
27
+ version: '1', status: artifact.status, artifactFingerprint: artifact.artifactFingerprint,
28
+ ...(artifact.parentArtifactFingerprint ? { parentArtifactFingerprint: artifact.parentArtifactFingerprint } : {}),
29
+ transitionFingerprint: artifact.transitionFingerprint, semanticPolicy: artifact.semanticPolicy,
30
+ semanticTaskFingerprint: artifact.semanticTaskFingerprint,
31
+ semanticEvidenceScopeFingerprint: artifact.semanticEvidenceScopeFingerprint,
32
+ verdictFingerprints: [...artifact.verdictFingerprints],
33
+ ...(artifact.capabilityFingerprint ? { capabilityFingerprint: artifact.capabilityFingerprint } : {}),
34
+ ...(artifact.reason ? { reason: artifact.reason } : {}),
35
+ };
36
+ }
37
+ export function validateFinalApproval(artifact, capability, context) {
38
+ try {
39
+ inspectLifecycle(artifact);
40
+ const result = verifyApprovalCapability(capability, context.trustStore, { now: context.now, expectedSubjectArtifactFingerprint: artifact.artifactFingerprint, binding: artifact.binding, expectedPurpose: 'hyv.final-approval' });
41
+ return result.ok ? result : { ok: false, error: 'capability_invalid' };
42
+ }
43
+ catch {
44
+ return { ok: false, error: 'capability_invalid' };
45
+ }
46
+ }
47
+ export function finalizeLifecycle(artifact, decision, context, capability) {
48
+ try {
49
+ inspectLifecycle(artifact);
50
+ }
51
+ catch {
52
+ return { ok: false, error: 'invalid_action' };
53
+ }
54
+ return reduceRewriteLifecycle(artifact, {
55
+ version: '1', type: 'human_finalization', parentArtifactFingerprint: artifact.artifactFingerprint,
56
+ finalization: { version: '1', judgmentType: 'human_finalization', parentArtifactFingerprint: artifact.artifactFingerprint, binding: artifact.binding, evaluatorId: decision.evaluatorId, evidenceScope: { kind: 'candidate' }, decision: decision.decision, ...(capability ? { capability } : {}) },
57
+ }, context);
58
+ }
59
+ export function recordApprovedLearning(request) {
60
+ const { ready, approved, decision, capability, source, candidate, profile, context, copySpec, writingBrief } = request;
61
+ if (Buffer.byteLength(source, 'utf8') > 1024 * 1024 || Buffer.byteLength(candidate, 'utf8') > 1024 * 1024)
62
+ throw new Error('Approved learning text exceeds the byte limit.');
63
+ inspectLifecycle(ready);
64
+ inspectLifecycle(approved);
65
+ const replay = finalizeLifecycle(ready, decision, context, capability);
66
+ if (!replay.ok || canonicalJson(replay.artifact) !== canonicalJson(approved) || approved.status !== 'approved')
67
+ throw new Error('Approved learning is not authorized.');
68
+ const deterministic = verifyDeterministically(source, candidate, profile, copySpec, writingBrief);
69
+ if (!deterministic.verification.passed || deterministic.artifact.artifactFingerprint !== approved.binding.deterministicArtifactFingerprint
70
+ || deterministic.artifact.sourceHash !== approved.binding.sourceHash || deterministic.artifact.candidateHash !== approved.binding.candidateHash
71
+ || deterministic.artifact.profileId !== approved.binding.profileId || deterministic.artifact.profileRevisionDigest !== approved.binding.profileRevisionDigest
72
+ || deterministic.artifact.rulesetVersion !== approved.binding.rulesetVersion)
73
+ throw new Error('Approved learning binding does not match deterministic verification.');
74
+ return recordVerifiedCandidate(profile, deterministic.verification, candidate, { mutationId: `approved:${approved.artifactFingerprint}`, authority: 'team', provenance: `approved:${approved.capabilityFingerprint}`, compatibility: 'exact' });
75
+ }
@@ -0,0 +1,56 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
3
+ import test from 'node:test';
4
+ import { canonicalJson, canonicalJsonBytes } from './canonical-json.js';
5
+ import { finalizeLifecycle, inspectLifecycle, prepareLifecycle, submitSemanticVerdict, validateFinalApproval } from './lifecycle-adapter.js';
6
+ 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: [] };
7
+ const deterministicFingerprint = createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(deterministicBase)}`).digest('hex');
8
+ const deterministic = { ...deterministicBase, artifactFingerprint: deterministicFingerprint };
9
+ const binding = { rewriteTaskFingerprint: '1'.repeat(64), rewriteResponseFingerprint: '2'.repeat(64), deterministicArtifactFingerprint: deterministicFingerprint, sourceHash: deterministic.sourceHash, candidateHash: deterministic.candidateHash, profileId: deterministic.profileId, profileRevisionDigest: deterministic.profileRevisionDigest, rulesetVersion: deterministic.rulesetVersion, schemaVersion: '1' };
10
+ const receipt = { version: '1', taskFingerprint: binding.rewriteTaskFingerprint, responseFingerprint: binding.rewriteResponseFingerprint, adapterIds: [], replacementSentenceIds: [1] };
11
+ const emptyTrust = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [] };
12
+ const context = { now: 150, trustStore: emptyTrust, authorizedSemanticEvaluatorIds: { normal: ['reviewer-1'], highAssurance: [] }, authorizedHumanFinalizerIds: ['human-1'] };
13
+ test('prepares and submits a normal semantic verdict through the shared adapter', () => {
14
+ const prepared = prepareLifecycle(deterministic, binding, receipt, 'normal', ['action_change']);
15
+ assert.equal(prepared.task.policy, 'normal');
16
+ assert.equal(prepared.artifact.status, 'needs_semantic_review');
17
+ const submitted = submitSemanticVerdict(prepared.artifact, prepared.task, 'reviewer-1', { approved: true, violations: [] }, context);
18
+ assert.equal(submitted.ok && submitted.artifact.status, 'ready_for_human_review');
19
+ const forged = submitSemanticVerdict({ ...prepared.artifact, artifactFingerprint: '9'.repeat(64) }, prepared.task, 'reviewer-1', { approved: true, violations: [] }, context);
20
+ assert.deepEqual(forged, { ok: false, error: 'invalid_action' });
21
+ });
22
+ test('inspection validates the artifact fingerprint and returns metadata only', () => {
23
+ const { artifact } = prepareLifecycle(deterministic, binding, receipt, 'normal', ['action_change']);
24
+ const inspected = inspectLifecycle(artifact);
25
+ assert.equal(inspected.status, 'needs_semantic_review');
26
+ assert.equal(inspected.artifactFingerprint, artifact.artifactFingerprint);
27
+ assert.doesNotMatch(JSON.stringify(inspected), /sourceHash|candidateHash|capability|signature|nonce|verdicts/);
28
+ assert.throws(() => inspectLifecycle({ ...artifact, artifactFingerprint: '9'.repeat(64) }), /invalid lifecycle artifact/i);
29
+ const { artifactFingerprint: _fingerprint, ...base } = artifact;
30
+ const malformedBase = { ...base, status: 'ready_for_human_review', parentArtifactFingerprint: artifact.artifactFingerprint };
31
+ const malformed = { ...malformedBase, artifactFingerprint: createHash('sha256').update(`hyv:lifecycle-artifact:v1\0${canonicalJson(malformedBase)}`).digest('hex') };
32
+ assert.throws(() => inspectLifecycle(malformed), /invalid lifecycle artifact/i);
33
+ for (const invalid of [{ ...base, status: 'garbage' }, { ...base, status: 'needs_escalation', parentArtifactFingerprint: artifact.artifactFingerprint, reason: 'garbage' }]) {
34
+ const recomputed = { ...invalid, artifactFingerprint: createHash('sha256').update(`hyv:lifecycle-artifact:v1\0${canonicalJson(invalid)}`).digest('hex') };
35
+ assert.throws(() => inspectLifecycle(recomputed), /invalid lifecycle artifact/i);
36
+ }
37
+ });
38
+ test('capability validation collapses verifier details and finalization stays reducer-backed', () => {
39
+ const prepared = prepareLifecycle(deterministic, binding, receipt, 'normal', ['action_change']);
40
+ const submitted = submitSemanticVerdict(prepared.artifact, prepared.task, 'reviewer-1', { approved: true, violations: [] }, context);
41
+ assert.equal(submitted.ok, true);
42
+ if (!submitted.ok)
43
+ return;
44
+ assert.deepEqual(validateFinalApproval(submitted.artifact, { payload: 'bad', signature: 'bad' }, context), { ok: false, error: 'capability_invalid' });
45
+ const rejected = finalizeLifecycle(submitted.artifact, { evaluatorId: 'human-1', decision: 'reject' }, context);
46
+ assert.equal(rejected.ok && rejected.artifact.status, 'needs_escalation');
47
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
48
+ 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' }] };
49
+ 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: 'secret-nonce' };
50
+ const payload = canonicalJsonBytes(claims);
51
+ const capability = { payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') };
52
+ assert.equal(validateFinalApproval(submitted.artifact, capability, { ...context, trustStore }).ok, true);
53
+ const approved = finalizeLifecycle(submitted.artifact, { evaluatorId: 'human-1', decision: 'approve' }, { ...context, trustStore }, capability);
54
+ assert.equal(approved.ok && approved.artifact.status, 'approved');
55
+ assert.doesNotMatch(JSON.stringify(approved), /secret-nonce|payload|signature/);
56
+ });
package/dist/mcp-tools.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { RULESET_VERSION, serializedRules } from './ai-editor.js';
2
2
  import { parseCopySpec } from './copy-spec.js';
3
3
  import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
4
- import { composeLearning, recordVerifiedCandidate } from './learning.js';
4
+ import { clearLearning, composeLearning, inspectLearning, migrateLearningV2ToV3, ratifyLearningEvent, recordLearningInstruction, supersedeLearningEvent } from './learning.js';
5
5
  import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
6
6
  import { parseProfile } from './profile.js';
7
7
  import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
8
+ import { parseJudgmentEnvelope, preparePostCandidateJudgment, preparePreEditJudgment, reducePostCandidate, reducePreEdit } from './judgment-task.js';
9
+ import { evaluateRebuildResponse, parseRebuildTask, prepareRebuildTask } from './rebuild-task.js';
8
10
  import { buildProfile } from './voice-dna.js';
9
11
  import { finalOutputCheck, inspectHygiene } from './hygiene.js';
12
+ import { finalizeLifecycle, inspectLifecycle, prepareLifecycle, recordApprovedLearning, submitSemanticVerdict, validateFinalApproval } from './lifecycle-adapter.js';
10
13
  function profileFromJson(profileJson) {
11
14
  try {
12
15
  return parseProfile(JSON.parse(profileJson));
@@ -15,6 +18,12 @@ function profileFromJson(profileJson) {
15
18
  throw new Error(error instanceof Error ? error.message : 'Profile is not valid JSON.');
16
19
  }
17
20
  }
21
+ function profileV3FromJson(profileJson) {
22
+ const profile = profileFromJson(profileJson);
23
+ if (profile.version !== '3')
24
+ throw new Error('This learning operation requires a Profile v3.');
25
+ return profile;
26
+ }
18
27
  function copySpecFromJson(copySpecJson) {
19
28
  try {
20
29
  return parseCopySpec(JSON.parse(copySpecJson));
@@ -55,15 +64,79 @@ export function prepareRewriteForMcp(draft, profileJson, copySpecJson, writingBr
55
64
  export function applyRewriteForMcp(taskJson, responseJson, profileJson) {
56
65
  return evaluateRewriteResponse(parseRewriteTask(JSON.parse(taskJson)), responseJson, profileFromJson(profileJson));
57
66
  }
58
- export function verifyForMcp(original, candidate, profileJson, options = {}, writingBriefJson) {
67
+ export function prepareJudgmentForMcp(stage, kind, draft, profileJson, candidate) {
68
+ const profile = profileFromJson(profileJson);
69
+ return stage === 'pre-edit'
70
+ ? preparePreEditJudgment(draft, profile, kind)
71
+ : preparePostCandidateJudgment(draft, candidate ?? '', profile, kind);
72
+ }
73
+ export function reduceJudgmentForMcp(envelopesJson) {
74
+ const envelopes = parsed(envelopesJson, 'Judgment envelopes').map(parseJudgmentEnvelope);
75
+ return envelopes[0]?.stage === 'pre-edit' ? reducePreEdit(envelopes) : reducePostCandidate(envelopes);
76
+ }
77
+ export function prepareRebuildForMcp(draft, profileJson, reductionJson, copySpecJson, capabilityJson, context, writingBriefJson) {
78
+ return prepareRebuildTask(draft, profileFromJson(profileJson), parsed(reductionJson, 'Rebuild recommendation'), copySpecFromJson(copySpecJson), parsed(capabilityJson, 'Approval capability'), context.trustStore, context.now, writingBriefFromJson(writingBriefJson));
79
+ }
80
+ export function applyRebuildForMcp(taskJson, responseJson, profileJson, capabilityJson, context) {
81
+ return evaluateRebuildResponse(parseRebuildTask(JSON.parse(taskJson)), responseJson, profileFromJson(profileJson), parsed(capabilityJson, 'Approval capability'), context.trustStore, context.now);
82
+ }
83
+ export function verifyForMcp(original, candidate, profileJson, writingBriefJson) {
59
84
  const profile = profileFromJson(profileJson);
60
- const result = verify(original, candidate, profile, writingBriefFromJson(writingBriefJson));
61
- return { ...result, learning: recordVerifiedCandidate(profile, result, candidate, options) };
85
+ return verify(original, candidate, profile, writingBriefFromJson(writingBriefJson));
62
86
  }
63
- export function verifyCopySpecForMcp(original, candidate, profileJson, copySpecJson, options = {}, writingBriefJson) {
87
+ export function verifyCopySpecForMcp(original, candidate, profileJson, copySpecJson, writingBriefJson) {
64
88
  const profile = profileFromJson(profileJson);
65
- const result = verifyWithCopySpec(original, candidate, profile, copySpecFromJson(copySpecJson), writingBriefFromJson(writingBriefJson));
66
- return { ...result, learning: result.passed ? recordVerifiedCandidate(profile, result, candidate, options) : 'nothing_to_learn' };
89
+ return verifyWithCopySpec(original, candidate, profile, copySpecFromJson(copySpecJson), writingBriefFromJson(writingBriefJson));
90
+ }
91
+ function parsed(json, label) {
92
+ if (Buffer.byteLength(json, 'utf8') > 1024 * 1024)
93
+ throw new Error(`${label} exceeds the byte limit.`);
94
+ try {
95
+ return JSON.parse(json);
96
+ }
97
+ catch {
98
+ throw new Error(`${label} is not valid JSON.`);
99
+ }
100
+ }
101
+ export function prepareLifecycleForMcp(deterministicJson, bindingJson, receiptJson, policy, allowedViolations) {
102
+ if (policy !== 'normal')
103
+ throw new Error('High-assurance semantic review requires a trusted embedding.');
104
+ return prepareLifecycle(parsed(deterministicJson, 'Deterministic artifact'), parsed(bindingJson, 'Lifecycle binding'), parsed(receiptJson, 'Rewrite receipt'), policy, allowedViolations);
105
+ }
106
+ export function submitSemanticVerdictForMcp(artifactJson, taskJson, evaluatorId, verdictJson, context) {
107
+ const task = parsed(taskJson, 'Semantic task');
108
+ if (task.policy !== 'normal')
109
+ throw new Error('High-assurance semantic review requires a trusted embedding.');
110
+ return submitSemanticVerdict(parsed(artifactJson, 'Lifecycle artifact'), task, evaluatorId, parsed(verdictJson, 'Semantic verdict'), context);
111
+ }
112
+ export function inspectLifecycleForMcp(artifactJson) {
113
+ return inspectLifecycle(parsed(artifactJson, 'Lifecycle artifact'));
114
+ }
115
+ export function validateFinalApprovalForMcp(artifactJson, capabilityJson, context) {
116
+ return validateFinalApproval(parsed(artifactJson, 'Lifecycle artifact'), parsed(capabilityJson, 'Approval capability'), context);
117
+ }
118
+ export function finalizeLifecycleForMcp(artifactJson, decisionJson, context, capabilityJson) {
119
+ const decision = parsed(decisionJson, 'Finalization decision');
120
+ if (decision.decision === 'approve' && !capabilityJson)
121
+ throw new Error('Approval requires a capability.');
122
+ if (decision.decision === 'reject' && capabilityJson)
123
+ throw new Error('Rejection does not accept a capability.');
124
+ return finalizeLifecycle(parsed(artifactJson, 'Lifecycle artifact'), decision, context, capabilityJson ? parsed(capabilityJson, 'Approval capability') : undefined);
125
+ }
126
+ export function finalizeRejectionForMcp(artifactJson, decisionJson, context) {
127
+ const decision = parsed(decisionJson, 'Finalization decision');
128
+ if (decision.decision !== 'reject')
129
+ throw new Error('Only rejection is available.');
130
+ return finalizeLifecycle(parsed(artifactJson, 'Lifecycle artifact'), decision, context);
131
+ }
132
+ export function recordApprovedLearningForMcp(request) {
133
+ const { readyJson, approvedJson, source, candidate, profileJson, decisionJson, capabilityJson, context, copySpecJson, writingBriefJson } = request;
134
+ return recordApprovedLearning({
135
+ ready: parsed(readyJson, 'Ready artifact'), approved: parsed(approvedJson, 'Approved artifact'),
136
+ decision: parsed(decisionJson, 'Finalization decision'), capability: parsed(capabilityJson, 'Approval capability'),
137
+ source, candidate, profile: profileFromJson(profileJson), context,
138
+ copySpec: copySpecJson ? copySpecFromJson(copySpecJson) : undefined, writingBrief: writingBriefFromJson(writingBriefJson),
139
+ });
67
140
  }
68
141
  export function patternsForMcp() {
69
142
  return { version: RULESET_VERSION, rules: serializedRules() };
@@ -71,3 +144,24 @@ export function patternsForMcp() {
71
144
  export function analyzeBatchForMcp(drafts) {
72
145
  return analyzeBatch(drafts);
73
146
  }
147
+ export function inspectLearningForMcp(profileJson, options = {}) {
148
+ return inspectLearning(profileFromJson(profileJson), options);
149
+ }
150
+ export function recordLearningForMcp(profileJson, instruction, options = {}) {
151
+ return recordLearningInstruction(profileFromJson(profileJson), instruction, options);
152
+ }
153
+ export function ratifyLearningForMcp(profileJson, eventId, options = {}) {
154
+ return ratifyLearningEvent(profileV3FromJson(profileJson), eventId, options);
155
+ }
156
+ export function supersedeLearningForMcp(profileJson, eventId, options = {}) {
157
+ return supersedeLearningEvent(profileV3FromJson(profileJson), eventId, options);
158
+ }
159
+ export function migrateLearningForMcp(sourceProfileJson, targetProfileJson, options = {}) {
160
+ const source = profileFromJson(sourceProfileJson);
161
+ if (source.version !== '2')
162
+ throw new Error('Learning migration requires a Profile v2 source.');
163
+ return migrateLearningV2ToV3(source, profileV3FromJson(targetProfileJson), options);
164
+ }
165
+ export function clearLearningForMcp(profileJson, options = {}) {
166
+ return { cleared: clearLearning(profileFromJson(profileJson), options) };
167
+ }