@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 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. It does not read or write files, make network requests, or retain writing. See the [Claude Desktop guide](docs/CLAUDE-DESKTOP.md).
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, feedback history, embeddings, and client data stay there.
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, embeddings, 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.
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 { analyze, rewritePrompt, verify } from './pipeline.js';
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
- console.log(rewritePrompt(input(draft), readProfile(profilePath)));
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 result = verify(input(original), input(candidate), readProfile(profilePath));
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(...args) {
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
+ }