@holdyourvoice/hyv 2.9.27 → 3.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.
- package/LICENSE +17 -22
- package/Readme.md +247 -0
- package/dist/ai-editor.js +9 -0
- package/dist/ai-editor.test.js +61 -0
- package/dist/cli.js +106 -0
- package/dist/cli.test.js +119 -0
- package/dist/contracts.js +1 -0
- package/dist/pipeline.js +64 -0
- package/dist/pipeline.test.js +36 -0
- package/dist/release-audit.test.js +29 -0
- package/dist/text.js +42 -0
- package/dist/text.test.js +16 -0
- package/dist/voice-dna.js +77 -0
- package/dist/voice-dna.test.js +32 -0
- package/package.json +14 -74
- package/CHANGELOG.md +0 -235
- package/README.md +0 -218
- package/agents/AGENTS.md +0 -80
- package/agents/README.md +0 -20
- package/agents/chatgpt.md +0 -45
- package/agents/claude-code.md +0 -50
- package/agents/codex.md +0 -30
- package/agents/cursor.md +0 -35
- package/agents/generic.md +0 -47
- package/agents/windsurf.md +0 -19
- package/assets/FREE-PAID.md +0 -46
- package/assets/README.md +0 -20
- package/assets/ai-eliminator-rules.md +0 -140
- package/assets/chatgpt-instructions.txt +0 -8
- package/assets/detection-rules.json +0 -18
- package/assets/economic-drift-voice.md +0 -42
- package/assets/voice-dna-template.md +0 -88
- package/assets/voice-profile-schema.json +0 -28
- package/dist/index.js +0 -20909
- package/scripts/README.md +0 -21
- package/scripts/check-no-duplicates.js +0 -32
- package/scripts/install.ps1 +0 -101
- package/scripts/install.sh +0 -155
- package/scripts/postinstall-lib.js +0 -796
- package/scripts/postinstall.js +0 -35
- package/skills/README.md +0 -20
- package/skills/ai-writing-eliminator/SKILL.md +0 -63
- package/skills/hold-your-voice/SKILL.md +0 -174
- package/skills/voice-matcher/SKILL.md +0 -57
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { analyzeAiEditor } from './ai-editor.js';
|
|
2
|
+
import { analyzeVoiceDna } from './voice-dna.js';
|
|
3
|
+
import { words } from './text.js';
|
|
4
|
+
export function analyze(text, profile) {
|
|
5
|
+
const voiceDna = analyzeVoiceDna(text, profile);
|
|
6
|
+
const aiEditor = analyzeAiEditor(text);
|
|
7
|
+
return { version: '2', voiceDna, aiEditor, passed: voiceDna.passed && aiEditor.passed };
|
|
8
|
+
}
|
|
9
|
+
function formatFindings(findings) {
|
|
10
|
+
return findings.map((finding) => `- Sentence ${finding.sentence} [${finding.engine}/${finding.id}]: ${finding.reason} Repair: ${finding.suggestion}`);
|
|
11
|
+
}
|
|
12
|
+
export function rewritePrompt(draft, profile) {
|
|
13
|
+
const result = analyze(draft, profile);
|
|
14
|
+
const allFindings = [...result.voiceDna.findings, ...result.aiEditor.findings];
|
|
15
|
+
const redFindings = allFindings.filter((finding) => finding.severity === 'red');
|
|
16
|
+
const yellowFindings = allFindings.filter((finding) => finding.severity === 'yellow');
|
|
17
|
+
const metrics = profile.metrics;
|
|
18
|
+
return [
|
|
19
|
+
'# Tier 0 — non-negotiable preservation',
|
|
20
|
+
'Preserve facts, names, numbers, claims, and every unflagged sentence exactly. Do not add claims, examples, sections, hooks, or CTAs.',
|
|
21
|
+
'',
|
|
22
|
+
'# Tier 1 — release blockers',
|
|
23
|
+
`VoiceDNA: ${result.voiceDna.score}/100 (${result.voiceDna.passed ? 'pass' : 'fail'}).`,
|
|
24
|
+
`AI Editor: ${result.aiEditor.score}/100 (${result.aiEditor.passed ? 'pass' : 'fail'}).`,
|
|
25
|
+
...profile.avoid.map((phrase) => `- Never use: ${phrase}`),
|
|
26
|
+
...(redFindings.length ? formatFindings(redFindings) : ['- None.']),
|
|
27
|
+
'',
|
|
28
|
+
'# Tier 2 — VoiceDNA fidelity',
|
|
29
|
+
`- Sentence length: ${metrics.sentenceLength}; sentence variation: ${metrics.sentenceVariation}; sentence structure: ${metrics.sentenceStructure.join(', ') || 'none recorded'}; rhythm: ${metrics.rhythm}.`,
|
|
30
|
+
`- 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}.`,
|
|
31
|
+
`- Openings: ${metrics.openingMoves.join(', ') || 'none recorded'}.`,
|
|
32
|
+
`- Vocabulary: ${metrics.vocabulary.join(', ') || 'none recorded'}.`,
|
|
33
|
+
`- Transitions: ${metrics.transitions.join(', ') || 'none recorded'}.`,
|
|
34
|
+
'',
|
|
35
|
+
'# Tier 3 — AI Editor improvements',
|
|
36
|
+
...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
|
|
37
|
+
'',
|
|
38
|
+
'# Tier 4 — output contract',
|
|
39
|
+
'Return only replacement sentences keyed by sentence number. Do not rewrite clean sentences. The candidate will be checked again by both engines.',
|
|
40
|
+
'',
|
|
41
|
+
'# Draft',
|
|
42
|
+
draft,
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
function preservationScore(original, candidate) {
|
|
46
|
+
const baseline = new Set(words(original.toLowerCase()).filter((word) => word.length > 4));
|
|
47
|
+
const rewritten = new Set(words(candidate.toLowerCase()));
|
|
48
|
+
return baseline.size ? Math.round([...baseline].filter((word) => rewritten.has(word)).length / baseline.size * 100) : 100;
|
|
49
|
+
}
|
|
50
|
+
export function verify(original, candidate, profile) {
|
|
51
|
+
const baseline = analyze(original, profile);
|
|
52
|
+
const checked = analyze(candidate, profile);
|
|
53
|
+
const known = new Set([...baseline.voiceDna.findings, ...baseline.aiEditor.findings].map((finding) => `${finding.engine}:${finding.id}:${finding.sentence}`));
|
|
54
|
+
const regressions = [...checked.voiceDna.findings, ...checked.aiEditor.findings].filter((finding) => !known.has(`${finding.engine}:${finding.id}:${finding.sentence}`));
|
|
55
|
+
const preservation = preservationScore(original, candidate);
|
|
56
|
+
return {
|
|
57
|
+
version: '2',
|
|
58
|
+
original: baseline,
|
|
59
|
+
candidate: checked,
|
|
60
|
+
preservationScore: preservation,
|
|
61
|
+
regressions,
|
|
62
|
+
passed: checked.passed && !regressions.some((finding) => finding.severity === 'red') && preservation >= 70,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { analyze, rewritePrompt, verify } from './pipeline.js';
|
|
4
|
+
import { buildProfile } from './voice-dna.js';
|
|
5
|
+
const profile = buildProfile([
|
|
6
|
+
'I ship clear ideas. The details stay concrete. I explain the mechanism without fuss.',
|
|
7
|
+
'I write short sentences. Then I explain the mechanism. My work stays plain and specific.',
|
|
8
|
+
], ['leverage']);
|
|
9
|
+
test('keeps the two engine scores independent', () => {
|
|
10
|
+
const result = analyze('Firstly, we leverage a holistic strategy.', profile);
|
|
11
|
+
assert.equal(result.aiEditor.passed, false);
|
|
12
|
+
assert.equal(typeof result.voiceDna.score, 'number');
|
|
13
|
+
});
|
|
14
|
+
test('builds all thirteen VoiceDNA measurements', () => {
|
|
15
|
+
assert.deepEqual(Object.keys(profile.metrics), ['sentenceLength', 'sentenceVariation', 'sentenceStructure', 'rhythm', 'paragraphLength', 'openingMoves', 'vocabulary', 'lexicalDensity', 'pointOfView', 'punctuation', 'caseStyle', 'questionRate', 'transitions']);
|
|
16
|
+
});
|
|
17
|
+
test('orders rewrite instructions by importance tier', () => {
|
|
18
|
+
const prompt = rewritePrompt('Firstly, we leverage a holistic strategy.', profile);
|
|
19
|
+
assert.ok(prompt.indexOf('# Tier 0') < prompt.indexOf('# Tier 1'));
|
|
20
|
+
assert.ok(prompt.indexOf('# Tier 1') < prompt.indexOf('# Tier 2'));
|
|
21
|
+
assert.ok(prompt.indexOf('# Tier 2') < prompt.indexOf('# Tier 3'));
|
|
22
|
+
assert.ok(prompt.indexOf('# Tier 3') < prompt.indexOf('# Tier 4'));
|
|
23
|
+
});
|
|
24
|
+
test('post gate reports a new AI regression', () => {
|
|
25
|
+
const result = verify('I ship clear ideas.', 'I ship clear ideas — a game-changer.', profile);
|
|
26
|
+
assert.equal(result.passed, false);
|
|
27
|
+
assert.ok(result.regressions.length > 0);
|
|
28
|
+
assert.equal(result.original.aiEditor.passed, true);
|
|
29
|
+
assert.equal(result.candidate.aiEditor.passed, false);
|
|
30
|
+
});
|
|
31
|
+
test('puts all thirteen VoiceDNA elements in the rewrite brief', () => {
|
|
32
|
+
const prompt = rewritePrompt('I ship clear ideas.', profile);
|
|
33
|
+
for (const element of ['Sentence length', 'sentence variation', 'sentence structure', 'rhythm', 'Paragraph length', 'lexical density', 'point of view', 'punctuation', 'case style', 'question rate', 'Openings', 'Vocabulary', 'Transitions']) {
|
|
34
|
+
assert.match(prompt, new RegExp(element));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
const audit = new URL('../scripts/release-audit.mjs', import.meta.url).pathname;
|
|
8
|
+
function fixture(files) {
|
|
9
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-audit-'));
|
|
10
|
+
execFileSync('git', ['init', '--quiet'], { cwd: directory });
|
|
11
|
+
for (const [file, text] of Object.entries(files)) {
|
|
12
|
+
const path = join(directory, file);
|
|
13
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
14
|
+
writeFileSync(path, text);
|
|
15
|
+
}
|
|
16
|
+
execFileSync('git', ['add', 'README.md'], { cwd: directory });
|
|
17
|
+
return directory;
|
|
18
|
+
}
|
|
19
|
+
test('rejects unquoted credentials in an untracked source file', () => {
|
|
20
|
+
const directory = fixture({ 'README.md': '# public', 'src/unsafe.ts': ['const ', 'API_KEY', '=topsecret;'].join('') });
|
|
21
|
+
try {
|
|
22
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
23
|
+
assert.notEqual(result.status, 0);
|
|
24
|
+
assert.match(result.stderr, /credential marker: src\/unsafe\.ts/);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
rmSync(directory, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const words = (text) => text.match(/\p{L}[\p{L}\p{M}'’-]*/gu) ?? [];
|
|
2
|
+
export const mean = (values) => values.length ? values.reduce((total, value) => total + value, 0) / values.length : 0;
|
|
3
|
+
export const deviation = (values, average = mean(values)) => values.length ? Math.sqrt(mean(values.map((value) => (value - average) ** 2))) : 0;
|
|
4
|
+
export function sentences(text) {
|
|
5
|
+
const output = [];
|
|
6
|
+
const abbreviations = new Set(['dr', 'mr', 'mrs', 'ms', 'prof', 'sr', 'jr', 'vs', 'etc', 'fig', 'no', 'inc', 'ltd', 'co', 'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct', 'nov', 'dec']);
|
|
7
|
+
let start = 0;
|
|
8
|
+
const add = (end) => {
|
|
9
|
+
const raw = text.slice(start, end);
|
|
10
|
+
const value = raw.trim();
|
|
11
|
+
if (!words(value).length)
|
|
12
|
+
return;
|
|
13
|
+
const offset = start + raw.indexOf(value);
|
|
14
|
+
output.push({ index: output.length + 1, start: offset, end: offset + value.length, text: value });
|
|
15
|
+
};
|
|
16
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
17
|
+
const character = text[index];
|
|
18
|
+
if (character === '\n') {
|
|
19
|
+
add(index);
|
|
20
|
+
start = index + 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!'.!?'.includes(character))
|
|
24
|
+
continue;
|
|
25
|
+
if (character === '.') {
|
|
26
|
+
const previous = text[index - 1] ?? '';
|
|
27
|
+
const next = text[index + 1] ?? '';
|
|
28
|
+
const previousWord = text.slice(start, index).match(/(\p{L}+)$/u)?.[1]?.toLowerCase();
|
|
29
|
+
if ((/\d/.test(previous) && /\d/.test(next)) || /\p{L}/u.test(next) || (previousWord && abbreviations.has(previousWord)))
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
let end = index + 1;
|
|
33
|
+
while (end < text.length && '.!?'.includes(text[end]))
|
|
34
|
+
end += 1;
|
|
35
|
+
add(end);
|
|
36
|
+
start = end;
|
|
37
|
+
index = end - 1;
|
|
38
|
+
}
|
|
39
|
+
add(text.length);
|
|
40
|
+
return output;
|
|
41
|
+
}
|
|
42
|
+
export const paragraphs = (text) => text.split(/\n\s*\n/).map((part) => part.trim()).filter(Boolean);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { sentences, words } from './text.js';
|
|
4
|
+
test('keeps decimals and common abbreviations inside a sentence', () => {
|
|
5
|
+
assert.deepEqual(sentences('Dr. Shah raised $3.5m. It funded the work.').map((sentence) => sentence.text), ['Dr. Shah raised $3.5m.', 'It funded the work.']);
|
|
6
|
+
});
|
|
7
|
+
test('records exact sentence offsets across newlines', () => {
|
|
8
|
+
const text = 'First line\nSecond line.';
|
|
9
|
+
assert.deepEqual(sentences(text), [
|
|
10
|
+
{ index: 1, start: 0, end: 10, text: 'First line' },
|
|
11
|
+
{ index: 2, start: 11, end: 23, text: 'Second line.' },
|
|
12
|
+
]);
|
|
13
|
+
});
|
|
14
|
+
test('counts Unicode writing as words', () => {
|
|
15
|
+
assert.deepEqual(words('café नमस्ते दुनिया'), ['café', 'नमस्ते', 'दुनिया']);
|
|
16
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { deviation, mean, paragraphs, sentences, words } from './text.js';
|
|
2
|
+
const STOP_WORDS = new Set(['the', 'and', 'that', 'with', 'this', 'from', 'your', 'have', 'were', 'they', 'will', 'into', 'about', 'what', 'when', 'where']);
|
|
3
|
+
const TRANSITIONS = ['but', 'because', 'instead', 'then', 'still', 'so', 'yet', 'therefore'];
|
|
4
|
+
function top(items, limit) {
|
|
5
|
+
const counts = new Map();
|
|
6
|
+
for (const item of items.filter(Boolean))
|
|
7
|
+
counts.set(item, (counts.get(item) ?? 0) + 1);
|
|
8
|
+
return [...counts].sort((left, right) => right[1] - left[1]).slice(0, limit).map(([item]) => item);
|
|
9
|
+
}
|
|
10
|
+
function profileMetrics(text) {
|
|
11
|
+
const draftSentences = sentences(text);
|
|
12
|
+
const draftWords = words(text);
|
|
13
|
+
const lengths = draftSentences.map((sentence) => words(sentence.text).length);
|
|
14
|
+
const starters = draftSentences.map((sentence) => words(sentence.text)[0]?.toLowerCase() ?? '');
|
|
15
|
+
const structures = draftSentences.map((sentence) => words(sentence.text).slice(0, 3).map((word) => word.toLowerCase()).join(' '));
|
|
16
|
+
const lowerWords = draftWords.map((word) => word.toLowerCase());
|
|
17
|
+
const nonStop = lowerWords.filter((word) => word.length > 3 && !STOP_WORDS.has(word));
|
|
18
|
+
const first = lowerWords.filter((word) => ['i', 'we', 'my', 'our', 'us'].includes(word)).length;
|
|
19
|
+
const second = lowerWords.filter((word) => ['you', 'your', 'yours'].includes(word)).length;
|
|
20
|
+
const third = lowerWords.filter((word) => ['he', 'she', 'they', 'their', 'them'].includes(word)).length;
|
|
21
|
+
const pov = first > second && first > third ? 'first_person' : second > first && second > third ? 'second_person' : third > first && third > second ? 'third_person' : 'mixed';
|
|
22
|
+
const paragraphSizes = paragraphs(text).map((paragraph) => sentences(paragraph).length);
|
|
23
|
+
const punctuation = Object.fromEntries(['!', '?', ';', ':', '—'].map((mark) => [mark, (text.match(new RegExp(mark.replace(/[?*+^$.[\]\\(){}|-]/g, '\\$&'), 'g')) ?? []).length]));
|
|
24
|
+
const sentenceLength = mean(lengths);
|
|
25
|
+
const sentenceVariation = deviation(lengths, sentenceLength);
|
|
26
|
+
const rhythm = lengths.length > 1 ? mean(lengths.slice(1).map((length, index) => Math.abs(length - lengths[index]))) : 0;
|
|
27
|
+
const uppercaseStarts = draftSentences.filter((sentence) => /^\p{Lu}/u.test(sentence.text)).length;
|
|
28
|
+
const caseStyle = uppercaseStarts / Math.max(1, draftSentences.length) > 0.8 ? 'standard' : uppercaseStarts === 0 ? 'lowercase' : 'mixed';
|
|
29
|
+
return {
|
|
30
|
+
sentenceLength: Number(sentenceLength.toFixed(2)),
|
|
31
|
+
sentenceVariation: Number(sentenceVariation.toFixed(2)),
|
|
32
|
+
sentenceStructure: top(structures, 8),
|
|
33
|
+
rhythm: Number(rhythm.toFixed(2)),
|
|
34
|
+
paragraphLength: Number(mean(paragraphSizes).toFixed(2)),
|
|
35
|
+
openingMoves: top(starters, 8),
|
|
36
|
+
vocabulary: top(nonStop, 20),
|
|
37
|
+
lexicalDensity: Number((nonStop.length / Math.max(1, lowerWords.length)).toFixed(3)),
|
|
38
|
+
pointOfView: pov,
|
|
39
|
+
punctuation,
|
|
40
|
+
caseStyle,
|
|
41
|
+
questionRate: Number((draftSentences.filter((sentence) => sentence.text.endsWith('?')).length / Math.max(1, draftSentences.length)).toFixed(3)),
|
|
42
|
+
transitions: top(lowerWords.filter((word) => TRANSITIONS.includes(word)), 8),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function buildProfile(samples, avoid = []) {
|
|
46
|
+
if (samples.length < 2)
|
|
47
|
+
throw new Error('Provide at least two local writing samples.');
|
|
48
|
+
if (samples.some((sample) => !words(sample).length))
|
|
49
|
+
throw new Error('Every local writing sample must contain writing.');
|
|
50
|
+
return { version: '2', sampleCount: samples.length, metrics: profileMetrics(samples.join('\n\n')), avoid };
|
|
51
|
+
}
|
|
52
|
+
function finding(id, severity, sentence, excerpt, reason, suggestion) {
|
|
53
|
+
return { engine: 'voice_dna', id, severity, sentence, excerpt, reason, suggestion };
|
|
54
|
+
}
|
|
55
|
+
export function analyzeVoiceDna(text, profile) {
|
|
56
|
+
const metrics = profileMetrics(text);
|
|
57
|
+
const findings = [];
|
|
58
|
+
const tolerance = Math.max(8, profile.metrics.sentenceVariation * 2.2);
|
|
59
|
+
for (const sentence of sentences(text)) {
|
|
60
|
+
const count = words(sentence.text).length;
|
|
61
|
+
if (Math.abs(count - profile.metrics.sentenceLength) > tolerance)
|
|
62
|
+
findings.push(finding('dna.sentence-length', 'yellow', sentence.index, sentence.text, `Sentence length (${count}) is outside the profile band around ${profile.metrics.sentenceLength}.`, 'Restore the writer’s usual sentence length where it improves clarity.'));
|
|
63
|
+
for (const banned of profile.avoid)
|
|
64
|
+
if (sentence.text.toLowerCase().includes(banned.toLowerCase()))
|
|
65
|
+
findings.push(finding('dna.avoid-list', 'red', sentence.index, sentence.text, `Uses profile avoid-list phrase: ${banned}.`, 'Replace it with the writer’s natural language.'));
|
|
66
|
+
}
|
|
67
|
+
if (Math.abs(metrics.questionRate - profile.metrics.questionRate) > 0.25)
|
|
68
|
+
findings.push(finding('dna.question-rate', 'yellow', 1, text.slice(0, 160), 'Question frequency differs materially from the profile.', 'Match the writer’s usual use of questions.'));
|
|
69
|
+
if (metrics.caseStyle !== profile.metrics.caseStyle)
|
|
70
|
+
findings.push(finding('dna.case-style', 'yellow', 1, text.slice(0, 160), `Draft uses ${metrics.caseStyle} casing; profile uses ${profile.metrics.caseStyle}.`, 'Use the profile’s normal casing.'));
|
|
71
|
+
if (metrics.pointOfView !== profile.metrics.pointOfView && profile.metrics.pointOfView !== 'mixed')
|
|
72
|
+
findings.push(finding('dna.point-of-view', 'yellow', 1, text.slice(0, 160), `Draft point of view is ${metrics.pointOfView}; profile is ${profile.metrics.pointOfView}.`, 'Restore the writer’s normal narrative distance.'));
|
|
73
|
+
const red = findings.filter((item) => item.severity === 'red').length;
|
|
74
|
+
const yellow = findings.length - red;
|
|
75
|
+
const score = Math.max(0, 100 - red * 25 - yellow * 7);
|
|
76
|
+
return { engine: 'voice_dna', version: '2', score, passed: red === 0, findings };
|
|
77
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { analyzeVoiceDna, buildProfile } from './voice-dna.js';
|
|
4
|
+
test('requires two local samples before creating a profile', () => {
|
|
5
|
+
assert.throws(() => buildProfile(['One sample is not enough.']), /at least two local writing samples/);
|
|
6
|
+
});
|
|
7
|
+
test('requires every local sample to contain writing', () => {
|
|
8
|
+
assert.throws(() => buildProfile(['', ' ']), /must contain writing/);
|
|
9
|
+
});
|
|
10
|
+
test('reports local avoid-list matches as red sentence findings', () => {
|
|
11
|
+
const profile = buildProfile(['i write plainly. i name the work.', 'i keep the mechanism clear. i avoid filler.'], ['unlock']);
|
|
12
|
+
const report = analyzeVoiceDna('i unlock the answer. i name the work.', profile);
|
|
13
|
+
assert.deepEqual(report.findings.filter((finding) => finding.id === 'dna.avoid-list').map((finding) => [finding.severity, finding.sentence]), [['red', 1]]);
|
|
14
|
+
assert.equal(report.passed, false);
|
|
15
|
+
});
|
|
16
|
+
test('keeps a case-style mismatch as a yellow review cue', () => {
|
|
17
|
+
const profile = buildProfile(['i write plainly. i name the work.', 'i keep the mechanism clear. i avoid filler.']);
|
|
18
|
+
const report = analyzeVoiceDna('This sentence starts in standard case.', profile);
|
|
19
|
+
assert.ok(report.findings.some((finding) => finding.id === 'dna.case-style' && finding.severity === 'yellow'));
|
|
20
|
+
assert.equal(report.passed, true);
|
|
21
|
+
});
|
|
22
|
+
test('keeps the documented VoiceDNA review checks as yellow findings', () => {
|
|
23
|
+
const profile = buildProfile([
|
|
24
|
+
'i write short sentences. i ask no questions. i name the mechanism.',
|
|
25
|
+
'i keep the language plain. i explain the work. i use first person.',
|
|
26
|
+
]);
|
|
27
|
+
const report = analyzeVoiceDna('You explain a much longer sentence with several extra words so the reader can follow the full mechanism in detail. Do you agree?', profile);
|
|
28
|
+
for (const id of ['dna.sentence-length', 'dna.question-rate', 'dna.point-of-view']) {
|
|
29
|
+
assert.ok(report.findings.some((finding) => finding.id === id && finding.severity === 'yellow'), id);
|
|
30
|
+
}
|
|
31
|
+
assert.equal(report.passed, true);
|
|
32
|
+
});
|
package/package.json
CHANGED
|
@@ -1,77 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holdyourvoice/hyv",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
},
|
|
10
|
-
"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"prepare": "npm run build",
|
|
18
|
-
"test": "npm run build && vitest run",
|
|
19
|
-
"test:smoke": "npm run build && bash scripts/smoke-test.sh",
|
|
20
|
-
"test:watch": "vitest",
|
|
21
|
-
"release:patch": "npm version patch && npm publish --access public",
|
|
22
|
-
"release:minor": "npm version minor && npm publish --access public",
|
|
23
|
-
"release:major": "npm version major && npm publish --access public"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"voice",
|
|
27
|
-
"writing",
|
|
28
|
-
"ai",
|
|
29
|
-
"cli",
|
|
30
|
-
"brand-voice",
|
|
31
|
-
"content-gate",
|
|
32
|
-
"hyv",
|
|
33
|
-
"cursor",
|
|
34
|
-
"claude",
|
|
35
|
-
"mcp",
|
|
36
|
-
"ai-agent",
|
|
37
|
-
"ai-writing",
|
|
38
|
-
"voice-profile"
|
|
39
|
-
],
|
|
40
|
-
"author": "Hold Your Voice",
|
|
41
|
-
"license": "UNLICENSED",
|
|
42
|
-
"private": false,
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"canvas": "^3.2.3",
|
|
45
|
-
"chalk": "^4.1.2",
|
|
46
|
-
"commander": "^12.1.0",
|
|
47
|
-
"glob": "^13.0.6",
|
|
48
|
-
"open": "^8.4.2"
|
|
49
|
-
},
|
|
50
|
-
"devDependencies": {
|
|
51
|
-
"@types/node": "^20.19.42",
|
|
52
|
-
"esbuild": "^0.20.0",
|
|
53
|
-
"typescript": "^5.3.3",
|
|
54
|
-
"vitest": "^2.0.0"
|
|
55
|
-
},
|
|
56
|
-
"engines": {
|
|
57
|
-
"node": ">=18"
|
|
58
|
-
},
|
|
59
|
-
"files": [
|
|
60
|
-
"dist/",
|
|
61
|
-
"scripts/postinstall.js",
|
|
62
|
-
"scripts/postinstall-lib.js",
|
|
63
|
-
"scripts/install.sh",
|
|
64
|
-
"scripts/install.ps1",
|
|
65
|
-
"scripts/check-no-duplicates.js",
|
|
66
|
-
"assets/",
|
|
67
|
-
"skills/",
|
|
68
|
-
"agents/",
|
|
69
|
-
"README.md",
|
|
70
|
-
"CHANGELOG.md",
|
|
71
|
-
"LICENSE"
|
|
72
|
-
],
|
|
73
|
-
"repository": {
|
|
74
|
-
"type": "git",
|
|
75
|
-
"url": "git+https://github.com/shashank-sn/hold-your-voice-app.git"
|
|
76
|
-
}
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "A local-first dual-engine writing gate that protects voice and catches generic AI patterns.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "hyv": "dist/cli.js" },
|
|
7
|
+
"files": ["dist", "Readme.md", "LICENSE"],
|
|
8
|
+
"scripts": { "build": "tsc -p tsconfig.json", "test": "npm run build && node --test dist/**/*.test.js", "check:release": "node scripts/release-audit.mjs", "prepack": "npm run check:release && npm test" },
|
|
9
|
+
"engines": { "node": ">=20" },
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"keywords": ["ai-writing", "cli", "editing", "voice", "writing"],
|
|
12
|
+
"repository": { "type": "git", "url": "git+https://github.com/shashank-sn/holdyourvoice.git" },
|
|
13
|
+
"bugs": { "url": "https://github.com/shashank-sn/holdyourvoice/issues" },
|
|
14
|
+
"homepage": "https://github.com/shashank-sn/holdyourvoice#readme",
|
|
15
|
+
"publishConfig": { "access": "public" },
|
|
16
|
+
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.7.0" }
|
|
77
17
|
}
|
package/CHANGELOG.md
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
# Changelog — @holdyourvoice/hyv
|
|
2
|
-
|
|
3
|
-
All notable CLI changes. Also mirrored to [holdyourvoice.com/changelog](https://holdyourvoice.com/changelog) for user-facing releases.
|
|
4
|
-
|
|
5
|
-
## [2.9.27] — 2026-07-15
|
|
6
|
-
|
|
7
|
-
### Fixed
|
|
8
|
-
- **MCP scan hang** — `hyv_scan`, `hyv_fix`, `hyv_check`, `hyv_score`, `hyv_diff`, and `hyv_validate` no longer block on account profile sync; they use local `~/.hyv` cache immediately (matching `hyv scan` CLI) and hydrate profiles in the background
|
|
9
|
-
|
|
10
|
-
## [2.9.26] — 2026-07-09
|
|
11
|
-
|
|
12
|
-
### Improved
|
|
13
|
-
- **Tier 1 structural detection** — cross-sentence binary reframes (`this is not X. it is Y.`), sermon anaphora, and founder-carousel metaphors now sort first in scan output and rewrite prompts
|
|
14
|
-
- **Rewrite prompts** split tier 1 structural hits vs other patterns so the model fixes architecture before vocabulary slop
|
|
15
|
-
- **18 new structural cousin rules** synced in local scan engine (`scan.ts`, `tier-one.ts`)
|
|
16
|
-
|
|
17
|
-
## [2.9.25] — 2026-07-04
|
|
18
|
-
|
|
19
|
-
### Fixed
|
|
20
|
-
- **MCP stdio pollution** — `hyv mcp` no longer prints setup banners to stdout when spawned by Cursor/Claude/ChatGPT (fixes "Unexpected token … is not valid JSON" toast spam on app open)
|
|
21
|
-
- Auto-detects piped stdout (`!isTTY`) or explicit `--stdio` flag; MCP configs now use `mcp --stdio`
|
|
22
|
-
- Interactive terminal runs still show setup on stderr before the JSON-RPC server starts
|
|
23
|
-
|
|
24
|
-
## [2.9.15] — 2026-06-18
|
|
25
|
-
|
|
26
|
-
### Added
|
|
27
|
-
- `mcp-profile-hydrate` — syncs voice profile into MCP agent configs after setup
|
|
28
|
-
- `mcp-integrate` — writes MCP server entries for cursor, claude, codex, windsurf, and more
|
|
29
|
-
- Postinstall detects installed agents and suggests `hyv mcp --setup`
|
|
30
|
-
|
|
31
|
-
### Fixed
|
|
32
|
-
- `hyv mcp --test` validates 13 setup checks including ChatGPT actions
|
|
33
|
-
- Agent docs updated for one-command MCP setup across all supported editors
|
|
34
|
-
|
|
35
|
-
## [2.9.11] — 2026-06-12
|
|
36
|
-
|
|
37
|
-
### Fixed
|
|
38
|
-
- Data safety: preserve profiles with voice anchors, learned patterns, or dashboard-edited markdown
|
|
39
|
-
- Welcome sync accepts `force: true` to intentionally overwrite preserved server profiles
|
|
40
|
-
- `hyv sync` keeps newer local profiles (uses `updated_at` metadata); `--force` overwrites
|
|
41
|
-
- JSON profile cache (`.hyv/cache/profiles/*.json`) backs up before overwrite; skips stale server snapshots
|
|
42
|
-
- `~/.hyv/auth.json` backed up to `.hyv.bak` before each write
|
|
43
|
-
- Web login: fixed email `body` ReferenceError in strict mode; Google sign-in warms `/ready` and retries on 503
|
|
44
|
-
- Legacy `dashboard.html` email login fixed (`user` response, OTP `code` field, cold-start retries)
|
|
45
|
-
- Static copy/sitemap: `holdyourvoice.com/dashboard` → `/app`
|
|
46
|
-
|
|
47
|
-
## [2.9.10] — 2026-06-12
|
|
48
|
-
|
|
49
|
-
### Fixed
|
|
50
|
-
- **Data safety** — welcome profile sync no longer overwrites enriched dashboard profiles (keywords, signature, rules, flashcard onboarding)
|
|
51
|
-
- Local profile cache backs up to `.hyv.bak` before overwrite; refuses empty content replacing real data
|
|
52
|
-
- `hyv sync` skips empty server profile payloads instead of wiping local cache
|
|
53
|
-
|
|
54
|
-
## [2.9.9] — 2026-06-12
|
|
55
|
-
|
|
56
|
-
### Fixed
|
|
57
|
-
- Welcome step 4 failure copy — says `hyv welcome` retry, not `hyv sync` (sync requires paid plan)
|
|
58
|
-
- CLI plan/init/export/api messages point to `/app` and `/app/billing`, not `/dashboard`
|
|
59
|
-
- Rewrite prompts no longer silently pick a cached profile when none is passed (generic rules as intended)
|
|
60
|
-
- Web auth forgot-password and session check retry on cold-start 503s
|
|
61
|
-
- Marketing email links point to `/app` not `/dashboard`
|
|
62
|
-
|
|
63
|
-
## [2.9.7] — 2026-06-12
|
|
64
|
-
|
|
65
|
-
### Fixed
|
|
66
|
-
- `hyv sync` — profiles with spaces or missing slugs (e.g. "Say About Us") no longer crash with "Invalid profile name"
|
|
67
|
-
- Welcome step 4 — clearer sync failure messages (HTTP status / server error)
|
|
68
|
-
|
|
69
|
-
## [2.9.6] — 2026-06-12
|
|
70
|
-
|
|
71
|
-
### Fixed
|
|
72
|
-
- Welcome profile sync — server accepts `content` markdown from step 4 (upsert, no duplicate-profile 403)
|
|
73
|
-
- Dashboard handoff — opens `/app/billing` with session cookie; falls back to billing URL if handoff fails
|
|
74
|
-
- Edge worker handles `/cli/auth/web-handoff` (no container lag)
|
|
75
|
-
|
|
76
|
-
### Changed
|
|
77
|
-
- `hyv open dashboard` and paid-feature copy point to `/app` not `/dashboard`
|
|
78
|
-
|
|
79
|
-
## [2.9.5] — 2026-06-12
|
|
80
|
-
|
|
81
|
-
### Fixed
|
|
82
|
-
- Post-signup browser opens `/app/billing` (live dashboard) instead of `/dashboard` (404)
|
|
83
|
-
- Worker redirects `/dashboard` → `/app` for old links
|
|
84
|
-
|
|
85
|
-
## [2.9.4] — 2026-06-12
|
|
86
|
-
|
|
87
|
-
### Fixed
|
|
88
|
-
- Welcome step 4 — one sign-in only; opens dashboard billing tab (no second Google login on marketing site)
|
|
89
|
-
- `hyv plan --upgrade` opens authenticated dashboard billing instead of public pricing page
|
|
90
|
-
|
|
91
|
-
### Changed
|
|
92
|
-
- Step 4 copy tightened; spinners while account + profile sync run
|
|
93
|
-
|
|
94
|
-
## [2.9.3] — 2026-06-12
|
|
95
|
-
|
|
96
|
-
### Fixed
|
|
97
|
-
- Browser signup OAuth — clearer error when Google denies; server fix for `redirect_uri_mismatch` (requires API deploy)
|
|
98
|
-
|
|
99
|
-
## [2.9.2] — 2026-06-12
|
|
100
|
-
|
|
101
|
-
### Changed
|
|
102
|
-
- Welcome step 4 copy — warmer signup pitch, highlights $1 first month and what paid unlocks
|
|
103
|
-
|
|
104
|
-
## [2.9.1] — 2026-06-12
|
|
105
|
-
|
|
106
|
-
### Added
|
|
107
|
-
- Welcome step 3 is skippable (Enter or `s`)
|
|
108
|
-
- Multiline draft paste for testing (no more single-line shell chaos)
|
|
109
|
-
- Document extraction for samples and scans: `.md`, `.txt`, `.html`, `.docx`, `.pdf`, `.skill`
|
|
110
|
-
- Recursive folder scan; single files (including `.skill` archives) work in step 2
|
|
111
|
-
- Minimal terminal spinners while reading, saving, and scanning
|
|
112
|
-
|
|
113
|
-
### Fixed
|
|
114
|
-
- Step 3 crash when scanning pasted text (`Cannot read properties of undefined (reading 'match')`) — profile now loads correctly
|
|
115
|
-
- `ENOTDIR` when pointing at a file instead of a folder
|
|
116
|
-
|
|
117
|
-
## [2.9.0] — 2026-06-12
|
|
118
|
-
|
|
119
|
-
### Changed
|
|
120
|
-
- `hyv welcome` — profile-first flow: name → samples (paste/folder/link/chat) → test draft → signup
|
|
121
|
-
- Keeps tagline; drops install/init/mcp step list from welcome
|
|
122
|
-
- MCP `hyv_welcome` supports `step`, `mode=extract_prompt`, and `profile` for agent-led onboarding
|
|
123
|
-
- Postinstall points to `hyv welcome` instead of `hyv init`
|
|
124
|
-
|
|
125
|
-
## [2.8.10] — 2026-06-12
|
|
126
|
-
|
|
127
|
-
### Fixed
|
|
128
|
-
- `hyv init` browser login opens Google OAuth again (`assertSafeOAuthUrl` allows `accounts.google.com`)
|
|
129
|
-
|
|
130
|
-
## [2.8.9] — 2026-06-12
|
|
131
|
-
|
|
132
|
-
### Improved
|
|
133
|
-
- `hyv fix --in-place` matches batch/watch safeguards: interactive `[y/N]` or `--yes` + `.bak` backup
|
|
134
|
-
- `hyv watch --command fix` ignores self-triggered saves after writing fixes
|
|
135
|
-
- `hyv doctor` checks auth.json permissions, stale `hyv.md` vs `hyv.mdc`, and MCP stdio health
|
|
136
|
-
- Shared `resolveCliEntry()` for MCP setup and stdio probes
|
|
137
|
-
- Auth refresh + destructive-write unit tests
|
|
138
|
-
|
|
139
|
-
### Changed
|
|
140
|
-
- `dist/` built at publish/prepare — no longer committed to git
|
|
141
|
-
|
|
142
|
-
## [2.8.8] — 2026-06-12
|
|
143
|
-
|
|
144
|
-
### Security
|
|
145
|
-
- OAuth browser flow verifies server `state` on callback (CSRF protection)
|
|
146
|
-
- Automatic token refresh via `getValidToken()` before API calls
|
|
147
|
-
- `HYV_API_URL` host allowlist; `assertSafeOpenUrl` for checkout/OAuth redirects
|
|
148
|
-
- Profile name path traversal blocked; MCP file reads symlink-safe with `isError` responses
|
|
149
|
-
- Sensitive `~/.hyv` files and dirs use `0o600`/`0o700` permissions
|
|
150
|
-
|
|
151
|
-
### Fixed
|
|
152
|
-
- `hyv doctor --fix-agents` uses correct postinstall-lib path
|
|
153
|
-
- `hyv import` registered; `--fail-on-hit` exits code 2 (scan + batch)
|
|
154
|
-
- Queue sync sends `original_text` / `accepted_text` for learning reinforce
|
|
155
|
-
- Postinstall: Claude Desktop MCP merge with backup, Cursor `alwaysApply` rule, absolute MCP command path
|
|
156
|
-
|
|
157
|
-
### Improved
|
|
158
|
-
- `hyv mcp --test` spawns stdio subprocess and validates JSON-RPC `tools/list`
|
|
159
|
-
- `hyv batch --fix --in-place` and `hyv watch --command fix` require `--yes` (`.bak` backups)
|
|
160
|
-
- Stale duplicate agent/skill markdown removed from `assets/` (canonical copies in `agents/` + `skills/`)
|
|
161
|
-
|
|
162
|
-
## [2.8.6] — 2026-06-12
|
|
163
|
-
|
|
164
|
-
### Changed
|
|
165
|
-
- `hyv welcome` — short onboarding: 4 steps, one-line demo, subscribe prompt (full lists moved to `hyv free`)
|
|
166
|
-
- Shorter postinstall message before auto-welcome
|
|
167
|
-
|
|
168
|
-
## [2.8.5] — 2026-06-12
|
|
169
|
-
|
|
170
|
-
### Changed
|
|
171
|
-
- `hyv welcome` slimmed down (superseded by 2.8.6 — use `npm i -g @holdyourvoice/hyv@2.8.6`)
|
|
172
|
-
|
|
173
|
-
## [2.8.4] — 2026-06-12
|
|
174
|
-
|
|
175
|
-
### Fixed
|
|
176
|
-
- Postinstall only auto-runs welcome when npm shows script output; otherwise first bare `hyv` shows full onboarding (fixes silent global installs)
|
|
177
|
-
|
|
178
|
-
## [2.8.3] — 2026-06-12
|
|
179
|
-
|
|
180
|
-
### Fixed
|
|
181
|
-
- `npm i -g` runs `hyv welcome` onboarding automatically on first install (no y/N prompt)
|
|
182
|
-
- First bare `hyv` shows full onboarding when npm hid postinstall output
|
|
183
|
-
- Upgraded `glob` to v13 — removes deprecated glob@11 warning during install
|
|
184
|
-
|
|
185
|
-
## [2.8.2] — 2026-06-12
|
|
186
|
-
|
|
187
|
-
### Added
|
|
188
|
-
- Phase 5 marketing: `hyv content` (blog outlines, CI snippets, share templates)
|
|
189
|
-
- `hyv plan --free` — canonical free vs paid matrix in terminal
|
|
190
|
-
- `cli/assets/FREE-PAID.md` — shipped free/paid reference
|
|
191
|
-
- Scan funnel hints + `scan_complete` / `init_success` telemetry events
|
|
192
|
-
- Landing + npm README: npx-first install, free CLI section, SEO keywords
|
|
193
|
-
|
|
194
|
-
### Improved
|
|
195
|
-
- Postinstall, welcome, plan, and init flows promote npx + free local tier
|
|
196
|
-
- npm package description optimized for cursor/claude/MCP discovery
|
|
197
|
-
|
|
198
|
-
## [2.8.1] — 2026-06-12
|
|
199
|
-
|
|
200
|
-
### Fixed
|
|
201
|
-
- `postinstall-lib.js` now included in published tarball — `npm install` no longer fails on postinstall
|
|
202
|
-
|
|
203
|
-
## [2.8.0] — 2026-06-12
|
|
204
|
-
|
|
205
|
-
### Added
|
|
206
|
-
- Full Phase 1–4 product plan: free local engine, hybrid analysis, MCP `hyv_analyze` / `hyv_clean`, learning loop UX, packaging hardening
|
|
207
|
-
- `npm run test:smoke` — 28+ CLI regression checks
|
|
208
|
-
- `npm run validate:publish` — prepublish tarball verification
|
|
209
|
-
- Golden prompt tests, postinstall idempotency tests, edge-case suite
|
|
210
|
-
|
|
211
|
-
### Fixed
|
|
212
|
-
- `hyv check ""` no longer reports false clean — rejects empty input
|
|
213
|
-
- Bundled CLI resolves correct package + rules version from `dist/`
|
|
214
|
-
- Smoke tests avoid SIGPIPE false failures when piping CLI output
|
|
215
|
-
|
|
216
|
-
## [2.7.1] — 2026-06-12
|
|
217
|
-
|
|
218
|
-
### Added
|
|
219
|
-
- `hyv upgrade` — check and install latest global CLI
|
|
220
|
-
- `hyv mcp --setup` and `hyv mcp --test` — agent setup + health check
|
|
221
|
-
- MCP tools: `hyv_analyze` (hybrid server+local), `hyv_clean` (scan→fix→validate pipeline)
|
|
222
|
-
- `hyv reinforce --last` — learn from last fix/rewrite session
|
|
223
|
-
- Free-first local engine: scan, fix, check, score, diff work offline without subscription
|
|
224
|
-
- `hyv welcome` / `hyv free` — onboarding with live demo
|
|
225
|
-
- Profile-aware local pipeline: never-list, learned patterns, anchors, cadence
|
|
226
|
-
- Opt-in telemetry: `HYV_TELEMETRY=1`
|
|
227
|
-
|
|
228
|
-
### Improved
|
|
229
|
-
- Postinstall upgrades agent instructions via `~/.hyv/agents-version.json`
|
|
230
|
-
- `hyv status` shows engine/rules version, drift, evolution summary
|
|
231
|
-
- `hyv doctor` reports full engine label + `--fix-agents`
|
|
232
|
-
- Published package excludes Python dev scripts and `src/`
|
|
233
|
-
|
|
234
|
-
### Fixed
|
|
235
|
-
- Bundled CLI reads correct `package.json` version from `dist/`
|