@holdyourvoice/hyv 3.6.1 → 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 (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 +45 -27
  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 +82 -0
  28. package/dist/rewrite-task.js +66 -65
  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
package/dist/pipeline.js CHANGED
@@ -1,95 +1,16 @@
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
- function analysisFindings(result) {
36
- return [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
37
- }
38
- export function strictFindings(result) {
39
- return analysisFindings(result).filter(isStrictFinding);
40
- }
41
- export function deriveEditScope(result, strict = false) {
42
- const findings = analysisFindings(result);
43
- const blocking = findings.filter(strict ? isStrictFinding : isBlockingFinding);
44
- const pendingJudgment = strict ? [] : findings.filter((finding) => finding.appliedPolicy === 'judgment-required');
45
- return {
46
- eligibleSentenceIds: [...new Set(blocking.map((finding) => finding.sentence))].sort((left, right) => left - right),
47
- blocking,
48
- pendingJudgment,
49
- };
50
- }
51
- export function renderRewritePrompt(draft, profile, result, learning = [], brief, examples = []) {
52
- const allFindings = analysisFindings(result);
53
- const scope = deriveEditScope(result, true);
54
- const redFindings = scope.blocking;
55
- const yellowFindings = allFindings.filter((finding) => !isStrictFinding(finding) && finding.appliedPolicy !== 'judgment-required');
56
- const metrics = profile.metrics;
57
- return [
58
- '# Tier 0 — non-negotiable preservation',
59
- 'Preserve facts, names, numbers, claims, and every unflagged sentence exactly. Do not add claims, examples, sections, hooks, or CTAs.',
60
- '',
61
- '# Tier 1 — strict repair requirements',
62
- '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.',
63
- '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.',
64
- `VoiceDNA: ${result.voiceDna.score}/100 (${result.voiceDna.passed ? 'pass' : 'fail'}).`,
65
- `AI Editor: ${result.aiEditor.score}/100 (${result.aiEditor.passed ? 'pass' : 'fail'}).`,
66
- ...profile.avoid.map((phrase) => `- Never use: ${phrase}`),
67
- ...(redFindings.length ? formatFindings(redFindings) : ['- None.']),
68
- '',
69
- '# Tier 2 — VoiceDNA fidelity',
70
- `- Sentence length: ${metrics.sentenceLength}; sentence variation: ${metrics.sentenceVariation}; sentence structure: ${metrics.sentenceStructure.join(', ') || 'none recorded'}; rhythm: ${metrics.rhythm}.`,
71
- `- 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}.`,
72
- `- Openings: ${metrics.openingMoves.join(', ') || 'none recorded'}.`,
73
- `- Vocabulary: ${metrics.vocabulary.join(', ') || 'none recorded'}.`,
74
- `- Transitions: ${metrics.transitions.join(', ') || 'none recorded'}.`,
75
- ...(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)}`)] : []),
76
- ...(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)}`)] : []),
77
- '',
78
- '# Tier 3 — AI Editor improvements',
79
- '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.',
80
- ...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
81
- '',
82
- '## Pending judgment — no edit permission in this task',
83
- ...(scope.pendingJudgment.length ? formatFindings(scope.pendingJudgment) : ['- None.']),
84
- ...(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.'] : [])] : []),
85
- '',
86
- '# Tier 4 — output contract',
87
- '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.',
88
- '',
89
- '# Draft',
90
- draft,
91
- ].join('\n');
92
- }
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';
93
14
  export function rewritePrompt(draft, profile, learning = [], brief, examples = []) {
94
15
  return renderRewritePrompt(draft, profile, analyze(draft, profile, brief), learning, brief, examples);
95
16
  }
@@ -131,6 +52,9 @@ function verifyCandidate(original, candidate, profile, brief, rebuild) {
131
52
  const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
132
53
  const requiredFacts = verifyRequiredFacts(candidate, brief);
133
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);
134
58
  return {
135
59
  version: '2',
136
60
  original: baseline,
@@ -142,7 +66,7 @@ function verifyCandidate(original, candidate, profile, brief, rebuild) {
142
66
  finalOutput,
143
67
  logicLint,
144
68
  ...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
145
- passed: checked.passed && unresolvedStrictFindings.length === 0 && !regressions.some(isBlockingFinding) && (claims ? claims.passed : 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,
146
70
  };
147
71
  }
148
72
  export function verify(original, candidate, profile, brief) {
@@ -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';
@@ -50,6 +51,9 @@ function renderRebuildPrompt(draft, copySpec, writingBrief, recompositionPolicy)
50
51
  'Keep every immutable CopySpec claim and atom. Do not add prohibited claims.',
51
52
  'Claim, polarity, hygiene, fingerprint, and semantic gates remain blocking. Lexical survival is not required.',
52
53
  '',
54
+ '# Word economy review',
55
+ WORD_ECONOMY_REVIEW,
56
+ '',
53
57
  '# CopySpec',
54
58
  canonicalJson({ audience: copySpec.audience, intent: copySpec.intent, channel: copySpec.channel, claims: copySpec.claims, ...(copySpec.prohibitedClaims ? { prohibitedClaims: copySpec.prohibitedClaims } : {}) }),
55
59
  ...(writingBrief ? ['', '# WritingBrief', canonicalJson(writingBrief)] : []),
@@ -130,16 +134,17 @@ export function parseRebuildTask(value) {
130
134
  throw new Error('Rebuild task fingerprint does not match its contents.');
131
135
  return task;
132
136
  }
133
- function rejected(task, raw, failures) {
137
+ function rebuildResult(task, raw, failures, response) {
134
138
  return {
135
- status: 'repairable',
139
+ status: response ? 'accepted' : 'repairable',
140
+ ...(response ? { candidate: response.candidate } : {}),
136
141
  failures,
137
142
  receipt: {
138
143
  version: '1',
139
144
  taskFingerprint: task.fingerprint,
140
145
  responseFingerprint: fingerprint(raw),
141
146
  adapterIds: [],
142
- replacementSentenceIds: [],
147
+ replacementSentenceIds: response ? sentences(response.candidate).map((sentence) => sentence.index) : [],
143
148
  mode: 'REBUILD',
144
149
  recommendationFingerprint: task.recommendationFingerprint,
145
150
  },
@@ -148,24 +153,11 @@ function rejected(task, raw, failures) {
148
153
  export function applyRebuildResponse(task, raw) {
149
154
  const response = parseRebuildResponse(typeof raw === 'string' ? parseResponseJson(raw) : raw);
150
155
  if (isFailure(response))
151
- return rejected(task, raw, [response]);
156
+ return rebuildResult(task, raw, [response]);
152
157
  if (response.taskFingerprint !== task.fingerprint) {
153
- 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')]);
154
159
  }
155
- return {
156
- status: 'accepted',
157
- candidate: response.candidate,
158
- failures: [],
159
- receipt: {
160
- version: '1',
161
- taskFingerprint: task.fingerprint,
162
- responseFingerprint: fingerprint(raw),
163
- adapterIds: [],
164
- replacementSentenceIds: sentences(response.candidate).map((sentence) => sentence.index),
165
- mode: 'REBUILD',
166
- recommendationFingerprint: task.recommendationFingerprint,
167
- },
168
- };
160
+ return rebuildResult(task, raw, [], response);
169
161
  }
170
162
  export function evaluateRebuildResponse(task, raw, profile, capability, trustStore, now) {
171
163
  const identity = profileIdentity(profile);
@@ -191,16 +183,13 @@ export function evaluateRebuildResponse(task, raw, profile, capability, trustSto
191
183
  ...(task.recompositionPolicy ? { lexicalResidual: measureLexicalResidual(task.draft, candidate, task.copySpec, task.recompositionPolicy) } : {}),
192
184
  provenanceStatus: provenanceStatusForRebuild(task),
193
185
  };
194
- if (!verification.passed) {
195
- const { candidate: _candidate, ...withheld } = applied;
196
- return { ...withheld, receipt, status: 'needs_escalation', verification, deterministicArtifact };
197
- }
198
- if (receipt.lexicalResidual && !receipt.lexicalResidual.passed) {
186
+ const residualFailed = receipt.lexicalResidual && !receipt.lexicalResidual.passed;
187
+ if (!verification.passed || residualFailed) {
199
188
  const { candidate: _candidate, ...withheld } = applied;
200
189
  return {
201
190
  ...withheld,
202
191
  receipt,
203
- 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.')] } : {}),
204
193
  status: 'needs_escalation',
205
194
  verification,
206
195
  deterministicArtifact,
@@ -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
+ }