@holdyourvoice/hyv 3.2.0 → 3.3.1

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 +51 -11
  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
package/dist/profile.js CHANGED
@@ -1,3 +1,25 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from './canonical-json.js';
3
+ const METRICS_KEYS = ['sentenceLength', 'sentenceVariation', 'sentenceStructure', 'rhythm', 'paragraphLength', 'openingMoves', 'vocabulary', 'lexicalDensity', 'pointOfView', 'punctuation', 'caseStyle', 'questionRate', 'transitions'];
4
+ const PROFILE_V3_KEYS = ['version', 'id', 'revision', 'revisionDigest', 'sampleCount', 'metrics', 'avoid', 'provenance', 'rulePolicy', 'fingerprint', 'tolerances', 'metricFixtures'];
5
+ const FINGERPRINT_METRICS = ['contractionRate', 'sentenceLengthDistribution', 'bulletRate', 'enDashRate'];
6
+ const STABLE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,127})$/;
7
+ function isPlainObject(value) {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
9
+ }
10
+ function hasKnownKeys(value, keys) {
11
+ return Object.keys(value).every((key) => keys.includes(key)) && keys.every((key) => key in value);
12
+ }
13
+ function isBoundedString(value, maximum = 256) {
14
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= maximum;
15
+ }
16
+ function isRate(value) {
17
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
18
+ }
19
+ function isBoundedStringArray(value, minimum = 0) {
20
+ return Array.isArray(value) && value.length >= minimum && value.length <= 64
21
+ && value.every((item) => isBoundedString(item)) && new Set(value).size === value.length;
22
+ }
1
23
  function isNumberRecord(value) {
2
24
  return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
3
25
  && Object.values(value).every((item) => typeof item === 'number' && Number.isFinite(item));
@@ -20,10 +42,75 @@ function isMetrics(value) {
20
42
  && stringArrays.every((items) => Array.isArray(items) && items.every((item) => typeof item === 'string'))
21
43
  && isPunctuation(metrics.punctuation);
22
44
  }
45
+ function isStrictMetrics(value) {
46
+ return isPlainObject(value) && hasKnownKeys(value, METRICS_KEYS) && isMetrics(value)
47
+ && [value.sentenceStructure, value.openingMoves, value.vocabulary, value.transitions].every((items) => isBoundedStringArray(items));
48
+ }
49
+ function isProvenance(value) {
50
+ if (!isPlainObject(value) || !hasKnownKeys(value, ['source', 'rights', 'createdAt']))
51
+ return false;
52
+ if (!isBoundedString(value.source) || !isBoundedString(value.rights) || !isBoundedString(value.createdAt, 64))
53
+ return false;
54
+ const parsed = new Date(value.createdAt);
55
+ return !Number.isNaN(parsed.valueOf()) && parsed.toISOString() === value.createdAt;
56
+ }
57
+ function isRulePolicy(value) {
58
+ if (!isPlainObject(value) || Object.keys(value).length > 512)
59
+ return false;
60
+ const states = ['blocking', 'advisory', 'judgment-required', 'disabled'];
61
+ return Object.entries(value).every(([id, state]) => STABLE_ID.test(id) && states.includes(state));
62
+ }
63
+ function isFingerprint(value) {
64
+ if (!isPlainObject(value) || !hasKnownKeys(value, FINGERPRINT_METRICS))
65
+ return false;
66
+ const distribution = value.sentenceLengthDistribution;
67
+ if (!isPlainObject(distribution) || !hasKnownKeys(distribution, ['short', 'medium', 'long']))
68
+ return false;
69
+ const parts = [distribution.short, distribution.medium, distribution.long];
70
+ return isRate(value.contractionRate) && isRate(value.bulletRate) && isRate(value.enDashRate)
71
+ && parts.every(isRate) && Math.abs(parts.reduce((sum, item) => sum + item, 0) - 1) < 1e-9;
72
+ }
73
+ function isTolerances(value) {
74
+ return isPlainObject(value) && hasKnownKeys(value, FINGERPRINT_METRICS) && FINGERPRINT_METRICS.every((key) => {
75
+ const tolerance = value[key];
76
+ return isPlainObject(tolerance) && hasKnownKeys(tolerance, ['absolute', 'calibrated'])
77
+ && isRate(tolerance.absolute) && typeof tolerance.calibrated === 'boolean';
78
+ });
79
+ }
80
+ function isMetricFixtures(value) {
81
+ return isPlainObject(value) && hasKnownKeys(value, FINGERPRINT_METRICS)
82
+ && FINGERPRINT_METRICS.every((key) => isBoundedStringArray(value[key], 1) && value[key].every((id) => STABLE_ID.test(id)));
83
+ }
84
+ function hasValidRevisionDigest(profile) {
85
+ if (typeof profile.revisionDigest !== 'string' || !/^[a-f0-9]{64}$/.test(profile.revisionDigest))
86
+ return false;
87
+ const { revisionDigest, ...unsigned } = profile;
88
+ return createHash('sha256').update(canonicalJson(unsigned)).digest('hex') === revisionDigest;
89
+ }
90
+ function parseProfileV3(value) {
91
+ const valid = isPlainObject(value) && hasKnownKeys(value, PROFILE_V3_KEYS)
92
+ && typeof value.id === 'string' && STABLE_ID.test(value.id)
93
+ && typeof value.revision === 'number' && Number.isSafeInteger(value.revision) && value.revision > 0
94
+ && typeof value.sampleCount === 'number' && Number.isInteger(value.sampleCount) && value.sampleCount >= 2
95
+ && isStrictMetrics(value.metrics)
96
+ && isBoundedStringArray(value.avoid)
97
+ && isProvenance(value.provenance)
98
+ && isRulePolicy(value.rulePolicy)
99
+ && isFingerprint(value.fingerprint)
100
+ && isTolerances(value.tolerances)
101
+ && isMetricFixtures(value.metricFixtures);
102
+ if (!valid)
103
+ throw new Error('Profile is not a valid Hold Your Voice version 3 profile. Rebuild it from fixture-backed metrics.');
104
+ if (!hasValidRevisionDigest(value))
105
+ throw new Error('Profile version 3 revision digest does not match its canonical contents.');
106
+ return value;
107
+ }
23
108
  export function parseProfile(value) {
24
109
  if (!value || typeof value !== 'object')
25
110
  throw new Error('Profile must be a JSON object.');
26
111
  const profile = value;
112
+ if (profile.version === '3')
113
+ return parseProfileV3(profile);
27
114
  if (profile.version !== '2' || typeof profile.sampleCount !== 'number' || !Number.isInteger(profile.sampleCount) || profile.sampleCount < 2 || !isMetrics(profile.metrics) || !Array.isArray(profile.avoid) || !profile.avoid.every((item) => typeof item === 'string' && item.trim().length > 0)) {
28
115
  throw new Error('Profile is not a valid Hold Your Voice version 2 profile. Rebuild it with the profile command.');
29
116
  }
@@ -0,0 +1,114 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import test from 'node:test';
4
+ import { parseProfile } from './profile.js';
5
+ function canonicalJson(value) {
6
+ if (Array.isArray(value))
7
+ return `[${value.map(canonicalJson).join(',')}]`;
8
+ if (value !== null && typeof value === 'object') {
9
+ return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
10
+ }
11
+ return JSON.stringify(value);
12
+ }
13
+ function profileV3() {
14
+ const unsigned = {
15
+ version: '3',
16
+ id: 'founder.primary',
17
+ revision: 1,
18
+ sampleCount: 2,
19
+ metrics: {
20
+ sentenceLength: 7,
21
+ sentenceVariation: 2,
22
+ sentenceStructure: ['i name the'],
23
+ rhythm: 2,
24
+ paragraphLength: 2,
25
+ openingMoves: ['i'],
26
+ vocabulary: ['mechanism'],
27
+ lexicalDensity: 0.5,
28
+ pointOfView: 'first_person',
29
+ punctuation: { '!': 0, '?': 0, ';': 0, ':': 0, '—': 0 },
30
+ caseStyle: 'lowercase',
31
+ questionRate: 0,
32
+ transitions: ['but'],
33
+ },
34
+ avoid: ['unlock'],
35
+ provenance: { source: 'local-author-owned-samples', rights: 'author-owned', createdAt: '2026-08-13T00:00:00.000Z' },
36
+ rulePolicy: {
37
+ 'ai.antithesis': 'advisory',
38
+ 'ai.staccato': 'judgment-required',
39
+ 'ai.generic': 'blocking',
40
+ 'ai.question-hook': 'disabled',
41
+ },
42
+ fingerprint: {
43
+ contractionRate: 0.3,
44
+ sentenceLengthDistribution: { short: 0.2, medium: 0.5, long: 0.3 },
45
+ bulletRate: 0.1,
46
+ enDashRate: 0.05,
47
+ },
48
+ tolerances: {
49
+ contractionRate: { absolute: 0.1, calibrated: true },
50
+ sentenceLengthDistribution: { absolute: 0.15, calibrated: false },
51
+ bulletRate: { absolute: 0.1, calibrated: false },
52
+ enDashRate: { absolute: 0.05, calibrated: false },
53
+ },
54
+ metricFixtures: {
55
+ contractionRate: ['fixture.contractions'],
56
+ sentenceLengthDistribution: ['fixture.sentences'],
57
+ bulletRate: ['fixture.bullets'],
58
+ enDashRate: ['fixture.en-dashes'],
59
+ },
60
+ };
61
+ return {
62
+ ...unsigned,
63
+ revisionDigest: createHash('sha256').update(canonicalJson(unsigned)).digest('hex'),
64
+ };
65
+ }
66
+ test('keeps Profile v2 parsing and runtime shape byte-for-byte compatible', () => {
67
+ const profile = {
68
+ version: '2', sampleCount: 2,
69
+ metrics: {
70
+ sentenceLength: 4, sentenceVariation: 1, sentenceStructure: [], rhythm: 1, paragraphLength: 1,
71
+ openingMoves: [], vocabulary: [], lexicalDensity: 0.5, pointOfView: 'mixed',
72
+ punctuation: { '!': 0, '?': 0, ';': 0, ':': 0, '—': 0 }, caseStyle: 'mixed', questionRate: 0, transitions: [],
73
+ },
74
+ avoid: [],
75
+ legacyExtension: true,
76
+ };
77
+ assert.strictEqual(parseProfile(profile), profile);
78
+ });
79
+ test('parses a strict Profile v3 with all four rule policy states and fixture-backed metrics', () => {
80
+ const profile = profileV3();
81
+ assert.strictEqual(parseProfile(profile), profile);
82
+ });
83
+ test('rejects a changed Profile v3 revision digest', () => {
84
+ const profile = profileV3();
85
+ profile.fingerprint.bulletRate = 0.2;
86
+ assert.throws(() => parseProfile(profile), /revision digest/);
87
+ });
88
+ test('rejects malformed Profile v3 identity, policy, provenance, and unknown keys', () => {
89
+ for (const mutate of [
90
+ (profile) => { profile.id = '../founder'; },
91
+ (profile) => { profile.revision = 0; },
92
+ (profile) => { profile.rulePolicy['ai.generic'] = 'warn'; },
93
+ (profile) => { profile.provenance.source = ''; },
94
+ (profile) => { profile.extra = true; },
95
+ ]) {
96
+ const profile = profileV3();
97
+ mutate(profile);
98
+ assert.throws(() => parseProfile(profile), /version 3 profile/);
99
+ }
100
+ });
101
+ test('rejects unbounded or invalid Profile v3 metrics and tolerances', () => {
102
+ for (const mutate of [
103
+ (profile) => { profile.fingerprint.contractionRate = Number.NaN; },
104
+ (profile) => { profile.fingerprint.sentenceLengthDistribution = { short: 0.2, medium: 0.2, long: 0.2 }; },
105
+ (profile) => { profile.tolerances.bulletRate.absolute = 1.1; },
106
+ (profile) => { profile.tolerances.enDashRate.calibrated = 'yes'; },
107
+ (profile) => { profile.metricFixtures.enDashRate = []; },
108
+ (profile) => { profile.metricFixtures.bulletRate = Array.from({ length: 65 }, (_, index) => `fixture.${index}`); },
109
+ ]) {
110
+ const profile = profileV3();
111
+ mutate(profile);
112
+ assert.throws(() => parseProfile(profile), /version 3 profile/);
113
+ }
114
+ });
@@ -0,0 +1,226 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from './canonical-json.js';
3
+ import { parseCopySpec } from './copy-spec.js';
4
+ import { parseWritingBrief } from './editorial-packs.js';
5
+ import { verifyApprovalCapability } from './approval-capability.js';
6
+ import { fingerprintPreEditReduction } from './judgment-task.js';
7
+ import { verifyRebuildDeterministically } from './pipeline.js';
8
+ import { sentences } from './text.js';
9
+ import { HYV_VERSION } from './version.js';
10
+ const MAX_RESPONSE_BYTES = 100_000;
11
+ const MAX_CANDIDATE_CHARACTERS = 100_000;
12
+ function fingerprint(value) {
13
+ return createHash('sha256').update(typeof value === 'string' ? value : canonicalJson(value)).digest('hex');
14
+ }
15
+ function digest(value) {
16
+ return createHash('sha256').update(value).digest('hex');
17
+ }
18
+ function digestCanonical(value) {
19
+ return digest(canonicalJson(value));
20
+ }
21
+ function failure(code, message, path) {
22
+ return { code, message, ...(path ? { path } : {}) };
23
+ }
24
+ function isFailure(value) {
25
+ return typeof value === 'object' && value !== null && 'code' in value && 'message' in value;
26
+ }
27
+ function profileIdentity(profile) {
28
+ if (profile.version === '3')
29
+ return { profileId: profile.id, profileRevisionDigest: profile.revisionDigest };
30
+ const legacy = `legacy-v2:${digestCanonical(profile)}`;
31
+ return { profileId: legacy, profileRevisionDigest: legacy };
32
+ }
33
+ function parseJson(value) {
34
+ if (Buffer.byteLength(value) > MAX_RESPONSE_BYTES)
35
+ return failure('response_too_large', `Response exceeds ${MAX_RESPONSE_BYTES} bytes.`);
36
+ try {
37
+ return JSON.parse(value);
38
+ }
39
+ catch {
40
+ return failure('invalid_json', 'Response must be valid JSON.');
41
+ }
42
+ }
43
+ function parseRebuildResponse(value) {
44
+ const raw = typeof value === 'string' ? parseJson(value) : value;
45
+ if (isFailure(raw))
46
+ return raw;
47
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
48
+ return failure('invalid_response_shape', 'Response must be an object.');
49
+ const response = raw;
50
+ if ('replacements' in response || 'operations' in response) {
51
+ return failure('edit_response_on_rebuild_task', 'Edit responses cannot satisfy rebuild tasks.');
52
+ }
53
+ if (response.mode === 'SHIP')
54
+ return failure('edit_response_on_rebuild_task', 'Edit responses cannot satisfy rebuild tasks.', 'mode');
55
+ if (response.version !== '1')
56
+ return failure('invalid_response_version', 'Rebuild response version must be "1".', 'version');
57
+ if (response.mode !== 'REBUILD')
58
+ return failure('invalid_response_shape', 'Rebuild responses require mode REBUILD.', 'mode');
59
+ if (typeof response.taskFingerprint !== 'string' || response.taskFingerprint.length !== 64) {
60
+ return failure('invalid_response_shape', 'Response must include the task fingerprint.', 'taskFingerprint');
61
+ }
62
+ if (typeof response.candidate !== 'string')
63
+ return failure('invalid_candidate_text', 'Rebuild candidate must be a string.', 'candidate');
64
+ if (!response.candidate.trim() || response.candidate.length > MAX_CANDIDATE_CHARACTERS) {
65
+ return failure('invalid_candidate_text', `Rebuild candidate must contain at most ${MAX_CANDIDATE_CHARACTERS} characters.`, 'candidate');
66
+ }
67
+ return response;
68
+ }
69
+ function renderRebuildPrompt(draft, copySpec, writingBrief) {
70
+ return [
71
+ '# Rebuild contract',
72
+ 'Return a whole-document candidate. Do not emit sentence replacements or range operations.',
73
+ 'Keep every immutable CopySpec claim and atom. Do not add prohibited claims.',
74
+ 'Claim, polarity, hygiene, fingerprint, and semantic gates remain blocking. Lexical survival is not required.',
75
+ '',
76
+ '# CopySpec',
77
+ canonicalJson({ audience: copySpec.audience, intent: copySpec.intent, channel: copySpec.channel, claims: copySpec.claims, ...(copySpec.prohibitedClaims ? { prohibitedClaims: copySpec.prohibitedClaims } : {}) }),
78
+ ...(writingBrief ? ['', '# WritingBrief', canonicalJson(writingBrief)] : []),
79
+ '',
80
+ '# Draft',
81
+ draft,
82
+ ].join('\n');
83
+ }
84
+ function authorizationBinding(draft, profile, recommendationFingerprint) {
85
+ const identity = profileIdentity(profile);
86
+ const sourceHash = digest(draft);
87
+ return {
88
+ rewriteTaskFingerprint: recommendationFingerprint,
89
+ rewriteResponseFingerprint: recommendationFingerprint,
90
+ deterministicArtifactFingerprint: recommendationFingerprint,
91
+ sourceHash,
92
+ candidateHash: sourceHash,
93
+ profileId: identity.profileId,
94
+ profileRevisionDigest: identity.profileRevisionDigest,
95
+ rulesetVersion: HYV_VERSION,
96
+ schemaVersion: '1',
97
+ };
98
+ }
99
+ function verifyRebuildAuthorization(draft, profile, recommendationFingerprint, capability, trustStore, now) {
100
+ const authorized = verifyApprovalCapability(capability, trustStore, {
101
+ now,
102
+ expectedSubjectArtifactFingerprint: recommendationFingerprint,
103
+ binding: authorizationBinding(draft, profile, recommendationFingerprint),
104
+ expectedPurpose: 'hyv.rebuild-authorization',
105
+ });
106
+ if (!authorized.ok)
107
+ throw new Error('Rebuild authorization is invalid.');
108
+ return authorized;
109
+ }
110
+ export function prepareRebuildTask(draft, profile, reduction, copySpec, capability, trustStore, now, writingBrief) {
111
+ if (reduction.decision !== 'REBUILD')
112
+ throw new Error('Rebuild requires an upstream REBUILD recommendation.');
113
+ if (fingerprintPreEditReduction(reduction) !== reduction.recommendationFingerprint || reduction.recommendationFingerprint.length !== 64) {
114
+ throw new Error('Rebuild requires an upstream REBUILD recommendation.');
115
+ }
116
+ const spec = parseCopySpec(copySpec);
117
+ const identity = profileIdentity(profile);
118
+ const authorized = verifyRebuildAuthorization(draft, profile, reduction.recommendationFingerprint, capability, trustStore, now);
119
+ if (writingBrief)
120
+ parseWritingBrief(writingBrief);
121
+ const taskBase = {
122
+ version: '1',
123
+ draft,
124
+ prompt: renderRebuildPrompt(draft, spec, writingBrief),
125
+ copySpec: spec,
126
+ recommendationFingerprint: reduction.recommendationFingerprint,
127
+ authorizationFingerprint: authorized.capabilityFingerprint,
128
+ profileId: identity.profileId,
129
+ profileRevisionDigest: identity.profileRevisionDigest,
130
+ ...(writingBrief ? { writingBrief } : {}),
131
+ };
132
+ return { ...taskBase, fingerprint: fingerprint(taskBase) };
133
+ }
134
+ export function parseRebuildTask(value) {
135
+ if (!value || typeof value !== 'object' || Array.isArray(value))
136
+ throw new Error('Rebuild task must be an object.');
137
+ const task = value;
138
+ if (task.version !== '1' || typeof task.fingerprint !== 'string' || typeof task.draft !== 'string' || typeof task.prompt !== 'string'
139
+ || typeof task.recommendationFingerprint !== 'string' || typeof task.authorizationFingerprint !== 'string'
140
+ || typeof task.profileId !== 'string' || typeof task.profileRevisionDigest !== 'string' || !task.copySpec) {
141
+ throw new Error('Rebuild task does not match version 1.');
142
+ }
143
+ parseCopySpec(task.copySpec);
144
+ if (task.writingBrief !== undefined)
145
+ parseWritingBrief(task.writingBrief);
146
+ const { fingerprint: suppliedFingerprint, ...base } = task;
147
+ if (fingerprint(base) !== suppliedFingerprint)
148
+ throw new Error('Rebuild task fingerprint does not match its contents.');
149
+ return task;
150
+ }
151
+ function rejected(task, raw, failures) {
152
+ return {
153
+ status: 'repairable',
154
+ failures,
155
+ receipt: {
156
+ version: '1',
157
+ taskFingerprint: task.fingerprint,
158
+ responseFingerprint: fingerprint(raw),
159
+ adapterIds: [],
160
+ replacementSentenceIds: [],
161
+ mode: 'REBUILD',
162
+ recommendationFingerprint: task.recommendationFingerprint,
163
+ },
164
+ };
165
+ }
166
+ export function applyRebuildResponse(task, raw) {
167
+ const response = parseRebuildResponse(typeof raw === 'string' ? parseJson(raw) : raw);
168
+ if (isFailure(response))
169
+ return rejected(task, raw, [response]);
170
+ if (response.taskFingerprint !== task.fingerprint) {
171
+ return rejected(task, raw, [failure('task_fingerprint_mismatch', 'Response task fingerprint does not match this task.', 'taskFingerprint')]);
172
+ }
173
+ return {
174
+ status: 'accepted',
175
+ candidate: response.candidate,
176
+ failures: [],
177
+ receipt: {
178
+ version: '1',
179
+ taskFingerprint: task.fingerprint,
180
+ responseFingerprint: fingerprint(raw),
181
+ adapterIds: [],
182
+ replacementSentenceIds: sentences(response.candidate).map((sentence) => sentence.index),
183
+ mode: 'REBUILD',
184
+ recommendationFingerprint: task.recommendationFingerprint,
185
+ },
186
+ };
187
+ }
188
+ export function evaluateRebuildResponse(task, raw, profile, capability, trustStore, now) {
189
+ const identity = profileIdentity(profile);
190
+ if (identity.profileId !== task.profileId || identity.profileRevisionDigest !== task.profileRevisionDigest) {
191
+ throw new Error('Rebuild profile binding does not match this task.');
192
+ }
193
+ const authorized = verifyRebuildAuthorization(task.draft, profile, task.recommendationFingerprint, capability, trustStore, now);
194
+ if (authorized.capabilityFingerprint !== task.authorizationFingerprint)
195
+ throw new Error('Rebuild authorization is invalid.');
196
+ const applied = applyRebuildResponse(task, raw);
197
+ if (applied.status !== 'accepted' || !applied.candidate)
198
+ return applied;
199
+ const { verification, artifact: deterministicArtifact } = verifyRebuildDeterministically(task.draft, applied.candidate, profile, task.copySpec, task.writingBrief);
200
+ const receipt = {
201
+ ...applied.receipt,
202
+ preservationBypass: true,
203
+ authorizationFingerprint: authorized.capabilityFingerprint,
204
+ preservationScore: verification.preservationScore,
205
+ };
206
+ if (!verification.passed)
207
+ return { ...applied, receipt, status: 'needs_escalation', verification, deterministicArtifact };
208
+ const lifecycleBinding = createRebuildLifecycleBinding(task, receipt, deterministicArtifact);
209
+ return { ...applied, receipt, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
210
+ }
211
+ export function createRebuildLifecycleBinding(task, receipt, deterministic) {
212
+ if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint || receipt.mode !== 'REBUILD' || deterministic.verificationKind !== 'rebuild') {
213
+ throw new Error('Lifecycle binding requires a passed rebuild artifact for this rebuild task.');
214
+ }
215
+ return {
216
+ rewriteTaskFingerprint: task.fingerprint,
217
+ rewriteResponseFingerprint: receipt.responseFingerprint,
218
+ deterministicArtifactFingerprint: deterministic.artifactFingerprint,
219
+ sourceHash: deterministic.sourceHash,
220
+ candidateHash: deterministic.candidateHash,
221
+ profileId: deterministic.profileId,
222
+ profileRevisionDigest: deterministic.profileRevisionDigest,
223
+ rulesetVersion: deterministic.rulesetVersion,
224
+ schemaVersion: '1',
225
+ };
226
+ }
@@ -0,0 +1,179 @@
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 { applyRewriteResponse, prepareRewriteTask } from './rewrite-task.js';
6
+ import { applyRebuildResponse, evaluateRebuildResponse, parseRebuildTask, prepareRebuildTask } from './rebuild-task.js';
7
+ import { bindJudgmentEnvelope, preparePreEditJudgment, reducePreEdit } from './judgment-task.js';
8
+ import { verifyDeterministically, verifyRebuildWithCopySpec } from './pipeline.js';
9
+ import { prepareLifecycle, recordApprovedLearning } from './lifecycle-adapter.js';
10
+ import { buildProfile } from './voice-dna.js';
11
+ import { HYV_VERSION } from './version.js';
12
+ const profile = buildProfile([
13
+ 'I write clear notes. I keep the mechanism visible.',
14
+ 'I name the trade-off. Then I make the next step plain.',
15
+ ], ['leverage']);
16
+ const draft = 'I leverage the answer. The launch is on 14 August.';
17
+ const rebuilt = '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.';
18
+ const copySpec = {
19
+ version: '1',
20
+ audience: 'operators',
21
+ intent: 'explain',
22
+ channel: 'email',
23
+ claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar, 7 August.' }],
24
+ };
25
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
26
+ const trustStore = {
27
+ version: '1',
28
+ audience: '@holdyourvoice/hyv',
29
+ maxCapabilityLifetimeSeconds: 300,
30
+ keys: [{ issuer: 'host.example', keyId: 'key-1', publicKeySpki: publicKey.export({ format: 'der', type: 'spki' }).toString('base64url'), status: 'active' }],
31
+ };
32
+ function envelope(task, decision, extra = {}) {
33
+ return {
34
+ version: '1',
35
+ stage: task.stage,
36
+ judgmentType: task.judgmentType,
37
+ taskFingerprint: task.taskFingerprint,
38
+ bindings: { ...task.bindings, evaluatorId: 'writer.1' },
39
+ findings: [],
40
+ decision,
41
+ ...extra,
42
+ };
43
+ }
44
+ function rebuildRecommendation(text = draft) {
45
+ const triage = preparePreEditJudgment(text, profile, 'triage');
46
+ const argument = preparePreEditJudgment(text, profile, 'argument');
47
+ const form = preparePreEditJudgment(text, profile, 'form');
48
+ return reducePreEdit([
49
+ bindJudgmentEnvelope(triage, envelope(triage, 'SHIP')),
50
+ bindJudgmentEnvelope(argument, envelope(argument, 'REBUILD')),
51
+ bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
52
+ ]);
53
+ }
54
+ function capability(reduction, overrides = {}, source = draft) {
55
+ const sourceHash = createHash('sha256').update(source).digest('hex');
56
+ const identity = `legacy-v2:${createHash('sha256').update(canonicalJson(profile)).digest('hex')}`;
57
+ const claims = {
58
+ version: '1',
59
+ purpose: 'hyv.rebuild-authorization',
60
+ issuer: 'host.example',
61
+ audience: '@holdyourvoice/hyv',
62
+ subjectArtifactFingerprint: reduction.recommendationFingerprint,
63
+ sourceHash,
64
+ candidateHash: sourceHash,
65
+ profileId: identity,
66
+ profileRevisionDigest: identity,
67
+ keyId: 'key-1',
68
+ issuedAt: 100,
69
+ notBefore: 100,
70
+ expiresAt: 200,
71
+ nonce: 'nonce-rebuild',
72
+ ...overrides,
73
+ };
74
+ const payload = canonicalJsonBytes(claims);
75
+ return { payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') };
76
+ }
77
+ function evaluate(task, raw, reduction, boundProfile = profile) {
78
+ return evaluateRebuildResponse(task, raw, boundProfile, capability(reduction), trustStore, 150);
79
+ }
80
+ test('pre-edit reductions carry a stable recommendation fingerprint', () => {
81
+ const left = rebuildRecommendation();
82
+ const right = rebuildRecommendation();
83
+ assert.equal(left.decision, 'REBUILD');
84
+ assert.match(left.recommendationFingerprint, /^[a-f0-9]{64}$/);
85
+ assert.equal(left.recommendationFingerprint, right.recommendationFingerprint);
86
+ });
87
+ test('a caller cannot self-select rebuild or submit a forged authorization', () => {
88
+ const reduction = rebuildRecommendation();
89
+ const edit = { ...reduction, decision: 'EDIT' };
90
+ assert.throws(() => prepareRebuildTask(draft, profile, edit, copySpec, capability(reduction), trustStore, 150), /upstream REBUILD recommendation/);
91
+ assert.throws(() => prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { purpose: 'hyv.final-approval' }), trustStore, 150), /Rebuild authorization is invalid/);
92
+ assert.throws(() => prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { nonce: 'stale' }), { ...trustStore, keys: [{ ...trustStore.keys[0], status: 'revoked' }] }, 150), /Rebuild authorization is invalid/);
93
+ assert.throws(() => prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { sourceHash: '8'.repeat(64) }), trustStore, 150), /Rebuild authorization is invalid/);
94
+ assert.throws(() => prepareRebuildTask(draft, profile, { ...reduction, recommendationFingerprint: 'a'.repeat(64) }, copySpec, capability(reduction), trustStore, 150), /upstream REBUILD recommendation/);
95
+ });
96
+ test('missing CopySpec blocks rebuild before a candidate is evaluated', () => {
97
+ const reduction = rebuildRecommendation();
98
+ assert.throws(() => prepareRebuildTask(draft, profile, reduction, { ...copySpec, claims: [] }, capability(reduction), trustStore, 150), /CopySpec/);
99
+ });
100
+ test('edit and rebuild responses are mutually incompatible', () => {
101
+ const reduction = rebuildRecommendation();
102
+ const rebuildTask = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
103
+ const editTask = prepareRewriteTask(draft, profile);
104
+ const rebuildOnEdit = applyRewriteResponse(editTask, { version: '1', mode: 'REBUILD', taskFingerprint: editTask.fingerprint, candidate: rebuilt });
105
+ assert.equal(rebuildOnEdit.status, 'repairable');
106
+ assert.equal(rebuildOnEdit.failures[0]?.code, 'rebuild_response_on_edit_task');
107
+ const replacementsOnRebuild = applyRebuildResponse(rebuildTask, { version: '1', taskFingerprint: rebuildTask.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer.' }] });
108
+ assert.equal(replacementsOnRebuild.status, 'repairable');
109
+ assert.equal(replacementsOnRebuild.failures[0]?.code, 'edit_response_on_rebuild_task');
110
+ const unsigned = applyRebuildResponse(rebuildTask, { version: '1', mode: 'REBUILD', taskFingerprint: rebuildTask.fingerprint, candidate: rebuilt });
111
+ assert.equal(unsigned.status, 'accepted');
112
+ assert.equal(unsigned.receipt.authorizationFingerprint, undefined);
113
+ assert.equal(unsigned.receipt.preservationBypass, undefined);
114
+ });
115
+ test('authorized rebuild allows low lexical survival while claims and hygiene still block', () => {
116
+ const reduction = rebuildRecommendation();
117
+ const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
118
+ const passed = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: rebuilt }, reduction);
119
+ assert.equal(passed.status, 'needs_semantic_review');
120
+ assert.ok((passed.verification?.preservationScore ?? 100) < 70);
121
+ assert.equal(passed.receipt.mode, 'REBUILD');
122
+ assert.equal(passed.receipt.preservationBypass, true);
123
+ assert.ok(Array.isArray(passed.receipt.replacementSentenceIds));
124
+ assert.ok((passed.receipt.replacementSentenceIds?.length ?? 0) > 0);
125
+ assert.equal(passed.deterministicArtifact?.verificationKind, 'rebuild');
126
+ assert.equal(prepareLifecycle(passed.deterministicArtifact, passed.lifecycleBinding, passed.receipt, 'normal', ['action_change']).artifact.status, 'needs_semantic_review');
127
+ assert.equal(verifyRebuildWithCopySpec(draft, rebuilt, profile, copySpec).passed, true);
128
+ const missingClaim = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: 'Operators should delay the launch indefinitely.' }, reduction);
129
+ assert.equal(missingClaim.status, 'needs_escalation');
130
+ const hygiene = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: `${rebuilt}\u200B` }, reduction);
131
+ assert.equal(hygiene.status, 'needs_escalation');
132
+ });
133
+ test('rebuild disagreement cannot record accepted learning', () => {
134
+ const reduction = rebuildRecommendation();
135
+ const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
136
+ const failed = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: 'Operators should delay the launch indefinitely.' }, reduction);
137
+ assert.equal(failed.status, 'needs_escalation');
138
+ const evaluated = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: rebuilt }, reduction);
139
+ assert.equal(evaluated.status, 'needs_semantic_review');
140
+ const standard = verifyDeterministically(draft, rebuilt, profile, copySpec);
141
+ assert.notEqual(standard.artifact.artifactFingerprint, evaluated.deterministicArtifact?.artifactFingerprint);
142
+ assert.throws(() => recordApprovedLearning({
143
+ ready: { version: '1', status: 'ready_for_human_review', artifactFingerprint: '1'.repeat(64), transitionFingerprint: '2'.repeat(64), binding: evaluated.lifecycleBinding, semanticPolicy: 'normal', semanticTaskFingerprint: '3'.repeat(64), semanticEvidenceScopeFingerprint: '4'.repeat(64), verdictFingerprints: [] },
144
+ approved: { version: '1', status: 'approved', artifactFingerprint: '5'.repeat(64), transitionFingerprint: '6'.repeat(64), binding: evaluated.lifecycleBinding, semanticPolicy: 'normal', semanticTaskFingerprint: '3'.repeat(64), semanticEvidenceScopeFingerprint: '4'.repeat(64), verdictFingerprints: [] },
145
+ decision: { evaluatorId: 'human.1', decision: 'approve' },
146
+ capability: capability(reduction),
147
+ source: draft,
148
+ candidate: rebuilt,
149
+ profile,
150
+ context: { now: 150, trustStore, authorizedSemanticEvaluatorIds: { normal: [], highAssurance: [] }, authorizedHumanFinalizerIds: ['human.1'] },
151
+ copySpec,
152
+ }), /Invalid lifecycle artifact|Approved learning is not authorized|does not match deterministic verification/);
153
+ });
154
+ test('CLI and MCP rebuild helpers share fingerprints', () => {
155
+ const reduction = rebuildRecommendation();
156
+ const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
157
+ const parsed = parseRebuildTask(JSON.parse(JSON.stringify(task)));
158
+ assert.equal(parsed.fingerprint, task.fingerprint);
159
+ assert.equal(parsed.authorizationFingerprint, task.authorizationFingerprint);
160
+ assert.match(task.prompt, /whole-document candidate/);
161
+ const briefTask = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { nonce: 'nonce-brief' }), trustStore, 150, {
162
+ version: '1', audience: 'operators', intent: 'explain', format: 'outreach',
163
+ });
164
+ assert.match(briefTask.prompt, /# WritingBrief/);
165
+ assert.equal(HYV_VERSION, '3.3.1');
166
+ });
167
+ test('apply rejects forged tasks, missing capability, and substituted profiles', () => {
168
+ const reduction = rebuildRecommendation();
169
+ const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
170
+ const response = { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: rebuilt };
171
+ const { fingerprint: _ignored, ...base } = task;
172
+ const forgedBase = { ...base, authorizationFingerprint: 'a'.repeat(64) };
173
+ const forged = { ...forgedBase, fingerprint: createHash('sha256').update(canonicalJson(forgedBase)).digest('hex') };
174
+ assert.equal(parseRebuildTask(forged).fingerprint, forged.fingerprint);
175
+ assert.throws(() => evaluateRebuildResponse(forged, { ...response, taskFingerprint: forged.fingerprint }, profile, capability(reduction), trustStore, 150), /Rebuild authorization is invalid/);
176
+ assert.throws(() => evaluateRebuildResponse(task, response, profile, {}, trustStore, 150), /Rebuild authorization is invalid/);
177
+ const other = buildProfile(['I speak in a different register altogether.', 'I keep every sentence longer than the first profile would.'], ['mechanism']);
178
+ assert.throws(() => evaluate(task, response, reduction, other), /Rebuild profile binding does not match this task/);
179
+ });