@holdyourvoice/hyv 3.3.1 → 3.3.2

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.
package/dist/mcp.test.js CHANGED
@@ -64,8 +64,8 @@ test('serves local Claude tools over stdio', async () => {
64
64
  assert.equal(code, 0);
65
65
  const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
66
66
  const tools = responses.find((response) => response.id === 2)?.result?.tools;
67
- assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_hygiene', 'hyv_final_check', 'hyv_rewrite_prompt', 'hyv_prepare_rewrite', 'hyv_apply_rewrite', 'hyv_prepare_judgment', 'hyv_reduce_judgment', 'hyv_verify', 'hyv_verify_copy_spec', 'hyv_batch_analyze', 'hyv_patterns', 'hyv_learning_inspect', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear', 'hyv_lifecycle_prepare_semantic', 'hyv_lifecycle_submit_verdict', 'hyv_lifecycle_inspect', 'hyv_lifecycle_finalize']);
68
- assert.ok(tools?.filter((tool) => !['hyv_verify', 'hyv_verify_copy_spec', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear'].includes(tool.name)).every((tool) => tool.annotations?.readOnlyHint));
67
+ assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_hygiene', 'hyv_inspect_hidden_text', 'hyv_apply_hidden_text_policy', 'hyv_final_check', 'hyv_rewrite_prompt', 'hyv_prepare_rewrite', 'hyv_apply_rewrite', 'hyv_prepare_judgment', 'hyv_reduce_judgment', 'hyv_verify', 'hyv_verify_copy_spec', 'hyv_batch_analyze', 'hyv_patterns', 'hyv_learning_inspect', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear', 'hyv_lifecycle_prepare_semantic', 'hyv_lifecycle_submit_verdict', 'hyv_lifecycle_inspect', 'hyv_lifecycle_finalize']);
68
+ assert.ok(tools?.filter((tool) => !['hyv_verify', 'hyv_verify_copy_spec', 'hyv_apply_hidden_text_policy', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear'].includes(tool.name)).every((tool) => tool.annotations?.readOnlyHint));
69
69
  assert.equal(tools?.find((tool) => tool.name === 'hyv_verify')?.annotations?.readOnlyHint, true);
70
70
  assert.equal(tools?.find((tool) => tool.name === 'hyv_verify_copy_spec')?.annotations?.readOnlyHint, true);
71
71
  assert.equal(tools?.some((tool) => tool.name === 'hyv_lifecycle_validate_final_approval'), false);
@@ -74,6 +74,8 @@ test('serves local Claude tools over stdio', async () => {
74
74
  assert.equal(tools?.some((tool) => tool.name === 'hyv_apply_rebuild'), false);
75
75
  assert.equal('capability_json' in (tools?.find((tool) => tool.name === 'hyv_lifecycle_finalize')?.inputSchema?.properties ?? {}), false);
76
76
  assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_inspect')?.annotations?.readOnlyHint, true);
77
+ assert.equal(tools?.find((tool) => tool.name === 'hyv_inspect_hidden_text')?.annotations?.readOnlyHint, true);
78
+ assert.equal(tools?.find((tool) => tool.name === 'hyv_apply_hidden_text_policy')?.annotations?.readOnlyHint, false);
77
79
  assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_clear')?.annotations?.readOnlyHint, false);
78
80
  assert.deepEqual(tools?.filter((tool) => tool.name.startsWith('hyv_learning_')).map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.annotations?.destructiveHint]), [
79
81
  ['hyv_learning_inspect', true, undefined], ['hyv_learning_record', false, false], ['hyv_learning_ratify', false, false],
@@ -95,11 +97,12 @@ test('registers capability tools only with host redaction attestation', async ()
95
97
  assert.equal(stderr, '');
96
98
  const response = stdout.trim().split('\n').map((line) => JSON.parse(line)).find((item) => item.id === 2);
97
99
  const names = response.result.tools.map((tool) => tool.name);
98
- assert.equal(names.length, 27);
100
+ assert.equal(names.length, 30);
99
101
  assert.ok(names.includes('hyv_lifecycle_validate_final_approval'));
100
102
  assert.ok(names.includes('hyv_learning_record_approved'));
101
103
  assert.ok(names.includes('hyv_prepare_rebuild'));
102
104
  assert.ok(names.includes('hyv_apply_rebuild'));
105
+ assert.ok(names.includes('hyv_rebuild_writer_request'));
103
106
  const finalize = response.result.tools.find((tool) => tool.name === 'hyv_lifecycle_finalize');
104
107
  assert.equal('capability_json' in finalize.inputSchema.properties, true);
105
108
  });
package/dist/pipeline.js CHANGED
@@ -7,6 +7,8 @@ import { analyzeEditorial } from './editorial-packs.js';
7
7
  import { finalOutputCheck, inspectHygiene } from './hygiene.js';
8
8
  import { analyzeVoiceDna } from './voice-dna.js';
9
9
  import { legacySetPreservation } from './preservation.js';
10
+ import { lintFacts } from './fact-linter.js';
11
+ import { sentences } from './text.js';
10
12
  export function analyze(text, profile, brief) {
11
13
  const voiceDna = analyzeVoiceDna(text, profile);
12
14
  const aiEditor = analyzeAiEditor(text, profile);
@@ -87,15 +89,40 @@ function compareCandidates(original, candidate, profile, brief) {
87
89
  const preservation = legacySetPreservation(original, candidate).score;
88
90
  return { baseline, checked, regressions, preservation };
89
91
  }
92
+ function verifyRequiredFacts(candidate, brief) {
93
+ if (!brief?.requiredFacts?.length)
94
+ return undefined;
95
+ const result = verifyClaims(candidate, {
96
+ version: '1', audience: brief.audience, intent: brief.intent, channel: brief.format,
97
+ claims: brief.requiredFacts.map((fact) => ({ ...fact, evidence: 'WritingBrief required fact.' })),
98
+ });
99
+ const draftSentences = sentences(candidate);
100
+ const reversed = brief.requiredFacts.filter((fact) => {
101
+ const terms = fact.atoms?.length ? fact.atoms : [fact.text];
102
+ return terms.some((term) => {
103
+ const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
104
+ const quotedDenial = new RegExp(`${escaped}(?:["']|\\s)*(?:is|was|are|were)?\\s*(?:not|false|untrue|incorrect)`, 'i');
105
+ return quotedDenial.test(candidate) || draftSentences.some((sentence, index) => sentence.text.toLowerCase().includes(term.toLowerCase()) && (/\b(?:not|false|untrue|incorrect)\b/i.test(sentence.text) || /^(?:that|this) (?:statement|claim|fact|assertion|point) (?:is|was) (?:not|false|untrue|incorrect)\b/i.test(draftSentences[index + 1]?.text.trim() ?? '')));
106
+ });
107
+ });
108
+ if (!reversed.length)
109
+ return result;
110
+ return { ...result, passed: false, failures: [...result.failures, ...reversed.map((fact) => ({ id: fact.id, code: 'missing_immutable_claim', message: `Required fact ${fact.id} is negated or denied.`, evidence: 'WritingBrief required fact.' }))] };
111
+ }
90
112
  export function verify(original, candidate, profile, brief) {
91
113
  const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
114
+ const finalOutput = finalOutputCheck(candidate);
115
+ const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
116
+ const requiredFacts = verifyRequiredFacts(candidate, brief);
92
117
  return {
93
118
  version: '2',
94
119
  original: baseline,
95
120
  candidate: checked,
96
121
  preservationScore: preservation,
97
122
  regressions,
98
- passed: checked.passed && !regressions.some(isBlockingFinding) && preservation >= 70,
123
+ finalOutput,
124
+ ...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
125
+ passed: checked.passed && !regressions.some(isBlockingFinding) && preservation >= 70 && finalOutput.accepted && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
99
126
  };
100
127
  }
101
128
  export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
@@ -106,8 +133,9 @@ export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
106
133
  export function verifyRebuildWithCopySpec(original, candidate, profile, spec, brief) {
107
134
  const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
108
135
  const claims = verifyClaims(candidate, spec);
109
- const hygiene = inspectHygiene(candidate);
110
136
  const finalCheck = finalOutputCheck(candidate);
137
+ const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
138
+ const requiredFacts = verifyRequiredFacts(candidate, brief);
111
139
  return {
112
140
  version: '2',
113
141
  original: baseline,
@@ -115,7 +143,9 @@ export function verifyRebuildWithCopySpec(original, candidate, profile, spec, br
115
143
  preservationScore: preservation,
116
144
  regressions,
117
145
  claims,
118
- passed: checked.passed && !regressions.some(isBlockingFinding) && claims.passed && hygiene.suspiciousCount === 0 && finalCheck.accepted,
146
+ finalOutput: finalCheck,
147
+ ...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
148
+ passed: checked.passed && !regressions.some(isBlockingFinding) && claims.passed && finalCheck.accepted && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
119
149
  };
120
150
  }
121
151
  function digest(value) { return createHash('sha256').update(value).digest('hex'); }
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { analyze, rewritePrompt, verify, verifyDeterministically, verifyWithCopySpec } from './pipeline.js';
3
+ import { analyze, rewritePrompt, verify, verifyDeterministically, verifyRebuildWithCopySpec, verifyWithCopySpec } from './pipeline.js';
4
4
  import { parseCopySpec } from './copy-spec.js';
5
5
  import { parseWritingBrief } from './editorial-packs.js';
6
6
  import { buildProfile } from './voice-dna.js';
@@ -32,6 +32,45 @@ test('keeps the existing VoiceDNA and AI Editor reports unchanged when no Writin
32
32
  assert.deepEqual(contextual.voiceDna, baseline.voiceDna);
33
33
  assert.deepEqual(contextual.aiEditor, baseline.aiEditor);
34
34
  });
35
+ test('runs fact lint automatically when a WritingBrief supplies valid local sources', () => {
36
+ const brief = parseWritingBrief({ version: '1', audience: 'operators', intent: 'explain', format: 'general', factSources: [{ id: 'release', text: 'Atlas launches on 14 August 2026.' }] });
37
+ const result = verify('Atlas launches on 14 August 2026.', 'Atlas launches on 15 August 2026.', profile, brief);
38
+ assert.equal(result.factLint?.findings[0]?.kind, 'date_drift');
39
+ assert.equal(result.passed, false);
40
+ });
41
+ test('blocks a final draft that drops a required source-backed fact', () => {
42
+ const brief = parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'bio', text: 'Shashank is a LinkedIn Top Voice.' }], requiredFacts: [{ id: 'linkedin-top-voice', text: 'Shashank is a LinkedIn Top Voice.' }] });
43
+ const missing = verify('Shashank is a LinkedIn Top Voice.', 'Shashank writes about AI systems.', profile, brief);
44
+ assert.equal(missing.requiredFacts?.failures[0]?.code, 'missing_immutable_claim');
45
+ assert.equal(missing.passed, false);
46
+ const retained = verify('Shashank is a LinkedIn Top Voice.', 'Shashank is a LinkedIn Top Voice. Shashank writes about AI systems.', profile, brief);
47
+ assert.equal(retained.requiredFacts?.passed, true);
48
+ });
49
+ test('requires source provenance and rejects negated required facts', () => {
50
+ assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', requiredFacts: [{ id: 'unsupported', text: 'Mars has two moons.' }] }), /WritingBrief/);
51
+ assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'denial', text: 'It is false that Shashank is a LinkedIn Top Voice.' }], requiredFacts: [{ id: 'linkedin-top-voice', text: 'Shashank is a LinkedIn Top Voice.' }] }), /WritingBrief/);
52
+ assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'cross-sentence-denial', text: 'Shashank is a LinkedIn Top Voice. That statement is false.' }], requiredFacts: [{ id: 'linkedin-top-voice', text: 'Shashank is a LinkedIn Top Voice.' }] }), /WritingBrief/);
53
+ const brief = parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'bio', text: 'Shashank is a LinkedIn Top Voice.' }], requiredFacts: [{ id: 'linkedin-top-voice', text: 'Shashank is a LinkedIn Top Voice.' }] });
54
+ const negated = verify('Shashank is a LinkedIn Top Voice.', 'Shashank is not a LinkedIn Top Voice.', profile, brief);
55
+ assert.equal(negated.requiredFacts?.passed, false);
56
+ const denied = verify('Shashank is a LinkedIn Top Voice.', 'The claim "Shashank is a LinkedIn Top Voice." is false.', profile, brief);
57
+ assert.equal(denied.requiredFacts?.passed, false);
58
+ assert.match(denied.requiredFacts?.failures.at(-1)?.message ?? '', /negated or denied/);
59
+ const crossSentenceDenial = verify('Shashank is a LinkedIn Top Voice.', 'Shashank is a LinkedIn Top Voice. That statement is false.', profile, brief);
60
+ assert.equal(crossSentenceDenial.requiredFacts?.passed, false);
61
+ const affirmed = verify('Shashank is a LinkedIn Top Voice.', 'This is not a controversial claim. Shashank is a LinkedIn Top Voice.', profile, brief);
62
+ assert.equal(affirmed.requiredFacts?.passed, true);
63
+ const atomBrief = parseWritingBrief({ version: '1', audience: 'founders', intent: 'write a post', format: 'social', factSources: [{ id: 'model', text: 'Kimi K2.6 uses INT4 weights. The payload is roughly 600 GB.' }], requiredFacts: [{ id: 'model-weights', text: 'Kimi K2.6 has roughly 600 GB of INT4 weights.', atoms: ['Kimi K2.6 uses INT4 weights', 'payload is roughly 600 GB'] }] });
64
+ const atomDenial = verify('Kimi K2.6 has roughly 600 GB of INT4 weights.', 'Kimi K2.6 uses INT4 weights, which is false. The payload is roughly 600 GB.', profile, atomBrief);
65
+ assert.equal(atomDenial.requiredFacts?.passed, false);
66
+ });
67
+ test('runs fact lint during source-backed rebuild verification', () => {
68
+ const brief = parseWritingBrief({ version: '1', audience: 'operators', intent: 'explain', format: 'general', factSources: [{ id: 'release', text: 'Atlas launches on 14 August 2026.' }] });
69
+ const spec = parseCopySpec({ version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'date', text: 'Atlas launches on 15 August 2026.', evidence: 'release' }] });
70
+ const result = verifyRebuildWithCopySpec('Atlas launches on 14 August 2026.', 'Atlas launches on 15 August 2026.', profile, spec, brief);
71
+ assert.equal(result.factLint?.findings[0]?.kind, 'date_drift');
72
+ assert.equal(result.passed, false);
73
+ });
35
74
  test('builds all thirteen VoiceDNA measurements', () => {
36
75
  assert.deepEqual(Object.keys(profile.metrics), ['sentenceLength', 'sentenceVariation', 'sentenceStructure', 'rhythm', 'paragraphLength', 'openingMoves', 'vocabulary', 'lexicalDensity', 'pointOfView', 'punctuation', 'caseStyle', 'questionRate', 'transitions']);
37
76
  });
@@ -49,6 +88,12 @@ test('post gate reports a new AI regression', () => {
49
88
  assert.equal(result.original.aiEditor.passed, true);
50
89
  assert.equal(result.candidate.aiEditor.passed, false);
51
90
  });
91
+ test('verification applies the final-output gate by default', () => {
92
+ const result = verify('I ship clear ideas.', 'I ship clear ideas.\u200B', profile);
93
+ assert.equal(result.passed, false);
94
+ assert.equal(result.finalOutput.accepted, false);
95
+ assert.equal('output' in result.finalOutput, false);
96
+ });
52
97
  test('advisory and pending-judgment findings pass while blocking findings fail', () => {
53
98
  const advisory = analyze('Firstly, check the invoice.', profile);
54
99
  assert.equal(advisory.passed, true);
@@ -0,0 +1,15 @@
1
+ import { canonicalJson } from './canonical-json.js';
2
+ import { createHash } from 'node:crypto';
3
+ function fingerprint(value) { return createHash('sha256').update(canonicalJson(value)).digest('hex'); }
4
+ export function writerRequestForRebuild(task) {
5
+ return {
6
+ version: '1', taskFingerprint: task.fingerprint, prompt: task.prompt,
7
+ copySpecFingerprint: fingerprint(task.copySpec),
8
+ ...(task.recompositionPolicy ? { recompositionPolicyFingerprint: fingerprint(task.recompositionPolicy) } : {}),
9
+ };
10
+ }
11
+ export function provenanceStatusForRebuild(task) {
12
+ return task.recompositionPolicy
13
+ ? { version: '1', state: 'unknown', reason: 'private_or_unavailable_verifier' }
14
+ : { version: '1', state: 'not_configured' };
15
+ }
@@ -0,0 +1,22 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { provenanceStatusForRebuild, writerRequestForRebuild } from './provenance-status.js';
4
+ const policy = { version: '1', mode: 'meaning-first', lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0.1, maxLongestSharedRunTokens: 8 }, acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.' };
5
+ const task = {
6
+ version: '1', fingerprint: 'a'.repeat(64), draft: 'private source phrase that must never leave the task', prompt: 'structured CopySpec-only prompt',
7
+ copySpec: { version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'one', text: 'The date is fixed.', evidence: 'calendar' }] },
8
+ recommendationFingerprint: 'b'.repeat(64), authorizationFingerprint: 'c'.repeat(64), profileId: 'profile', profileRevisionDigest: 'd'.repeat(64), recompositionPolicy: policy,
9
+ };
10
+ test('writer request omits source, authorization, and profile body while retaining stable bindings', () => {
11
+ const request = writerRequestForRebuild(task);
12
+ const serialized = JSON.stringify(request);
13
+ assert.equal(request.taskFingerprint, task.fingerprint);
14
+ assert.match(request.prompt, /structured CopySpec-only prompt/);
15
+ assert.doesNotMatch(serialized, /private source phrase|authorizationFingerprint|profileRevisionDigest|draft/);
16
+ assert.match(request.copySpecFingerprint, /^[a-f0-9]{64}$/);
17
+ assert.match(request.recompositionPolicyFingerprint ?? '', /^[a-f0-9]{64}$/);
18
+ });
19
+ test('private-provider status is unknown and ordinary rebuild has no configured verifier', () => {
20
+ assert.deepEqual(provenanceStatusForRebuild(task), { version: '1', state: 'unknown', reason: 'private_or_unavailable_verifier' });
21
+ assert.deepEqual(provenanceStatusForRebuild({ ...task, recompositionPolicy: undefined }), { version: '1', state: 'not_configured' });
22
+ });
@@ -5,8 +5,11 @@ import { parseWritingBrief } from './editorial-packs.js';
5
5
  import { verifyApprovalCapability } from './approval-capability.js';
6
6
  import { fingerprintPreEditReduction } from './judgment-task.js';
7
7
  import { verifyRebuildDeterministically } from './pipeline.js';
8
+ import { finalOutputCheck } from './hygiene.js';
8
9
  import { sentences } from './text.js';
9
10
  import { HYV_VERSION } from './version.js';
11
+ import { buildRecompositionBrief, measureLexicalResidual, parseRecompositionPolicy } from './recomposition.js';
12
+ import { provenanceStatusForRebuild, writerRequestForRebuild } from './provenance-status.js';
10
13
  const MAX_RESPONSE_BYTES = 100_000;
11
14
  const MAX_CANDIDATE_CHARACTERS = 100_000;
12
15
  function fingerprint(value) {
@@ -66,7 +69,9 @@ function parseRebuildResponse(value) {
66
69
  }
67
70
  return response;
68
71
  }
69
- function renderRebuildPrompt(draft, copySpec, writingBrief) {
72
+ function renderRebuildPrompt(draft, copySpec, writingBrief, recompositionPolicy) {
73
+ if (recompositionPolicy)
74
+ return buildRecompositionBrief(copySpec, writingBrief);
70
75
  return [
71
76
  '# Rebuild contract',
72
77
  'Return a whole-document candidate. Do not emit sentence replacements or range operations.',
@@ -107,7 +112,7 @@ function verifyRebuildAuthorization(draft, profile, recommendationFingerprint, c
107
112
  throw new Error('Rebuild authorization is invalid.');
108
113
  return authorized;
109
114
  }
110
- export function prepareRebuildTask(draft, profile, reduction, copySpec, capability, trustStore, now, writingBrief) {
115
+ export function prepareRebuildTask(draft, profile, reduction, copySpec, capability, trustStore, now, writingBrief, recompositionPolicy) {
111
116
  if (reduction.decision !== 'REBUILD')
112
117
  throw new Error('Rebuild requires an upstream REBUILD recommendation.');
113
118
  if (fingerprintPreEditReduction(reduction) !== reduction.recommendationFingerprint || reduction.recommendationFingerprint.length !== 64) {
@@ -118,16 +123,19 @@ export function prepareRebuildTask(draft, profile, reduction, copySpec, capabili
118
123
  const authorized = verifyRebuildAuthorization(draft, profile, reduction.recommendationFingerprint, capability, trustStore, now);
119
124
  if (writingBrief)
120
125
  parseWritingBrief(writingBrief);
126
+ if (recompositionPolicy)
127
+ parseRecompositionPolicy(recompositionPolicy);
121
128
  const taskBase = {
122
129
  version: '1',
123
130
  draft,
124
- prompt: renderRebuildPrompt(draft, spec, writingBrief),
131
+ prompt: renderRebuildPrompt(draft, spec, writingBrief, recompositionPolicy),
125
132
  copySpec: spec,
126
133
  recommendationFingerprint: reduction.recommendationFingerprint,
127
134
  authorizationFingerprint: authorized.capabilityFingerprint,
128
135
  profileId: identity.profileId,
129
136
  profileRevisionDigest: identity.profileRevisionDigest,
130
137
  ...(writingBrief ? { writingBrief } : {}),
138
+ ...(recompositionPolicy ? { recompositionPolicy } : {}),
131
139
  };
132
140
  return { ...taskBase, fingerprint: fingerprint(taskBase) };
133
141
  }
@@ -143,6 +151,8 @@ export function parseRebuildTask(value) {
143
151
  parseCopySpec(task.copySpec);
144
152
  if (task.writingBrief !== undefined)
145
153
  parseWritingBrief(task.writingBrief);
154
+ if (task.recompositionPolicy !== undefined)
155
+ parseRecompositionPolicy(task.recompositionPolicy);
146
156
  const { fingerprint: suppliedFingerprint, ...base } = task;
147
157
  if (fingerprint(base) !== suppliedFingerprint)
148
158
  throw new Error('Rebuild task fingerprint does not match its contents.');
@@ -196,18 +206,38 @@ export function evaluateRebuildResponse(task, raw, profile, capability, trustSto
196
206
  const applied = applyRebuildResponse(task, raw);
197
207
  if (applied.status !== 'accepted' || !applied.candidate)
198
208
  return applied;
199
- const { verification, artifact: deterministicArtifact } = verifyRebuildDeterministically(task.draft, applied.candidate, profile, task.copySpec, task.writingBrief);
209
+ const output = finalOutputCheck(applied.candidate);
210
+ const candidate = output.accepted ? output.output : applied.candidate;
211
+ const checked = verifyRebuildDeterministically(task.draft, candidate, profile, task.copySpec, task.writingBrief);
212
+ const verification = { ...checked.verification, finalOutput: output };
213
+ const deterministicArtifact = checked.artifact;
200
214
  const receipt = {
201
215
  ...applied.receipt,
202
216
  preservationBypass: true,
203
217
  authorizationFingerprint: authorized.capabilityFingerprint,
204
218
  preservationScore: verification.preservationScore,
219
+ ...(task.recompositionPolicy ? { lexicalResidual: measureLexicalResidual(task.draft, candidate, task.copySpec, task.recompositionPolicy) } : {}),
220
+ provenanceStatus: provenanceStatusForRebuild(task),
205
221
  };
206
- if (!verification.passed)
207
- return { ...applied, receipt, status: 'needs_escalation', verification, deterministicArtifact };
222
+ if (!verification.passed) {
223
+ const { candidate: _candidate, ...withheld } = applied;
224
+ return { ...withheld, receipt, status: 'needs_escalation', verification, deterministicArtifact };
225
+ }
226
+ if (receipt.lexicalResidual && !receipt.lexicalResidual.passed) {
227
+ const { candidate: _candidate, ...withheld } = applied;
228
+ return {
229
+ ...withheld,
230
+ receipt,
231
+ failures: [failure('lexical_residual_exceeds_policy', 'Candidate exceeds the configured lexical-residual policy.')],
232
+ status: 'needs_escalation',
233
+ verification,
234
+ deterministicArtifact,
235
+ };
236
+ }
208
237
  const lifecycleBinding = createRebuildLifecycleBinding(task, receipt, deterministicArtifact);
209
- return { ...applied, receipt, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
238
+ return { ...applied, candidate, receipt, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
210
239
  }
240
+ export { writerRequestForRebuild };
211
241
  export function createRebuildLifecycleBinding(task, receipt, deterministic) {
212
242
  if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint || receipt.mode !== 'REBUILD' || deterministic.verificationKind !== 'rebuild') {
213
243
  throw new Error('Lifecycle binding requires a passed rebuild artifact for this rebuild task.');
@@ -22,6 +22,11 @@ const copySpec = {
22
22
  channel: 'email',
23
23
  claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar, 7 August.' }],
24
24
  };
25
+ const recompositionPolicy = {
26
+ version: '1', mode: 'meaning-first',
27
+ lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0, maxLongestSharedRunTokens: 4 },
28
+ acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.',
29
+ };
25
30
  const { publicKey, privateKey } = generateKeyPairSync('ed25519');
26
31
  const trustStore = {
27
32
  version: '1',
@@ -129,6 +134,13 @@ test('authorized rebuild allows low lexical survival while claims and hygiene st
129
134
  assert.equal(missingClaim.status, 'needs_escalation');
130
135
  const hygiene = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: `${rebuilt}\u200B` }, reduction);
131
136
  assert.equal(hygiene.status, 'needs_escalation');
137
+ assert.equal(hygiene.candidate, undefined);
138
+ assert.equal(hygiene.verification?.finalOutput.accepted, false);
139
+ const cleaned = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: `${rebuilt}\u0007` }, reduction);
140
+ assert.equal(cleaned.status, 'needs_semantic_review');
141
+ assert.equal(cleaned.candidate, rebuilt);
142
+ assert.equal(cleaned.verification?.finalOutput.changed, true);
143
+ assert.equal(cleaned.deterministicArtifact?.candidateHash, createHash('sha256').update(rebuilt).digest('hex'));
132
144
  });
133
145
  test('rebuild disagreement cannot record accepted learning', () => {
134
146
  const reduction = rebuildRecommendation();
@@ -162,7 +174,7 @@ test('CLI and MCP rebuild helpers share fingerprints', () => {
162
174
  version: '1', audience: 'operators', intent: 'explain', format: 'outreach',
163
175
  });
164
176
  assert.match(briefTask.prompt, /# WritingBrief/);
165
- assert.equal(HYV_VERSION, '3.3.1');
177
+ assert.equal(HYV_VERSION, '3.3.2');
166
178
  });
167
179
  test('apply rejects forged tasks, missing capability, and substituted profiles', () => {
168
180
  const reduction = rebuildRecommendation();
@@ -177,3 +189,18 @@ test('apply rejects forged tasks, missing capability, and substituted profiles',
177
189
  const other = buildProfile(['I speak in a different register altogether.', 'I keep every sentence longer than the first profile would.'], ['mechanism']);
178
190
  assert.throws(() => evaluate(task, response, reduction, other), /Rebuild profile binding does not match this task/);
179
191
  });
192
+ test('meaning-first rebuild binds the residual policy and blocks carried-over wording', () => {
193
+ const source = 'The operating review keeps the launch checklist small and the handoff calm. The launch is on 14 August.';
194
+ const reduction = rebuildRecommendation(source);
195
+ const task = prepareRebuildTask(source, profile, reduction, copySpec, capability(reduction, {}, source), trustStore, 150, undefined, recompositionPolicy);
196
+ assert.doesNotMatch(task.prompt, /The operating review keeps the launch checklist/);
197
+ assert.match(task.prompt, /Meaning-first recomposition contract/);
198
+ const repeated = evaluateRebuildResponse(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: source }, profile, capability(reduction, {}, source), trustStore, 150);
199
+ assert.equal(repeated.status, 'needs_escalation');
200
+ assert.equal(repeated.failures[0]?.code, 'lexical_residual_exceeds_policy');
201
+ assert.equal(repeated.receipt.lexicalResidual?.passed, false);
202
+ assert.equal(repeated.candidate, undefined);
203
+ const fresh = evaluateRebuildResponse(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: 'Release owners now work from a compact calendar note. The launch is on 14 August. Nothing else in this message repeats the original framing.' }, profile, capability(reduction, {}, source), trustStore, 150);
204
+ assert.equal(fresh.status, 'needs_semantic_review');
205
+ assert.equal(fresh.receipt.lexicalResidual?.passed, true);
206
+ });
@@ -0,0 +1,97 @@
1
+ import { canonicalJson } from './canonical-json.js';
2
+ import { words } from './text.js';
3
+ const ACKNOWLEDGEMENT = 'Measures shared wording only; does not detect or prove removal of a watermark.';
4
+ const STATEMENT = 'Lexical overlap is not a watermark detector.';
5
+ function tokens(text) {
6
+ return words(text.normalize('NFKC')).map((word) => word.toLowerCase());
7
+ }
8
+ function ngrams(value, size) {
9
+ const result = [];
10
+ for (let index = 0; index + size <= value.length; index += 1)
11
+ result.push(value.slice(index, index + size).join('\u0001'));
12
+ return result;
13
+ }
14
+ function allowedPhrases(copySpec) {
15
+ const result = [];
16
+ for (const claim of copySpec.claims) {
17
+ if (claim.atoms?.length)
18
+ result.push(...claim.atoms.map((text) => ({ reason: 'copy-spec-atom', text })));
19
+ else
20
+ result.push({ reason: 'copy-spec-claim', text: claim.text });
21
+ }
22
+ return result;
23
+ }
24
+ function longestSharedRun(source, candidate, allowed) {
25
+ let longest = 0;
26
+ let previous = new Array(source.length + 1).fill(0);
27
+ for (let candidateIndex = 1; candidateIndex <= candidate.length; candidateIndex += 1) {
28
+ const current = new Array(source.length + 1).fill(0);
29
+ for (let sourceIndex = 1; sourceIndex <= source.length; sourceIndex += 1) {
30
+ if (candidate[candidateIndex - 1] !== source[sourceIndex - 1])
31
+ continue;
32
+ current[sourceIndex] = previous[sourceIndex - 1] + 1;
33
+ const run = current[sourceIndex];
34
+ const key = candidate.slice(candidateIndex - run, candidateIndex).join('\u0001');
35
+ if (!allowed.has(key))
36
+ longest = Math.max(longest, run);
37
+ }
38
+ previous = current;
39
+ }
40
+ return longest;
41
+ }
42
+ export function parseRecompositionPolicy(value) {
43
+ if (!value || typeof value !== 'object' || Array.isArray(value))
44
+ throw new Error('Recomposition policy must be an object.');
45
+ const policy = value;
46
+ const residual = policy.lexicalResidual;
47
+ if (policy.version !== '1' || policy.mode !== 'meaning-first' || policy.acknowledgement !== ACKNOWLEDGEMENT
48
+ || !residual || residual.ngramSize !== 5
49
+ || typeof residual.maxSharedNgramFraction !== 'number' || !Number.isFinite(residual.maxSharedNgramFraction) || residual.maxSharedNgramFraction < 0 || residual.maxSharedNgramFraction > 1
50
+ || !Number.isInteger(residual.maxLongestSharedRunTokens) || residual.maxLongestSharedRunTokens < 0 || residual.maxLongestSharedRunTokens > 100_000) {
51
+ throw new Error('Recomposition policy is not valid.');
52
+ }
53
+ return policy;
54
+ }
55
+ export function buildRecompositionBrief(copySpec, writingBrief) {
56
+ return [
57
+ '# Meaning-first recomposition contract',
58
+ 'Write a new whole-document candidate from the structured facts and constraints below.',
59
+ 'Do not edit, quote, or mirror source wording unless a CopySpec claim or atom requires it.',
60
+ 'Return only the candidate. Do not claim anything about authorship, AI origin, or watermark status.',
61
+ '',
62
+ '# CopySpec',
63
+ canonicalJson({ audience: copySpec.audience, intent: copySpec.intent, channel: copySpec.channel, claims: copySpec.claims, ...(copySpec.prohibitedClaims ? { prohibitedClaims: copySpec.prohibitedClaims } : {}) }),
64
+ ...(writingBrief ? ['', '# WritingBrief', canonicalJson(writingBrief)] : []),
65
+ ].join('\n');
66
+ }
67
+ export function measureLexicalResidual(sourceText, candidateText, copySpec, policy) {
68
+ const source = tokens(sourceText);
69
+ const candidate = tokens(candidateText);
70
+ const allowed = allowedPhrases(copySpec);
71
+ const allowedNgrams = new Set();
72
+ const allowedRuns = new Set();
73
+ const counts = new Map();
74
+ for (const phrase of allowed) {
75
+ const phraseTokens = tokens(phrase.text);
76
+ counts.set(phrase.reason, (counts.get(phrase.reason) ?? 0) + phraseTokens.length);
77
+ for (const ngram of ngrams(phraseTokens, policy.lexicalResidual.ngramSize))
78
+ allowedNgrams.add(ngram);
79
+ for (let length = 1; length <= phraseTokens.length; length += 1) {
80
+ for (let index = 0; index + length <= phraseTokens.length; index += 1)
81
+ allowedRuns.add(phraseTokens.slice(index, index + length).join('\u0001'));
82
+ }
83
+ }
84
+ const sourceNgrams = new Set(ngrams(source, policy.lexicalResidual.ngramSize).filter((ngram) => !allowedNgrams.has(ngram)));
85
+ const candidateNgrams = ngrams(candidate, policy.lexicalResidual.ngramSize).filter((ngram) => !allowedNgrams.has(ngram));
86
+ const shared = candidateNgrams.filter((ngram) => sourceNgrams.has(ngram)).length;
87
+ const sharedNgramFraction = candidateNgrams.length === 0 ? 0 : shared / candidateNgrams.length;
88
+ const longestSharedRunTokens = longestSharedRun(source, candidate, allowedRuns);
89
+ const passed = sharedNgramFraction <= policy.lexicalResidual.maxSharedNgramFraction
90
+ && longestSharedRunTokens <= policy.lexicalResidual.maxLongestSharedRunTokens;
91
+ return {
92
+ version: '1', sourceTokenCount: source.length, candidateTokenCount: candidate.length, ngramSize: policy.lexicalResidual.ngramSize,
93
+ sharedNgramFraction, longestSharedRunTokens,
94
+ allowedResiduals: [...counts.entries()].map(([reason, count]) => ({ reason, count })),
95
+ passed, statement: STATEMENT,
96
+ };
97
+ }
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { buildRecompositionBrief, measureLexicalResidual, parseRecompositionPolicy } from './recomposition.js';
4
+ const policy = {
5
+ version: '1', mode: 'meaning-first',
6
+ lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0, maxLongestSharedRunTokens: 4 },
7
+ acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.',
8
+ };
9
+ const copySpec = {
10
+ version: '1', audience: 'operators', intent: 'explain', channel: 'email',
11
+ claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar.', atoms: ['The launch is on 14 August.'] }],
12
+ };
13
+ test('parses only explicit meaning-first recomposition policies', () => {
14
+ assert.deepEqual(parseRecompositionPolicy(policy), policy);
15
+ assert.throws(() => parseRecompositionPolicy({ ...policy, lexicalResidual: { ...policy.lexicalResidual, ngramSize: 4 } }), /not valid/);
16
+ assert.throws(() => parseRecompositionPolicy({ ...policy, acknowledgement: 'watermark removed' }), /not valid/);
17
+ });
18
+ test('builds a recomposition brief without carrying the source draft', () => {
19
+ const brief = buildRecompositionBrief(copySpec, { version: '1', audience: 'operators', intent: 'explain', format: 'outreach' });
20
+ assert.match(brief, /Meaning-first recomposition contract/);
21
+ assert.match(brief, /The launch is on 14 August/);
22
+ assert.doesNotMatch(brief, /watermark-free|dewatermarked/i);
23
+ });
24
+ test('measures lexical carry-over without calling it watermark detection', () => {
25
+ const source = 'The operating review keeps the launch checklist small and the handoff calm. The launch is on 14 August.';
26
+ const identical = measureLexicalResidual(source, source, copySpec, policy);
27
+ assert.equal(identical.passed, false);
28
+ assert.ok(identical.sharedNgramFraction > 0);
29
+ assert.match(identical.statement, /not a watermark detector/);
30
+ const fresh = measureLexicalResidual(source, 'Release owners now work from a compact calendar note. The launch is on 14 August. Nothing else in this message repeats the original framing.', copySpec, policy);
31
+ assert.equal(fresh.passed, true);
32
+ assert.equal(fresh.sharedNgramFraction, 0);
33
+ assert.ok(fresh.allowedResiduals.some((item) => item.reason === 'copy-spec-atom'));
34
+ });
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { canonicalJson } from './canonical-json.js';
3
3
  import { parseWritingBrief } from './editorial-packs.js';
4
- import { hygieneSourceFindings } from './hygiene.js';
4
+ import { finalOutputCheck, hygieneSourceFindings } from './hygiene.js';
5
5
  import { analyze, deriveEditScope, renderRewritePrompt, verifyDeterministically } from './pipeline.js';
6
6
  import { sentences } from './text.js';
7
7
  const MAX_RESPONSE_BYTES = 100_000;
@@ -251,11 +251,17 @@ export function evaluateRewriteResponse(task, raw, profile) {
251
251
  const applied = applyRewriteResponse(task, raw);
252
252
  if (applied.status !== 'accepted' || !applied.candidate)
253
253
  return applied;
254
- const { verification, artifact: deterministicArtifact } = verifyDeterministically(task.draft, applied.candidate, profile, task.copySpec, task.writingBrief);
255
- if (!verification.passed)
256
- return { ...applied, status: 'needs_escalation', verification };
254
+ const output = finalOutputCheck(applied.candidate);
255
+ const candidate = output.accepted ? output.output : applied.candidate;
256
+ const checked = verifyDeterministically(task.draft, candidate, profile, task.copySpec, task.writingBrief);
257
+ const verification = { ...checked.verification, finalOutput: output };
258
+ const deterministicArtifact = checked.artifact;
259
+ if (!verification.passed) {
260
+ const { candidate: _candidate, ...withheld } = applied;
261
+ return { ...withheld, status: 'needs_escalation', verification };
262
+ }
257
263
  const lifecycleBinding = createRewriteLifecycleBinding(task, applied.receipt, deterministicArtifact);
258
- return { ...applied, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
264
+ return { ...applied, candidate, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
259
265
  }
260
266
  export function createRewriteLifecycleBinding(task, receipt, deterministic) {
261
267
  if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint)
@@ -116,6 +116,21 @@ test('keeps a valid response byte-for-byte unchanged by repair adapters', () =>
116
116
  assert.deepEqual(result.receipt.adapterIds, []);
117
117
  assert.deepEqual(result.receipt.replacementSentenceIds, [1]);
118
118
  });
119
+ test('withholds an unresolved final-output candidate from evaluation results', () => {
120
+ const task = prepareRewriteTask('I leverage the answer.', profile);
121
+ const result = evaluateRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer.\u200B' }] }, profile);
122
+ assert.equal(result.status, 'needs_escalation');
123
+ assert.equal(result.candidate, undefined);
124
+ assert.equal(result.verification?.finalOutput.accepted, false);
125
+ });
126
+ test('returns the final-gate-cleaned candidate and hashes that value', () => {
127
+ const task = prepareRewriteTask('I leverage the answer with useful detail and clear mechanism.', profile);
128
+ const result = evaluateRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the\u0007 answer with useful detail and clear mechanism.' }] }, profile);
129
+ assert.equal(result.status, 'needs_semantic_review');
130
+ assert.equal(result.candidate, 'I use the answer with useful detail and clear mechanism.');
131
+ assert.equal(result.verification?.finalOutput.changed, true);
132
+ assert.equal(result.deterministicArtifact?.candidateHash, createHash('sha256').update('I use the answer with useful detail and clear mechanism.').digest('hex'));
133
+ });
119
134
  test('binds the task, response, deterministic artifact, text hashes, and profile revision', () => {
120
135
  const draft = 'I write clear notes.';
121
136
  const task = prepareRewriteTask(draft, profile);
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import { applyHiddenTextPolicy, inspectHiddenText } from './hidden-text.js';
5
+ import { provenanceStatusForRebuild, writerRequestForRebuild } from './provenance-status.js';
6
+ const feature = readFileSync(new URL('../features/text-provenance.feature', import.meta.url), 'utf8');
7
+ const policy = { version: '1', mode: 'meaning-first', lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0.1, maxLongestSharedRunTokens: 8 }, acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.' };
8
+ const task = {
9
+ version: '1', fingerprint: 'a'.repeat(64), draft: 'private source phrase', prompt: 'structured task prompt',
10
+ copySpec: { version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'date', text: 'The date is fixed.', evidence: 'calendar' }] },
11
+ recommendationFingerprint: 'b'.repeat(64), authorizationFingerprint: 'c'.repeat(64), profileId: 'profile', profileRevisionDigest: 'd'.repeat(64), recompositionPolicy: policy,
12
+ };
13
+ function scenario(name, verify) {
14
+ test(`Feature: bounded text provenance sanitation — Scenario: ${name}`, () => {
15
+ assert.match(feature, new RegExp(`Scenario: ${name}`));
16
+ verify();
17
+ });
18
+ }
19
+ scenario('explicitly approved non-semantic controls are removed with evidence', () => {
20
+ const receipt = applyHiddenTextPolicy('one\u0007two\uFEFFthree');
21
+ assert.deepEqual(receipt.proposedChanges.map((item) => item.codepoint), ['U+0007', 'U+FEFF']);
22
+ assert.match(receipt.inputHash, /^[a-f0-9]{64}$/);
23
+ assert.match(receipt.outputHash, /^[a-f0-9]{64}$/);
24
+ assert.equal(receipt.idempotent, true);
25
+ });
26
+ scenario('multilingual and structured text is review-only by default', () => {
27
+ const source = '```md\nالعربية\u202E ไทย\u200B 👩\u200D💻\t\n```';
28
+ const receipt = applyHiddenTextPolicy(source);
29
+ assert.equal(receipt.output, source);
30
+ assert.equal(receipt.proposedChanges.length, 0);
31
+ assert.ok(inspectHiddenText(source).findings.every((item) => item.action === 'review'));
32
+ });
33
+ scenario('an external writer receives no source prose', () => {
34
+ const request = writerRequestForRebuild(task);
35
+ assert.match(request.prompt, /structured task prompt/);
36
+ assert.doesNotMatch(JSON.stringify(request), /private source phrase|authorizationFingerprint|profileRevisionDigest|draft/);
37
+ });
38
+ scenario('unknown provider status remains explicit', () => {
39
+ assert.deepEqual(provenanceStatusForRebuild(task), { version: '1', state: 'unknown', reason: 'private_or_unavailable_verifier' });
40
+ assert.match(feature, /does not claim watermark removal or absence/);
41
+ });
42
+ scenario('a controlled verifier is not configured', () => {
43
+ assert.deepEqual(provenanceStatusForRebuild({ ...task, recompositionPolicy: undefined }), { version: '1', state: 'not_configured' });
44
+ assert.match(feature, /lexical residual is not used as a provider verifier/);
45
+ });
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const HYV_VERSION = '3.3.1';
1
+ export const HYV_VERSION = '3.3.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holdyourvoice/hyv",
3
- "version": "3.3.1",
3
+ "version": "3.3.2",
4
4
  "description": "A local-first dual-engine writing gate that protects voice and catches generic AI patterns.",
5
5
  "type": "module",
6
6
  "bin": { "hyv": "dist/cli.js" },