@holdyourvoice/hyv 3.6.0 → 4.0.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 (92) hide show
  1. package/Readme.md +93 -154
  2. package/dist/ai-editor-rules.js +1 -0
  3. package/dist/ai-editor.js +23 -33
  4. package/dist/analysis.js +33 -0
  5. package/dist/cli/agents.js +88 -0
  6. package/dist/cli/checks.js +226 -0
  7. package/dist/cli/io.js +141 -0
  8. package/dist/cli/lifecycle.js +162 -0
  9. package/dist/cli/profiles.js +246 -0
  10. package/dist/cli/rewriting.js +111 -0
  11. package/dist/cli.js +5 -974
  12. package/dist/copy-spec.js +6 -6
  13. package/dist/editorial-packs.js +4 -3
  14. package/dist/fact-linter.js +45 -27
  15. package/dist/hidden-text.js +6 -6
  16. package/dist/hold-your-voice.mcpb +0 -0
  17. package/dist/internal.js +8 -0
  18. package/dist/judgment-task.js +10 -14
  19. package/dist/learning.js +77 -71
  20. package/dist/lifecycle-adapter.js +2 -5
  21. package/dist/local-eval.js +16 -31
  22. package/dist/mcp-server.js +272 -0
  23. package/dist/mcp-tools.js +10 -19
  24. package/dist/mcp.js +2 -267
  25. package/dist/pipeline.js +18 -105
  26. package/dist/profile-compose.js +18 -11
  27. package/dist/profile-score.js +6 -6
  28. package/dist/profile.js +17 -19
  29. package/dist/provenance-status.js +1 -3
  30. package/dist/rebuild-task.js +18 -52
  31. package/dist/recomposition.js +18 -13
  32. package/dist/rewrite-prompt.js +82 -0
  33. package/dist/rewrite-response.js +27 -0
  34. package/dist/rewrite-task.js +73 -98
  35. package/dist/rule-allowances.js +6 -10
  36. package/dist/semantic-review.js +71 -47
  37. package/dist/stage1-evaluation.js +29 -14
  38. package/dist/strict-quality.js +10 -18
  39. package/dist/version.js +1 -1
  40. package/dist/voice-dna.js +34 -21
  41. package/dist/writing-examples.js +2 -7
  42. package/package.json +6 -3
  43. package/skills/hyv-prepare-judgment/SKILL.md +4 -0
  44. package/dist/agents/catalog.test.js +0 -60
  45. package/dist/agents/emit.test.js +0 -64
  46. package/dist/agents/load.test.js +0 -149
  47. package/dist/ai-editor.test.js +0 -265
  48. package/dist/approval-capability.test.js +0 -52
  49. package/dist/approval-context.test.js +0 -38
  50. package/dist/backtest.test.js +0 -20
  51. package/dist/benchmark.test.js +0 -328
  52. package/dist/canonical-json.test.js +0 -24
  53. package/dist/cli.test.js +0 -731
  54. package/dist/editorial-packs.test.js +0 -94
  55. package/dist/fact-linter.test.js +0 -85
  56. package/dist/hidden-text.test.js +0 -26
  57. package/dist/hygiene.test.js +0 -83
  58. package/dist/judgment-task.test.js +0 -162
  59. package/dist/learning.test.js +0 -325
  60. package/dist/lifecycle-adapter.test.js +0 -56
  61. package/dist/local-eval.test.js +0 -20
  62. package/dist/logic-linter-corpus.test.js +0 -22
  63. package/dist/logic-linter.test.js +0 -39
  64. package/dist/mcp-tools.test.js +0 -286
  65. package/dist/mcp.test.js +0 -312
  66. package/dist/mirror-refs.test.js +0 -63
  67. package/dist/pipeline.test.js +0 -247
  68. package/dist/preservation.test.js +0 -22
  69. package/dist/production-gates.test.js +0 -34
  70. package/dist/profile-compose.test.js +0 -32
  71. package/dist/profile-score.test.js +0 -22
  72. package/dist/profile-watch.test.js +0 -23
  73. package/dist/profile.test.js +0 -141
  74. package/dist/provenance-status.test.js +0 -22
  75. package/dist/rebuild-task.test.js +0 -206
  76. package/dist/recomposition.test.js +0 -34
  77. package/dist/release-audit.test.js +0 -292
  78. package/dist/rewrite-task.test.js +0 -166
  79. package/dist/rule-allowances.test.js +0 -17
  80. package/dist/rule-reconciliation.test.js +0 -50
  81. package/dist/sample-ingest.test.js +0 -52
  82. package/dist/semantic-review.test.js +0 -101
  83. package/dist/stage1-dry-run.test.js +0 -39
  84. package/dist/stage1-evaluation.test.js +0 -184
  85. package/dist/stage1-human-packet.test.js +0 -102
  86. package/dist/stage1-schema-contract.test.js +0 -95
  87. package/dist/stage2-human-packet.test.js +0 -81
  88. package/dist/strict-quality.test.js +0 -62
  89. package/dist/text-provenance.feature.test.js +0 -45
  90. package/dist/text.test.js +0 -16
  91. package/dist/voice-dna.test.js +0 -121
  92. package/dist/writing-examples.test.js +0 -35
package/dist/pipeline.js CHANGED
@@ -1,100 +1,24 @@
1
1
  import { canonicalJson } from './canonical-json.js';
2
2
  import { HYV_VERSION } from './version.js';
3
- import { analyzeAiEditor } from './ai-editor.js';
4
3
  import { verifyClaims } from './copy-spec.js';
5
- import { analyzeEditorial } from './editorial-packs.js';
6
- import { finalOutputCheck, inspectHygiene } from './hygiene.js';
7
- import { analyzeVoiceDna } from './voice-dna.js';
4
+ import { finalOutputCheck } from './hygiene.js';
8
5
  import { legacySetPreservation, LEGACY_SET_PRESERVATION_VERSION } from './preservation.js';
9
6
  import { lintFacts } from './fact-linter.js';
10
7
  import { lintLogic } from './logic-linter.js';
11
8
  import { sentences } from './text.js';
12
9
  import { digestCanonical, escaped as escapeRegex, profileIdentity, sha256 as digest } from './internal.js';
13
- export function analyze(text, profile, brief) {
14
- const voiceDna = analyzeVoiceDna(text, profile);
15
- const aiEditor = analyzeAiEditor(text, profile);
16
- const editorial = brief ? analyzeEditorial(text, brief) : undefined;
17
- const hygiene = inspectHygiene(text);
18
- return { version: '2', voiceDna, aiEditor, ...(editorial ? { editorial } : {}), hygiene, passed: voiceDna.passed && aiEditor.passed && (editorial?.passed ?? true) };
19
- }
20
- function formatLearningPreference(preference) {
21
- return preference.text.replace(/[\\`*_{\[\]}<>#]/g, '\\$&');
22
- }
23
- function formatBriefValue(value) {
24
- return value.replace(/[\\`*_{\[\]}<>#\r\n]/g, (character) => character === '\r' || character === '\n' ? ' ' : `\\${character}`);
25
- }
26
- function formatFindings(findings) {
27
- return findings.map((finding) => `- Sentence ${finding.sentence} [${finding.engine}/${finding.id}]: ${formatBriefValue(finding.reason)} Repair: ${formatBriefValue(finding.suggestion)}`);
28
- }
29
- export function isBlockingFinding(finding) {
30
- return finding.engine === 'ai_editor' ? finding.appliedPolicy === 'blocking' : finding.severity === 'red';
31
- }
32
- export function isStrictFinding(finding) {
33
- return finding.engine === 'ai_editor' || isBlockingFinding(finding);
34
- }
35
- export function strictFindings(result) {
36
- return [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])].filter(isStrictFinding);
37
- }
38
- export function deriveEditScope(result, strict = false) {
39
- const findings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
40
- const blocking = findings.filter(strict ? isStrictFinding : isBlockingFinding);
41
- const pendingJudgment = strict ? [] : findings.filter((finding) => finding.appliedPolicy === 'judgment-required');
42
- return {
43
- eligibleSentenceIds: [...new Set(blocking.map((finding) => finding.sentence))].sort((left, right) => left - right),
44
- blocking,
45
- pendingJudgment,
46
- };
47
- }
48
- export function renderRewritePrompt(draft, profile, result, learning = [], brief, examples = []) {
49
- const allFindings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
50
- const scope = deriveEditScope(result, true);
51
- const redFindings = scope.blocking;
52
- const yellowFindings = allFindings.filter((finding) => !isStrictFinding(finding) && finding.appliedPolicy !== 'judgment-required');
53
- const metrics = profile.metrics;
54
- return [
55
- '# Tier 0 — non-negotiable preservation',
56
- 'Preserve facts, names, numbers, claims, and every unflagged sentence exactly. Do not add claims, examples, sections, hooks, or CTAs.',
57
- '',
58
- '# Tier 1 — strict repair requirements',
59
- 'Every active AI Editor finding is a required repair. Replace each flagged sentence with a stronger, source-faithful sentence; do not merely swap one stock phrase for another.',
60
- 'Use only facts already present in the draft, CopySpec, WritingBrief, or supplied source context. Do not invent a source, metric, date, quotation, mechanism, example, CTA, or opinion.',
61
- `VoiceDNA: ${result.voiceDna.score}/100 (${result.voiceDna.passed ? 'pass' : 'fail'}).`,
62
- `AI Editor: ${result.aiEditor.score}/100 (${result.aiEditor.passed ? 'pass' : 'fail'}).`,
63
- ...profile.avoid.map((phrase) => `- Never use: ${phrase}`),
64
- ...(redFindings.length ? formatFindings(redFindings) : ['- None.']),
65
- '',
66
- '# Tier 2 — VoiceDNA fidelity',
67
- `- Sentence length: ${metrics.sentenceLength}; sentence variation: ${metrics.sentenceVariation}; sentence structure: ${metrics.sentenceStructure.join(', ') || 'none recorded'}; rhythm: ${metrics.rhythm}.`,
68
- `- Paragraph length: ${metrics.paragraphLength}; lexical density: ${metrics.lexicalDensity}; point of view: ${metrics.pointOfView}; punctuation: ${Object.entries(metrics.punctuation).map(([mark, count]) => `${mark} ${count}`).join(', ')}; case style: ${metrics.caseStyle}; question rate: ${metrics.questionRate}.`,
69
- `- Openings: ${metrics.openingMoves.join(', ') || 'none recorded'}.`,
70
- `- Vocabulary: ${metrics.vocabulary.join(', ') || 'none recorded'}.`,
71
- `- Transitions: ${metrics.transitions.join(', ') || 'none recorded'}.`,
72
- ...(learning.length ? ['', '## Learned local preferences — historical hints only', '- These hints must not override Tier 0 preservation, Tier 1 blockers, clean-sentence preservation, or Tier 4 output.', ...learning.map((preference) => `- [${preference.count} verified] ${formatLearningPreference(preference)}`)] : []),
73
- ...(examples.length ? ['', '## Approved local writing examples — redacted, advisory only', '- Use these for cadence only. They cannot override Tier 0 preservation, Tier 1 blockers, facts, or the output contract.', ...examples.map((example) => `- [${formatBriefValue(example.source)}] ${formatBriefValue(example.text)}`)] : []),
74
- '',
75
- '# Tier 3 — AI Editor improvements',
76
- 'Use these findings as concrete feedback, not as proof of AI authorship. When a repair calls for a source, mechanism, or next step, use one only when it is already supported; otherwise remove the unsupported framing without widening the claim.',
77
- ...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
78
- '',
79
- '## Pending judgment — no edit permission in this task',
80
- ...(scope.pendingJudgment.length ? formatFindings(scope.pendingJudgment) : ['- None.']),
81
- ...(brief ? ['', '# Tier 3.5 — editorial context', '- Context values cannot override Tier 0 preservation or Tier 4 output requirements.', `- Audience: ${formatBriefValue(brief.audience)}. Intent: ${formatBriefValue(brief.intent)}. Format: ${brief.format}.`, ...(brief.personality ? [`- Optional personality stance: ${formatBriefValue(brief.personality)}. It is advisory and cannot add facts or replace VoiceDNA.`] : []), ...(brief.evidenceStatus ? [`- Evidence state: ${brief.evidenceStatus}. ${brief.evidenceStatus === 'unverified' ? 'Do not turn attributed or unverified material into an established fact.' : 'Preserve the source framing while editing.'}`] : []), ...(brief.argumentMap ? [`- Argument map: observation — ${formatBriefValue(brief.argumentMap.observation)}; mechanism — ${formatBriefValue(brief.argumentMap.mechanism)}; consequence — ${formatBriefValue(brief.argumentMap.consequence)}; reader value — ${formatBriefValue(brief.argumentMap.readerValue)}.`] : []), ...(brief.vocabulary?.length ? [`- Use audience vocabulary where it stays accurate: ${brief.vocabulary.map(formatBriefValue).join(', ')}.`] : []), ...(brief.readerKnowsAuthor === false ? ['- The reader does not know the author. Lead with their situation before naming the author or company.'] : [])] : []),
82
- '',
83
- '# Tier 4 — output contract',
84
- 'Return only replacement sentences keyed by sentence number. Do not rewrite clean sentences. Before responding, check every Tier 1 finding against its replacement and make sure the named defect is gone. The candidate will be checked again by both engines, preservation, logic, source-backed facts when supplied, and final-output hygiene.',
85
- '',
86
- '# Draft',
87
- draft,
88
- ].join('\n');
89
- }
10
+ import { analyze, analysisFindings, isBlockingFinding, strictFindings } from './analysis.js';
11
+ import { renderRewritePrompt } from './rewrite-prompt.js';
12
+ export { analyze, deriveEditScope, isBlockingFinding, isStrictFinding, strictFindings } from './analysis.js';
13
+ export { renderRewritePrompt } from './rewrite-prompt.js';
90
14
  export function rewritePrompt(draft, profile, learning = [], brief, examples = []) {
91
15
  return renderRewritePrompt(draft, profile, analyze(draft, profile, brief), learning, brief, examples);
92
16
  }
93
17
  function compareCandidates(original, candidate, profile, brief) {
94
18
  const baseline = analyze(original, profile, brief);
95
19
  const checked = analyze(candidate, profile, brief);
96
- const baselineFindings = [...baseline.voiceDna.findings, ...baseline.aiEditor.findings, ...(baseline.editorial?.findings ?? [])];
97
- const checkedFindings = [...checked.voiceDna.findings, ...checked.aiEditor.findings, ...(checked.editorial?.findings ?? [])];
20
+ const baselineFindings = analysisFindings(baseline);
21
+ const checkedFindings = analysisFindings(checked);
98
22
  const known = new Set(baselineFindings.map((finding) => `${finding.engine}:${finding.id}:${finding.sentence}`));
99
23
  const regressions = checkedFindings.filter((finding) => !known.has(`${finding.engine}:${finding.id}:${finding.sentence}`));
100
24
  const preservation = legacySetPreservation(original, candidate).score;
@@ -120,13 +44,17 @@ function verifyRequiredFacts(candidate, brief) {
120
44
  return result;
121
45
  return { ...result, passed: false, failures: [...result.failures, ...reversed.map((fact) => ({ id: fact.id, code: 'missing_immutable_claim', message: `Required fact ${fact.id} is negated or denied.`, evidence: 'WritingBrief required fact.' }))] };
122
46
  }
123
- export function verify(original, candidate, profile, brief) {
47
+ function verifyCandidate(original, candidate, profile, brief, rebuild) {
124
48
  const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
49
+ const claims = rebuild ? verifyClaims(candidate, rebuild.copySpec) : undefined;
125
50
  const finalOutput = finalOutputCheck(candidate);
126
51
  const logicLint = lintLogic(candidate, brief);
127
52
  const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
128
53
  const requiredFacts = verifyRequiredFacts(candidate, brief);
129
54
  const unresolvedStrictFindings = strictFindings(checked);
55
+ const preservationPassed = claims ? claims.passed : preservation >= 70;
56
+ const factsPassed = !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true);
57
+ const analysisPassed = checked.passed && unresolvedStrictFindings.length === 0 && !regressions.some(isBlockingFinding);
130
58
  return {
131
59
  version: '2',
132
60
  original: baseline,
@@ -134,38 +62,23 @@ export function verify(original, candidate, profile, brief) {
134
62
  preservationScore: preservation,
135
63
  regressions,
136
64
  strictFindings: unresolvedStrictFindings,
65
+ ...(claims ? { claims } : {}),
137
66
  finalOutput,
138
67
  logicLint,
139
68
  ...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
140
- passed: checked.passed && unresolvedStrictFindings.length === 0 && !regressions.some(isBlockingFinding) && preservation >= 70 && finalOutput.accepted && logicLint.passed && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
69
+ passed: analysisPassed && preservationPassed && finalOutput.accepted && logicLint.passed && factsPassed,
141
70
  };
142
71
  }
72
+ export function verify(original, candidate, profile, brief) {
73
+ return verifyCandidate(original, candidate, profile, brief);
74
+ }
143
75
  export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
144
76
  const verification = verify(original, candidate, profile, brief);
145
77
  const claims = verifyClaims(candidate, spec);
146
78
  return { ...verification, claims, passed: verification.passed && claims.passed };
147
79
  }
148
80
  export function verifyRebuildWithCopySpec(original, candidate, profile, spec, brief) {
149
- const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
150
- const claims = verifyClaims(candidate, spec);
151
- const finalCheck = finalOutputCheck(candidate);
152
- const logicLint = lintLogic(candidate, brief);
153
- const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
154
- const requiredFacts = verifyRequiredFacts(candidate, brief);
155
- const unresolvedStrictFindings = strictFindings(checked);
156
- return {
157
- version: '2',
158
- original: baseline,
159
- candidate: checked,
160
- preservationScore: preservation,
161
- regressions,
162
- strictFindings: unresolvedStrictFindings,
163
- claims,
164
- finalOutput: finalCheck,
165
- logicLint,
166
- ...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
167
- passed: checked.passed && unresolvedStrictFindings.length === 0 && !regressions.some(isBlockingFinding) && claims.passed && finalCheck.accepted && logicLint.passed && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
168
- };
81
+ return verifyCandidate(original, candidate, profile, brief, { copySpec: spec });
169
82
  }
170
83
  function projectDeterministicVerificationArtifact(source, candidate, profile, verification, copySpec, writingBrief, verificationKind = 'claims' in verification ? 'copy_spec' : 'standard') {
171
84
  const identity = profileIdentity(profile);
@@ -3,18 +3,19 @@ import { canonicalJson } from './canonical-json.js';
3
3
  function weighted(values, weights) {
4
4
  return Number((values.reduce((sum, value, index) => sum + value * weights[index], 0) / weights.reduce((sum, value) => sum + value, 0)).toFixed(3));
5
5
  }
6
- function weightedList(lists, weights, limit) {
6
+ function rankedValues(lists, weights) {
7
7
  const scores = new Map();
8
- for (let index = 0; index < lists.length; index += 1)
8
+ for (let index = 0; index < lists.length; index += 1) {
9
9
  for (const item of lists[index])
10
10
  scores.set(item, (scores.get(item) ?? 0) + weights[index]);
11
- return [...scores].sort(([leftKey, leftScore], [rightKey, rightScore]) => rightScore - leftScore || leftKey.localeCompare(rightKey)).slice(0, limit).map(([item]) => item);
11
+ }
12
+ return [...scores].sort(([leftKey, leftScore], [rightKey, rightScore]) => rightScore - leftScore || leftKey.localeCompare(rightKey)).map(([item]) => item);
13
+ }
14
+ function weightedList(lists, weights, limit) {
15
+ return rankedValues(lists, weights).slice(0, limit);
12
16
  }
13
17
  function categorical(values, weights) {
14
- const scores = new Map();
15
- for (let index = 0; index < values.length; index += 1)
16
- scores.set(values[index], (scores.get(values[index]) ?? 0) + weights[index]);
17
- return [...scores].sort(([leftKey, leftScore], [rightKey, rightScore]) => rightScore - leftScore || leftKey.localeCompare(rightKey))[0][0];
18
+ return rankedValues(values.map((value) => [value]), weights)[0];
18
19
  }
19
20
  function strictest(states) {
20
21
  const rank = { disabled: 0, advisory: 1, 'judgment-required': 2, blocking: 3 };
@@ -58,6 +59,12 @@ function tone(profiles, weights) {
58
59
  complexity: weighted(profiles.map((profile) => profile.tone.complexity), weights),
59
60
  };
60
61
  }
62
+ function strictestTolerance(profiles, metric) {
63
+ return {
64
+ absolute: Math.min(...profiles.map((profile) => profile.tolerances[metric].absolute)),
65
+ calibrated: profiles.every((profile) => profile.tolerances[metric].calibrated),
66
+ };
67
+ }
61
68
  export function parseProfileRatio(value, count) {
62
69
  const values = value.split(':').map((part) => Number(part));
63
70
  if (values.length !== count || values.some((item) => !Number.isFinite(item) || item <= 0))
@@ -86,10 +93,10 @@ export function composeProfiles(profiles, ratio) {
86
93
  ...(composedTone ? { tone: composedTone } : {}),
87
94
  fingerprint: fingerprint(profiles, ratio),
88
95
  tolerances: {
89
- contractionRate: { absolute: Math.min(...profiles.map((profile) => profile.tolerances.contractionRate.absolute)), calibrated: profiles.every((profile) => profile.tolerances.contractionRate.calibrated) },
90
- sentenceLengthDistribution: { absolute: Math.min(...profiles.map((profile) => profile.tolerances.sentenceLengthDistribution.absolute)), calibrated: profiles.every((profile) => profile.tolerances.sentenceLengthDistribution.calibrated) },
91
- bulletRate: { absolute: Math.min(...profiles.map((profile) => profile.tolerances.bulletRate.absolute)), calibrated: profiles.every((profile) => profile.tolerances.bulletRate.calibrated) },
92
- enDashRate: { absolute: Math.min(...profiles.map((profile) => profile.tolerances.enDashRate.absolute)), calibrated: profiles.every((profile) => profile.tolerances.enDashRate.calibrated) },
96
+ contractionRate: strictestTolerance(profiles, 'contractionRate'),
97
+ sentenceLengthDistribution: strictestTolerance(profiles, 'sentenceLengthDistribution'),
98
+ bulletRate: strictestTolerance(profiles, 'bulletRate'),
99
+ enDashRate: strictestTolerance(profiles, 'enDashRate'),
93
100
  },
94
101
  metricFixtures: { contractionRate: fixtures('contractionRate'), sentenceLengthDistribution: fixtures('sentenceLengthDistribution'), bulletRate: fixtures('bulletRate'), enDashRate: fixtures('enDashRate') },
95
102
  };
@@ -40,11 +40,10 @@ function componentScores(candidate, target) {
40
40
  function score(components) {
41
41
  return rounded(100 * METRICS.reduce((sum, key) => sum + components[key], 0) / METRICS.length);
42
42
  }
43
- function percentile(values, fraction) {
44
- const sorted = [...values].sort((left, right) => left - right);
45
- if (!sorted.length)
43
+ function sortedPercentile(values, fraction) {
44
+ if (!values.length)
46
45
  return 0;
47
- return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * fraction))];
46
+ return values[Math.min(values.length - 1, Math.floor((values.length - 1) * fraction))];
48
47
  }
49
48
  function languageConfidence(text) {
50
49
  const letters = text.match(/\p{L}/gu) ?? [];
@@ -64,10 +63,11 @@ export function scoreHeldoutProfile(candidate, profile, heldoutSamples, channel)
64
63
  for (let left = 0; left < heldout.length; left += 1)
65
64
  for (let right = left + 1; right < heldout.length; right += 1)
66
65
  pairScores.push(score(componentScores(heldout[left], heldout[right])));
66
+ pairScores.sort((left, right) => left - right);
67
67
  const components = componentScores(profileMetrics(candidate), profile.metrics);
68
68
  const candidateScore = score(components);
69
- const lowerBound = rounded(percentile(pairScores, 0.1));
70
- const median = rounded(percentile(pairScores, 0.5));
69
+ const lowerBound = rounded(sortedPercentile(pairScores, 0.1));
70
+ const median = rounded(sortedPercentile(pairScores, 0.5));
71
71
  return {
72
72
  version: '1',
73
73
  disposition: candidateScore >= lowerBound ? 'inside_band' : 'review',
package/dist/profile.js CHANGED
@@ -20,13 +20,11 @@ function isBoundedStringArray(value, minimum = 0) {
20
20
  return Array.isArray(value) && value.length >= minimum && value.length <= 64
21
21
  && value.every((item) => isBoundedString(item)) && new Set(value).size === value.length;
22
22
  }
23
- function isNumberRecord(value) {
24
- return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
25
- && Object.values(value).every((item) => typeof item === 'number' && Number.isFinite(item));
26
- }
27
23
  function isPunctuation(value) {
28
24
  const marks = ['!', '?', ';', ':', '—'];
29
- return isNumberRecord(value) && Object.values(value).every((item) => item >= 0) && Object.keys(value).length === marks.length && marks.every((mark) => mark in value);
25
+ return isPlainObject(value)
26
+ && Object.values(value).every((item) => typeof item === 'number' && Number.isFinite(item) && item >= 0)
27
+ && Object.keys(value).length === marks.length && hasKnownKeys(value, marks);
30
28
  }
31
29
  function isMetrics(value) {
32
30
  if (!value || typeof value !== 'object')
@@ -106,20 +104,20 @@ function hasValidRevisionDigest(profile) {
106
104
  return createHash('sha256').update(canonicalJson(unsigned)).digest('hex') === revisionDigest;
107
105
  }
108
106
  function parseProfileV3(value) {
109
- const valid = isPlainObject(value) && Object.keys(value).every((key) => PROFILE_V3_ALLOWED_KEYS.includes(key))
110
- && PROFILE_V3_REQUIRED_KEYS.every((key) => key in value)
111
- && typeof value.id === 'string' && STABLE_ID.test(value.id)
112
- && typeof value.revision === 'number' && Number.isSafeInteger(value.revision) && value.revision > 0
113
- && typeof value.sampleCount === 'number' && Number.isInteger(value.sampleCount) && value.sampleCount >= 2
114
- && isStrictMetrics(value.metrics)
115
- && isBoundedStringArray(value.avoid)
116
- && isProvenance(value.provenance)
117
- && isRulePolicy(value.rulePolicy)
118
- && isFingerprint(value.fingerprint)
119
- && isTolerances(value.tolerances)
120
- && isMetricFixtures(value.metricFixtures);
121
- if (!valid)
122
- throw new Error('Profile is not a valid Hold Your Voice version 3 profile. Rebuild it from fixture-backed metrics.');
107
+ const invalid = () => { throw new Error('Profile is not a valid Hold Your Voice version 3 profile. Rebuild it from fixture-backed metrics.'); };
108
+ if (!isPlainObject(value)
109
+ || !Object.keys(value).every((key) => PROFILE_V3_ALLOWED_KEYS.includes(key))
110
+ || !PROFILE_V3_REQUIRED_KEYS.every((key) => key in value))
111
+ invalid();
112
+ if (typeof value.id !== 'string' || !STABLE_ID.test(value.id)
113
+ || typeof value.revision !== 'number' || !Number.isSafeInteger(value.revision) || value.revision <= 0
114
+ || typeof value.sampleCount !== 'number' || !Number.isInteger(value.sampleCount) || value.sampleCount < 2)
115
+ invalid();
116
+ if (!isStrictMetrics(value.metrics) || !isBoundedStringArray(value.avoid)
117
+ || !isProvenance(value.provenance) || !isRulePolicy(value.rulePolicy)
118
+ || !isFingerprint(value.fingerprint) || !isTolerances(value.tolerances)
119
+ || !isMetricFixtures(value.metricFixtures))
120
+ invalid();
123
121
  if (value.ruleAllowances !== undefined && !isRuleAllowances(value.ruleAllowances, value.sampleCount)) {
124
122
  throw new Error('Profile version 3 rule allowances must be derived from at least two samples and use eligible rule IDs.');
125
123
  }
@@ -1,6 +1,4 @@
1
- import { canonicalJson } from './canonical-json.js';
2
- import { createHash } from 'node:crypto';
3
- function fingerprint(value) { return createHash('sha256').update(canonicalJson(value)).digest('hex'); }
1
+ import { digestCanonical as fingerprint } from './internal.js';
4
2
  export function writerRequestForRebuild(task) {
5
3
  return {
6
4
  version: '1', taskFingerprint: task.fingerprint, prompt: task.prompt,
@@ -1,3 +1,4 @@
1
+ import { WORD_ECONOMY_REVIEW } from './rewrite-prompt.js';
1
2
  import { canonicalJson } from './canonical-json.js';
2
3
  import { parseCopySpec } from './copy-spec.js';
3
4
  import { parseWritingBrief } from './editorial-packs.js';
@@ -10,26 +11,13 @@ import { HYV_VERSION } from './version.js';
10
11
  import { buildRecompositionBrief, measureLexicalResidual, parseRecompositionPolicy } from './recomposition.js';
11
12
  import { provenanceStatusForRebuild, writerRequestForRebuild } from './provenance-status.js';
12
13
  import { fingerprint, profileIdentity, sha256 as digest } from './internal.js';
13
- const MAX_RESPONSE_BYTES = 100_000;
14
+ import { failure, parseResponseJson, projectLifecycleBinding } from './rewrite-response.js';
14
15
  const MAX_CANDIDATE_CHARACTERS = 100_000;
15
- function failure(code, message, path) {
16
- return { code, message, ...(path ? { path } : {}) };
17
- }
18
16
  function isFailure(value) {
19
17
  return typeof value === 'object' && value !== null && 'code' in value && 'message' in value;
20
18
  }
21
- function parseJson(value) {
22
- if (Buffer.byteLength(value) > MAX_RESPONSE_BYTES)
23
- return failure('response_too_large', `Response exceeds ${MAX_RESPONSE_BYTES} bytes.`);
24
- try {
25
- return JSON.parse(value);
26
- }
27
- catch {
28
- return failure('invalid_json', 'Response must be valid JSON.');
29
- }
30
- }
31
19
  function parseRebuildResponse(value) {
32
- const raw = typeof value === 'string' ? parseJson(value) : value;
20
+ const raw = typeof value === 'string' ? parseResponseJson(value) : value;
33
21
  if (isFailure(raw))
34
22
  return raw;
35
23
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
@@ -63,6 +51,9 @@ function renderRebuildPrompt(draft, copySpec, writingBrief, recompositionPolicy)
63
51
  'Keep every immutable CopySpec claim and atom. Do not add prohibited claims.',
64
52
  'Claim, polarity, hygiene, fingerprint, and semantic gates remain blocking. Lexical survival is not required.',
65
53
  '',
54
+ '# Word economy review',
55
+ WORD_ECONOMY_REVIEW,
56
+ '',
66
57
  '# CopySpec',
67
58
  canonicalJson({ audience: copySpec.audience, intent: copySpec.intent, channel: copySpec.channel, claims: copySpec.claims, ...(copySpec.prohibitedClaims ? { prohibitedClaims: copySpec.prohibitedClaims } : {}) }),
68
59
  ...(writingBrief ? ['', '# WritingBrief', canonicalJson(writingBrief)] : []),
@@ -143,42 +134,30 @@ export function parseRebuildTask(value) {
143
134
  throw new Error('Rebuild task fingerprint does not match its contents.');
144
135
  return task;
145
136
  }
146
- function rejected(task, raw, failures) {
137
+ function rebuildResult(task, raw, failures, response) {
147
138
  return {
148
- status: 'repairable',
139
+ status: response ? 'accepted' : 'repairable',
140
+ ...(response ? { candidate: response.candidate } : {}),
149
141
  failures,
150
142
  receipt: {
151
143
  version: '1',
152
144
  taskFingerprint: task.fingerprint,
153
145
  responseFingerprint: fingerprint(raw),
154
146
  adapterIds: [],
155
- replacementSentenceIds: [],
147
+ replacementSentenceIds: response ? sentences(response.candidate).map((sentence) => sentence.index) : [],
156
148
  mode: 'REBUILD',
157
149
  recommendationFingerprint: task.recommendationFingerprint,
158
150
  },
159
151
  };
160
152
  }
161
153
  export function applyRebuildResponse(task, raw) {
162
- const response = parseRebuildResponse(typeof raw === 'string' ? parseJson(raw) : raw);
154
+ const response = parseRebuildResponse(typeof raw === 'string' ? parseResponseJson(raw) : raw);
163
155
  if (isFailure(response))
164
- return rejected(task, raw, [response]);
156
+ return rebuildResult(task, raw, [response]);
165
157
  if (response.taskFingerprint !== task.fingerprint) {
166
- return rejected(task, raw, [failure('task_fingerprint_mismatch', 'Response task fingerprint does not match this task.', 'taskFingerprint')]);
158
+ return rebuildResult(task, raw, [failure('task_fingerprint_mismatch', 'Response task fingerprint does not match this task.', 'taskFingerprint')]);
167
159
  }
168
- return {
169
- status: 'accepted',
170
- candidate: response.candidate,
171
- failures: [],
172
- receipt: {
173
- version: '1',
174
- taskFingerprint: task.fingerprint,
175
- responseFingerprint: fingerprint(raw),
176
- adapterIds: [],
177
- replacementSentenceIds: sentences(response.candidate).map((sentence) => sentence.index),
178
- mode: 'REBUILD',
179
- recommendationFingerprint: task.recommendationFingerprint,
180
- },
181
- };
160
+ return rebuildResult(task, raw, [], response);
182
161
  }
183
162
  export function evaluateRebuildResponse(task, raw, profile, capability, trustStore, now) {
184
163
  const identity = profileIdentity(profile);
@@ -204,16 +183,13 @@ export function evaluateRebuildResponse(task, raw, profile, capability, trustSto
204
183
  ...(task.recompositionPolicy ? { lexicalResidual: measureLexicalResidual(task.draft, candidate, task.copySpec, task.recompositionPolicy) } : {}),
205
184
  provenanceStatus: provenanceStatusForRebuild(task),
206
185
  };
207
- if (!verification.passed) {
208
- const { candidate: _candidate, ...withheld } = applied;
209
- return { ...withheld, receipt, status: 'needs_escalation', verification, deterministicArtifact };
210
- }
211
- if (receipt.lexicalResidual && !receipt.lexicalResidual.passed) {
186
+ const residualFailed = receipt.lexicalResidual && !receipt.lexicalResidual.passed;
187
+ if (!verification.passed || residualFailed) {
212
188
  const { candidate: _candidate, ...withheld } = applied;
213
189
  return {
214
190
  ...withheld,
215
191
  receipt,
216
- failures: [failure('lexical_residual_exceeds_policy', 'Candidate exceeds the configured lexical-residual policy.')],
192
+ ...(verification.passed ? { failures: [failure('lexical_residual_exceeds_policy', 'Candidate exceeds the configured lexical-residual policy.')] } : {}),
217
193
  status: 'needs_escalation',
218
194
  verification,
219
195
  deterministicArtifact,
@@ -227,15 +203,5 @@ export function createRebuildLifecycleBinding(task, receipt, deterministic) {
227
203
  if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint || receipt.mode !== 'REBUILD' || deterministic.verificationKind !== 'rebuild') {
228
204
  throw new Error('Lifecycle binding requires a passed rebuild artifact for this rebuild task.');
229
205
  }
230
- return {
231
- rewriteTaskFingerprint: task.fingerprint,
232
- rewriteResponseFingerprint: receipt.responseFingerprint,
233
- deterministicArtifactFingerprint: deterministic.artifactFingerprint,
234
- sourceHash: deterministic.sourceHash,
235
- candidateHash: deterministic.candidateHash,
236
- profileId: deterministic.profileId,
237
- profileRevisionDigest: deterministic.profileRevisionDigest,
238
- rulesetVersion: deterministic.rulesetVersion,
239
- schemaVersion: '1',
240
- };
206
+ return projectLifecycleBinding(task.fingerprint, receipt, deterministic);
241
207
  }
@@ -1,3 +1,4 @@
1
+ import { WORD_ECONOMY_REVIEW } from './rewrite-prompt.js';
1
2
  import { canonicalJson } from './canonical-json.js';
2
3
  import { words } from './text.js';
3
4
  const ACKNOWLEDGEMENT = 'Measures shared wording only; does not detect or prove removal of a watermark.';
@@ -23,19 +24,17 @@ function allowedPhrases(copySpec) {
23
24
  }
24
25
  function longestSharedRun(source, candidate, allowed) {
25
26
  let longest = 0;
26
- let previous = new Array(source.length + 1).fill(0);
27
+ const runs = new Array(source.length + 1).fill(0);
27
28
  for (let candidateIndex = 1; candidateIndex <= candidate.length; candidateIndex += 1) {
28
- const current = new Array(source.length + 1).fill(0);
29
- for (let sourceIndex = 1; sourceIndex <= source.length; sourceIndex += 1) {
30
- if (candidate[candidateIndex - 1] !== source[sourceIndex - 1])
29
+ for (let sourceIndex = source.length; sourceIndex > 0; sourceIndex -= 1) {
30
+ const run = candidate[candidateIndex - 1] === source[sourceIndex - 1] ? runs[sourceIndex - 1] + 1 : 0;
31
+ runs[sourceIndex] = run;
32
+ if (run <= longest)
31
33
  continue;
32
- current[sourceIndex] = previous[sourceIndex - 1] + 1;
33
- const run = current[sourceIndex];
34
34
  const key = candidate.slice(candidateIndex - run, candidateIndex).join('\u0001');
35
35
  if (!allowed.has(key))
36
- longest = Math.max(longest, run);
36
+ longest = run;
37
37
  }
38
- previous = current;
39
38
  }
40
39
  return longest;
41
40
  }
@@ -59,6 +58,9 @@ export function buildRecompositionBrief(copySpec, writingBrief) {
59
58
  'Do not edit, quote, or mirror source wording unless a CopySpec claim or atom requires it.',
60
59
  'Return only the candidate. Do not claim anything about authorship, AI origin, or watermark status.',
61
60
  '',
61
+ '# Word economy review',
62
+ WORD_ECONOMY_REVIEW,
63
+ '',
62
64
  '# CopySpec',
63
65
  canonicalJson({ audience: copySpec.audience, intent: copySpec.intent, channel: copySpec.channel, claims: copySpec.claims, ...(copySpec.prohibitedClaims ? { prohibitedClaims: copySpec.prohibitedClaims } : {}) }),
64
66
  ...(writingBrief ? ['', '# WritingBrief', canonicalJson(writingBrief)] : []),
@@ -74,16 +76,19 @@ export function measureLexicalResidual(sourceText, candidateText, copySpec, poli
74
76
  for (const phrase of allowed) {
75
77
  const phraseTokens = tokens(phrase.text);
76
78
  counts.set(phrase.reason, (counts.get(phrase.reason) ?? 0) + phraseTokens.length);
77
- for (const ngram of ngrams(phraseTokens, policy.lexicalResidual.ngramSize))
78
- allowedNgrams.add(ngram);
79
79
  for (let length = 1; length <= phraseTokens.length; length += 1) {
80
- for (let index = 0; index + length <= phraseTokens.length; index += 1)
81
- allowedRuns.add(phraseTokens.slice(index, index + length).join('\u0001'));
80
+ for (const phrase of ngrams(phraseTokens, length))
81
+ allowedRuns.add(phrase);
82
82
  }
83
+ for (const phrase of ngrams(phraseTokens, policy.lexicalResidual.ngramSize))
84
+ allowedNgrams.add(phrase);
83
85
  }
84
86
  const sourceNgrams = new Set(ngrams(source, policy.lexicalResidual.ngramSize).filter((ngram) => !allowedNgrams.has(ngram)));
85
87
  const candidateNgrams = ngrams(candidate, policy.lexicalResidual.ngramSize).filter((ngram) => !allowedNgrams.has(ngram));
86
- const shared = candidateNgrams.filter((ngram) => sourceNgrams.has(ngram)).length;
88
+ let shared = 0;
89
+ for (const ngram of candidateNgrams)
90
+ if (sourceNgrams.has(ngram))
91
+ shared += 1;
87
92
  const sharedNgramFraction = candidateNgrams.length === 0 ? 0 : shared / candidateNgrams.length;
88
93
  const longestSharedRunTokens = longestSharedRun(source, candidate, allowedRuns);
89
94
  const passed = sharedNgramFraction <= policy.lexicalResidual.maxSharedNgramFraction
@@ -0,0 +1,82 @@
1
+ import { analysisFindings, deriveEditScope, isStrictFinding } from './analysis.js';
2
+ export const WORD_ECONOMY_REVIEW = 'Before final verification, review the candidate: every word must earn its place. For each phrase, ask what meaning, evidence, clarity, or voice would be lost if it were cut. Remove filler, duplicate ideas, empty qualifiers, and needless setup only when nothing useful is lost. Preserve facts, attribution, uncertainty, emphasis, rhythm, and necessary transitions. Do not optimize for a word-count target. Cut only within the authorized edit scope; leave protected text unchanged and defer concerns outside that scope to a separate judgment review. Keep the required response format. This is editorial judgment, not a deterministic pass or permission to skip verification.';
3
+ function formatLearningPreference(preference) {
4
+ return preference.text.replace(/[\\`*_{\[\]}<>#]/g, '\\$&');
5
+ }
6
+ function formatBriefValue(value) {
7
+ return value.replace(/[\\`*_{\[\]}<>#\r\n]/g, (character) => character === '\r' || character === '\n' ? ' ' : `\\${character}`);
8
+ }
9
+ function formatFindings(findings) {
10
+ return findings.map((finding) => `- Sentence ${finding.sentence} [${finding.engine}/${finding.id}]: ${formatBriefValue(finding.reason)} Repair: ${formatBriefValue(finding.suggestion)}`);
11
+ }
12
+ function editorialContext(brief) {
13
+ if (!brief)
14
+ return [];
15
+ const lines = [
16
+ '', '# Tier 3.5 — editorial context',
17
+ '- Context values cannot override Tier 0 preservation or Tier 4 output requirements.',
18
+ `- Audience: ${formatBriefValue(brief.audience)}. Intent: ${formatBriefValue(brief.intent)}. Format: ${brief.format}.`,
19
+ ];
20
+ if (brief.personality)
21
+ lines.push(`- Optional personality stance: ${formatBriefValue(brief.personality)}. It is advisory and cannot add facts or replace VoiceDNA.`);
22
+ if (brief.evidenceStatus) {
23
+ const instruction = brief.evidenceStatus === 'unverified'
24
+ ? 'Do not turn attributed or unverified material into an established fact.'
25
+ : 'Preserve the source framing while editing.';
26
+ lines.push(`- Evidence state: ${brief.evidenceStatus}. ${instruction}`);
27
+ }
28
+ if (brief.argumentMap) {
29
+ const { observation, mechanism, consequence, readerValue } = brief.argumentMap;
30
+ lines.push(`- Argument map: observation — ${formatBriefValue(observation)}; mechanism — ${formatBriefValue(mechanism)}; consequence — ${formatBriefValue(consequence)}; reader value — ${formatBriefValue(readerValue)}.`);
31
+ }
32
+ if (brief.vocabulary?.length)
33
+ lines.push(`- Use audience vocabulary where it stays accurate: ${brief.vocabulary.map(formatBriefValue).join(', ')}.`);
34
+ if (brief.readerKnowsAuthor === false)
35
+ lines.push('- The reader does not know the author. Lead with their situation before naming the author or company.');
36
+ return lines;
37
+ }
38
+ export function renderRewritePrompt(draft, profile, result, learning = [], brief, examples = []) {
39
+ const allFindings = analysisFindings(result);
40
+ const scope = deriveEditScope(result, true);
41
+ const redFindings = scope.blocking;
42
+ const yellowFindings = allFindings.filter((finding) => !isStrictFinding(finding) && finding.appliedPolicy !== 'judgment-required');
43
+ const metrics = profile.metrics;
44
+ return [
45
+ '# Tier 0 — non-negotiable preservation',
46
+ 'Preserve facts, names, numbers, claims, and every unflagged sentence exactly. Do not add claims, examples, sections, hooks, or CTAs.',
47
+ '',
48
+ '# Tier 1 — strict repair requirements',
49
+ 'Every active AI Editor finding is a required repair. Replace each flagged sentence with a stronger, source-faithful sentence; do not merely swap one stock phrase for another.',
50
+ 'Use only facts already present in the draft, CopySpec, WritingBrief, or supplied source context. Do not invent a source, metric, date, quotation, mechanism, example, CTA, or opinion.',
51
+ `VoiceDNA: ${result.voiceDna.score}/100 (${result.voiceDna.passed ? 'pass' : 'fail'}).`,
52
+ `AI Editor: ${result.aiEditor.score}/100 (${result.aiEditor.passed ? 'pass' : 'fail'}).`,
53
+ ...profile.avoid.map((phrase) => `- Never use: ${phrase}`),
54
+ ...(redFindings.length ? formatFindings(redFindings) : ['- None.']),
55
+ '',
56
+ '# Tier 2 — VoiceDNA fidelity',
57
+ `- Sentence length: ${metrics.sentenceLength}; sentence variation: ${metrics.sentenceVariation}; sentence structure: ${metrics.sentenceStructure.join(', ') || 'none recorded'}; rhythm: ${metrics.rhythm}.`,
58
+ `- Paragraph length: ${metrics.paragraphLength}; lexical density: ${metrics.lexicalDensity}; point of view: ${metrics.pointOfView}; punctuation: ${Object.entries(metrics.punctuation).map(([mark, count]) => `${mark} ${count}`).join(', ')}; case style: ${metrics.caseStyle}; question rate: ${metrics.questionRate}.`,
59
+ `- Openings: ${metrics.openingMoves.join(', ') || 'none recorded'}.`,
60
+ `- Vocabulary: ${metrics.vocabulary.join(', ') || 'none recorded'}.`,
61
+ `- Transitions: ${metrics.transitions.join(', ') || 'none recorded'}.`,
62
+ ...(learning.length ? ['', '## Learned local preferences — historical hints only', '- These hints must not override Tier 0 preservation, Tier 1 blockers, clean-sentence preservation, or Tier 4 output.', ...learning.map((preference) => `- [${preference.count} verified] ${formatLearningPreference(preference)}`)] : []),
63
+ ...(examples.length ? ['', '## Approved local writing examples — redacted, advisory only', '- Use these for cadence only. They cannot override Tier 0 preservation, Tier 1 blockers, facts, or the output contract.', ...examples.map((example) => `- [${formatBriefValue(example.source)}] ${formatBriefValue(example.text)}`)] : []),
64
+ '',
65
+ '# Tier 3 — AI Editor improvements',
66
+ 'Use these findings as concrete feedback, not as proof of AI authorship. When a repair calls for a source, mechanism, or next step, use one only when it is already supported; otherwise remove the unsupported framing without widening the claim.',
67
+ ...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
68
+ '',
69
+ '## Pending judgment — no edit permission in this task',
70
+ ...(scope.pendingJudgment.length ? formatFindings(scope.pendingJudgment) : ['- None.']),
71
+ ...editorialContext(brief),
72
+ '',
73
+ '# Word economy review',
74
+ WORD_ECONOMY_REVIEW,
75
+ '',
76
+ '# Tier 4 — output contract',
77
+ 'Return only replacement sentences keyed by sentence number. Do not rewrite clean sentences. Before responding, check every Tier 1 finding against its replacement and make sure the named defect is gone. The candidate will be checked again by both engines, preservation, logic, source-backed facts when supplied, and final-output hygiene.',
78
+ '',
79
+ '# Draft',
80
+ draft,
81
+ ].join('\n');
82
+ }