@holdyourvoice/hyv 3.4.5 → 3.5.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 (67) hide show
  1. package/Readme.md +7 -0
  2. package/dist/agents/load.test.js +1 -1
  3. package/dist/ai-editor-rules.js +34 -0
  4. package/dist/ai-editor.js +120 -9
  5. package/dist/ai-editor.test.js +99 -8
  6. package/dist/ai-shadow-fixtures.js +7 -0
  7. package/dist/ai-shadow-generator.js +17 -0
  8. package/dist/backtest.js +16 -0
  9. package/dist/backtest.test.js +20 -0
  10. package/dist/cli.js +225 -8
  11. package/dist/cli.test.js +102 -5
  12. package/dist/editorial-packs.js +1 -0
  13. package/dist/hold-your-voice.mcpb +0 -0
  14. package/dist/local-eval.js +98 -0
  15. package/dist/local-eval.test.js +20 -0
  16. package/dist/mcp-tools.js +22 -2
  17. package/dist/mcp-tools.test.js +33 -1
  18. package/dist/mcp.js +35 -4
  19. package/dist/mcp.test.js +2 -2
  20. package/dist/pipeline.js +5 -4
  21. package/dist/pipeline.test.js +12 -0
  22. package/dist/profile-compose.js +97 -0
  23. package/dist/profile-compose.test.js +32 -0
  24. package/dist/profile-score.js +79 -0
  25. package/dist/profile-score.test.js +22 -0
  26. package/dist/profile-watch.js +34 -0
  27. package/dist/profile-watch.test.js +23 -0
  28. package/dist/profile.js +33 -2
  29. package/dist/profile.test.js +27 -0
  30. package/dist/rebuild-task.test.js +1 -1
  31. package/dist/rule-allowances.js +27 -0
  32. package/dist/rule-allowances.test.js +17 -0
  33. package/dist/sample-ingest.js +94 -0
  34. package/dist/sample-ingest.test.js +52 -0
  35. package/dist/strict-quality.js +64 -0
  36. package/dist/strict-quality.test.js +62 -0
  37. package/dist/version.js +1 -1
  38. package/dist/voice-dna.js +33 -1
  39. package/dist/voice-dna.test.js +12 -1
  40. package/dist/writing-examples.js +83 -0
  41. package/dist/writing-examples.test.js +35 -0
  42. package/package.json +1 -1
  43. package/skills/hyv-analyze/SKILL.md +3 -1
  44. package/skills/hyv-analyze/agent.json +2 -1
  45. package/skills/hyv-backtest/SKILL.md +14 -0
  46. package/skills/hyv-backtest/agent.json +16 -0
  47. package/skills/hyv-backtest/agents/openai.yaml +4 -0
  48. package/skills/hyv-evaluate-local/SKILL.md +14 -0
  49. package/skills/hyv-evaluate-local/agent.json +16 -0
  50. package/skills/hyv-evaluate-local/agents/openai.yaml +4 -0
  51. package/skills/hyv-final-check/SKILL.md +2 -0
  52. package/skills/hyv-find-writing-examples/SKILL.md +10 -0
  53. package/skills/hyv-find-writing-examples/agent.json +16 -0
  54. package/skills/hyv-find-writing-examples/agents/openai.yaml +4 -0
  55. package/skills/hyv-ingest/SKILL.md +31 -0
  56. package/skills/hyv-ingest/agent.json +28 -0
  57. package/skills/hyv-ingest/agents/openai.yaml +4 -0
  58. package/skills/hyv-patterns/SKILL.md +2 -0
  59. package/skills/hyv-profile/SKILL.md +2 -0
  60. package/skills/hyv-score/SKILL.md +26 -0
  61. package/skills/hyv-score/agent.json +28 -0
  62. package/skills/hyv-score/agents/openai.yaml +4 -0
  63. package/skills/hyv-strict-check/SKILL.md +26 -0
  64. package/skills/hyv-strict-check/agent.json +33 -0
  65. package/skills/hyv-strict-check/agents/openai.yaml +4 -0
  66. package/skills/hyv-verify/SKILL.md +1 -1
  67. package/skills/hyv-verify/agent.json +1 -0
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { SAMPLE_ALLOWANCE_RULE_IDS } from './profile.js';
3
+ const RULE_MATCHERS = {
4
+ 'punct.em-dash': /—/u,
5
+ 'punct.en-dash': /–/u,
6
+ 'format.curly-quotes': /[“”]/u,
7
+ };
8
+ function evidenceDigest(ruleId, samples) {
9
+ const evidence = samples
10
+ .filter((sample) => RULE_MATCHERS[ruleId].test(sample))
11
+ .map((sample) => sample.replace(/\s+/gu, ' ').trim())
12
+ .sort();
13
+ return createHash('sha256').update(JSON.stringify({ ruleId, evidence })).digest('hex');
14
+ }
15
+ /**
16
+ * Returns only non-verbatim evidence for stylistic exceptions shown in two or
17
+ * more author-owned samples. Callers add the result to a signed Profile v3.
18
+ */
19
+ export function deriveRuleAllowances(samples) {
20
+ const allowances = {};
21
+ for (const ruleId of SAMPLE_ALLOWANCE_RULE_IDS) {
22
+ const count = samples.filter((sample) => RULE_MATCHERS[ruleId].test(sample)).length;
23
+ if (count >= 2)
24
+ allowances[ruleId] = { sampleCount: count, evidenceDigest: evidenceDigest(ruleId, samples) };
25
+ }
26
+ return allowances;
27
+ }
@@ -0,0 +1,17 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { deriveRuleAllowances } from './rule-allowances.js';
4
+ test('derives only non-verbatim style allowances from repeated sample evidence', () => {
5
+ const allowances = deriveRuleAllowances([
6
+ 'I write this way — with a deliberate turn.',
7
+ 'The point is direct — then I explain it.',
8
+ 'The note says “ship Tuesday” after the check.',
9
+ 'The runbook repeats “ship Tuesday” as a decision.',
10
+ 'A plain sample has no allowance.',
11
+ ]);
12
+ assert.deepEqual(Object.keys(allowances), ['punct.em-dash', 'format.curly-quotes']);
13
+ assert.equal(allowances['punct.em-dash'].sampleCount, 2);
14
+ assert.match(allowances['punct.em-dash'].evidenceDigest, /^[a-f0-9]{64}$/);
15
+ assert.equal(allowances['format.curly-quotes'].sampleCount, 2);
16
+ assert.equal(JSON.stringify(allowances).includes('deliberate turn'), false);
17
+ });
@@ -0,0 +1,94 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { sentences } from './text.js';
3
+ const EMPTY_REDACTIONS = { email: 0, phone: 0, card: 0, ip: 0, url: 0 };
4
+ const REDACTORS = [
5
+ ['email', /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi],
6
+ ['phone', /(?<!\w)(?:\+?\d[\d .()/-]{7,}\d)(?!\w)/g],
7
+ ['card', /(?<!\d)(?:\d[ -]?){13,19}(?!\d)/g],
8
+ ['ip', /\b(?:\d{1,3}\.){3}\d{1,3}\b/g],
9
+ ['url', /\bhttps?:\/\/[^\s<>()]+/gi],
10
+ ];
11
+ function digest(text) { return createHash('sha256').update(text).digest('hex'); }
12
+ function textFromTelegram(value) {
13
+ if (typeof value === 'string')
14
+ return value;
15
+ if (!Array.isArray(value))
16
+ return '';
17
+ return value.map((part) => typeof part === 'string' ? part : part && typeof part === 'object' && typeof part.text === 'string' ? part.text : '').join('');
18
+ }
19
+ function decodeMboxBody(message) {
20
+ const body = message.replace(/^[\s\S]*?\r?\n\r?\n/, '');
21
+ return body.replace(/^>.*$/gm, '').replace(/=\r?\n/g, '').replace(/=([A-F0-9]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))).trim();
22
+ }
23
+ function mboxMessages(source) {
24
+ return source.split(/^From .+$/m).slice(1).flatMap((part) => {
25
+ const from = part.match(/^From:\s*(.+)$/mi)?.[1]?.trim() ?? '';
26
+ const text = decodeMboxBody(part);
27
+ return from && text ? [{ owner: from, text }] : [];
28
+ });
29
+ }
30
+ function telegramMessages(source) {
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(source);
34
+ }
35
+ catch {
36
+ throw new Error('Telegram Desktop export is not valid JSON.');
37
+ }
38
+ const values = parsed && typeof parsed === 'object' && Array.isArray(parsed.messages) ? parsed.messages : [];
39
+ return values.flatMap((value) => {
40
+ if (!value || typeof value !== 'object')
41
+ return [];
42
+ const message = value;
43
+ const owner = typeof message.from_id === 'string' ? message.from_id : typeof message.from === 'string' ? message.from : '';
44
+ const text = textFromTelegram(message.text);
45
+ return message.type === 'message' && owner && text ? [{ owner, text }] : [];
46
+ });
47
+ }
48
+ function containsBlockedWord(text, blockedWords) {
49
+ const lower = text.toLocaleLowerCase();
50
+ return blockedWords.some((word) => lower.includes(word.toLocaleLowerCase()));
51
+ }
52
+ function redact(text, counts) {
53
+ let output = text;
54
+ for (const [category, expression] of REDACTORS)
55
+ output = output.replace(expression, (value) => {
56
+ counts[category] += 1;
57
+ return '[REDACTED:' + category.toUpperCase() + ']';
58
+ });
59
+ return output;
60
+ }
61
+ function ingest(source, raw, messages, owner, blockedWords = []) {
62
+ if (!owner.trim())
63
+ throw new Error('Sample ingest requires an explicit owner identifier.');
64
+ if (blockedWords.some((word) => !word.trim()))
65
+ throw new Error('Blocked words must be non-empty.');
66
+ const redactions = { ...EMPTY_REDACTIONS };
67
+ let droppedSentences = 0;
68
+ const samples = [];
69
+ const accepted = messages.filter((message) => message.owner.toLocaleLowerCase() === owner.toLocaleLowerCase());
70
+ for (const message of accepted) {
71
+ const safe = sentences(message.text).flatMap((sentence) => {
72
+ if (containsBlockedWord(sentence.text, blockedWords)) {
73
+ droppedSentences += 1;
74
+ return [];
75
+ }
76
+ return [redact(sentence.text, redactions)];
77
+ }).join(' ').replace(/\s+/g, ' ').trim();
78
+ if (safe)
79
+ samples.push(safe);
80
+ }
81
+ return {
82
+ samples,
83
+ receipt: {
84
+ version: '1', source, sourceDigest: digest(raw), messagesSeen: messages.length, messagesAccepted: accepted.length, samplesAccepted: samples.length,
85
+ droppedSentences, redactions, presidio: 'NOT_CONFIGURED',
86
+ },
87
+ };
88
+ }
89
+ export function ingestGmailSentMbox(source, owner, blockedWords = []) {
90
+ return ingest('gmail-sent-mbox', source, mboxMessages(source), owner, blockedWords);
91
+ }
92
+ export function ingestTelegramDesktopJson(source, owner, blockedWords = []) {
93
+ return ingest('telegram-desktop-json', source, telegramMessages(source), owner, blockedWords);
94
+ }
@@ -0,0 +1,52 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { ingestGmailSentMbox, ingestTelegramDesktopJson } from './sample-ingest.js';
4
+ test('ingests only owner Gmail sent messages and redacts sensitive data before output', () => {
5
+ const mbox = [
6
+ 'From sender@example.com Mon Jan 1 00:00:00 2026',
7
+ 'From: Owner <owner@example.com>',
8
+ '',
9
+ 'Call +1 415 555 0100 or owner@example.com. Secret client detail stays out.',
10
+ 'From sender@example.com Mon Jan 2 00:00:00 2026',
11
+ 'From: Other <other@example.com>',
12
+ '',
13
+ 'This message must not become a sample.',
14
+ ].join('\n');
15
+ const result = ingestGmailSentMbox(mbox, 'Owner <owner@example.com>', ['secret client']);
16
+ assert.equal(result.samples.length, 1);
17
+ assert.match(result.samples[0], /REDACTED:PHONE/);
18
+ assert.match(result.samples[0], /REDACTED:EMAIL/);
19
+ assert.equal(result.samples[0].includes('Secret client'), false);
20
+ assert.equal(result.receipt.droppedSentences, 1);
21
+ assert.equal(JSON.stringify(result.receipt).includes('Secret client'), false);
22
+ assert.equal(JSON.stringify(result.receipt).includes('owner@example.com'), false);
23
+ });
24
+ test('ingests Telegram Desktop JSON by explicit owner and never places raw text in the receipt', () => {
25
+ const source = JSON.stringify({ messages: [
26
+ { type: 'message', from: 'Shashank', text: ['A link is ', { type: 'link', text: 'https://example.com/private' }, '.'] },
27
+ { type: 'message', from: 'Other', text: 'Ignore me.' },
28
+ ] });
29
+ const result = ingestTelegramDesktopJson(source, 'Shashank');
30
+ assert.equal(result.samples.length, 1);
31
+ assert.match(result.samples[0], /REDACTED:URL/);
32
+ assert.equal(JSON.stringify(result.receipt).includes('Ignore me'), false);
33
+ assert.equal(JSON.stringify(result.receipt).includes('example.com'), false);
34
+ assert.equal(result.receipt.presidio, 'NOT_CONFIGURED');
35
+ });
36
+ test('parsing local exports cannot open a socket', async () => {
37
+ const net = (await import('node:' + 'net')).default;
38
+ const writable = net;
39
+ const originalConnect = writable.connect;
40
+ const originalCreateConnection = writable.createConnection;
41
+ const denied = () => { throw new Error('network must remain unavailable'); };
42
+ writable.connect = denied;
43
+ writable.createConnection = denied;
44
+ try {
45
+ const result = ingestTelegramDesktopJson(JSON.stringify({ messages: [{ type: 'message', from: 'Owner', text: 'A local export stays local.' }] }), 'Owner');
46
+ assert.equal(result.samples.length, 1);
47
+ }
48
+ finally {
49
+ writable.connect = originalConnect;
50
+ writable.createConnection = originalCreateConnection;
51
+ }
52
+ });
@@ -0,0 +1,64 @@
1
+ import { finalOutputCheck } from './hygiene.js';
2
+ import { analyze } from './pipeline.js';
3
+ import { assessProfileReadiness } from './profile-quality.js';
4
+ const MINIMUM_SAMPLES = 5;
5
+ const MINIMUM_WORDS = 1_500;
6
+ function finding(id, disposition, reason, suggestion, sentence) {
7
+ return { id, disposition, reason, suggestion, ...(sentence === undefined ? {} : { sentence }) };
8
+ }
9
+ function strictFinding(source, profile) {
10
+ if (source.engine === 'ai_editor') {
11
+ const disposition = source.appliedPolicy === 'blocking' ? 'block' : 'review';
12
+ return finding(`strict.${source.engine}.${source.id}`, disposition, source.reason, source.suggestion, source.sentence);
13
+ }
14
+ if (source.engine === 'voice_dna') {
15
+ if (source.severity === 'red')
16
+ return finding(`strict.${source.engine}.${source.id}`, 'block', source.reason, source.suggestion, source.sentence);
17
+ if (source.id.startsWith('dna.fingerprint.')) {
18
+ const metric = source.id.slice('dna.fingerprint.'.length);
19
+ const key = metric === 'contraction-rate' ? 'contractionRate'
20
+ : metric === 'sentence-length-distribution' ? 'sentenceLengthDistribution'
21
+ : metric === 'bullet-rate' ? 'bulletRate'
22
+ : metric === 'en-dash-rate' ? 'enDashRate' : undefined;
23
+ if (key && profile.tolerances[key].calibrated)
24
+ return finding(`strict.${source.engine}.${source.id}`, 'block', source.reason, source.suggestion, source.sentence);
25
+ }
26
+ return finding(`strict.${source.engine}.${source.id}`, 'review', source.reason, source.suggestion, source.sentence);
27
+ }
28
+ return finding(`strict.${source.engine}.${source.id}`, source.severity === 'red' ? 'block' : 'review', source.reason, source.suggestion, source.sentence);
29
+ }
30
+ export function evaluateStrictQuality(draft, profile, samples, brief) {
31
+ const readiness = assessProfileReadiness(samples);
32
+ const analysis = analyze(draft, profile, brief);
33
+ const output = finalOutputCheck(draft);
34
+ const findings = [];
35
+ if (profile.version !== '3') {
36
+ findings.push(finding('strict.profile.version', 'block', 'Strict quality requires a fixture-backed Profile v3.', 'Build and validate a Profile v3 before enabling strict quality.'));
37
+ }
38
+ else {
39
+ if (profile.sampleCount < MINIMUM_SAMPLES)
40
+ findings.push(finding('strict.profile.profile-sample-count', 'block', `Strict quality requires a Profile v3 built from at least ${MINIMUM_SAMPLES} samples; the profile declares ${profile.sampleCount}.`, 'Rebuild and calibrate the profile from at least five distinct, rights-cleared samples.'));
41
+ if (samples.length < MINIMUM_SAMPLES)
42
+ findings.push(finding('strict.profile.sample-count', 'block', `Strict quality requires at least ${MINIMUM_SAMPLES} local samples; received ${samples.length}.`, 'Add channel-matched, rights-cleared samples from the same writer.'));
43
+ if (readiness.totalWords < MINIMUM_WORDS)
44
+ findings.push(finding('strict.profile.sample-words', 'block', `Strict quality requires at least ${MINIMUM_WORDS} sample words; received ${readiness.totalWords}.`, 'Add channel-matched, rights-cleared samples before treating voice drift as strict evidence.'));
45
+ if (readiness.findings.some((item) => item.id === 'sample_length'))
46
+ findings.push(finding('strict.profile.sample-coverage', 'block', 'Strict quality requires enough sentence-level sample coverage to calibrate voice drift.', 'Add longer samples with distinct sentences before treating voice drift as strict evidence.'));
47
+ if (readiness.findings.some((item) => item.id === 'duplicate_sample'))
48
+ findings.push(finding('strict.profile.duplicate-sample', 'block', 'Strict quality cannot calibrate from duplicate samples.', 'Replace duplicate samples with distinct writing occasions.'));
49
+ if (readiness.findings.some((item) => item.id === 'format_spread'))
50
+ findings.push(finding('strict.profile.format-spread', 'block', 'Strict quality cannot use a mixed-format sample set as one voice baseline.', 'Use samples from the same intended format and audience, or maintain separate calibrated profiles.'));
51
+ for (const [metric, tolerance] of Object.entries(profile.tolerances)) {
52
+ if (!tolerance.calibrated)
53
+ findings.push(finding(`strict.profile.uncalibrated.${metric}`, 'block', `Strict quality requires a calibrated ${metric} tolerance.`, 'Calibrate this metric against held-out writing before treating its drift as a strict result.'));
54
+ }
55
+ for (const source of [...analysis.voiceDna.findings, ...analysis.aiEditor.findings, ...(analysis.editorial?.findings ?? [])])
56
+ findings.push(strictFinding(source, profile));
57
+ }
58
+ if (!output.accepted)
59
+ findings.push(finding('strict.final-output', 'block', 'Final output contains unresolved hidden text.', 'Resolve hidden-text findings before treating the draft as strict-ready.'));
60
+ const disposition = findings.some((item) => item.disposition === 'block')
61
+ ? 'blocked'
62
+ : findings.length ? 'needs-human-review' : 'strict-ready';
63
+ return { version: '1', disposition, readiness, analysis, finalOutputAccepted: output.accepted, findings };
64
+ }
@@ -0,0 +1,62 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import test from 'node:test';
4
+ import { evaluateStrictQuality } from './strict-quality.js';
5
+ function canonical(value) {
6
+ if (Array.isArray(value))
7
+ return `[${value.map(canonical).join(',')}]`;
8
+ if (value !== null && typeof value === 'object')
9
+ return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`;
10
+ return JSON.stringify(value);
11
+ }
12
+ function profile() {
13
+ const unsigned = {
14
+ version: '3', id: 'strict.author', revision: 1, sampleCount: 5,
15
+ metrics: { sentenceLength: 6, sentenceVariation: 2, sentenceStructure: [], rhythm: 1, paragraphLength: 1, openingMoves: [], vocabulary: [], lexicalDensity: 0.5, pointOfView: 'mixed', punctuation: { '!': 0, '?': 0, ';': 0, ':': 0, '—': 0 }, caseStyle: 'standard', questionRate: 0, transitions: [] },
16
+ avoid: [], provenance: { source: 'local', rights: 'author-owned', createdAt: '2026-08-25T00:00:00.000Z' }, rulePolicy: {},
17
+ fingerprint: { contractionRate: 0, sentenceLengthDistribution: { short: 1, medium: 0, long: 0 }, bulletRate: 0, enDashRate: 0 },
18
+ tolerances: { contractionRate: { absolute: 1, calibrated: true }, sentenceLengthDistribution: { absolute: 1, calibrated: true }, bulletRate: { absolute: 1, calibrated: true }, enDashRate: { absolute: 1, calibrated: true } },
19
+ metricFixtures: { contractionRate: ['fixture.a'], sentenceLengthDistribution: ['fixture.b'], bulletRate: ['fixture.c'], enDashRate: ['fixture.d'] },
20
+ };
21
+ return { ...unsigned, revisionDigest: createHash('sha256').update(canonical(unsigned)).digest('hex') };
22
+ }
23
+ function samples() { return Array.from({ length: 5 }, (_, index) => Array.from({ length: 100 }, () => `sample${index} records one concrete detail.`).join(' ')); }
24
+ test('returns strict-ready only for a calibrated, adequately sampled, clean v3 profile', () => {
25
+ const report = evaluateStrictQuality('The launch starts Tuesday. The owner signed the checklist.', profile(), samples());
26
+ assert.equal(report.disposition, 'strict-ready');
27
+ assert.deepEqual(report.findings, []);
28
+ });
29
+ test('blocks an underpowered or uncalibrated voice profile before trusting voice matching', () => {
30
+ const underpowered = evaluateStrictQuality('The launch starts Tuesday.', profile(), samples().slice(0, 2));
31
+ assert.equal(underpowered.disposition, 'blocked');
32
+ assert.ok(underpowered.findings.some((item) => item.id === 'strict.profile.sample-count'));
33
+ const sparse = evaluateStrictQuality('The launch starts Tuesday.', profile(), Array.from({ length: 5 }, (_, index) => `sample ${index}.`));
34
+ assert.ok(sparse.findings.some((item) => item.id === 'strict.profile.sample-coverage'));
35
+ const uncalibrated = profile();
36
+ uncalibrated.tolerances.bulletRate.calibrated = false;
37
+ const report = evaluateStrictQuality('The launch starts Tuesday.', uncalibrated, samples());
38
+ assert.ok(report.findings.some((item) => item.id === 'strict.profile.uncalibrated.bulletRate'));
39
+ const thinProfile = profile();
40
+ thinProfile.sampleCount = 2;
41
+ const thinProfileReport = evaluateStrictQuality('The launch starts Tuesday.', thinProfile, samples());
42
+ assert.ok(thinProfileReport.findings.some((item) => item.id === 'strict.profile.profile-sample-count'));
43
+ const mixedFormats = samples();
44
+ mixedFormats[0] = Array.from({ length: 100 }, () => 'subject: a concise update for the release list.').join(' ');
45
+ const mixedFormatReport = evaluateStrictQuality('The launch starts Tuesday.', profile(), mixedFormats);
46
+ assert.ok(mixedFormatReport.findings.some((item) => item.id === 'strict.profile.format-spread'));
47
+ });
48
+ test('requires human review for a judgment-required AI pattern and blocks a configured one', () => {
49
+ const review = evaluateStrictQuality('We leverage the existing checklist.', profile(), samples());
50
+ assert.equal(review.disposition, 'needs-human-review');
51
+ assert.ok(review.findings.some((item) => item.id === 'strict.ai_editor.ai.leverage' && item.disposition === 'review'));
52
+ const blockedProfile = profile();
53
+ blockedProfile.rulePolicy['ai.leverage'] = 'blocking';
54
+ const blocked = evaluateStrictQuality('We leverage the existing checklist.', blockedProfile, samples());
55
+ assert.equal(blocked.disposition, 'blocked');
56
+ assert.ok(blocked.findings.some((item) => item.id === 'strict.ai_editor.ai.leverage' && item.disposition === 'block'));
57
+ });
58
+ test('blocks unresolved final-output hygiene', () => {
59
+ const report = evaluateStrictQuality('The launch starts\u200b Tuesday.', profile(), samples());
60
+ assert.equal(report.disposition, 'blocked');
61
+ assert.ok(report.findings.some((item) => item.id === 'strict.final-output'));
62
+ });
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const HYV_VERSION = '3.4.5';
1
+ export const HYV_VERSION = '3.5.0';
package/dist/voice-dna.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from './canonical-json.js';
3
+ import { deriveRuleAllowances } from './rule-allowances.js';
1
4
  import { deviation, mean, paragraphs, sentences, words } from './text.js';
2
5
  const STOP_WORDS = new Set(['the', 'and', 'that', 'with', 'this', 'from', 'your', 'have', 'were', 'they', 'will', 'into', 'about', 'what', 'when', 'where']);
3
6
  const TRANSITIONS = ['but', 'because', 'instead', 'then', 'still', 'so', 'yet', 'therefore'];
@@ -13,7 +16,7 @@ function top(items, limit) {
13
16
  counts.set(item, (counts.get(item) ?? 0) + 1);
14
17
  return [...counts].sort((left, right) => right[1] - left[1]).slice(0, limit).map(([item]) => item);
15
18
  }
16
- function profileMetrics(text) {
19
+ export function profileMetrics(text) {
17
20
  const draftSentences = sentences(text);
18
21
  const draftWords = words(text);
19
22
  const lengths = draftSentences.map((sentence) => words(sentence.text).length);
@@ -85,6 +88,35 @@ export function buildProfile(samples, avoid = []) {
85
88
  throw new Error('Every local writing sample must contain writing.');
86
89
  return { version: '2', sampleCount: samples.length, metrics: profileMetrics(samples.join('\n\n')), avoid };
87
90
  }
91
+ export function buildProfileV3(samples, id, channel, avoid = [], tone) {
92
+ const base = buildProfile(samples, avoid);
93
+ const sampleText = samples.join('\n\n');
94
+ const fixtureIds = samples.map((_, index) => 'sample.' + String(index + 1).padStart(3, '0'));
95
+ const unsigned = {
96
+ version: '3',
97
+ id,
98
+ revision: 1,
99
+ sampleCount: base.sampleCount,
100
+ metrics: base.metrics,
101
+ avoid: base.avoid,
102
+ provenance: { source: 'local-author-owned-samples', rights: 'author-owned', createdAt: new Date().toISOString() },
103
+ rulePolicy: {},
104
+ ...(Object.keys(deriveRuleAllowances(samples)).length ? { ruleAllowances: deriveRuleAllowances(samples) } : {}),
105
+ channel,
106
+ ...(tone ? { tone } : {}),
107
+ fingerprint: measureFounderFingerprint(sampleText),
108
+ tolerances: {
109
+ contractionRate: { absolute: 0.1, calibrated: false },
110
+ sentenceLengthDistribution: { absolute: 0.15, calibrated: false },
111
+ bulletRate: { absolute: 0.1, calibrated: false },
112
+ enDashRate: { absolute: 0.05, calibrated: false },
113
+ },
114
+ metricFixtures: {
115
+ contractionRate: fixtureIds, sentenceLengthDistribution: fixtureIds, bulletRate: fixtureIds, enDashRate: fixtureIds,
116
+ },
117
+ };
118
+ return { ...unsigned, revisionDigest: createHash('sha256').update(canonicalJson(unsigned)).digest('hex') };
119
+ }
88
120
  function finding(id, severity, sentence, excerpt, reason, suggestion) {
89
121
  return { engine: 'voice_dna', id, severity, sentence, excerpt, reason, suggestion };
90
122
  }
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { analyzeVoiceDna, buildProfile, measureFounderFingerprint } from './voice-dna.js';
3
+ import { analyzeVoiceDna, buildProfile, buildProfileV3, measureFounderFingerprint } from './voice-dna.js';
4
+ import { parseProfile } from './profile.js';
4
5
  function profileV3(overrides = {}) {
5
6
  const base = buildProfile(['i write plainly. i name the work.', 'i keep the mechanism clear. i avoid filler.']);
6
7
  return {
@@ -64,6 +65,16 @@ test('keeps buildProfile and v2 analysis output exactly on version 2', () => {
64
65
  assert.deepEqual(Object.keys(profile), ['version', 'sampleCount', 'metrics', 'avoid']);
65
66
  assert.equal(analyzeVoiceDna('i write plainly.', profile).version, '2');
66
67
  });
68
+ test('builds a signed channel-specific Profile v3 without retaining sample prose', () => {
69
+ const profile = buildProfileV3([
70
+ 'I write directly about the release — and name the owner.',
71
+ 'I keep the mechanism visible — then choose the next step.',
72
+ ], 'founder.email', 'email', [], { formality: 0.4, confidence: 0.7, warmth: 0.6, energy: 0.3, complexity: 0.5 });
73
+ assert.equal(profile.channel, 'email');
74
+ assert.equal(profile.ruleAllowances?.['punct.em-dash']?.sampleCount, 2);
75
+ assert.strictEqual(parseProfile(profile), profile);
76
+ assert.equal(JSON.stringify(profile).includes('keep the mechanism visible'), false);
77
+ });
67
78
  test('measures contractions in both directions and accepts curly apostrophes', () => {
68
79
  assert.equal(measureFounderFingerprint("I can't stop because I won't stop.").contractionRate, 2 / 7);
69
80
  assert.equal(measureFounderFingerprint('I cannot stop because I will not stop.').contractionRate, 0);
@@ -0,0 +1,83 @@
1
+ import { basename } from 'node:path';
2
+ import { sentences } from './text.js';
3
+ const STOP_WORDS = new Set(['the', 'and', 'that', 'with', 'this', 'from', 'your', 'have', 'were', 'they', 'will', 'into', 'about', 'what', 'when', 'where', 'then', 'than']);
4
+ const REDACTIONS = [
5
+ ['EMAIL', /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi],
6
+ ['PHONE', /(?<!\w)(?:\+?\d[\d .()/-]{7,}\d)(?!\w)/g],
7
+ ['CARD', /(?<!\d)(?:\d[ -]?){13,19}(?!\d)/g],
8
+ ['IP', /\b(?:\d{1,3}\.){3}\d{1,3}\b/g],
9
+ ['URL', /\bhttps?:\/\/[^\s<>()]+/gi],
10
+ ];
11
+ function tokens(text) {
12
+ return (text.toLocaleLowerCase().match(/\p{L}[\p{L}\p{N}'’-]*/gu) ?? []).filter((token) => token.length > 2 && !STOP_WORDS.has(token));
13
+ }
14
+ function redact(text) {
15
+ let output = text;
16
+ for (const [kind, expression] of REDACTIONS)
17
+ output = output.replace(expression, `[REDACTED:${kind}]`);
18
+ return output.replace(/\s+/g, ' ').trim().slice(0, 480);
19
+ }
20
+ function validate(inputs) {
21
+ if (!inputs.length || inputs.length > 64)
22
+ throw new Error('Writing example lookup needs one to 64 explicit local samples.');
23
+ for (const input of inputs) {
24
+ if (!input.text.trim() || input.text.length > 100_000)
25
+ throw new Error('Every writing example must contain at most 100,000 characters of text.');
26
+ if (!input.basename || basename(input.basename) !== input.basename || input.basename.length > 160)
27
+ throw new Error('Writing example sources must use basenames only.');
28
+ }
29
+ }
30
+ /** Builds an explicit in-memory inverted index; it is never written or retained. */
31
+ function indexExamples(inputs) {
32
+ const entries = inputs.flatMap((input) => sentences(input.text).map((sentence, position) => ({ source: input.basename, position, text: sentence.text, terms: tokens(sentence.text) })));
33
+ const postings = new Map();
34
+ const documentFrequency = new Map();
35
+ for (const [entryIndex, entry] of entries.entries()) {
36
+ const seen = new Set();
37
+ for (const term of entry.terms) {
38
+ const posting = postings.get(term) ?? new Map();
39
+ posting.set(entryIndex, (posting.get(entryIndex) ?? 0) + 1);
40
+ postings.set(term, posting);
41
+ seen.add(term);
42
+ }
43
+ for (const term of seen)
44
+ documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
45
+ }
46
+ return { entries, postings, documentFrequency };
47
+ }
48
+ /** Finds redacted local examples in supplied memory only; it never creates a corpus or index on disk. */
49
+ export function findWritingExamples(query, inputs, limit = 3) {
50
+ validate(inputs);
51
+ const queryTokens = new Set(tokens(query));
52
+ if (!queryTokens.size)
53
+ return [];
54
+ const index = indexExamples(inputs);
55
+ const scores = new Map();
56
+ const averageLength = index.entries.reduce((sum, entry) => sum + entry.terms.length, 0) / Math.max(1, index.entries.length);
57
+ for (const term of queryTokens) {
58
+ const posting = index.postings.get(term);
59
+ if (!posting)
60
+ continue;
61
+ const idf = Math.log(1 + (index.entries.length - (index.documentFrequency.get(term) ?? 0) + 0.5) / ((index.documentFrequency.get(term) ?? 0) + 0.5));
62
+ for (const [entryIndex, frequency] of posting) {
63
+ const entry = index.entries[entryIndex];
64
+ const denominator = frequency + 1.2 * (1 - 0.75 + 0.75 * entry.terms.length / Math.max(1, averageLength));
65
+ scores.set(entryIndex, (scores.get(entryIndex) ?? 0) + idf * frequency * 2.2 / denominator);
66
+ }
67
+ }
68
+ const ranked = [...scores].map(([entryIndex, score]) => ({ ...index.entries[entryIndex], score })).sort((left, right) => right.score - left.score || left.source.localeCompare(right.source) || left.position - right.position);
69
+ const usedSources = new Set();
70
+ const excerpts = [];
71
+ for (const candidate of ranked) {
72
+ if (usedSources.has(candidate.source))
73
+ continue;
74
+ const text = redact(candidate.text);
75
+ if (!text)
76
+ continue;
77
+ excerpts.push({ source: candidate.source, text });
78
+ usedSources.add(candidate.source);
79
+ if (excerpts.length === Math.min(3, Math.max(1, limit)))
80
+ break;
81
+ }
82
+ return excerpts;
83
+ }
@@ -0,0 +1,35 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { findWritingExamples } from './writing-examples.js';
4
+ test('retrieves at most three redacted excerpts with basenames only', () => {
5
+ const result = findWritingExamples('The rollout preserves the retry queue.', [
6
+ { basename: 'email.md', text: 'The retry queue preserves work after a rollout; contact owner@example.com.' },
7
+ { basename: 'notes.md', text: 'A rollback keeps the queue visible for operators.' },
8
+ { basename: 'third.md', text: 'This unrelated sentence does not rank.' },
9
+ ]);
10
+ assert.equal(result.length, 2);
11
+ assert.deepEqual(result.map((item) => item.source), ['email.md', 'notes.md']);
12
+ assert.match(result[0].text, /REDACTED:EMAIL/);
13
+ assert.equal(result[0].text.includes('owner@example.com'), false);
14
+ assert.equal(JSON.stringify(result).includes('/'), false);
15
+ });
16
+ test('requires explicit bounded in-memory samples and a real basename', () => {
17
+ assert.throws(() => findWritingExamples('queue', []), /one to 64/);
18
+ assert.throws(() => findWritingExamples('queue', [{ basename: '/private/email.md', text: 'The queue is visible.' }]), /basenames only/);
19
+ });
20
+ test('lookup cannot open a socket', async () => {
21
+ const net = (await import('node:' + 'net')).default;
22
+ const writable = net;
23
+ const originalConnect = writable.connect;
24
+ const originalCreateConnection = writable.createConnection;
25
+ const denied = () => { throw new Error('network must remain unavailable'); };
26
+ writable.connect = denied;
27
+ writable.createConnection = denied;
28
+ try {
29
+ assert.equal(findWritingExamples('queue', [{ basename: 'one.md', text: 'The queue stays local.' }]).length, 1);
30
+ }
31
+ finally {
32
+ writable.connect = originalConnect;
33
+ writable.createConnection = originalCreateConnection;
34
+ }
35
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holdyourvoice/hyv",
3
- "version": "3.4.5",
3
+ "version": "3.5.0",
4
4
  "description": "A local-first dual-engine writing gate that protects voice and catches generic AI patterns.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,9 +16,11 @@ hyv analyze draft.md profile.json [writing-brief.json]
16
16
  ## Behavior
17
17
 
18
18
  - Deterministic and local-first: this command never calls a provider and never sends drafts, samples, profiles, or telemetry to a service.
19
+ - AI Editor scans author prose only. Frontmatter, fenced code, inline code, URLs, and Markdown link targets are excluded; a match remains an editorial signal, not proof of AI authorship.
20
+ - A Profile v3 can carry a signed, non-verbatim allowance for a narrow stylistic default. An explicit profile policy always takes precedence, and no allowance weakens hygiene, fact, logic, or avoid-list checks.
19
21
  - Commands that write use an explicit output path. Confirm that path before running the command.
20
22
  - This agent describes how to invoke the command and what it returns. It does not change command behavior or exit codes.
21
23
 
22
24
  ## Handoff
23
25
 
24
- Run `hyv analyze` directly to execute the operation. Follow-on agents: hyv-rewrite-prompt, hyv-prepare-rewrite.
26
+ Run `hyv analyze` directly to execute the operation. For ordinary editing, follow on with hyv-rewrite-prompt or hyv-prepare-rewrite. When the caller asks for a strict AI-pattern and voice-match decision, hand off to hyv-strict-check with a calibrated Profile v3 and the required local samples; analysis alone is not a strict-ready result.
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "handoff_to": [
46
46
  "hyv-rewrite-prompt",
47
- "hyv-prepare-rewrite"
47
+ "hyv-prepare-rewrite",
48
+ "hyv-strict-check"
48
49
  ]
49
50
  }
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: hyv-backtest
3
+ description: Evaluate a caller-supplied reconstruction against a held-out local target without generating text.
4
+ ---
5
+
6
+ # hyv-backtest
7
+
8
+ Run an information-isolated evaluation. HYV receives context, a real held-out target, and a candidate supplied by a person or external writer. It does not generate the candidate, return either text, or call a provider.
9
+
10
+ ```text
11
+ hyv backtest context.md heldout-target.md candidate.md profile.json heldout-a.md heldout-b.md heldout-c.md
12
+ ```
13
+
14
+ The result carries only digests plus separate preservation, AI Editor, and held-out-band reports. It is not an authorship score or publishing approval.
@@ -0,0 +1,16 @@
1
+ {
2
+ "schema_version": "1.0.0",
3
+ "id": "hyv-backtest",
4
+ "title": "hyv-backtest",
5
+ "description": "Evaluate a caller-supplied reconstruction against a held-out local target without generating text.",
6
+ "instruction_file": "SKILL.md",
7
+ "role": "Information-isolated evaluator",
8
+ "workflow_phase": "verify",
9
+ "input": { "required": ["context path", "held-out target path", "candidate path", "profile path", "three or more held-out sample paths"], "optional": [] },
10
+ "output": { "required": ["text-free backtest report"], "optional": [] },
11
+ "evidence_requirements": ["Report preservation, AI Editor, and held-out-band results separately; never call the report an authorship or generation result."],
12
+ "permissions": ["execute_commands", "read_repository"],
13
+ "stop_conditions": ["Stop when the candidate was not supplied by the caller or fewer than three held-out samples are supplied."],
14
+ "tool_free_mode": { "available": true, "behavior": "Provide the exact command and state that the local backtest is NOT_RUN; never invent a score.", "unavailable_statuses": ["NOT_AVAILABLE", "NOT_CONFIGURED", "NOT_RUN", "STALE", "ERROR"] },
15
+ "handoff_to": ["hyv-verify"]
16
+ }
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Hyv Backtest"
3
+ short_description: "Evaluate a supplied reconstruction without generating text."
4
+ default_prompt: "Use $hyv-backtest to score a caller-supplied reconstruction against held-out local writing without making an authorship claim."
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: hyv-evaluate-local
3
+ description: Run the optional deterministic local writing evaluation composite without model calls.
4
+ ---
5
+
6
+ # hyv-evaluate-local
7
+
8
+ Evaluate a caller-supplied candidate against an input and separate local user and AI-shadow paragraph fixtures. The module trains only on the paragraph IDs assigned to the train split. It does not generate prose, transmit samples, retain text, or establish authorship.
9
+
10
+ ```text
11
+ hyv evaluate-local input.md candidate.md user-paragraphs.json ai-shadow-paragraphs.json
12
+ ```
13
+
14
+ Each JSON file is an array of `{ "paragraph_id": "stable-id", "text": "..." }`. Keep variants under the same paragraph ID; HYV splits by ID before training. The report contains a train-only TF-IDF logistic-regression proxy, content F1, AI-tell change, and 9-d stylometric cosine. Treat every number as a local evaluation signal, not a publishing decision or identity claim.
@@ -0,0 +1,16 @@
1
+ {
2
+ "schema_version": "1.0.0",
3
+ "id": "hyv-evaluate-local",
4
+ "title": "hyv-evaluate-local",
5
+ "description": "Run the optional deterministic local writing evaluation composite without model calls.",
6
+ "instruction_file": "SKILL.md",
7
+ "role": "Local writing evaluation reporter",
8
+ "workflow_phase": "verify",
9
+ "input": { "required": ["input path", "candidate path", "user paragraph JSON", "AI-shadow paragraph JSON"], "optional": [] },
10
+ "output": { "required": ["paragraph-isolated evaluation report"], "optional": [] },
11
+ "evidence_requirements": ["Report that the authorship component is a local train-only proxy, not an authorship verdict.", "Do not allow paragraph IDs in both train and test sets."],
12
+ "permissions": ["execute_commands", "read_repository"],
13
+ "stop_conditions": ["Stop when either fixture has fewer than two unique paragraph IDs or the JSON schema is invalid."],
14
+ "tool_free_mode": { "available": true, "behavior": "Provide the exact command and state that the report is NOT_RUN; never invent scores.", "unavailable_statuses": ["NOT_AVAILABLE", "NOT_CONFIGURED", "NOT_RUN", "STALE", "ERROR"] },
15
+ "handoff_to": ["hyv-verify"]
16
+ }