@holdyourvoice/hyv 3.0.2 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +80 -15
- package/dist/cli.js +116 -11
- package/dist/cli.test.js +117 -12
- package/dist/copy-spec.js +48 -0
- package/dist/editorial-packs.js +102 -0
- package/dist/editorial-packs.test.js +49 -0
- package/dist/learning.js +211 -0
- package/dist/learning.test.js +125 -0
- package/dist/mcp-tools.js +46 -7
- package/dist/mcp-tools.test.js +40 -5
- package/dist/mcp.js +62 -12
- package/dist/mcp.test.js +51 -3
- package/dist/pipeline.js +29 -11
- package/dist/pipeline.test.js +45 -1
- package/dist/release-audit.test.js +16 -0
- package/dist/rewrite-task.js +154 -0
- package/dist/rewrite-task.test.js +82 -0
- package/dist/semantic-review.js +20 -0
- package/dist/semantic-review.test.js +17 -0
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -55,18 +55,7 @@ To contribute, clone this repository, run `npm install`, then run `npm test` and
|
|
|
55
55
|
|
|
56
56
|
### Use it in Claude Desktop
|
|
57
57
|
|
|
58
|
-
Build the fully local Claude Desktop extension with `npm run pack:claude`, then install `dist/hold-your-voice.mcpb` from **Settings → Extensions → Advanced settings → Install Extension**. The extension accepts text and portable profile JSON in the current conversation only.
|
|
59
|
-
|
|
60
|
-
### Use it in Claude Code
|
|
61
|
-
|
|
62
|
-
Hold Your Voice is also a free Claude Code plugin. It starts the same local MCP server through the public npm package; drafts, samples, and profiles stay on your machine.
|
|
63
|
-
|
|
64
|
-
```text
|
|
65
|
-
/plugin marketplace add shashank-sn/holdyourvoice
|
|
66
|
-
/plugin install hold-your-voice@hold-your-voice
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
It requires Node.js 20 or newer and npm. See the [Claude Code guide](docs/CLAUDE-CODE.md).
|
|
58
|
+
Build the fully local Claude Desktop extension with `npm run pack:claude`, then install `dist/hold-your-voice.mcpb` from **Settings → Extensions → Advanced settings → Install Extension**. The extension accepts text and portable profile JSON in the current conversation only. A successful verification records resolved finding IDs in local learning state; it never retains writing text or makes network requests. See the [Claude Desktop guide](docs/CLAUDE-DESKTOP.md).
|
|
70
59
|
|
|
71
60
|
### Build a local VoiceDNA profile
|
|
72
61
|
|
|
@@ -94,6 +83,38 @@ The result is JSON with independent reports:
|
|
|
94
83
|
|
|
95
84
|
Read both reports. The outer `passed` field means each engine passed. Scores remain independent.
|
|
96
85
|
|
|
86
|
+
### Add contextual editorial guidance
|
|
87
|
+
|
|
88
|
+
Use an optional local WritingBrief when the same writer needs different guidance for a social post, deck, outreach note, blog, audit, or website. A brief activates only the relevant advisory format checks and can block explicitly prohibited local terms. It never changes your VoiceDNA profile or the default two-engine analysis.
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"version": "1",
|
|
93
|
+
"audience": "technical founders",
|
|
94
|
+
"intent": "start a useful discussion",
|
|
95
|
+
"format": "social",
|
|
96
|
+
"readerKnowsAuthor": false,
|
|
97
|
+
"vocabulary": ["deployment", "incident"],
|
|
98
|
+
"prohibitedTerms": ["internal contract value"]
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
hyv analyze draft.md profile.json writing-brief.json
|
|
104
|
+
hyv rewrite-prompt draft.md profile.json writing-brief.json > rewrite-brief.md
|
|
105
|
+
hyv verify original.md candidate.md profile.json writing-brief.json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Format checks are yellow review cues. Explicit `prohibitedTerms` are red release blockers. Keep client-specific briefs outside public repositories unless you have the right to publish them.
|
|
109
|
+
|
|
110
|
+
### Inspect a batch
|
|
111
|
+
|
|
112
|
+
Use batch analysis to catch exact repeated opening or closing sentences across two or more drafts. It is advisory and keeps all drafts local.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
hyv batch-analyze posts/one.md posts/two.md posts/three.md
|
|
116
|
+
```
|
|
117
|
+
|
|
97
118
|
### Create an editing brief
|
|
98
119
|
|
|
99
120
|
```bash
|
|
@@ -108,7 +129,46 @@ Give the brief and draft to a human editor or any model you trust. This reposito
|
|
|
108
129
|
npx @holdyourvoice/hyv verify draft.md candidate.md profile.json
|
|
109
130
|
```
|
|
110
131
|
|
|
111
|
-
`verify` returns the original and candidate reports, identifies newly introduced findings, calculates a coarse preservation score, and exits with status `2` when the candidate fails the dual gate. It exits with `1` for a usage or runtime error. Treat status `2` as a release signal in scripts or CI.
|
|
132
|
+
`verify` returns the original and candidate reports, identifies newly introduced findings, calculates a coarse preservation score, and exits with status `2` when the candidate fails the dual gate. A passing verification automatically records only the resolved finding IDs for that profile in local learning state. It exits with `1` for a usage or runtime error. Treat status `2` as a release signal in scripts or CI.
|
|
133
|
+
|
|
134
|
+
### Lock factual claims with a CopySpec
|
|
135
|
+
|
|
136
|
+
Use `verify-spec` when a draft has claims that must remain exact. A local CopySpec records each immutable claim alongside its evidence, then blocks a candidate if that claim is absent, changed, or joined by a prohibited claim.
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"version": "1",
|
|
141
|
+
"audience": "operators",
|
|
142
|
+
"intent": "explain a launch date",
|
|
143
|
+
"channel": "email",
|
|
144
|
+
"claims": [
|
|
145
|
+
{
|
|
146
|
+
"id": "launch-date",
|
|
147
|
+
"text": "The launch is on 14 August.",
|
|
148
|
+
"evidence": "Release calendar, checked 7 August."
|
|
149
|
+
}
|
|
150
|
+
],
|
|
151
|
+
"prohibitedClaims": ["The launch is guaranteed to double revenue."]
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
hyv verify-spec original.md candidate.md profile.json copy-spec.json
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The check is deterministic. It covers declared claims and prohibited text; arbitrary unsupported assertions need a separate factual evaluator.
|
|
160
|
+
|
|
161
|
+
### Local voice memory
|
|
162
|
+
|
|
163
|
+
Learning is on by default. After a successful `verify`, Hold Your Voice records resolved rule IDs under `~/.hyv/learning/`, scoped to a fingerprint of the portable profile. It stores no draft or candidate text. The next `rewrite-prompt` uses a bounded list of those verified repairs.
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
hyv learning show profile.json
|
|
167
|
+
hyv learning add profile.json "Keep the direct opening."
|
|
168
|
+
hyv learning clear profile.json
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`show` lets you inspect the exact local preferences. `clear` removes only that profile's learning file. Set `HYV_HOME` to place this local state elsewhere.
|
|
112
172
|
|
|
113
173
|
## The editing loop
|
|
114
174
|
|
|
@@ -198,6 +258,8 @@ The preservation score is a guardrail based on retained original words longer th
|
|
|
198
258
|
| `hyv analyze <draft> <profile.json>` | Draft and profile | Analysis JSON | You need both reports before editing. |
|
|
199
259
|
| `hyv rewrite-prompt <draft> <profile.json>` | Draft and profile | Markdown editing brief | You need a constrained request for an editor or model. |
|
|
200
260
|
| `hyv verify <original> <candidate> <profile.json>` | Original, candidate, profile | Verification JSON and exit code | You need the candidate gate. |
|
|
261
|
+
| `hyv verify-spec <original> <candidate> <profile.json> <copy-spec.json>` | Original, candidate, profile, CopySpec | Verification JSON with hard claim gate | A brief contains locked facts or prohibited claims. |
|
|
262
|
+
| `hyv learning <show\|add\|clear> <profile.json>` | Profile and optional instruction | Local learning JSON | You need to inspect or manage profile-scoped learning. |
|
|
201
263
|
| `hyv patterns` | None | Ruleset JSON | You need the exact enabled rules. |
|
|
202
264
|
|
|
203
265
|
Every file argument can be `-` when the command accepts text input from standard input. Profile output is always written to the path you give it. Use `npx @holdyourvoice/hyv <command>` in place of `hyv <command>` when you have not installed the CLI globally.
|
|
@@ -210,6 +272,8 @@ Every file argument can be `-` when the command accepts text input from standard
|
|
|
210
272
|
| `src/text.ts` | Sentence, paragraph, word, and basic statistics helpers. |
|
|
211
273
|
| `src/voice-dna.ts` | Builds profiles and runs VoiceDNA checks. |
|
|
212
274
|
| `src/ai-editor.ts` | Owns the versioned deterministic editorial rules. |
|
|
275
|
+
| `src/editorial-packs.ts` | Parses WritingBrief context and runs format and batch checks. |
|
|
276
|
+
| `src/learning.ts` | Stores text-free, profile-scoped verified repairs and composes bounded local preferences. |
|
|
213
277
|
| `src/pipeline.ts` | Combines pass states, makes briefs, and verifies candidates. |
|
|
214
278
|
| `src/cli.ts` | Local file and standard-input command adapter. |
|
|
215
279
|
| `src/pipeline.test.ts` | Contract and regression tests. |
|
|
@@ -220,9 +284,9 @@ Every file argument can be `-` when the command accepts text input from standard
|
|
|
220
284
|
|
|
221
285
|
## Privacy and data rights
|
|
222
286
|
|
|
223
|
-
The runtime uses files on your machine. Samples, drafts, profiles, candidates,
|
|
287
|
+
The runtime uses files on your machine. Samples, drafts, profiles, candidates, and client data stay there. Successful verification writes a text-free local learning event under `~/.hyv/learning/`: profile fingerprint, finding IDs, severities, counts, timestamp, and an opaque one-way candidate digest for retry deduplication. An instruction added through `hyv learning add` is stored as entered.
|
|
224
288
|
|
|
225
|
-
Keep writing samples, edit histories, client text,
|
|
289
|
+
The package does not upload writing, use embeddings, or make runtime network requests. Keep writing samples, edit histories, client text, local learning files, and datasets out of public commits unless you hold explicit rights and a provenance record. A profile is aggregated JSON and can still reveal vocabulary and preferences. Store private profiles outside public repositories.
|
|
226
290
|
|
|
227
291
|
See the [privacy guide](https://github.com/shashank-sn/holdyourvoice/wiki/Privacy-and-Data-Rights) for maintainer and contributor boundaries.
|
|
228
292
|
|
|
@@ -239,6 +303,7 @@ Treat those as dated reference material. A reproducible benchmark needs rights-c
|
|
|
239
303
|
| [The complete Wiki](https://github.com/shashank-sn/holdyourvoice/wiki) | Product, workflow, and contributor documentation. |
|
|
240
304
|
| [Thesis](docs/THESIS.md) | The design argument for two independent engines. |
|
|
241
305
|
| [Architecture](docs/ARCHITECTURE.md) | Source boundaries and extension rules. |
|
|
306
|
+
| [Local voice memory](docs/wiki/Local-Voice-Memory.md) | What default local learning stores, uses, and never changes. |
|
|
242
307
|
| [Prompt contract](docs/PROMPT-CONTRACT.md) | The tier order and editing constraints. |
|
|
243
308
|
| [Pattern taxonomy](docs/PATTERN-TAXONOMY.md) | The catalog/executable-rule boundary. |
|
|
244
309
|
| [Support](SUPPORT.md) | Funding without a feature gate. |
|
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { rules, RULESET_VERSION } from './ai-editor.js';
|
|
4
|
-
import {
|
|
4
|
+
import { parseCopySpec } from './copy-spec.js';
|
|
5
|
+
import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
|
|
6
|
+
import { addLearningInstruction, clearLearning, composeLearning, profileFingerprint, recordVerifiedCandidate } from './learning.js';
|
|
7
|
+
import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
|
|
5
8
|
import { parseProfile } from './profile.js';
|
|
9
|
+
import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
|
|
6
10
|
import { buildProfile } from './voice-dna.js';
|
|
7
|
-
const usage = 'Commands: profile, analyze, rewrite-prompt, verify, patterns, mcp';
|
|
11
|
+
const usage = 'Commands: profile, analyze, batch-analyze, rewrite-prompt, prepare-rewrite, apply-rewrite, verify, verify-spec, learning, patterns, mcp';
|
|
8
12
|
function input(path) {
|
|
9
13
|
return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
|
|
10
14
|
}
|
|
11
15
|
function readProfile(path) {
|
|
12
16
|
return parseProfile(JSON.parse(input(path)));
|
|
13
17
|
}
|
|
18
|
+
function readBrief(path) {
|
|
19
|
+
return path ? parseWritingBrief(JSON.parse(input(path))) : undefined;
|
|
20
|
+
}
|
|
21
|
+
function prepareContext(paths) {
|
|
22
|
+
let copySpec;
|
|
23
|
+
let writingBrief;
|
|
24
|
+
for (const path of paths) {
|
|
25
|
+
const value = JSON.parse(input(path));
|
|
26
|
+
try {
|
|
27
|
+
const parsed = parseCopySpec(value);
|
|
28
|
+
if (copySpec)
|
|
29
|
+
throw new Error('Prepare-rewrite accepts at most one CopySpec.');
|
|
30
|
+
copySpec = parsed;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof Error && error.message === 'Prepare-rewrite accepts at most one CopySpec.')
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const parsed = parseWritingBrief(value);
|
|
39
|
+
if (writingBrief)
|
|
40
|
+
throw new Error('Prepare-rewrite accepts at most one WritingBrief.');
|
|
41
|
+
writingBrief = parsed;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof Error && error.message === 'Prepare-rewrite accepts at most one WritingBrief.')
|
|
45
|
+
throw error;
|
|
46
|
+
throw new Error(`Expected a valid CopySpec or WritingBrief at ${path}.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { copySpec, writingBrief };
|
|
50
|
+
}
|
|
14
51
|
function json(value) {
|
|
15
52
|
console.log(JSON.stringify(value, null, 2));
|
|
16
53
|
}
|
|
@@ -41,27 +78,95 @@ export async function runCli(args) {
|
|
|
41
78
|
return 0;
|
|
42
79
|
}
|
|
43
80
|
if (command === 'analyze') {
|
|
44
|
-
const [draft, profilePath] = rest;
|
|
81
|
+
const [draft, profilePath, briefPath] = rest;
|
|
45
82
|
if (!draft || !profilePath)
|
|
46
|
-
throw new Error('Usage: hyv analyze draft.md profile.json');
|
|
47
|
-
json(analyze(input(draft), readProfile(profilePath)));
|
|
83
|
+
throw new Error('Usage: hyv analyze draft.md profile.json [writing-brief.json]');
|
|
84
|
+
json(analyze(input(draft), readProfile(profilePath), readBrief(briefPath)));
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
if (command === 'batch-analyze') {
|
|
88
|
+
if (rest.length < 2)
|
|
89
|
+
throw new Error('Usage: hyv batch-analyze draft-a.md draft-b.md [draft-c.md]');
|
|
90
|
+
json(analyzeBatch(rest.map(input)));
|
|
48
91
|
return 0;
|
|
49
92
|
}
|
|
50
93
|
if (command === 'rewrite-prompt') {
|
|
51
|
-
const [draft, profilePath] = rest;
|
|
94
|
+
const [draft, profilePath, briefPath] = rest;
|
|
52
95
|
if (!draft || !profilePath)
|
|
53
|
-
throw new Error('Usage: hyv rewrite-prompt draft.md profile.json');
|
|
54
|
-
|
|
96
|
+
throw new Error('Usage: hyv rewrite-prompt draft.md profile.json [writing-brief.json]');
|
|
97
|
+
const profile = readProfile(profilePath);
|
|
98
|
+
console.log(rewritePrompt(input(draft), profile, composeLearning(profile), readBrief(briefPath)));
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
if (command === 'prepare-rewrite') {
|
|
102
|
+
const [draft, profilePath, output, ...contextPaths] = rest;
|
|
103
|
+
if (!draft || !profilePath || !output)
|
|
104
|
+
throw new Error('Usage: hyv prepare-rewrite draft.md profile.json task.json [copy-spec.json] [writing-brief.json]');
|
|
105
|
+
const context = prepareContext(contextPaths);
|
|
106
|
+
const task = prepareRewriteTask(input(draft), readProfile(profilePath), context.copySpec, context.writingBrief);
|
|
107
|
+
writeFileSync(output, `${JSON.stringify(task, null, 2)}\n`);
|
|
108
|
+
json({ version: task.version, fingerprint: task.fingerprint, eligibleSentenceIds: task.eligibleSentenceIds });
|
|
55
109
|
return 0;
|
|
56
110
|
}
|
|
111
|
+
if (command === 'apply-rewrite') {
|
|
112
|
+
const [taskPath, responsePath, profilePath] = rest;
|
|
113
|
+
if (!taskPath || !responsePath || !profilePath)
|
|
114
|
+
throw new Error('Usage: hyv apply-rewrite task.json response.json profile.json');
|
|
115
|
+
const result = evaluateRewriteResponse(parseRewriteTask(JSON.parse(input(taskPath))), input(responsePath), readProfile(profilePath));
|
|
116
|
+
json(result);
|
|
117
|
+
return result.status === 'accepted' ? 0 : 2;
|
|
118
|
+
}
|
|
57
119
|
if (command === 'verify') {
|
|
58
|
-
const [original, candidate, profilePath] = rest;
|
|
120
|
+
const [original, candidate, profilePath, briefPath] = rest;
|
|
59
121
|
if (!original || !candidate || !profilePath)
|
|
60
|
-
throw new Error('Usage: hyv verify original.md candidate.md profile.json');
|
|
61
|
-
const
|
|
122
|
+
throw new Error('Usage: hyv verify original.md candidate.md profile.json [writing-brief.json]');
|
|
123
|
+
const profile = readProfile(profilePath);
|
|
124
|
+
const originalText = input(original);
|
|
125
|
+
const candidateText = input(candidate);
|
|
126
|
+
const result = verify(originalText, candidateText, profile, readBrief(briefPath));
|
|
127
|
+
const learning = recordVerifiedCandidate(profile, result, candidateText);
|
|
128
|
+
if (learning === 'write_failed')
|
|
129
|
+
console.error('Warning: verification passed, but local learning could not be saved.');
|
|
130
|
+
json(result);
|
|
131
|
+
return result.passed ? 0 : 2;
|
|
132
|
+
}
|
|
133
|
+
if (command === 'verify-spec') {
|
|
134
|
+
const [original, candidate, profilePath, specPath, briefPath] = rest;
|
|
135
|
+
if (!original || !candidate || !profilePath || !specPath)
|
|
136
|
+
throw new Error('Usage: hyv verify-spec original.md candidate.md profile.json copy-spec.json [writing-brief.json]');
|
|
137
|
+
const profile = readProfile(profilePath);
|
|
138
|
+
const candidateText = input(candidate);
|
|
139
|
+
const result = verifyWithCopySpec(input(original), candidateText, profile, parseCopySpec(JSON.parse(input(specPath))), readBrief(briefPath));
|
|
140
|
+
if (result.passed) {
|
|
141
|
+
const learning = recordVerifiedCandidate(profile, result, candidateText);
|
|
142
|
+
if (learning === 'write_failed')
|
|
143
|
+
console.error('Warning: verification passed, but local learning could not be saved.');
|
|
144
|
+
}
|
|
62
145
|
json(result);
|
|
63
146
|
return result.passed ? 0 : 2;
|
|
64
147
|
}
|
|
148
|
+
if (command === 'learning') {
|
|
149
|
+
const [action, profilePath, ...instruction] = rest;
|
|
150
|
+
if (!action || !profilePath)
|
|
151
|
+
throw new Error('Usage: hyv learning <show|add|clear> profile.json [instruction]');
|
|
152
|
+
const profile = readProfile(profilePath);
|
|
153
|
+
if (action === 'show') {
|
|
154
|
+
json({ profile: profileFingerprint(profile), preferences: composeLearning(profile) });
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
if (action === 'add') {
|
|
158
|
+
const text = instruction.join(' ').trim();
|
|
159
|
+
if (!text)
|
|
160
|
+
throw new Error('Usage: hyv learning add profile.json "instruction"');
|
|
161
|
+
json({ added: addLearningInstruction(profile, text) });
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
if (action === 'clear') {
|
|
165
|
+
json({ cleared: clearLearning(profile) });
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
throw new Error('Usage: hyv learning <show|add|clear> profile.json [instruction]');
|
|
169
|
+
}
|
|
65
170
|
if (command === 'patterns') {
|
|
66
171
|
json({ version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) });
|
|
67
172
|
return 0;
|
package/dist/cli.test.js
CHANGED
|
@@ -5,8 +5,8 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
const cli = new URL('./cli.js', import.meta.url).pathname;
|
|
8
|
-
function run(
|
|
9
|
-
return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' });
|
|
8
|
+
function run(args, env = process.env) {
|
|
9
|
+
return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env });
|
|
10
10
|
}
|
|
11
11
|
test('creates an explicit local avoid list and exposes the ruleset', () => {
|
|
12
12
|
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
@@ -16,10 +16,10 @@ test('creates an explicit local avoid list and exposes the ruleset', () => {
|
|
|
16
16
|
const profile = join(directory, 'profile.json');
|
|
17
17
|
writeFileSync(first, 'i write plainly. i name the work.');
|
|
18
18
|
writeFileSync(second, 'i keep the mechanism clear. i avoid filler.');
|
|
19
|
-
const created = run('profile', profile, first, second, '--avoid=unlock');
|
|
19
|
+
const created = run(['profile', profile, first, second, '--avoid=unlock']);
|
|
20
20
|
assert.equal(created.status, 0, created.stderr);
|
|
21
21
|
assert.deepEqual(JSON.parse(readFileSync(profile, 'utf8')).avoid, ['unlock']);
|
|
22
|
-
const patterns = run('patterns');
|
|
22
|
+
const patterns = run(['patterns']);
|
|
23
23
|
assert.equal(patterns.status, 0, patterns.stderr);
|
|
24
24
|
assert.ok(JSON.parse(patterns.stdout).rules.every((rule) => rule.id && rule.severity && rule.reason && rule.suggestion));
|
|
25
25
|
}
|
|
@@ -27,6 +27,33 @@ test('creates an explicit local avoid list and exposes the ruleset', () => {
|
|
|
27
27
|
rmSync(directory, { recursive: true, force: true });
|
|
28
28
|
}
|
|
29
29
|
});
|
|
30
|
+
test('runs contextual analysis and batch analysis without changing the profile contract', () => {
|
|
31
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
32
|
+
try {
|
|
33
|
+
const first = join(directory, 'first.md');
|
|
34
|
+
const second = join(directory, 'second.md');
|
|
35
|
+
const profile = join(directory, 'profile.json');
|
|
36
|
+
const brief = join(directory, 'brief.json');
|
|
37
|
+
const draft = join(directory, 'draft.md');
|
|
38
|
+
const duplicate = join(directory, 'duplicate.md');
|
|
39
|
+
const task = join(directory, 'task.json');
|
|
40
|
+
writeFileSync(first, 'I write plainly. I name the work.');
|
|
41
|
+
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
42
|
+
writeFileSync(brief, JSON.stringify({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' }));
|
|
43
|
+
writeFileSync(draft, 'A pattern I keep seeing in founder posts is vague advice.');
|
|
44
|
+
writeFileSync(duplicate, 'A pattern I keep seeing in founder posts is vague advice.');
|
|
45
|
+
assert.equal(run(['profile', profile, first, second]).status, 0);
|
|
46
|
+
const contextual = JSON.parse(run(['analyze', draft, profile, brief]).stdout);
|
|
47
|
+
assert.equal(contextual.editorial.findings[0].id, 'editorial.social.generic-opener');
|
|
48
|
+
const batch = JSON.parse(run(['batch-analyze', draft, duplicate]).stdout);
|
|
49
|
+
assert.equal(batch.findings.length, 2);
|
|
50
|
+
assert.equal(run(['prepare-rewrite', draft, profile, task, brief]).status, 0);
|
|
51
|
+
assert.equal(JSON.parse(readFileSync(task, 'utf8')).writingBrief.format, 'social');
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
rmSync(directory, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
30
57
|
test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
|
|
31
58
|
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
32
59
|
try {
|
|
@@ -39,12 +66,61 @@ test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
|
|
|
39
66
|
writeFileSync(second, 'i keep the mechanism clear. i avoid filler.');
|
|
40
67
|
writeFileSync(original, 'i name the work.');
|
|
41
68
|
writeFileSync(candidate, 'i unlock the answer.');
|
|
42
|
-
assert.equal(run('profile', profile, first, second, '--avoid=unlock').status, 0);
|
|
43
|
-
const verification = run('verify', original, candidate, profile);
|
|
69
|
+
assert.equal(run(['profile', profile, first, second, '--avoid=unlock']).status, 0);
|
|
70
|
+
const verification = run(['verify', original, candidate, profile]);
|
|
44
71
|
assert.equal(verification.status, 2);
|
|
45
72
|
assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'original', 'passed', 'preservationScore', 'regressions', 'version']);
|
|
46
|
-
assert.equal(run('unknown-command').status, 1);
|
|
47
|
-
assert.equal(run('mcp', 'unexpected').status, 1);
|
|
73
|
+
assert.equal(run(['unknown-command']).status, 1);
|
|
74
|
+
assert.equal(run(['mcp', 'unexpected']).status, 1);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
rmSync(directory, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
test('fails the CopySpec gate when a locked claim changes', () => {
|
|
81
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
82
|
+
try {
|
|
83
|
+
const first = join(directory, 'first.md');
|
|
84
|
+
const second = join(directory, 'second.md');
|
|
85
|
+
const profile = join(directory, 'profile.json');
|
|
86
|
+
const original = join(directory, 'original.md');
|
|
87
|
+
const candidate = join(directory, 'candidate.md');
|
|
88
|
+
const spec = join(directory, 'copy-spec.json');
|
|
89
|
+
writeFileSync(first, 'I write plainly. I name the work.');
|
|
90
|
+
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
91
|
+
writeFileSync(original, 'The launch is on 14 August.');
|
|
92
|
+
writeFileSync(candidate, 'The launch is next month.');
|
|
93
|
+
writeFileSync(spec, JSON.stringify({ version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar.' }] }));
|
|
94
|
+
assert.equal(run(['profile', profile, first, second]).status, 0);
|
|
95
|
+
const result = run(['verify-spec', original, candidate, profile, spec]);
|
|
96
|
+
assert.equal(result.status, 2);
|
|
97
|
+
assert.equal(JSON.parse(result.stdout).claims.failures[0].code, 'missing_immutable_claim');
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
rmSync(directory, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
test('prepares and applies the same constrained rewrite task without a provider call', () => {
|
|
104
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
105
|
+
try {
|
|
106
|
+
const first = join(directory, 'first.md');
|
|
107
|
+
const second = join(directory, 'second.md');
|
|
108
|
+
const profile = join(directory, 'profile.json');
|
|
109
|
+
const draft = join(directory, 'draft.md');
|
|
110
|
+
const task = join(directory, 'task.json');
|
|
111
|
+
const response = join(directory, 'response.json');
|
|
112
|
+
writeFileSync(first, 'I write plainly. I name the work.');
|
|
113
|
+
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
114
|
+
writeFileSync(draft, 'I leverage the answer with useful detail and clear mechanism.');
|
|
115
|
+
assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
|
|
116
|
+
assert.equal(run(['prepare-rewrite', draft, profile, task]).status, 0);
|
|
117
|
+
const prepared = JSON.parse(readFileSync(task, 'utf8'));
|
|
118
|
+
writeFileSync(response, JSON.stringify({ version: '1', taskFingerprint: prepared.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer with useful detail and clear mechanism.' }] }));
|
|
119
|
+
const result = run(['apply-rewrite', task, response, profile]);
|
|
120
|
+
assert.equal(result.status, 2, result.stderr);
|
|
121
|
+
const applied = JSON.parse(result.stdout);
|
|
122
|
+
assert.equal(applied.status, 'needs_semantic_review');
|
|
123
|
+
assert.equal(applied.candidate, 'I use the answer with useful detail and clear mechanism.');
|
|
48
124
|
}
|
|
49
125
|
finally {
|
|
50
126
|
rmSync(directory, { recursive: true, force: true });
|
|
@@ -57,7 +133,7 @@ test('rejects a malformed hand-edited profile before analysis', () => {
|
|
|
57
133
|
const profile = join(directory, 'profile.json');
|
|
58
134
|
writeFileSync(draft, 'i name the work.');
|
|
59
135
|
writeFileSync(profile, JSON.stringify({ version: '2', sampleCount: 2, metrics: {}, avoid: [1] }));
|
|
60
|
-
const result = run('analyze', draft, profile);
|
|
136
|
+
const result = run(['analyze', draft, profile]);
|
|
61
137
|
assert.equal(result.status, 1);
|
|
62
138
|
assert.match(result.stderr, /not a valid Hold Your Voice version 2 profile/);
|
|
63
139
|
}
|
|
@@ -91,7 +167,7 @@ test('rejects malformed profile enum values and punctuation', () => {
|
|
|
91
167
|
},
|
|
92
168
|
avoid: [''],
|
|
93
169
|
}));
|
|
94
|
-
assert.equal(run('analyze', draft, profile).status, 1);
|
|
170
|
+
assert.equal(run(['analyze', draft, profile]).status, 1);
|
|
95
171
|
}
|
|
96
172
|
finally {
|
|
97
173
|
rmSync(directory, { recursive: true, force: true });
|
|
@@ -107,12 +183,41 @@ test('rejects hand-edited metrics outside their semantic bounds', () => {
|
|
|
107
183
|
writeFileSync(first, 'i write plainly.');
|
|
108
184
|
writeFileSync(second, 'i name the work.');
|
|
109
185
|
writeFileSync(draft, 'i name the work.');
|
|
110
|
-
assert.equal(run('profile', profile, first, second).status, 0);
|
|
186
|
+
assert.equal(run(['profile', profile, first, second]).status, 0);
|
|
111
187
|
const malformed = JSON.parse(readFileSync(profile, 'utf8'));
|
|
112
188
|
malformed.sampleCount = 2.5;
|
|
113
189
|
malformed.metrics.questionRate = 1.2;
|
|
114
190
|
writeFileSync(profile, JSON.stringify(malformed));
|
|
115
|
-
assert.equal(run('analyze', draft, profile).status, 1);
|
|
191
|
+
assert.equal(run(['analyze', draft, profile]).status, 1);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
rmSync(directory, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
test('learns from a successful local verification by default and exposes local controls', () => {
|
|
198
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
199
|
+
try {
|
|
200
|
+
const first = join(directory, 'first.md');
|
|
201
|
+
const second = join(directory, 'second.md');
|
|
202
|
+
const profile = join(directory, 'profile.json');
|
|
203
|
+
const original = join(directory, 'original.md');
|
|
204
|
+
const candidate = join(directory, 'candidate.md');
|
|
205
|
+
const env = { ...process.env, HYV_HOME: join(directory, 'state') };
|
|
206
|
+
writeFileSync(first, 'I write plainly. I name the work.');
|
|
207
|
+
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
208
|
+
writeFileSync(original, 'I leverage the answer with useful detail and clear mechanism.');
|
|
209
|
+
writeFileSync(candidate, 'I use the answer with useful detail and clear mechanism.');
|
|
210
|
+
assert.equal(run(['profile', profile, first, second, '--avoid=leverage'], env).status, 0);
|
|
211
|
+
assert.equal(run(['verify', original, candidate, profile], env).status, 0);
|
|
212
|
+
const brief = run(['rewrite-prompt', candidate, profile], env);
|
|
213
|
+
assert.equal(brief.status, 0, brief.stderr);
|
|
214
|
+
assert.match(brief.stdout, /Learned local preferences/);
|
|
215
|
+
assert.match(brief.stdout, /ai\\_editor\/ai\.leverage/);
|
|
216
|
+
const learned = run(['learning', 'show', profile], env);
|
|
217
|
+
assert.equal(learned.status, 0, learned.stderr);
|
|
218
|
+
assert.ok(JSON.parse(learned.stdout).preferences.some((item) => item.text.includes('ai_editor/ai.leverage')));
|
|
219
|
+
assert.equal(run(['learning', 'clear', profile], env).status, 0);
|
|
220
|
+
assert.deepEqual(JSON.parse(run(['learning', 'show', profile], env).stdout).preferences, []);
|
|
116
221
|
}
|
|
117
222
|
finally {
|
|
118
223
|
rmSync(directory, { recursive: true, force: true });
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { sentences } from './text.js';
|
|
2
|
+
function normalized(value) {
|
|
3
|
+
return value.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
4
|
+
}
|
|
5
|
+
function isText(value, limit) {
|
|
6
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= limit;
|
|
7
|
+
}
|
|
8
|
+
function isClaim(value) {
|
|
9
|
+
if (!value || typeof value !== 'object')
|
|
10
|
+
return false;
|
|
11
|
+
const claim = value;
|
|
12
|
+
return isText(claim.id, 100) && /^[A-Za-z0-9._-]+$/.test(claim.id)
|
|
13
|
+
&& isText(claim.text, 2_000) && isText(claim.evidence, 4_000)
|
|
14
|
+
&& (claim.mutable === undefined || typeof claim.mutable === 'boolean');
|
|
15
|
+
}
|
|
16
|
+
export function parseCopySpec(value) {
|
|
17
|
+
if (!value || typeof value !== 'object')
|
|
18
|
+
throw new Error('CopySpec must be a JSON object.');
|
|
19
|
+
const spec = value;
|
|
20
|
+
if (spec.version !== '1' || !isText(spec.audience, 500) || !isText(spec.intent, 500) || !isText(spec.channel, 100)
|
|
21
|
+
|| !Array.isArray(spec.claims) || spec.claims.length === 0 || spec.claims.length > 100 || !spec.claims.every(isClaim)
|
|
22
|
+
|| new Set(spec.claims.map((claim) => claim.id)).size !== spec.claims.length
|
|
23
|
+
|| (spec.prohibitedClaims !== undefined && (!Array.isArray(spec.prohibitedClaims) || spec.prohibitedClaims.length > 100 || !spec.prohibitedClaims.every((claim) => isText(claim, 2_000))))) {
|
|
24
|
+
throw new Error('CopySpec is not valid. It needs version "1", audience, intent, channel, unique claims with text and evidence, and optional prohibitedClaims.');
|
|
25
|
+
}
|
|
26
|
+
return spec;
|
|
27
|
+
}
|
|
28
|
+
export function verifyClaims(candidate, spec) {
|
|
29
|
+
const draftSentences = sentences(candidate);
|
|
30
|
+
const normalizedCandidate = normalized(candidate);
|
|
31
|
+
const sentenceClaims = {};
|
|
32
|
+
const failures = [];
|
|
33
|
+
for (const claim of spec.claims) {
|
|
34
|
+
const claimText = normalized(claim.text);
|
|
35
|
+
const matching = draftSentences.filter((sentence) => normalized(sentence.text).includes(claimText));
|
|
36
|
+
for (const sentence of matching)
|
|
37
|
+
(sentenceClaims[sentence.index] ??= []).push(claim.id);
|
|
38
|
+
if (!claim.mutable && matching.length === 0) {
|
|
39
|
+
failures.push({ id: claim.id, code: 'missing_immutable_claim', message: `Immutable claim ${claim.id} is absent or changed.`, evidence: claim.evidence });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const claim of spec.prohibitedClaims ?? []) {
|
|
43
|
+
if (normalizedCandidate.includes(normalized(claim))) {
|
|
44
|
+
failures.push({ id: claim, code: 'prohibited_claim', message: `Prohibited claim appears in the candidate: ${claim}` });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { passed: failures.length === 0, failures, sentenceClaims };
|
|
48
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { paragraphs, sentences, words } from './text.js';
|
|
2
|
+
const formats = ['general', 'social', 'deck', 'outreach', 'blog', 'audit', 'website'];
|
|
3
|
+
function isText(value, limit) {
|
|
4
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= limit;
|
|
5
|
+
}
|
|
6
|
+
function isTerms(value) {
|
|
7
|
+
return Array.isArray(value) && value.length <= 100 && value.every((term) => isText(term, 200));
|
|
8
|
+
}
|
|
9
|
+
export function parseWritingBrief(value) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
11
|
+
throw new Error('WritingBrief must be a JSON object.');
|
|
12
|
+
const brief = value;
|
|
13
|
+
if (brief.version !== '1' || !isText(brief.audience, 500) || !isText(brief.intent, 500) || !formats.includes(brief.format)
|
|
14
|
+
|| (brief.readerKnowsAuthor !== undefined && typeof brief.readerKnowsAuthor !== 'boolean')
|
|
15
|
+
|| (brief.vocabulary !== undefined && !isTerms(brief.vocabulary))
|
|
16
|
+
|| (brief.prohibitedTerms !== undefined && !isTerms(brief.prohibitedTerms))
|
|
17
|
+
|| (brief.title !== undefined && !isText(brief.title, 500))) {
|
|
18
|
+
throw new Error('WritingBrief needs version "1", audience, intent, a known format, and optional bounded context fields.');
|
|
19
|
+
}
|
|
20
|
+
return brief;
|
|
21
|
+
}
|
|
22
|
+
function finding(id, severity, sentence, excerpt, reason, suggestion) {
|
|
23
|
+
return { engine: 'editorial', id, severity, sentence, excerpt, reason, suggestion };
|
|
24
|
+
}
|
|
25
|
+
function formatFindings(text, draftSentences, brief) {
|
|
26
|
+
const findings = [];
|
|
27
|
+
const first = draftSentences[0];
|
|
28
|
+
if (brief.format === 'social') {
|
|
29
|
+
for (const sentence of draftSentences) {
|
|
30
|
+
if (/^(a pattern|a theme|something) i (keep )?(seeing|noticing)\b/i.test(sentence.text)) {
|
|
31
|
+
findings.push(finding('editorial.social.generic-opener', 'yellow', sentence.index, sentence.text, 'Uses a generic observation opener that often reads as templated.', 'Open from the concrete observation or claim instead.'));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const draftParagraphs = paragraphs(text);
|
|
35
|
+
const allSingleSentence = draftParagraphs.length >= 3 && draftParagraphs.every((paragraph) => sentences(paragraph).length === 1);
|
|
36
|
+
if (allSingleSentence && first)
|
|
37
|
+
findings.push(finding('editorial.social.one-line-run', 'yellow', first.index, first.text, 'Every paragraph contains one sentence.', 'Combine related sentences where the writing needs a fuller rhythm.'));
|
|
38
|
+
}
|
|
39
|
+
if (brief.format === 'deck') {
|
|
40
|
+
if (first && /^we\b/i.test(first.text))
|
|
41
|
+
findings.push(finding('editorial.deck.first-slide-we', 'yellow', first.index, first.text, 'The opening starts with the company rather than the reader or claim.', 'Lead with the reader context or the slide claim.'));
|
|
42
|
+
if (brief.title && /^\s*\d/.test(brief.title))
|
|
43
|
+
findings.push(finding('editorial.deck.numeric-title', 'yellow', 1, brief.title, 'The title starts with a number.', 'State the slide claim without leading with a count.'));
|
|
44
|
+
}
|
|
45
|
+
if (brief.format === 'outreach') {
|
|
46
|
+
for (const sentence of draftSentences) {
|
|
47
|
+
if (/\b(would you be open to|does that sound interesting|what do you think)\??$/i.test(sentence.text)) {
|
|
48
|
+
findings.push(finding('editorial.outreach.generic-question-cta', 'yellow', sentence.index, sentence.text, 'Uses a stock outbound question CTA.', 'Close with a specific next step or a direct observation.'));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return findings;
|
|
53
|
+
}
|
|
54
|
+
export function analyzeEditorial(text, brief) {
|
|
55
|
+
const draftSentences = sentences(text);
|
|
56
|
+
const findings = formatFindings(text, draftSentences, brief);
|
|
57
|
+
const prohibitedTerms = (brief.prohibitedTerms ?? []).map((term) => [term, normalizedWords(term)]).filter(([, term]) => term);
|
|
58
|
+
for (const sentence of draftSentences) {
|
|
59
|
+
const normalizedSentence = normalizedWords(sentence.text);
|
|
60
|
+
for (const [term, normalizedTerm] of prohibitedTerms) {
|
|
61
|
+
if (normalizedTerm && ` ${normalizedSentence} `.includes(` ${normalizedTerm} `)) {
|
|
62
|
+
findings.push(finding('editorial.prohibited-term', 'red', sentence.index, sentence.text, `Uses prohibited term: ${term}.`, 'Remove the term or replace it with approved wording.'));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const red = findings.filter((item) => item.severity === 'red').length;
|
|
67
|
+
const yellow = findings.length - red;
|
|
68
|
+
return { engine: 'editorial', version: '1', score: Math.max(0, 100 - red * 25 - yellow * 6), passed: red === 0, findings };
|
|
69
|
+
}
|
|
70
|
+
function normalizedWords(text) {
|
|
71
|
+
return words(text.toLowerCase()).join(' ');
|
|
72
|
+
}
|
|
73
|
+
function normalizedBoundary(text) {
|
|
74
|
+
if (!text)
|
|
75
|
+
return undefined;
|
|
76
|
+
const normalized = normalizedWords(text);
|
|
77
|
+
return normalized || undefined;
|
|
78
|
+
}
|
|
79
|
+
function duplicateBoundary(id, boundaries, reason, suggestion) {
|
|
80
|
+
const groups = new Map();
|
|
81
|
+
boundaries.forEach((boundary, index) => {
|
|
82
|
+
if (boundary) {
|
|
83
|
+
const indexes = groups.get(boundary) ?? [];
|
|
84
|
+
indexes.push(index + 1);
|
|
85
|
+
groups.set(boundary, indexes);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return [...groups.values()].filter((indexes) => indexes.length > 1).map((draftIndexes) => ({ id, severity: 'yellow', draftIndexes, reason, suggestion }));
|
|
89
|
+
}
|
|
90
|
+
export function analyzeBatch(drafts) {
|
|
91
|
+
if (drafts.length < 2 || drafts.length > 100 || drafts.some((draft) => !isText(draft, 100_000)))
|
|
92
|
+
throw new Error('Batch analysis needs 2 to 100 non-empty drafts.');
|
|
93
|
+
const parsed = drafts.map(sentences);
|
|
94
|
+
return {
|
|
95
|
+
version: '1',
|
|
96
|
+
findings: [
|
|
97
|
+
...duplicateBoundary('batch.repeated-opening', parsed.map((draft) => normalizedBoundary(draft[0]?.text)), 'Drafts share the same opening sentence.', 'Vary the opening shape or lead with a different concrete observation.'),
|
|
98
|
+
...duplicateBoundary('batch.repeated-ending', parsed.map((draft) => normalizedBoundary(draft.at(-1)?.text)), 'Drafts share the same closing sentence.', 'Give each draft a closing beat that fits its own argument.'),
|
|
99
|
+
],
|
|
100
|
+
passed: true,
|
|
101
|
+
};
|
|
102
|
+
}
|