@holdyourvoice/hyv 3.6.1 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/Readme.md +91 -96
  2. package/dist/ai-editor-rules.js +1 -0
  3. package/dist/ai-editor.js +19 -13
  4. package/dist/analysis.js +33 -0
  5. package/dist/cli/agents.js +15 -35
  6. package/dist/cli/io.js +41 -37
  7. package/dist/copy-spec.js +6 -6
  8. package/dist/editorial-packs.js +4 -3
  9. package/dist/fact-linter.js +87 -37
  10. package/dist/hidden-text.js +6 -6
  11. package/dist/hold-your-voice.mcpb +0 -0
  12. package/dist/internal.js +8 -0
  13. package/dist/judgment-task.js +10 -14
  14. package/dist/learning.js +66 -67
  15. package/dist/lifecycle-adapter.js +2 -5
  16. package/dist/local-eval.js +16 -31
  17. package/dist/mcp-server.js +272 -0
  18. package/dist/mcp-tools.js +2 -2
  19. package/dist/mcp.js +2 -261
  20. package/dist/pipeline.js +9 -85
  21. package/dist/profile-compose.js +18 -11
  22. package/dist/profile-score.js +6 -6
  23. package/dist/profile.js +17 -19
  24. package/dist/provenance-status.js +1 -3
  25. package/dist/rebuild-task.js +14 -25
  26. package/dist/recomposition.js +18 -13
  27. package/dist/rewrite-prompt.js +125 -0
  28. package/dist/rewrite-task.js +117 -70
  29. package/dist/rule-allowances.js +6 -10
  30. package/dist/semantic-review.js +71 -47
  31. package/dist/stage1-evaluation.js +29 -14
  32. package/dist/strict-quality.js +10 -18
  33. package/dist/version.js +1 -1
  34. package/dist/voice-dna.js +34 -21
  35. package/dist/writing-examples.js +2 -7
  36. package/package.json +6 -3
  37. package/skills/hyv-prepare-judgment/SKILL.md +4 -0
  38. package/dist/agents/catalog.test.js +0 -60
  39. package/dist/agents/emit.test.js +0 -64
  40. package/dist/agents/load.test.js +0 -149
  41. package/dist/ai-editor.test.js +0 -265
  42. package/dist/approval-capability.test.js +0 -52
  43. package/dist/approval-context.test.js +0 -38
  44. package/dist/backtest.test.js +0 -20
  45. package/dist/benchmark.test.js +0 -328
  46. package/dist/canonical-json.test.js +0 -24
  47. package/dist/cli/context.test.js +0 -55
  48. package/dist/cli.test.js +0 -731
  49. package/dist/editorial-packs.test.js +0 -94
  50. package/dist/fact-linter.test.js +0 -85
  51. package/dist/hidden-text.test.js +0 -26
  52. package/dist/hygiene.test.js +0 -83
  53. package/dist/judgment-task.test.js +0 -162
  54. package/dist/learning.test.js +0 -325
  55. package/dist/lifecycle-adapter.test.js +0 -56
  56. package/dist/local-eval.test.js +0 -20
  57. package/dist/logic-linter-corpus.test.js +0 -22
  58. package/dist/logic-linter.test.js +0 -39
  59. package/dist/mcp-tools.test.js +0 -286
  60. package/dist/mcp.test.js +0 -312
  61. package/dist/mirror-refs.test.js +0 -63
  62. package/dist/pipeline.test.js +0 -247
  63. package/dist/preservation.test.js +0 -22
  64. package/dist/production-gates.test.js +0 -34
  65. package/dist/profile-compose.test.js +0 -32
  66. package/dist/profile-score.test.js +0 -22
  67. package/dist/profile-watch.test.js +0 -23
  68. package/dist/profile.test.js +0 -141
  69. package/dist/provenance-status.test.js +0 -22
  70. package/dist/rebuild-task.test.js +0 -206
  71. package/dist/recomposition.test.js +0 -34
  72. package/dist/release-audit.test.js +0 -292
  73. package/dist/rewrite-task.test.js +0 -166
  74. package/dist/rule-allowances.test.js +0 -17
  75. package/dist/rule-reconciliation.test.js +0 -50
  76. package/dist/sample-ingest.test.js +0 -52
  77. package/dist/semantic-review.test.js +0 -101
  78. package/dist/stage1-dry-run.test.js +0 -39
  79. package/dist/stage1-evaluation.test.js +0 -184
  80. package/dist/stage1-human-packet.test.js +0 -102
  81. package/dist/stage1-schema-contract.test.js +0 -95
  82. package/dist/stage2-human-packet.test.js +0 -81
  83. package/dist/strict-quality.test.js +0 -62
  84. package/dist/text-provenance.feature.test.js +0 -45
  85. package/dist/text.test.js +0 -16
  86. package/dist/voice-dna.test.js +0 -121
  87. package/dist/writing-examples.test.js +0 -35
@@ -1,94 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { analyzeBatch, analyzeEditorial, parseWritingBrief } from './editorial-packs.js';
4
- test('uses only the selected format pack and keeps advisory format findings non-blocking', () => {
5
- const brief = parseWritingBrief({
6
- version: '1', audience: 'founders', intent: 'start a discussion', format: 'social',
7
- });
8
- const report = analyzeEditorial('A pattern I keep seeing in founder posts is vague advice.', brief);
9
- assert.equal(report.engine, 'editorial');
10
- assert.equal(report.passed, true);
11
- assert.deepEqual(report.findings.map((finding) => [finding.id, finding.severity, finding.sentence]), [['editorial.social.generic-opener', 'yellow', 1]]);
12
- });
13
- test('enforces explicit local disclosure terms independently from format guidance', () => {
14
- const brief = parseWritingBrief({
15
- version: '1', audience: 'operators', intent: 'write an update', format: 'general', prohibitedTerms: ['internal contract value'],
16
- });
17
- const report = analyzeEditorial('The internal contract value is $12,000.', brief);
18
- assert.equal(report.passed, false);
19
- assert.deepEqual(report.findings.map((finding) => [finding.id, finding.severity]), [['editorial.prohibited-term', 'red']]);
20
- });
21
- test('matches prohibited terms as complete normalized words or phrases', () => {
22
- const brief = parseWritingBrief({ version: '1', audience: 'operators', intent: 'write an update', format: 'general', prohibitedTerms: ['art'] });
23
- assert.equal(analyzeEditorial('We start with the delivery date.', brief).passed, true);
24
- assert.equal(analyzeEditorial('The art needs a signed owner.', brief).passed, false);
25
- });
26
- test('covers the remaining contextual format checks without applying packs outside their format', () => {
27
- const social = parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'social' });
28
- assert.deepEqual(analyzeEditorial('One point.\n\nSecond point.\n\nThird point.', social).findings.map((item) => item.id), ['editorial.social.one-line-run']);
29
- const deck = parseWritingBrief({ version: '1', audience: 'buyers', intent: 'present', format: 'deck', title: '3 ways to improve delivery' });
30
- assert.deepEqual(analyzeEditorial('We make delivery simpler.', deck).findings.map((item) => item.id), ['editorial.deck.first-slide-we', 'editorial.deck.numeric-title']);
31
- const outreach = parseWritingBrief({ version: '1', audience: 'operators', intent: 'start a conversation', format: 'outreach' });
32
- assert.deepEqual(analyzeEditorial('Does that sound interesting?', outreach).findings.map((item) => item.id), ['editorial.outreach.generic-question-cta']);
33
- assert.deepEqual(analyzeEditorial('We make delivery simpler.', outreach).findings, []);
34
- });
35
- test('detects exact repeated openings and endings across a batch without judging deliberate variation', () => {
36
- const report = analyzeBatch([
37
- 'The launch needs a clear owner. Start with the dependency map.',
38
- 'The launch needs a clear owner. Start with the dependency map.',
39
- 'The invoice needs a clear owner. Check the due date.',
40
- ]);
41
- assert.deepEqual(report.findings.map((finding) => [finding.id, finding.draftIndexes]), [
42
- ['batch.repeated-opening', [1, 2]],
43
- ['batch.repeated-ending', [1, 2]],
44
- ]);
45
- });
46
- test('rejects malformed writing briefs before they activate editorial checks', () => {
47
- assert.throws(() => parseWritingBrief({ version: '1', audience: '', intent: 'write', format: 'social' }), /WritingBrief/);
48
- assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'unknown' }), /WritingBrief/);
49
- assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'social', evidenceStatus: 'unknown' }), /WritingBrief/);
50
- assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'social', argumentMap: { observation: 'A', mechanism: 'B', consequence: 'C' } }), /WritingBrief/);
51
- });
52
- test('adds opt-in evidence and reader-value review cues without blocking publication', () => {
53
- const brief = parseWritingBrief({
54
- version: '1',
55
- audience: 'operators',
56
- intent: 'explain a reliability cost',
57
- format: 'social',
58
- evidenceStatus: 'unverified',
59
- argumentMap: {
60
- observation: 'A worker failed.',
61
- mechanism: 'The cache was lost.',
62
- consequence: 'The request restarts.',
63
- readerValue: 'Avoid the cold restart cost.',
64
- },
65
- });
66
- const report = analyzeEditorial('A worker failed. The request restarts.', brief);
67
- assert.equal(report.passed, true);
68
- assert.deepEqual(report.findings.map((finding) => finding.id), [
69
- 'editorial.evidence.unverified',
70
- 'editorial.argument-map.reader-value-missing',
71
- ]);
72
- });
73
- test('does not flag an argument map when the draft carries the reader value', () => {
74
- const brief = parseWritingBrief({
75
- version: '1',
76
- audience: 'operators',
77
- intent: 'explain a reliability cost',
78
- format: 'social',
79
- argumentMap: {
80
- observation: 'A worker failed.',
81
- mechanism: 'The cache was lost.',
82
- consequence: 'The request restarts.',
83
- readerValue: 'Avoid the cold restart cost.',
84
- },
85
- });
86
- assert.deepEqual(analyzeEditorial('A worker failed. Avoid the cold restart cost.', brief).findings, []);
87
- });
88
- test('keeps the reader-value cue when only one generic term overlaps', () => {
89
- const brief = parseWritingBrief({
90
- version: '1', audience: 'operators', intent: 'explain a reliability cost', format: 'social',
91
- argumentMap: { observation: 'A worker failed.', mechanism: 'The cache was lost.', consequence: 'The request restarts.', readerValue: 'Avoid the cold restart cost.' },
92
- });
93
- assert.ok(analyzeEditorial('A worker failed. We avoid a delay.', brief).findings.some((item) => item.id === 'editorial.argument-map.reader-value-missing'));
94
- });
@@ -1,85 +0,0 @@
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
- });
@@ -1,26 +0,0 @@
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
- });
@@ -1,83 +0,0 @@
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 byte-order marks 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\u200Bbc\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'], ['U+FEFF', 'removed']]);
23
- assert.equal(result.report.suspiciousCount, 8);
24
- assert.equal(result.report.fixableCount, 2);
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 removes only non-semantic controls by default', () => {
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
- 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']);
70
- });
71
- test('withholds output when hidden characters remain unresolved', () => {
72
- const result = finalOutputCheck('Thai\u200Bboundary 👩\u200D💻');
73
- assert.equal(result.accepted, false);
74
- assert.equal('output' in result, false);
75
- assert.equal(result.changed, false);
76
- assert.deepEqual(result.remaining.hits.map((hit) => hit.codepoint), ['U+200B', 'U+200D']);
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
- });
@@ -1,162 +0,0 @@
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
- });