@holdyourvoice/hyv 3.1.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/Readme.md +76 -17
  2. package/dist/ai-editor-rules.js +151 -0
  3. package/dist/ai-editor.js +104 -8
  4. package/dist/ai-editor.test.js +135 -22
  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 +359 -21
  14. package/dist/cli.test.js +275 -7
  15. package/dist/copy-spec.js +35 -8
  16. package/dist/editorial-packs.js +25 -1
  17. package/dist/editorial-packs.test.js +45 -0
  18. package/dist/hygiene.js +91 -0
  19. package/dist/hygiene.test.js +73 -0
  20. package/dist/judgment-task.js +171 -0
  21. package/dist/judgment-task.test.js +162 -0
  22. package/dist/learning.js +240 -100
  23. package/dist/learning.test.js +203 -3
  24. package/dist/lifecycle-adapter.js +75 -0
  25. package/dist/lifecycle-adapter.test.js +56 -0
  26. package/dist/mcp-tools.js +110 -9
  27. package/dist/mcp-tools.test.js +188 -10
  28. package/dist/mcp.js +228 -9
  29. package/dist/mcp.test.js +248 -12
  30. package/dist/pipeline.js +81 -15
  31. package/dist/pipeline.test.js +94 -2
  32. package/dist/preservation.js +89 -0
  33. package/dist/preservation.test.js +22 -0
  34. package/dist/profile.js +87 -0
  35. package/dist/profile.test.js +114 -0
  36. package/dist/rebuild-task.js +226 -0
  37. package/dist/rebuild-task.test.js +179 -0
  38. package/dist/release-audit.test.js +144 -2
  39. package/dist/rewrite-task.js +136 -16
  40. package/dist/rewrite-task.test.js +72 -4
  41. package/dist/rule-reconciliation.test.js +50 -0
  42. package/dist/semantic-review.js +176 -7
  43. package/dist/semantic-review.test.js +98 -14
  44. package/dist/stage1-dry-run.test.js +39 -0
  45. package/dist/stage1-evaluation.js +579 -0
  46. package/dist/stage1-evaluation.test.js +184 -0
  47. package/dist/stage1-human-packet.test.js +102 -0
  48. package/dist/stage1-schema-contract.test.js +95 -0
  49. package/dist/stage2-human-packet.test.js +81 -0
  50. package/dist/version.js +1 -0
  51. package/dist/voice-dna.js +53 -1
  52. package/dist/voice-dna.test.js +79 -1
  53. package/package.json +2 -2
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { cleanHygiene, finalOutputCheck, hygieneSourceFindings, inspectHygiene } from './hygiene.js';
4
+ test('reports zero-width, bidi, tag, and unusual-space characters with exact offsets', () => {
5
+ const text = `one\u200Btwo\u202Ethree\u{E0001}\u00A0four`;
6
+ const report = inspectHygiene(text);
7
+ assert.equal(report.suspiciousCount, 4);
8
+ assert.equal(report.fixableCount, 0);
9
+ assert.deepEqual(report.hits.map((hit) => [hit.codepoint, hit.kind, hit.count]), [
10
+ ['U+00A0', 'unusual_space', 1],
11
+ ['U+200B', 'zero_width', 1],
12
+ ['U+202E', 'bidi', 1],
13
+ ['U+E0001', 'tag', 1],
14
+ ]);
15
+ assert.deepEqual(report.hits.find((hit) => hit.codepoint === 'U+E0001')?.offsets, [13]);
16
+ });
17
+ test('removes only a leading byte-order mark and preserves language, spacing, bidi, and tag controls', () => {
18
+ const text = `\uFEFFa\u200Bb\uFEFFc\u00A0d\u200Ce\u200Df\u202Eg\u{E0001}`;
19
+ const result = cleanHygiene(text);
20
+ assert.equal(result.cleaned, `a\u200Bb\uFEFFc\u00A0d\u200Ce\u200Df\u202Eg\u{E0001}`);
21
+ assert.equal(result.changed, true);
22
+ assert.deepEqual(result.changes.map((change) => [change.codepoint, change.action]), [['U+FEFF', 'removed']]);
23
+ assert.equal(result.report.suspiciousCount, 8);
24
+ assert.equal(result.report.fixableCount, 1);
25
+ });
26
+ test('leaves clean text byte-for-byte unchanged', () => {
27
+ const text = 'plain text\nwith normal spaces.';
28
+ const result = cleanHygiene(text);
29
+ assert.equal(result.cleaned, text);
30
+ assert.equal(result.changed, false);
31
+ assert.deepEqual(result.changes, []);
32
+ assert.deepEqual(result.report.hits, []);
33
+ });
34
+ test('projects eligible hygiene hits as source-offset findings', () => {
35
+ const findings = hygieneSourceFindings('\uFEFFplain');
36
+ assert.equal(findings.length, 1);
37
+ assert.equal(findings[0]?.start, 0);
38
+ assert.equal(findings[0]?.eligible, true);
39
+ });
40
+ test('groups repeated report-only hits and preserves supplementary characters', () => {
41
+ const text = `😀\u200Bword\u200B`;
42
+ const result = cleanHygiene(text);
43
+ assert.equal(result.cleaned, text);
44
+ assert.equal(result.report.suspiciousCount, 2);
45
+ assert.equal(result.report.hits.length, 1);
46
+ assert.equal(result.report.hits[0]?.count, 2);
47
+ assert.deepEqual(result.report.hits[0]?.offsets, [2, 7]);
48
+ });
49
+ test('preserves multilingual spacing and word-boundary controls byte-for-byte', () => {
50
+ const text = `ไทย\u200Bภาษา 10\u00A0kg 日本語\u3000本文 ᠮ\u180Eᠣ a\u2060b`;
51
+ const result = cleanHygiene(text);
52
+ assert.equal(result.cleaned, text);
53
+ assert.equal(result.changed, false);
54
+ assert.equal(result.report.suspiciousCount, 5);
55
+ assert.equal(result.report.fixableCount, 0);
56
+ });
57
+ test('accepts exact clean output and minimally removes only a leading BOM', () => {
58
+ const clean = finalOutputCheck('exact output\n');
59
+ assert.equal(clean.accepted, true);
60
+ assert.equal(clean.accepted && clean.output, 'exact output\n');
61
+ assert.equal(clean.changed, false);
62
+ const bom = finalOutputCheck('\uFEFFexact output');
63
+ assert.equal(bom.accepted, true);
64
+ assert.equal(bom.accepted && bom.output, 'exact output');
65
+ assert.deepEqual(bom.changes, [{ offset: 0, codepoint: 'U+FEFF', action: 'removed' }]);
66
+ });
67
+ test('withholds output when hidden characters remain unresolved', () => {
68
+ const result = finalOutputCheck('Thai\u200Bboundary 👩\u200D💻');
69
+ assert.equal(result.accepted, false);
70
+ assert.equal('output' in result, false);
71
+ assert.equal(result.changed, false);
72
+ assert.deepEqual(result.remaining.hits.map((hit) => hit.codepoint), ['U+200B', 'U+200D']);
73
+ });
@@ -0,0 +1,171 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from './canonical-json.js';
3
+ import { sentences } from './text.js';
4
+ import { HYV_VERSION } from './version.js';
5
+ const PRE_EDIT_KINDS = ['triage', 'argument', 'form'];
6
+ const POST_CANDIDATE_KINDS = ['argument', 'polarity', 'form', 'flatness', 'semantic'];
7
+ function digest(value) {
8
+ return createHash('sha256').update(value).digest('hex');
9
+ }
10
+ function digestCanonical(value) {
11
+ return digest(canonicalJson(value));
12
+ }
13
+ function profileIdentity(profile) {
14
+ if (profile.version === '3')
15
+ return { profileId: profile.id, profileRevisionDigest: profile.revisionDigest };
16
+ const legacy = `legacy-v2:${digestCanonical(profile)}`;
17
+ return { profileId: legacy, profileRevisionDigest: legacy };
18
+ }
19
+ function fingerprintTask(task) {
20
+ return digest(`hyv:judgment-task:v1\0${canonicalJson(task)}`);
21
+ }
22
+ function sentenceIds(text) {
23
+ return sentences(text).map((sentence) => sentence.index);
24
+ }
25
+ function isRange(value) {
26
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
27
+ && Number.isInteger(value.startSentenceId)
28
+ && Number.isInteger(value.endSentenceId)
29
+ && value.startSentenceId >= 1
30
+ && value.endSentenceId >= value.startSentenceId;
31
+ }
32
+ function rangesContiguous(ranges) {
33
+ return ranges.every((range) => range.endSentenceId >= range.startSentenceId);
34
+ }
35
+ export function parseJudgmentEnvelope(value) {
36
+ if (!value || typeof value !== 'object' || Array.isArray(value))
37
+ throw new Error('Judgment envelope must be an object.');
38
+ const envelope = value;
39
+ if (envelope.version !== '1')
40
+ throw new Error('Judgment envelope version must be "1".');
41
+ if (envelope.stage !== 'pre-edit' && envelope.stage !== 'post-candidate')
42
+ throw new Error('Judgment stage is invalid.');
43
+ if (typeof envelope.judgmentType !== 'string' || typeof envelope.taskFingerprint !== 'string' || envelope.taskFingerprint.length !== 64) {
44
+ throw new Error('Judgment envelope is missing a bound task.');
45
+ }
46
+ if (!envelope.bindings || typeof envelope.bindings !== 'object' || typeof envelope.bindings.sourceHash !== 'string' || typeof envelope.bindings.evaluatorId !== 'string') {
47
+ throw new Error('Judgment envelope is missing bindings.');
48
+ }
49
+ if (!Array.isArray(envelope.findings) || typeof envelope.decision !== 'string')
50
+ throw new Error('Judgment envelope is missing findings or a decision.');
51
+ for (const [index, finding] of envelope.findings.entries()) {
52
+ if (!finding || typeof finding !== 'object' || typeof finding.kind !== 'string')
53
+ throw new Error(`Finding ${index} is invalid.`);
54
+ if (finding.ranges && (!Array.isArray(finding.ranges) || !finding.ranges.every(isRange)))
55
+ throw new Error(`Finding ${index} ranges are invalid.`);
56
+ }
57
+ if (envelope.editScope && (!Array.isArray(envelope.editScope.ranges) || !envelope.editScope.ranges.every(isRange))) {
58
+ throw new Error('Edit scope ranges are invalid.');
59
+ }
60
+ return envelope;
61
+ }
62
+ function prepareTask(stage, judgmentType, draft, profile, candidate) {
63
+ const identity = profileIdentity(profile);
64
+ const allowedDecisions = stage === 'pre-edit' ? ['SHIP', 'EDIT', 'REBUILD'] : ['CLEAR', 'ESCALATE', 'REBUILD'];
65
+ const base = {
66
+ version: '1',
67
+ stage,
68
+ judgmentType,
69
+ ...(stage === 'pre-edit' ? { draft } : { draft, candidate }),
70
+ bindings: {
71
+ sourceHash: digest(draft),
72
+ ...(candidate !== undefined ? { candidateHash: digest(candidate) } : {}),
73
+ ...identity,
74
+ rulesetVersion: HYV_VERSION,
75
+ evidenceScope: { sentenceIds: sentenceIds(candidate ?? draft) },
76
+ },
77
+ allowedDecisions,
78
+ };
79
+ return { ...base, taskFingerprint: fingerprintTask(base) };
80
+ }
81
+ export function preparePreEditJudgment(draft, profile, kind) {
82
+ return prepareTask('pre-edit', kind, draft, profile);
83
+ }
84
+ export function preparePostCandidateJudgment(draft, candidate, profile, kind) {
85
+ return prepareTask('post-candidate', kind, draft, profile, candidate);
86
+ }
87
+ export function bindJudgmentEnvelope(task, envelope) {
88
+ const parsed = parseJudgmentEnvelope(envelope);
89
+ if (parsed.taskFingerprint !== task.taskFingerprint)
90
+ throw new Error('Judgment envelope task fingerprint does not match.');
91
+ if (parsed.stage !== task.stage || parsed.judgmentType !== task.judgmentType)
92
+ throw new Error('Judgment envelope type does not match the task.');
93
+ if (parsed.bindings.sourceHash !== task.bindings.sourceHash)
94
+ throw new Error('Judgment envelope source hash does not match.');
95
+ if (task.bindings.candidateHash && parsed.bindings.candidateHash !== task.bindings.candidateHash)
96
+ throw new Error('Judgment envelope candidate hash does not match.');
97
+ if (parsed.bindings.profileId !== task.bindings.profileId || parsed.bindings.profileRevisionDigest !== task.bindings.profileRevisionDigest) {
98
+ throw new Error('Judgment envelope profile binding does not match.');
99
+ }
100
+ if (parsed.bindings.rulesetVersion !== task.bindings.rulesetVersion)
101
+ throw new Error('Judgment envelope ruleset does not match.');
102
+ if (canonicalJson(parsed.bindings.evidenceScope) !== canonicalJson(task.bindings.evidenceScope))
103
+ throw new Error('Judgment envelope evidence scope does not match.');
104
+ if (!task.allowedDecisions.includes(parsed.decision))
105
+ throw new Error('Judgment decision is not allowed at this stage.');
106
+ return parsed;
107
+ }
108
+ function namedRanges(findings) {
109
+ return findings.flatMap((finding) => finding.ranges ?? []);
110
+ }
111
+ function fingerprintReduction(decision, editScope, reason) {
112
+ return digest(`hyv:pre-edit-reduction:v1\0${canonicalJson({ decision, editScope, ...(reason ? { reason } : {}) })}`);
113
+ }
114
+ export function fingerprintPreEditReduction(reduction) {
115
+ return fingerprintReduction(reduction.decision, reduction.editScope, reduction.reason);
116
+ }
117
+ function reduced(decision, editScope, reason) {
118
+ return { decision, editScope, ...(reason ? { reason } : {}), recommendationFingerprint: fingerprintReduction(decision, editScope, reason) };
119
+ }
120
+ export function reducePreEdit(envelopes) {
121
+ if (envelopes.length !== PRE_EDIT_KINDS.length)
122
+ throw new Error('Pre-edit reduction requires triage, argument, and form envelopes.');
123
+ const kinds = new Set(envelopes.map((envelope) => envelope.judgmentType));
124
+ if (PRE_EDIT_KINDS.some((kind) => !kinds.has(kind)))
125
+ throw new Error('Pre-edit reduction requires triage, argument, and form envelopes.');
126
+ if (envelopes.some((envelope) => envelope.stage !== 'pre-edit'))
127
+ throw new Error('Pre-edit reduction rejects post-candidate envelopes.');
128
+ const argument = envelopes.find((envelope) => envelope.judgmentType === 'argument');
129
+ if (argument.findings.some((finding) => finding.unbounded) || argument.decision === 'REBUILD') {
130
+ return reduced('REBUILD', { ranges: [] }, argument.findings.some((finding) => finding.unbounded) ? 'unbounded_argument_failure' : undefined);
131
+ }
132
+ if (envelopes.some((envelope) => envelope.decision === 'REBUILD')) {
133
+ return reduced('REBUILD', { ranges: [] });
134
+ }
135
+ const ranges = envelopes.flatMap((envelope) => envelope.editScope?.ranges ?? namedRanges(envelope.findings));
136
+ if (!rangesContiguous(ranges) || ranges.some((range) => range.endSentenceId < range.startSentenceId)) {
137
+ throw new Error('Edit scope must name contiguous sentence ranges.');
138
+ }
139
+ if (envelopes.every((envelope) => envelope.decision === 'SHIP') && ranges.length === 0) {
140
+ return reduced('SHIP', { ranges: [] });
141
+ }
142
+ if (ranges.length === 0)
143
+ throw new Error('Paragraph-level findings cannot unlock text unless they name contiguous sentence ranges.');
144
+ return reduced('EDIT', { ranges });
145
+ }
146
+ export function reducePostCandidate(envelopes) {
147
+ if (envelopes.length !== POST_CANDIDATE_KINDS.length)
148
+ throw new Error('Post-candidate reduction requires argument, polarity, form, flatness, and semantic envelopes.');
149
+ const kinds = new Set(envelopes.map((envelope) => envelope.judgmentType));
150
+ if (POST_CANDIDATE_KINDS.some((kind) => !kinds.has(kind)))
151
+ throw new Error('Post-candidate reduction requires argument, polarity, form, flatness, and semantic envelopes.');
152
+ if (envelopes.some((envelope) => envelope.stage !== 'post-candidate'))
153
+ throw new Error('Post-candidate reduction rejects pre-edit envelopes.');
154
+ if (envelopes.some((envelope) => envelope.decision === 'REBUILD'))
155
+ return { decision: 'REBUILD' };
156
+ if (envelopes.some((envelope) => envelope.decision === 'ESCALATE'))
157
+ return { decision: 'ESCALATE' };
158
+ if (!envelopes.every((envelope) => envelope.decision === 'CLEAR'))
159
+ throw new Error('Post-candidate envelopes must CLEAR, ESCALATE, or REBUILD.');
160
+ return { decision: 'CLEAR' };
161
+ }
162
+ export function authorizedSentenceIds(reduction) {
163
+ if (reduction.decision !== 'EDIT')
164
+ return [];
165
+ const ids = new Set();
166
+ for (const range of reduction.editScope.ranges) {
167
+ for (let id = range.startSentenceId; id <= range.endSentenceId; id += 1)
168
+ ids.add(id);
169
+ }
170
+ return [...ids].sort((left, right) => left - right);
171
+ }
@@ -0,0 +1,162 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { bindJudgmentEnvelope, preparePostCandidateJudgment, preparePreEditJudgment, reducePostCandidate, reducePreEdit } from './judgment-task.js';
4
+ import { applyRewriteResponse, applyShip, prepareRewriteTask } from './rewrite-task.js';
5
+ import { hygieneSourceFindings } from './hygiene.js';
6
+ import { buildProfile } from './voice-dna.js';
7
+ const profile = buildProfile([
8
+ 'I write clear notes. I keep the mechanism visible.',
9
+ 'I name the trade-off. Then I make the next step plain.',
10
+ ], ['leverage']);
11
+ function envelope(task, decision, extra = {}) {
12
+ return {
13
+ version: '1',
14
+ stage: task.stage,
15
+ judgmentType: task.judgmentType,
16
+ taskFingerprint: task.taskFingerprint,
17
+ bindings: { ...task.bindings, evaluatorId: 'writer.1' },
18
+ findings: [],
19
+ decision,
20
+ ...extra,
21
+ };
22
+ }
23
+ test('pre-edit findings select SHIP, bounded EDIT, or REBUILD', () => {
24
+ const draft = 'I leverage the answer. The launch is on 14 August.';
25
+ const triage = preparePreEditJudgment(draft, profile, 'triage');
26
+ const argument = preparePreEditJudgment(draft, profile, 'argument');
27
+ const form = preparePreEditJudgment(draft, profile, 'form');
28
+ const ship = reducePreEdit([
29
+ bindJudgmentEnvelope(triage, envelope(triage, 'SHIP')),
30
+ bindJudgmentEnvelope(argument, envelope(argument, 'SHIP')),
31
+ bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
32
+ ]);
33
+ assert.equal(ship.decision, 'SHIP');
34
+ const edit = reducePreEdit([
35
+ bindJudgmentEnvelope(triage, envelope(triage, 'EDIT', { editScope: { ranges: [{ startSentenceId: 1, endSentenceId: 1 }] } })),
36
+ bindJudgmentEnvelope(argument, envelope(argument, 'SHIP')),
37
+ bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
38
+ ]);
39
+ assert.equal(edit.decision, 'EDIT');
40
+ assert.deepEqual(edit.editScope.ranges, [{ startSentenceId: 1, endSentenceId: 1 }]);
41
+ const rebuild = reducePreEdit([
42
+ bindJudgmentEnvelope(triage, envelope(triage, 'SHIP')),
43
+ bindJudgmentEnvelope(argument, envelope(argument, 'REBUILD')),
44
+ bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
45
+ ]);
46
+ assert.equal(rebuild.decision, 'REBUILD');
47
+ assert.match(rebuild.recommendationFingerprint, /^[a-f0-9]{64}$/);
48
+ });
49
+ test('unbounded argument failure can recommend only rebuild', () => {
50
+ const draft = 'I leverage the answer. The launch is on 14 August.';
51
+ const triage = preparePreEditJudgment(draft, profile, 'triage');
52
+ const argument = preparePreEditJudgment(draft, profile, 'argument');
53
+ const form = preparePreEditJudgment(draft, profile, 'form');
54
+ const reduced = reducePreEdit([
55
+ bindJudgmentEnvelope(triage, envelope(triage, 'EDIT', { editScope: { ranges: [{ startSentenceId: 1, endSentenceId: 1 }] } })),
56
+ bindJudgmentEnvelope(argument, envelope(argument, 'EDIT', { findings: [{ kind: 'argument', unbounded: true }] })),
57
+ bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
58
+ ]);
59
+ assert.equal(reduced.decision, 'REBUILD');
60
+ assert.equal(reduced.reason, 'unbounded_argument_failure');
61
+ });
62
+ test('paragraph-level findings cannot unlock text without named ranges', () => {
63
+ const draft = 'I leverage the answer. The launch is on 14 August.';
64
+ const triage = preparePreEditJudgment(draft, profile, 'triage');
65
+ const argument = preparePreEditJudgment(draft, profile, 'argument');
66
+ const form = preparePreEditJudgment(draft, profile, 'form');
67
+ assert.throws(() => reducePreEdit([
68
+ bindJudgmentEnvelope(triage, envelope(triage, 'EDIT')),
69
+ bindJudgmentEnvelope(argument, envelope(argument, 'SHIP')),
70
+ bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
71
+ ]), /contiguous sentence ranges/);
72
+ });
73
+ test('deleting one eligible sentence or merging two adjacent sentences succeeds', () => {
74
+ const draft = 'I leverage the answer. I leverage the second point. The launch is on 14 August.';
75
+ const task = prepareRewriteTask(draft, profile, undefined, undefined, [1, 2]);
76
+ const deleted = applyRewriteResponse(task, {
77
+ version: '2',
78
+ taskFingerprint: task.fingerprint,
79
+ operations: [{ startSentenceId: 1, endSentenceId: 1, text: '' }],
80
+ });
81
+ assert.equal(deleted.status, 'accepted');
82
+ assert.equal(deleted.candidate, ' I leverage the second point. The launch is on 14 August.');
83
+ const merged = applyRewriteResponse(task, {
84
+ version: '2',
85
+ taskFingerprint: task.fingerprint,
86
+ operations: [{ startSentenceId: 1, endSentenceId: 2, text: 'I use both points.' }],
87
+ });
88
+ assert.equal(merged.status, 'accepted');
89
+ assert.equal(merged.candidate, 'I use both points. The launch is on 14 August.');
90
+ });
91
+ test('overlapping, noncontiguous, out-of-order, or partly locked ranges fail before candidate construction', () => {
92
+ const draft = 'I leverage the answer. The launch is on 14 August. I keep the mechanism visible.';
93
+ const task = prepareRewriteTask(draft, profile);
94
+ const overlap = applyRewriteResponse(task, {
95
+ version: '2',
96
+ taskFingerprint: task.fingerprint,
97
+ operations: [
98
+ { startSentenceId: 1, endSentenceId: 1, text: 'I use the answer.' },
99
+ { startSentenceId: 1, endSentenceId: 1, text: 'I choose the answer.' },
100
+ ],
101
+ });
102
+ assert.equal(overlap.status, 'repairable');
103
+ assert.equal(overlap.candidate, undefined);
104
+ assert.equal(overlap.failures[0]?.code, 'overlapping_range');
105
+ const locked = applyRewriteResponse(task, {
106
+ version: '2',
107
+ taskFingerprint: task.fingerprint,
108
+ operations: [{ startSentenceId: 1, endSentenceId: 2, text: 'I use the answer. The launch is on 14 August.' }],
109
+ });
110
+ assert.equal(locked.status, 'repairable');
111
+ assert.equal(locked.failures[0]?.code, 'partly_locked_range');
112
+ });
113
+ test('SHIP returns original bytes without a model response body', () => {
114
+ const draft = 'I leverage the answer. The launch is on 14 August.';
115
+ const task = prepareRewriteTask(draft, profile);
116
+ const shipped = applyShip(task);
117
+ assert.equal(shipped.status, 'accepted');
118
+ assert.equal(shipped.candidate, draft);
119
+ assert.equal(shipped.receipt.mode, 'SHIP');
120
+ const viaResponse = applyRewriteResponse(task, { version: '1', mode: 'SHIP', taskFingerprint: task.fingerprint });
121
+ assert.equal(viaResponse.candidate, draft);
122
+ });
123
+ test('hygiene changes occur only through eligible source-offset findings', () => {
124
+ const draft = `\uFEFFI leverage the answer.`;
125
+ const findings = hygieneSourceFindings(draft);
126
+ assert.equal(findings[0]?.eligible, true);
127
+ const task = prepareRewriteTask(draft, profile);
128
+ const rejected = applyRewriteResponse(task, {
129
+ version: '2',
130
+ taskFingerprint: task.fingerprint,
131
+ operations: [],
132
+ hygieneOperations: [{ start: 1, end: 2, text: '' }],
133
+ });
134
+ assert.equal(rejected.status, 'repairable');
135
+ assert.equal(rejected.failures[0]?.code, 'ineligible_hygiene_offset');
136
+ const cleaned = applyRewriteResponse(task, {
137
+ version: '2',
138
+ taskFingerprint: task.fingerprint,
139
+ operations: [{ startSentenceId: 1, endSentenceId: 1, text: 'I use the answer.' }],
140
+ hygieneOperations: [{ start: findings[0].start, end: findings[0].end, text: '' }],
141
+ });
142
+ assert.equal(cleaned.status, 'accepted');
143
+ assert.equal(cleaned.candidate, 'I use the answer.');
144
+ });
145
+ test('post-candidate reduction requires the full judgment set', () => {
146
+ const draft = 'I write clear notes.';
147
+ const candidate = 'I write clear notes.';
148
+ const kinds = ['argument', 'polarity', 'form', 'flatness', 'semantic'];
149
+ const envelopes = kinds.map((kind) => {
150
+ const task = preparePostCandidateJudgment(draft, candidate, profile, kind);
151
+ return bindJudgmentEnvelope(task, {
152
+ version: '1',
153
+ stage: 'post-candidate',
154
+ judgmentType: kind,
155
+ taskFingerprint: task.taskFingerprint,
156
+ bindings: { ...task.bindings, evaluatorId: 'writer.1' },
157
+ findings: [],
158
+ decision: 'CLEAR',
159
+ });
160
+ });
161
+ assert.equal(reducePostCandidate(envelopes).decision, 'CLEAR');
162
+ });