@holdyourvoice/hyv 3.3.0 → 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.
@@ -0,0 +1,191 @@
1
+ import { sentences } from './text.js';
2
+ const STOP_WORDS = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'have', 'in', 'is', 'it', 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'was', 'were', 'will', 'with']);
3
+ const DATE = /\b(?:\d{1,2}\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}|(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},?\s+\d{4}|\d{4}-\d{2}-\d{2})\b/gi;
4
+ const QUOTE = /["“]([^"”]+)["”]/;
5
+ function normal(value) { return value.toLowerCase().replace(/[^\p{L}\p{N}%]+/gu, ' ').trim(); }
6
+ function tokens(value) { return normal(value).split(' ').filter((word) => word.length > 1 && !STOP_WORDS.has(word)); }
7
+ function evidence(source, text) { const start = source.text.indexOf(text); return { sourceId: source.id, excerpt: text, start: Math.max(0, start), end: Math.max(0, start) + text.length }; }
8
+ function sourceSentences(sources) { return sources.flatMap((source) => sentences(source.text).map((sentence) => ({ source, text: sentence.text }))); }
9
+ function hasOverlap(claim, source) {
10
+ const claimTokens = tokens(claim);
11
+ const sourceTokens = new Set(tokens(source));
12
+ return claimTokens.length > 0 && claimTokens.filter((token) => sourceTokens.has(token)).length / claimTokens.length >= 0.8;
13
+ }
14
+ function dates(value) { return [...value.matchAll(DATE)].map((match) => new Date(match[0]).toISOString().slice(0, 10)).filter((value) => value !== ''); }
15
+ function numbers(value) { return value.match(/\b\d+(?:\.\d+)?\s*(?:%|days?|hours?|weeks?|months?|years?)?\b/gi) ?? []; }
16
+ function negated(value) { return /\b(?:does not|do not|did not|is not|are not|cannot|can't|won't|not)\b/i.test(value); }
17
+ const NON_ENTITIES = new Set(['A', 'An', 'And', 'After', 'As', 'At', 'But', 'For', 'From', 'He', 'I', 'In', 'It', 'Its', 'On', 'Or', 'She', 'The', 'This', 'That', 'They', 'We', 'With', 'You']);
18
+ const CAPABILITY = /\b(?:exports?|supports|includes?|works with|can\s+(?:export|support|include)|(?:does not|do not|did not|is not|are not)\s+support)\s+([^.!?]+)/gi;
19
+ const CAPABILITY_FILLER = new Set(['a', 'an', 'as', 'data', 'file', 'files', 'report', 'reports', 'the']);
20
+ const CAPABILITY_FORMATS = new Set(['csv', 'json', 'pdf', 'xml']);
21
+ function entities(value) {
22
+ return (value.match(/\b[A-Z][\p{L}\p{M}'-]*(?:\s+[A-Z][\p{L}\p{M}'-]+)?\b/gu) ?? []).filter((entity) => !NON_ENTITIES.has(entity) && entity !== entity.toUpperCase());
23
+ }
24
+ function capabilityObjects(text) {
25
+ return [...text.matchAll(CAPABILITY)].map((match) => [...new Set(tokens(match[1]).map((token) => token.replace(/s$/, '')).filter((token) => !CAPABILITY_FILLER.has(token)))]).filter((items) => items.length > 0);
26
+ }
27
+ function kindFor(text) {
28
+ const kinds = [];
29
+ if (/\b(i think|i feel|in my view|we believe)\b/i.test(text))
30
+ kinds.push('opinion');
31
+ if (/\b(may|might|could|likely|hypothesis)\b/i.test(text))
32
+ kinds.push('hypothesis');
33
+ if (/\b(caused?|because|led to|resulted in)\b/i.test(text))
34
+ kinds.push('causal');
35
+ if (/\b(best|better|more|less|fastest|largest|every alternative|than)\b/i.test(text))
36
+ kinds.push('comparative');
37
+ if (QUOTE.test(text) || /\b(said|according to|reported)\b/i.test(text))
38
+ kinds.push('attribution_quote');
39
+ if (dates(text).length)
40
+ kinds.push('date_time');
41
+ if (/\b\d+(?:\.\d+)?%?\b/.test(text))
42
+ kinds.push('number');
43
+ if (entities(text).length)
44
+ kinds.push('entity');
45
+ if (!kinds.length)
46
+ kinds.push('fact');
47
+ return kinds;
48
+ }
49
+ function findRelevant(claim, sources) {
50
+ const terms = tokens(claim);
51
+ return sources.filter(({ text }) => terms.some((term) => tokens(text).includes(term)));
52
+ }
53
+ function fallbackEvidence(sources) {
54
+ const line = sources[0];
55
+ return line ? [evidence(line.source, line.text)] : [];
56
+ }
57
+ function finding(claim, kind, severity, reason, evidenceItems, confidence, suggestedAction) {
58
+ return { severity, kind, claim: claim.text, draftLocation: { sentence: claim.sentence, start: claim.start, end: claim.end }, reason, evidence: evidenceItems, confidence, suggestedAction };
59
+ }
60
+ export function extractFactClaims(draft) {
61
+ const output = sentences(draft).map((sentence) => ({ text: sentence.text, sentence: sentence.index, start: sentence.start, end: sentence.end, kinds: kindFor(sentence.text) }));
62
+ for (const match of draft.matchAll(/["“]([^"”]+)["”]/g)) {
63
+ const start = match.index ?? 0;
64
+ const containing = output.find((claim) => claim.start <= start && claim.end >= start) ?? output.find((claim) => claim.start <= start) ?? output[0];
65
+ if (containing)
66
+ output.push({ text: match[0], sentence: containing.sentence, start, end: start + match[0].length, kinds: ['attribution_quote'] });
67
+ }
68
+ return output;
69
+ }
70
+ export function lintFacts(input) {
71
+ if (!input.sources.length)
72
+ throw new Error('Fact lint requires at least one source document.');
73
+ if (!input.sources.every((source) => source.id.trim() && source.text.trim()))
74
+ throw new Error('Every fact-lint source needs a non-empty id and text.');
75
+ const claims = extractFactClaims(input.draft);
76
+ const sourceLines = sourceSentences(input.sources);
77
+ const findings = [];
78
+ const semanticAdapter = input.semanticAdapter && (!input.semanticAdapter.external || input.allowExternalSemantic) ? input.semanticAdapter : undefined;
79
+ const approved = new Set(input.metadata?.approvedHypotheses?.map(normal) ?? []);
80
+ const allowed = new Set(input.metadata?.allowedAssumptions?.map(normal) ?? []);
81
+ for (const claim of claims) {
82
+ if (claim.kinds.includes('opinion') || allowed.has(normal(claim.text)) || (claim.kinds.includes('hypothesis') && approved.has(normal(claim.text))))
83
+ continue;
84
+ const relevant = findRelevant(claim.text, sourceLines);
85
+ const same = sourceLines.find(({ text }) => hasOverlap(claim.text, text));
86
+ const evidenceItems = relevant.length ? relevant.slice(0, 2).map(({ source, text }) => evidence(source, text)) : fallbackEvidence(sourceLines);
87
+ const quote = claim.text.match(QUOTE)?.[1];
88
+ const quoteRelevant = sourceLines.filter(({ text }) => /\b(said|according to|reported)\b/i.test(text));
89
+ if (quote && input.sources.some((source) => /\b(said|according to|reported)\b/i.test(source.text)) && !input.sources.some((source) => source.text.includes(quote))) {
90
+ const quoteEvidence = quoteRelevant.length ? quoteRelevant.slice(0, 2).map(({ source, text }) => evidence(source, text)) : input.sources.slice(0, 1).map((source) => evidence(source, source.text));
91
+ findings.push(finding(claim, 'quote_drift', 'error', 'The quoted wording differs from the supplied source.', quoteEvidence, 'high', 'Use the source wording or label the text as a paraphrase.'));
92
+ continue;
93
+ }
94
+ const claimDates = dates(claim.text);
95
+ const sourceDates = relevant.flatMap(({ text }) => dates(text));
96
+ const claimNumbers = numbers(claim.text);
97
+ const sourceNumbers = relevant.flatMap(({ text }) => numbers(text));
98
+ if (!claimDates.length && !claim.kinds.includes('attribution_quote') && claimNumbers.length && sourceNumbers.length && claimNumbers.some((number) => !sourceNumbers.some((sourceNumber) => normal(sourceNumber) === normal(number)))) {
99
+ findings.push(finding(claim, 'number_drift', 'error', 'A number or unit differs from relevant source evidence.', evidenceItems, 'high', 'Correct the number or unit, or cite a newer source.'));
100
+ continue;
101
+ }
102
+ if (claimDates.length && sourceDates.length && claimDates.some((date) => !sourceDates.includes(date))) {
103
+ findings.push(finding(claim, 'date_drift', 'error', 'The draft date differs from relevant source evidence.', evidenceItems, 'high', 'Correct the date or cite a newer source.'));
104
+ continue;
105
+ }
106
+ const claimEntities = entities(claim.text);
107
+ const sourceEntities = relevant.flatMap(({ text }) => entities(text));
108
+ if (claimEntities.length && sourceEntities.length && claimEntities.some((entity) => !sourceEntities.some((sourceEntity) => normal(sourceEntity) === normal(entity)))) {
109
+ findings.push(finding(claim, 'entity_drift', 'error', 'A named entity differs from relevant source evidence.', evidenceItems, 'high', 'Correct the name or cite the source that supports it.'));
110
+ continue;
111
+ }
112
+ const claimCapabilities = capabilityObjects(claim.text);
113
+ if (claimCapabilities.length && relevant.length) {
114
+ const sourceCapabilities = relevant.flatMap(({ text }) => capabilityObjects(text));
115
+ const supportedCapability = claimCapabilities.every((capability) => sourceCapabilities.some((sourceCapability) => capability.every((token) => sourceCapability.includes(token))));
116
+ if (supportedCapability && relevant.some(({ text }) => negated(text) !== negated(claim.text))) {
117
+ findings.push(finding(claim, 'capability_drift', 'error', 'The draft reverses the source capability.', evidenceItems, 'high', 'Match the source capability polarity or cite contrary evidence.'));
118
+ continue;
119
+ }
120
+ if (supportedCapability)
121
+ continue;
122
+ const knownFormatMismatch = claimCapabilities.some((capability) => {
123
+ const claimFormats = capability.filter((token) => CAPABILITY_FORMATS.has(token));
124
+ return claimFormats.length > 0 && sourceCapabilities.some((sourceCapability) => {
125
+ const sourceFormats = sourceCapability.filter((token) => CAPABILITY_FORMATS.has(token));
126
+ return sourceFormats.length > 0 && claimFormats.some((format) => !sourceFormats.includes(format));
127
+ });
128
+ });
129
+ if (knownFormatMismatch) {
130
+ findings.push(finding(claim, 'capability_drift', 'error', 'The product capability differs from the supplied source.', evidenceItems, 'high', 'Match the source capability or cite contrary evidence.'));
131
+ continue;
132
+ }
133
+ findings.push(finding(claim, 'missing_evidence', 'needs_human_review', 'The product capability is not established by the relevant source wording.', evidenceItems, 'medium', 'Confirm the capability with a reviewer or add evidence.'));
134
+ continue;
135
+ }
136
+ const causalOverreach = claim.kinds.includes('causal') && relevant.length && !relevant.some(({ text }) => /\b(caused?|because|led to|resulted in)\b/i.test(text));
137
+ const comparativeOverreach = claim.kinds.includes('comparative') && relevant.length && !relevant.some(({ text }) => /\b(better|more|less|than|best|largest|fastest)\b/i.test(text));
138
+ if (causalOverreach)
139
+ findings.push(finding(claim, 'causal_overreach', 'warning', 'The sources describe an outcome but do not establish causation.', evidenceItems, 'medium', 'Use an association claim or add causal evidence.'));
140
+ if (comparativeOverreach)
141
+ findings.push(finding(claim, 'comparative_overreach', 'warning', 'The sources do not establish the comparison.', evidenceItems, 'medium', 'Narrow the comparison or add comparative evidence.'));
142
+ if (causalOverreach || comparativeOverreach)
143
+ continue;
144
+ if (claimDates.length && claimDates.some((date) => sourceDates.includes(date)))
145
+ continue;
146
+ if (same)
147
+ continue;
148
+ if (!relevant.length && claim.kinds.includes('fact') && tokens(claim.text).length <= 4) {
149
+ findings.push(finding(claim, 'missing_evidence', 'needs_human_review', 'No close source evidence was found; the wording is too sparse for a reliable deterministic verdict.', evidenceItems, 'low', 'Confirm with a reviewer or provide a source.'));
150
+ continue;
151
+ }
152
+ if ((!relevant.length || (claim.kinds.includes('number') && !relevant.some(({ text }) => /\b\d+(?:\.\d+)?%?\b/.test(text))))) {
153
+ findings.push(finding(claim, 'unsupported_claim', 'error', 'No supplied source supports this checkable claim.', evidenceItems, 'medium', 'Add a source, remove the claim, or mark it as an approved hypothesis.'));
154
+ continue;
155
+ }
156
+ const semantic = semanticAdapter?.compare({ claim: claim.text, sources: input.sources });
157
+ if (semantic === 'supported')
158
+ continue;
159
+ if (semantic === 'contradicted') {
160
+ findings.push(finding(claim, 'semantic_contradiction', 'error', 'The configured semantic adapter found contradictory source evidence.', evidenceItems, 'medium', 'Review the cited sources and correct or qualify the claim.'));
161
+ continue;
162
+ }
163
+ findings.push(finding(claim, 'missing_evidence', 'needs_human_review', 'Relevant source material exists, but deterministic matching could not establish support.', evidenceItems, 'low', 'Review the source context or enable an approved semantic adapter.'));
164
+ }
165
+ const normalizedClaims = claims.map((claim) => ({ claim, text: normal(claim.text) }));
166
+ for (let index = 0; index < normalizedClaims.length; index += 1)
167
+ for (let other = index + 1; other < normalizedClaims.length; other += 1) {
168
+ const left = normalizedClaims[index];
169
+ const right = normalizedClaims[other];
170
+ const leftCore = normal(left.text.replace(/\bnot\b/g, ''));
171
+ const rightCore = normal(right.text.replace(/\bnot\b/g, ''));
172
+ if (leftCore === rightCore && /\bnot\b/.test(left.text) !== /\bnot\b/.test(right.text)) {
173
+ findings.push(finding(right.claim, 'draft_contradiction', 'error', 'This draft claim contradicts an earlier draft claim.', fallbackEvidence(sourceLines), 'high', 'Resolve the two claims before publishing.'));
174
+ }
175
+ }
176
+ const claimKey = (item) => `${item.draftLocation.start}:${item.draftLocation.end}`;
177
+ const unsupported = new Set(findings.filter((item) => item.kind === 'unsupported_claim').map(claimKey)).size;
178
+ const contradicted = new Set(findings.filter((item) => ['draft_contradiction', 'number_drift', 'date_drift', 'entity_drift', 'quote_drift', 'capability_drift', 'semantic_contradiction'].includes(item.kind)).map(claimKey)).size;
179
+ const humanReview = new Set(findings.filter((item) => item.severity === 'needs_human_review' || item.severity === 'warning').map(claimKey)).size;
180
+ const checked = claims.filter((claim) => !claim.kinds.includes('opinion')).length;
181
+ const affected = new Set(findings.map(claimKey)).size;
182
+ return { version: '1', summary: { checked, supported: Math.max(0, checked - affected), unsupported, contradicted, humanReview }, claims, findings, skippedChecks: semanticAdapter ? [] : ['semantic_matching'] };
183
+ }
184
+ export function formatFactLintReport(report) {
185
+ const lines = [`fact lint: ${report.summary.checked} checked, ${report.summary.supported} supported, ${report.findings.length} findings`];
186
+ for (const item of report.findings)
187
+ lines.push(`${item.severity} ${item.kind} s${item.draftLocation.sentence} [${item.evidence[0]?.sourceId ?? 'no-source'}]: ${item.reason}`);
188
+ if (report.skippedChecks.length)
189
+ lines.push(`skipped: ${report.skippedChecks.join(', ')}`);
190
+ return lines.join('\n');
191
+ }
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import { lintFacts } from './fact-linter.js';
5
+ test('supports evidence-backed facts and harmless paraphrases', () => {
6
+ const report = lintFacts({
7
+ sources: [{ id: 'release-notes', text: 'Acme launched Atlas on 14 August 2026. Atlas exports reports as CSV.' }],
8
+ draft: 'Atlas shipped on August 14, 2026. It can export reports as CSV.',
9
+ });
10
+ assert.equal(report.summary.supported, 2);
11
+ assert.equal(report.findings.length, 0);
12
+ });
13
+ test('flags numeric, date, entity, quote, and capability drift with exact evidence', () => {
14
+ const report = lintFacts({
15
+ sources: [{ id: 'brief', text: 'Maya Chen said, "We support 12 teams." The launch is on 14 August 2026. Atlas exports CSV reports.' }],
16
+ draft: 'Maya Chan said, "We support 20 teams." The launch is on 15 August 2026. Atlas exports PDF reports.',
17
+ });
18
+ assert.deepEqual(new Set(report.findings.map((finding) => finding.kind)), new Set(['entity_drift', 'quote_drift', 'date_drift', 'capability_drift']));
19
+ assert.ok(report.findings.every((finding) => finding.evidence[0]?.sourceId === 'brief' && finding.evidence[0]?.excerpt));
20
+ });
21
+ test('flags numeric values and units that differ from relevant evidence', () => {
22
+ const report = lintFacts({ sources: [{ id: 'brief', text: 'Acme retains exports for 12 days.' }], draft: 'Acme retains exports for 12 hours.' });
23
+ assert.equal(report.findings[0]?.kind, 'number_drift');
24
+ });
25
+ test('flags a capability whose polarity reverses the supplied source', () => {
26
+ const report = lintFacts({ sources: [{ id: 'brief', text: 'Atlas supports CSV exports.' }], draft: 'Atlas does not support CSV exports.' });
27
+ assert.equal(report.findings[0]?.kind, 'capability_drift');
28
+ });
29
+ test('routes an unsupported product capability to human review', () => {
30
+ const report = lintFacts({ sources: [{ id: 'brief', text: 'Atlas supports CSV exports.' }], draft: 'Atlas supports real-time API alerts.' });
31
+ assert.equal(report.findings[0]?.kind, 'missing_evidence');
32
+ assert.equal(report.findings[0]?.severity, 'needs_human_review');
33
+ });
34
+ test('routes sparse overlap without a matching predicate to human review', () => {
35
+ const report = lintFacts({ sources: [{ id: 'brief', text: 'Harbor Studio exists.' }], draft: 'Harbor Studio grows.' });
36
+ assert.equal(report.findings[0]?.kind, 'missing_evidence');
37
+ assert.equal(report.findings[0]?.severity, 'needs_human_review');
38
+ });
39
+ test('flags a single-token product name that drifts from source evidence', () => {
40
+ const report = lintFacts({ sources: [{ id: 'brief', text: 'Atlas exports CSV reports.' }], draft: 'Atlus exports CSV reports.' });
41
+ assert.equal(report.findings[0]?.kind, 'entity_drift');
42
+ });
43
+ test('flags unsupported facts and draft-internal contradictions', () => {
44
+ const report = lintFacts({
45
+ sources: [{ id: 'brief', text: 'The service is available in India.' }],
46
+ draft: 'The service is available in India. The service is not available in India. The service has 99.99% uptime.',
47
+ });
48
+ assert.ok(report.findings.some((finding) => finding.kind === 'draft_contradiction'));
49
+ assert.ok(report.findings.some((finding) => finding.kind === 'unsupported_claim'));
50
+ });
51
+ test('treats causal and comparative overreach as reviewable material problems', () => {
52
+ const report = lintFacts({
53
+ sources: [{ id: 'study', text: 'After the training, support tickets fell from 12 to 8. The study did not test causes or competitors.' }],
54
+ draft: 'The training caused support tickets to fall and is better than every alternative.',
55
+ });
56
+ assert.deepEqual(new Set(report.findings.map((finding) => finding.kind)), new Set(['causal_overreach', 'comparative_overreach']));
57
+ });
58
+ test('does not flag opinions or approved hypotheses, and routes ambiguous gaps to human review', () => {
59
+ const report = lintFacts({
60
+ sources: [{ id: 'brief', text: 'The team is exploring a mobile app.' }],
61
+ draft: 'I think the mobile app is a good idea. The app may reduce churn. The team is popular.',
62
+ metadata: { approvedHypotheses: ['The app may reduce churn.'], allowedAssumptions: ['The team is popular.'] },
63
+ });
64
+ assert.equal(report.findings.some((finding) => finding.claim.includes('good idea')), false);
65
+ assert.equal(report.findings.some((finding) => finding.claim.includes('may reduce churn')), false);
66
+ assert.equal(report.findings.some((finding) => finding.claim.includes('popular')), false);
67
+ });
68
+ test('reports skipped semantic checks unless an explicitly configured adapter runs them', () => {
69
+ const report = lintFacts({ sources: [{ id: 'brief', text: 'Atlas exists.' }], draft: 'Atlas exists.' });
70
+ assert.deepEqual(report.skippedChecks, ['semantic_matching']);
71
+ const external = { id: 'remote', external: true, compare: () => 'supported' };
72
+ assert.deepEqual(lintFacts({ sources: [{ id: 'brief', text: 'Atlas exists.' }], draft: 'Atlas exists.', semanticAdapter: external }).skippedChecks, ['semantic_matching']);
73
+ assert.deepEqual(lintFacts({ sources: [{ id: 'brief', text: 'Atlas exists.' }], draft: 'Atlas exists.', semanticAdapter: external, allowExternalSemantic: true }).skippedChecks, []);
74
+ const contradicted = lintFacts({ sources: [{ id: 'brief', text: 'Atlas exists.' }], draft: 'Atlas is reliable.', semanticAdapter: { id: 'local', compare: () => 'contradicted' } });
75
+ assert.equal(contradicted.findings[0]?.kind, 'semantic_contradiction');
76
+ });
77
+ test('keeps twenty synthetic source-grounded posts as supported regression fixtures', () => {
78
+ const fixture = new URL('../fixtures/fact-linter/posts.json', import.meta.url);
79
+ const posts = JSON.parse(readFileSync(fixture, 'utf8'));
80
+ assert.equal(posts.length, 20);
81
+ for (const post of posts) {
82
+ const report = lintFacts({ sources: [{ id: post.id, text: post.source }], draft: post.draft });
83
+ assert.equal(report.findings.filter((finding) => finding.severity === 'error').length, 0, post.id);
84
+ }
85
+ });
@@ -0,0 +1,77 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from './canonical-json.js';
3
+ const ACKNOWLEDGEMENT = 'Removes only listed non-semantic controls; all other findings remain review-only.';
4
+ export const minimalHiddenTextPolicy = {
5
+ version: '1', name: 'minimal-text-control-cleanup', approvedRemovals: ['ascii_control', 'mid_document_bom'], acknowledgement: ACKNOWLEDGEMENT,
6
+ };
7
+ function hash(value) { return createHash('sha256').update(typeof value === 'string' ? value : canonicalJson(value)).digest('hex'); }
8
+ function codepoint(value) { return `U+${value.toString(16).toUpperCase().padStart(4, '0')}`; }
9
+ export function parseHiddenTextPolicy(value) {
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ throw new Error('Hidden-text policy must be an object.');
12
+ const policy = value;
13
+ const removals = policy.approvedRemovals;
14
+ if (policy.version !== '1' || policy.name !== 'minimal-text-control-cleanup' || policy.acknowledgement !== ACKNOWLEDGEMENT
15
+ || !Array.isArray(removals) || removals.some((item) => item !== 'ascii_control' && item !== 'mid_document_bom')
16
+ || new Set(removals).size !== removals.length)
17
+ throw new Error('Hidden-text policy is not valid.');
18
+ return { ...policy, approvedRemovals: [...removals] };
19
+ }
20
+ function classified(codepointValue, offset) {
21
+ if ((codepointValue <= 0x1f && ![0x09, 0x0a, 0x0d].includes(codepointValue)) || codepointValue === 0x7f)
22
+ return { kind: 'ascii_control', action: 'remove', reason: 'ASCII control is not permitted in user-facing text.' };
23
+ if (codepointValue >= 0x80 && codepointValue <= 0x9f)
24
+ return { kind: 'c1_control', action: 'review', reason: 'C1 controls can change terminal or renderer behavior.' };
25
+ if (codepointValue === 0xfeff && offset > 0)
26
+ return { kind: 'mid_document_bom', action: 'remove', reason: 'A byte-order mark is only valid at document start.' };
27
+ if ([0x200b, 0x200c, 0x200d, 0x2060, 0x180e].includes(codepointValue))
28
+ return { kind: 'zero_width', action: 'review', reason: 'May be required for script shaping or word boundaries.' };
29
+ if ([0x061c, 0x200e, 0x200f, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069].includes(codepointValue))
30
+ return { kind: 'bidi', action: 'review', reason: 'May be required for bidirectional text.' };
31
+ if (codepointValue >= 0xe0001 && codepointValue <= 0xe007f)
32
+ return { kind: 'tag', action: 'review', reason: 'Unicode tags can be meaningful in emoji sequences.' };
33
+ if ([0x00a0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x202f, 0x205f, 0x3000].includes(codepointValue))
34
+ return { kind: 'unusual_space', action: 'review', reason: 'Spacing may be intentional or language-specific.' };
35
+ return undefined;
36
+ }
37
+ export function inspectHiddenText(text, policy = minimalHiddenTextPolicy) {
38
+ const parsed = parseHiddenTextPolicy(policy);
39
+ const findings = [];
40
+ for (let offset = 0; offset < text.length;) {
41
+ const value = text.codePointAt(offset);
42
+ const found = classified(value, offset);
43
+ if (found) {
44
+ const action = found.action === 'remove' && !parsed.approvedRemovals.includes(found.kind) ? 'review' : found.action;
45
+ findings.push({ kind: found.kind, action, codepoint: codepoint(value), offset, reason: found.reason });
46
+ }
47
+ offset += String.fromCodePoint(value).length;
48
+ }
49
+ return { version: '1', inputHash: hash(text), policyFingerprint: hash(parsed), findings, proposedChanges: findings.filter((item) => item.action === 'remove').map((item) => ({ offset: item.offset, codepoint: item.codepoint, action: 'removed' })) };
50
+ }
51
+ export function applyHiddenTextPolicy(text, policy = minimalHiddenTextPolicy) {
52
+ const report = inspectHiddenText(text, policy);
53
+ const offsets = new Set(report.proposedChanges.map((item) => item.offset));
54
+ let output = '';
55
+ for (let offset = 0; offset < text.length;) {
56
+ const value = text.codePointAt(offset);
57
+ const character = String.fromCodePoint(value);
58
+ if (!offsets.has(offset))
59
+ output += character;
60
+ offset += character.length;
61
+ }
62
+ const remaining = inspectHiddenText(output, policy).findings;
63
+ const again = applyOnce(output, policy);
64
+ return { ...report, outputHash: hash(output), output, remaining, idempotent: again === output };
65
+ }
66
+ function applyOnce(text, policy) {
67
+ const offsets = new Set(inspectHiddenText(text, policy).proposedChanges.map((item) => item.offset));
68
+ let output = '';
69
+ for (let offset = 0; offset < text.length;) {
70
+ const value = text.codePointAt(offset);
71
+ const character = String.fromCodePoint(value);
72
+ if (!offsets.has(offset))
73
+ output += character;
74
+ offset += character.length;
75
+ }
76
+ return output;
77
+ }
@@ -0,0 +1,26 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { applyHiddenTextPolicy, inspectHiddenText, minimalHiddenTextPolicy, parseHiddenTextPolicy } from './hidden-text.js';
4
+ test('removes only explicitly approved ASCII controls and mid-document BOMs with a stable receipt', () => {
5
+ const source = 'start\u0007middle\uFEFFend';
6
+ const receipt = applyHiddenTextPolicy(source);
7
+ assert.equal(receipt.output, 'startmiddleend');
8
+ assert.deepEqual(receipt.proposedChanges.map((item) => [item.codepoint, item.offset]), [['U+0007', 5], ['U+FEFF', 12]]);
9
+ assert.equal(receipt.remaining.length, 0);
10
+ assert.equal(receipt.idempotent, true);
11
+ assert.notEqual(receipt.inputHash, receipt.outputHash);
12
+ });
13
+ test('keeps bidi, script joiners, emoji joiners, Markdown and tabs review-only', () => {
14
+ const source = '```ts\nconst x = 1;\n```\nالعربية\u202E ไทย\u200Bภาษา 👩\u200D💻\tend';
15
+ const report = inspectHiddenText(source);
16
+ const receipt = applyHiddenTextPolicy(source);
17
+ assert.equal(receipt.output, source);
18
+ assert.equal(receipt.proposedChanges.length, 0);
19
+ assert.ok(report.findings.some((item) => item.kind === 'bidi' && item.action === 'review'));
20
+ assert.ok(report.findings.some((item) => item.kind === 'zero_width' && item.action === 'review'));
21
+ });
22
+ test('requires the exact policy shape and never broadens removals from malformed input', () => {
23
+ assert.deepEqual(parseHiddenTextPolicy(minimalHiddenTextPolicy), minimalHiddenTextPolicy);
24
+ assert.throws(() => parseHiddenTextPolicy({ ...minimalHiddenTextPolicy, approvedRemovals: ['zero_width'] }), /not valid/);
25
+ assert.throws(() => parseHiddenTextPolicy({ ...minimalHiddenTextPolicy, acknowledgement: 'watermark removed' }), /not valid/);
26
+ });
package/dist/hygiene.js CHANGED
@@ -24,13 +24,20 @@ function formattedCodepoint(codepoint) {
24
24
  return `U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`;
25
25
  }
26
26
  function classification(codepoint) {
27
+ if ((codepoint <= 0x1f && ![0x09, 0x0a, 0x0d].includes(codepoint)) || codepoint === 0x7f) {
28
+ return { kind: 'ascii_control', label: 'ASCII control character', fix: 'remove' };
29
+ }
30
+ if (codepoint >= 0x80 && codepoint <= 0x9f)
31
+ return { kind: 'c1_control', label: 'C1 control character', fix: 'none' };
27
32
  return CHARACTER_POLICIES.get(codepoint) ?? (codepoint >= 0xe0001 && codepoint <= 0xe007f
28
33
  ? { kind: 'tag', label: 'Unicode tag character', fix: 'none' }
29
34
  : undefined);
30
35
  }
31
36
  function policyAt(codepoint, offset) {
32
37
  const policy = classification(codepoint);
33
- return codepoint === 0xfeff && offset === 0 && policy ? { ...policy, fix: 'remove' } : policy;
38
+ if (codepoint === 0xfeff && policy)
39
+ return { ...policy, kind: offset === 0 ? 'zero_width' : 'mid_document_bom', fix: 'remove' };
40
+ return policy;
34
41
  }
35
42
  function scanHygiene(text, clean) {
36
43
  const grouped = new Map();
@@ -14,14 +14,14 @@ test('reports zero-width, bidi, tag, and unusual-space characters with exact off
14
14
  ]);
15
15
  assert.deepEqual(report.hits.find((hit) => hit.codepoint === 'U+E0001')?.offsets, [13]);
16
16
  });
17
- test('removes only a leading byte-order mark and preserves language, spacing, bidi, and tag controls', () => {
17
+ test('removes byte-order marks and preserves language, spacing, bidi, and tag controls', () => {
18
18
  const text = `\uFEFFa\u200Bb\uFEFFc\u00A0d\u200Ce\u200Df\u202Eg\u{E0001}`;
19
19
  const result = cleanHygiene(text);
20
- assert.equal(result.cleaned, `a\u200Bb\uFEFFc\u00A0d\u200Ce\u200Df\u202Eg\u{E0001}`);
20
+ assert.equal(result.cleaned, `a\u200Bbc\u00A0d\u200Ce\u200Df\u202Eg\u{E0001}`);
21
21
  assert.equal(result.changed, true);
22
- assert.deepEqual(result.changes.map((change) => [change.codepoint, change.action]), [['U+FEFF', 'removed']]);
22
+ assert.deepEqual(result.changes.map((change) => [change.codepoint, change.action]), [['U+FEFF', 'removed'], ['U+FEFF', 'removed']]);
23
23
  assert.equal(result.report.suspiciousCount, 8);
24
- assert.equal(result.report.fixableCount, 1);
24
+ assert.equal(result.report.fixableCount, 2);
25
25
  });
26
26
  test('leaves clean text byte-for-byte unchanged', () => {
27
27
  const text = 'plain text\nwith normal spaces.';
@@ -54,7 +54,7 @@ test('preserves multilingual spacing and word-boundary controls byte-for-byte',
54
54
  assert.equal(result.report.suspiciousCount, 5);
55
55
  assert.equal(result.report.fixableCount, 0);
56
56
  });
57
- test('accepts exact clean output and minimally removes only a leading BOM', () => {
57
+ test('accepts exact clean output and removes only non-semantic controls by default', () => {
58
58
  const clean = finalOutputCheck('exact output\n');
59
59
  assert.equal(clean.accepted, true);
60
60
  assert.equal(clean.accepted && clean.output, 'exact output\n');
@@ -63,6 +63,10 @@ test('accepts exact clean output and minimally removes only a leading BOM', () =
63
63
  assert.equal(bom.accepted, true);
64
64
  assert.equal(bom.accepted && bom.output, 'exact output');
65
65
  assert.deepEqual(bom.changes, [{ offset: 0, codepoint: 'U+FEFF', action: 'removed' }]);
66
+ const controls = finalOutputCheck('one\u0007two\uFEFFthree');
67
+ assert.equal(controls.accepted, true);
68
+ assert.equal(controls.accepted && controls.output, 'onetwothree');
69
+ assert.deepEqual(controls.changes.map((change) => change.codepoint), ['U+0007', 'U+FEFF']);
66
70
  });
67
71
  test('withholds output when hidden characters remain unresolved', () => {
68
72
  const result = finalOutputCheck('Thai\u200Bboundary 👩\u200D💻');
@@ -71,3 +75,9 @@ test('withholds output when hidden characters remain unresolved', () => {
71
75
  assert.equal(result.changed, false);
72
76
  assert.deepEqual(result.remaining.hits.map((hit) => hit.codepoint), ['U+200B', 'U+200D']);
73
77
  });
78
+ test('withholds C1 controls rather than removing them', () => {
79
+ const result = finalOutputCheck('safe\u009Bhidden');
80
+ assert.equal(result.accepted, false);
81
+ assert.equal('output' in result, false);
82
+ assert.deepEqual(result.remaining.hits.map((hit) => [hit.codepoint, hit.kind]), [['U+009B', 'c1_control']]);
83
+ });
package/dist/mcp-tools.js CHANGED
@@ -6,7 +6,8 @@ import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.j
6
6
  import { parseProfile } from './profile.js';
7
7
  import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
8
8
  import { parseJudgmentEnvelope, preparePostCandidateJudgment, preparePreEditJudgment, reducePostCandidate, reducePreEdit } from './judgment-task.js';
9
- import { evaluateRebuildResponse, parseRebuildTask, prepareRebuildTask } from './rebuild-task.js';
9
+ import { evaluateRebuildResponse, parseRebuildTask, prepareRebuildTask, writerRequestForRebuild } from './rebuild-task.js';
10
+ import { inspectHiddenText, applyHiddenTextPolicy, parseHiddenTextPolicy } from './hidden-text.js';
10
11
  import { buildProfile } from './voice-dna.js';
11
12
  import { finalOutputCheck, inspectHygiene } from './hygiene.js';
12
13
  import { finalizeLifecycle, inspectLifecycle, prepareLifecycle, recordApprovedLearning, submitSemanticVerdict, validateFinalApproval } from './lifecycle-adapter.js';
@@ -74,12 +75,21 @@ export function reduceJudgmentForMcp(envelopesJson) {
74
75
  const envelopes = parsed(envelopesJson, 'Judgment envelopes').map(parseJudgmentEnvelope);
75
76
  return envelopes[0]?.stage === 'pre-edit' ? reducePreEdit(envelopes) : reducePostCandidate(envelopes);
76
77
  }
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));
78
+ export function prepareRebuildForMcp(draft, profileJson, reductionJson, copySpecJson, capabilityJson, context, writingBriefJson, recompositionPolicyJson) {
79
+ return prepareRebuildTask(draft, profileFromJson(profileJson), parsed(reductionJson, 'Rebuild recommendation'), copySpecFromJson(copySpecJson), parsed(capabilityJson, 'Approval capability'), context.trustStore, context.now, writingBriefFromJson(writingBriefJson), recompositionPolicyJson ? parsed(recompositionPolicyJson, 'Recomposition policy') : undefined);
79
80
  }
80
81
  export function applyRebuildForMcp(taskJson, responseJson, profileJson, capabilityJson, context) {
81
82
  return evaluateRebuildResponse(parseRebuildTask(JSON.parse(taskJson)), responseJson, profileFromJson(profileJson), parsed(capabilityJson, 'Approval capability'), context.trustStore, context.now);
82
83
  }
84
+ export function inspectHiddenTextForMcp(text, policyJson) {
85
+ return inspectHiddenText(text, policyJson ? parseHiddenTextPolicy(parsed(policyJson, 'Hidden-text policy')) : undefined);
86
+ }
87
+ export function applyHiddenTextPolicyForMcp(text, policyJson) {
88
+ return applyHiddenTextPolicy(text, parseHiddenTextPolicy(parsed(policyJson, 'Hidden-text policy')));
89
+ }
90
+ export function rebuildWriterRequestForMcp(taskJson) {
91
+ return writerRequestForRebuild(parseRebuildTask(parsed(taskJson, 'Rebuild task')));
92
+ }
83
93
  export function verifyForMcp(original, candidate, profileJson, writingBriefJson) {
84
94
  const profile = profileFromJson(profileJson);
85
95
  return verify(original, candidate, profile, writingBriefFromJson(writingBriefJson));
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
- import { analyzeBatchForMcp, analyzeForMcp, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, recordApprovedLearningForMcp, recordLearningForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
7
+ import { analyzeBatchForMcp, analyzeForMcp, applyHiddenTextPolicyForMcp, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, inspectHiddenTextForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, recordApprovedLearningForMcp, recordLearningForMcp, rebuildWriterRequestForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
8
8
  import { canonicalJson } from './canonical-json.js';
9
9
  const profile = buildProfileForMcp(['I write clearly. I keep the useful detail.', 'I make the call. Then I explain the trade-off.'], ['leverage']);
10
10
  const profileJson = JSON.stringify(profile);
@@ -23,6 +23,14 @@ test('inspects Unicode hygiene through MCP without a voice profile', () => {
23
23
  assert.equal(result.suspiciousCount, 2);
24
24
  assert.equal(result.fixableCount, 0);
25
25
  });
26
+ test('applies only explicit hidden-text removals through MCP', () => {
27
+ const policy = JSON.stringify({ version: '1', name: 'minimal-text-control-cleanup', approvedRemovals: ['ascii_control'], acknowledgement: 'Removes only listed non-semantic controls; all other findings remain review-only.' });
28
+ const inspected = inspectHiddenTextForMcp('one\u0007two\uFEFFthree\u200D', policy);
29
+ assert.deepEqual(inspected.proposedChanges.map((change) => change.codepoint), ['U+0007']);
30
+ const applied = applyHiddenTextPolicyForMcp('one\u0007two\uFEFFthree\u200D', policy);
31
+ assert.equal(applied.output, 'onetwo\uFEFFthree\u200D');
32
+ assert.equal(applied.idempotent, true);
33
+ });
26
34
  test('gates exact final output through MCP without a voice profile', () => {
27
35
  const accepted = finalOutputCheckForMcp('exact output');
28
36
  assert.equal(accepted.accepted && accepted.output, 'exact output');
@@ -226,11 +234,16 @@ test('prepares and evaluates authorized rebuild through MCP helpers', () => {
226
234
  const capability = { payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') };
227
235
  const copySpec = JSON.stringify({ version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar, 7 August.' }] });
228
236
  const context = { now: 150, trustStore, authorizedSemanticEvaluatorIds: { normal: [], highAssurance: [] }, authorizedHumanFinalizerIds: [] };
229
- const task = prepareRebuildForMcp(draft, profileJson, JSON.stringify(reduction), copySpec, JSON.stringify(capability), context);
237
+ const policy = JSON.stringify({ version: '1', mode: 'meaning-first', lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0, maxLongestSharedRunTokens: 4 }, acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.' });
238
+ const task = prepareRebuildForMcp(draft, profileJson, JSON.stringify(reduction), copySpec, JSON.stringify(capability), context, undefined, policy);
239
+ assert.doesNotMatch(task.prompt, /I leverage the answer/);
240
+ const writerRequest = rebuildWriterRequestForMcp(JSON.stringify(task));
241
+ assert.doesNotMatch(JSON.stringify(writerRequest), /I leverage the answer|capability|profile/i);
230
242
  const result = applyRebuildForMcp(JSON.stringify(task), JSON.stringify({
231
243
  version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint,
232
244
  candidate: 'Ship planning now treats one calendar fact as fixed. The launch is on 14 August. Every other sentence in this note is new operational language for the release desk.',
233
245
  }), profileJson, JSON.stringify(capability), context);
234
246
  assert.equal(result.status, 'needs_semantic_review');
235
247
  assert.equal(result.receipt.mode, 'REBUILD');
248
+ assert.equal(result.receipt.lexicalResidual?.passed, true);
236
249
  });
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
- import { analyzeBatchForMcp, analyzeForMcp, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, finalizeRejectionForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, migrateLearningForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, recordApprovedLearningForMcp, recordLearningForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
4
+ import { analyzeBatchForMcp, analyzeForMcp, applyHiddenTextPolicyForMcp, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, finalizeRejectionForMcp, inspectHiddenTextForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, migrateLearningForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, rebuildWriterRequestForMcp, recordApprovedLearningForMcp, recordLearningForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
5
5
  import { HYV_VERSION } from './version.js';
6
6
  import { loadApprovalContext } from './approval-context.js';
7
7
  const writing = z.string().min(1).max(100_000);
@@ -65,6 +65,14 @@ server.registerTool('hyv_hygiene', {
65
65
  inputSchema: { draft: hygieneText },
66
66
  annotations: { readOnlyHint: true },
67
67
  }, async ({ draft }) => json(inspectHygieneForMcp(draft)));
68
+ server.registerTool('hyv_inspect_hidden_text', {
69
+ description: 'Inspect hidden text controls with a non-mutating policy report. Findings are not watermark verdicts.',
70
+ inputSchema: { text: hygieneText, policy_json: lifecycleJson.optional() }, annotations: { readOnlyHint: true },
71
+ }, async ({ text, policy_json }) => json(inspectHiddenTextForMcp(text, policy_json)));
72
+ server.registerTool('hyv_apply_hidden_text_policy', {
73
+ description: 'Apply only explicitly approved minimal hidden-text removals and return hashes, exact changes, and remaining review findings.',
74
+ inputSchema: { text: hygieneText, policy_json: lifecycleJson }, annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
75
+ }, async ({ text, policy_json }) => json(applyHiddenTextPolicyForMcp(text, policy_json)));
68
76
  server.registerTool('hyv_final_check', {
69
77
  description: 'Gate exact user-facing text from any model, tool, or interface. Returns output only when clean or after removing a leading byte-order mark; unresolved hidden characters withhold output.',
70
78
  inputSchema: { text: hygieneText },
@@ -299,11 +307,12 @@ if (redactsSensitiveInputs) {
299
307
  copy_spec_json: copySpecJson,
300
308
  capability_json: lifecycleJson,
301
309
  writing_brief_json: writingBriefJson.optional(),
310
+ recomposition_policy_json: lifecycleJson.optional(),
302
311
  },
303
312
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
304
313
  }, async (args) => {
305
314
  try {
306
- return json(prepareRebuildForMcp(args.draft, args.profile_json, args.reduction_json, args.copy_spec_json, args.capability_json, loadApprovalContext(), args.writing_brief_json));
315
+ return json(prepareRebuildForMcp(args.draft, args.profile_json, args.reduction_json, args.copy_spec_json, args.capability_json, loadApprovalContext(), args.writing_brief_json, args.recomposition_policy_json));
307
316
  }
308
317
  catch {
309
318
  return failure(new Error('Rebuild preparation failed.'));
@@ -321,6 +330,17 @@ if (redactsSensitiveInputs) {
321
330
  return failure(new Error('Rebuild application failed.'));
322
331
  }
323
332
  });
333
+ server.registerTool('hyv_rebuild_writer_request', {
334
+ description: 'Create the writer-only payload for a prepared rebuild. It excludes source draft, capability, profile body, and validation evidence.',
335
+ inputSchema: { task_json: lifecycleJson }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
336
+ }, async ({ task_json }) => {
337
+ try {
338
+ return json(rebuildWriterRequestForMcp(task_json));
339
+ }
340
+ catch {
341
+ return failure(new Error('Writer request could not be prepared.'));
342
+ }
343
+ });
324
344
  }
325
345
  else {
326
346
  server.registerTool('hyv_lifecycle_finalize', {