@holdyourvoice/hyv 2.9.28 → 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 -241
- package/README.md +0 -218
- package/agents/AGENTS.md +0 -82
- package/agents/README.md +0 -20
- package/agents/chatgpt.md +0 -47
- package/agents/claude-code.md +0 -45
- package/agents/codex.md +0 -31
- package/agents/cursor.md +0 -36
- package/agents/generic.md +0 -49
- package/agents/windsurf.md +0 -20
- 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 -20911
- package/scripts/README.md +0 -23
- 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/LICENSE
CHANGED
|
@@ -1,26 +1,21 @@
|
|
|
1
|
-
License
|
|
2
|
-
Copyright © 2025 Hold Your Voice (holdyourvoice.com)
|
|
1
|
+
MIT License
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
Copyright (c) 2026 Shashank
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
analysis tool.
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
3. **No Warranty**: This software is provided "as is" without warranty of
|
|
21
|
-
any kind, express or implied.
|
|
22
|
-
|
|
23
|
-
4. **Limitation of Liability**: Hold Your Voice shall not be liable for any
|
|
24
|
-
damages arising from the use of this software.
|
|
25
|
-
|
|
26
|
-
For licensing inquiries: hello@holdyourvoice.com
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/Readme.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Hold Your Voice
|
|
2
|
+
|
|
3
|
+
Hold Your Voice is an MIT-licensed, local-first writing gate for people who want AI help without losing the parts of their writing that make it theirs.
|
|
4
|
+
|
|
5
|
+
It checks a draft through two separate programs:
|
|
6
|
+
|
|
7
|
+
- **VoiceDNA** compares the draft with 13 observable elements from your own local writing samples.
|
|
8
|
+
- **AI Editor** flags a small, versioned set of editorial patterns that can make writing generic, formulaic, or inflated.
|
|
9
|
+
|
|
10
|
+
Those programs keep separate findings, scores, and pass states. A strong result from one never cancels a failure in the other. The tool creates a tiered editing brief, then checks the candidate again before you accept it.
|
|
11
|
+
|
|
12
|
+
Everything runs from local files: accounts, API calls, MCP servers, telemetry, payment collection, and runtime network requests stay out of the core path.
|
|
13
|
+
|
|
14
|
+
> **Status:** the public CLI is published as [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv). It runs locally and makes no runtime network requests.
|
|
15
|
+
|
|
16
|
+
## Why it exists
|
|
17
|
+
|
|
18
|
+
A draft can avoid obvious AI-shaped writing and still sound unlike its author. It can also match a writer’s short sentences while leaning on empty persuasion templates. Those are different problems. Treating them as one generic score hides the useful signal.
|
|
19
|
+
|
|
20
|
+
Hold Your Voice keeps the work visible:
|
|
21
|
+
|
|
22
|
+
| Question | Program | Result |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| Does the draft still resemble this writer’s observable mechanics? | VoiceDNA | A profile-based score, findings, and pass state. |
|
|
25
|
+
| Does the draft contain a configured editorial pattern worth inspecting? | AI Editor | A rule-based score, sentence findings, and pass state. |
|
|
26
|
+
| Did the rewrite introduce a new blocker or replace too much? | Verification | Regressions, preservation score, and a release decision. |
|
|
27
|
+
|
|
28
|
+
Its scope is a local writing gate. Authorship detection, fact checking, plagiarism review, and hosted generation each need their own tools. Hold Your Voice gives a writer or chosen model a narrow editing brief, then asks the same two engines to inspect the result.
|
|
29
|
+
|
|
30
|
+
## Start here
|
|
31
|
+
|
|
32
|
+
### Requirements
|
|
33
|
+
|
|
34
|
+
- Node.js 20 or newer.
|
|
35
|
+
- npm.
|
|
36
|
+
- At least two local writing samples you have the right to use.
|
|
37
|
+
|
|
38
|
+
### Install and verify
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx @holdyourvoice/hyv patterns
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run any command without a global install with `npx @holdyourvoice/hyv`. To use the short `hyv` command repeatedly:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npm install --global @holdyourvoice/hyv
|
|
48
|
+
hyv patterns
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
To contribute, clone this repository, run `npm install`, then run `npm test` and `npm run check:release`.
|
|
52
|
+
|
|
53
|
+
### Build a local VoiceDNA profile
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx @holdyourvoice/hyv profile profile.json samples/one.md samples/two.md --avoid=overused-phrase
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Use writing by one person, with a similar audience and format where possible. The command needs at least two samples. It creates a portable JSON profile and keeps the samples on your machine. Repeat `--avoid=phrase` for each local phrase that must block a candidate.
|
|
60
|
+
|
|
61
|
+
### Inspect a draft
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npx @holdyourvoice/hyv analyze draft.md profile.json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The result is JSON with independent reports:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"voiceDna": { "score": 93, "passed": true, "findings": [] },
|
|
72
|
+
"aiEditor": { "score": 88, "passed": true, "findings": [] },
|
|
73
|
+
"passed": true
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Read both reports. The outer `passed` field means each engine passed. Scores remain independent.
|
|
78
|
+
|
|
79
|
+
### Create an editing brief
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx @holdyourvoice/hyv rewrite-prompt draft.md profile.json > rewrite-brief.md
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Give the brief and draft to a human editor or any model you trust. This repository stays out of that provider call. Ask for replacement sentences keyed by sentence number, then save the output as a separate candidate file.
|
|
86
|
+
|
|
87
|
+
### Verify the candidate
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx @holdyourvoice/hyv verify draft.md candidate.md profile.json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`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.
|
|
94
|
+
|
|
95
|
+
## The editing loop
|
|
96
|
+
|
|
97
|
+
```mermaid
|
|
98
|
+
flowchart LR
|
|
99
|
+
S[Your local samples] --> P[Build profile]
|
|
100
|
+
P --> D[VoiceDNA JSON]
|
|
101
|
+
T[Draft] --> A[Analyze]
|
|
102
|
+
D --> A
|
|
103
|
+
A --> V[VoiceDNA report]
|
|
104
|
+
A --> E[AI Editor report]
|
|
105
|
+
V --> B[Tiered brief]
|
|
106
|
+
E --> B
|
|
107
|
+
B --> C[Human or chosen model]
|
|
108
|
+
T --> G[Verify candidate]
|
|
109
|
+
C --> G
|
|
110
|
+
D --> G
|
|
111
|
+
G --> R[Pass or inspect regressions]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The tool never applies changes to your draft. You decide which findings are valid, apply replacement sentences deliberately, and run the final check.
|
|
115
|
+
|
|
116
|
+
## The five rewrite tiers
|
|
117
|
+
|
|
118
|
+
The prompt has an order. Lower tiers can refine a higher tier; they cannot override it.
|
|
119
|
+
|
|
120
|
+
1. **Tier 0: preservation.** Keep facts, names, numbers, claims, and every unflagged sentence exactly. Keep the response within the supplied sentences.
|
|
121
|
+
2. **Tier 1: release blockers.** Resolve profile avoid-list phrases and red findings.
|
|
122
|
+
3. **Tier 2: VoiceDNA.** Use the 13 profile elements as a writer-specific target.
|
|
123
|
+
4. **Tier 3: AI Editor.** Inspect yellow findings. Change a line only when the repair helps.
|
|
124
|
+
5. **Tier 4: output.** Return replacement sentences keyed by sentence number.
|
|
125
|
+
|
|
126
|
+
This order protects meaning before style. Read the complete [prompt contract](docs/PROMPT-CONTRACT.md) before changing it.
|
|
127
|
+
|
|
128
|
+
## VoiceDNA: 13 observable elements
|
|
129
|
+
|
|
130
|
+
VoiceDNA is a local profile of writing mechanics drawn from the samples you choose. It makes no claim about personality. Its structural measurements support Unicode writing; the current point-of-view and transition lists are English-specific evidence.
|
|
131
|
+
|
|
132
|
+
| # | Element | What it captures | Current gate behavior |
|
|
133
|
+
| --- | --- | --- | --- |
|
|
134
|
+
| 1 | Sentence length | Mean words per sentence | Yellow finding outside the profile band. |
|
|
135
|
+
| 2 | Sentence variation | Spread of sentence lengths | Used to set the sentence-length tolerance. |
|
|
136
|
+
| 3 | Sentence structure | Frequent three-word openings | Profile evidence. |
|
|
137
|
+
| 4 | Rhythm | Change between neighbouring sentence lengths | Profile evidence. |
|
|
138
|
+
| 5 | Paragraph length | Mean sentences per paragraph | Profile evidence. |
|
|
139
|
+
| 6 | Opening moves | Frequent first words | Profile evidence. |
|
|
140
|
+
| 7 | Vocabulary | Frequent non-stop words | Profile evidence and rewrite context. |
|
|
141
|
+
| 8 | Lexical density | Share of non-stop words | Profile evidence. |
|
|
142
|
+
| 9 | Point of view | First, second, third, or mixed | Yellow finding for a dominant-profile mismatch. |
|
|
143
|
+
| 10 | Punctuation | Counts of selected marks | Profile evidence. |
|
|
144
|
+
| 11 | Case style | Lowercase, standard, or mixed | Yellow finding on mismatch. |
|
|
145
|
+
| 12 | Question rate | Sentences ending in questions | Yellow finding for a material difference. |
|
|
146
|
+
| 13 | Transitions | Frequent recognised connectors | Profile evidence and rewrite context. |
|
|
147
|
+
|
|
148
|
+
An explicit profile avoid list creates red findings. The other evidence-only elements are calculated and shown. Future enforcement needs a written policy, counterexamples, and tests.
|
|
149
|
+
|
|
150
|
+
Read the full [VoiceDNA reference](docs/VOICE-DNA.md) and [Wiki guide](https://github.com/shashank-sn/holdyourvoice/wiki/VoiceDNA).
|
|
151
|
+
|
|
152
|
+
## AI Editor: inspectable rules
|
|
153
|
+
|
|
154
|
+
AI Editor uses a local, deterministic ruleset. Each rule has a stable ID, severity, reason, and repair direction. Run this command to see the rules that actually execute in your checkout:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npx @holdyourvoice/hyv patterns
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Red findings are release blockers. Yellow findings are a request to inspect a sentence in context. A match never proves who wrote the text, and a clean scan never proves the text is good.
|
|
161
|
+
|
|
162
|
+
The repository also includes a public [220-pattern editorial catalog](docs/patterns/AI-WRITING-PATTERNS-1-220.md). That catalog is broader than the executable ruleset on purpose. A catalog entry becomes executable only after the project has defined its counterexamples, reviewed public provenance, written tests, and decided the rule is narrow enough to help without creating noise.
|
|
163
|
+
|
|
164
|
+
## Verification contract
|
|
165
|
+
|
|
166
|
+
Verification analyzes the original and candidate with the same profile and ruleset. It passes only when:
|
|
167
|
+
|
|
168
|
+
- VoiceDNA passes.
|
|
169
|
+
- AI Editor passes.
|
|
170
|
+
- The candidate introduces zero new red findings.
|
|
171
|
+
- The lexical preservation score is at least 70.
|
|
172
|
+
|
|
173
|
+
The preservation score is a guardrail based on retained original words longer than four characters. A human still needs to review facts, source links, intent, and reader value.
|
|
174
|
+
|
|
175
|
+
## Commands
|
|
176
|
+
|
|
177
|
+
| Command | Input | Output | Use it when |
|
|
178
|
+
| --- | --- | --- | --- |
|
|
179
|
+
| `hyv profile <profile.json> <sample...>` | Two or more text files | Profile JSON | You need a new local reference. |
|
|
180
|
+
| `hyv analyze <draft> <profile.json>` | Draft and profile | Analysis JSON | You need both reports before editing. |
|
|
181
|
+
| `hyv rewrite-prompt <draft> <profile.json>` | Draft and profile | Markdown editing brief | You need a constrained request for an editor or model. |
|
|
182
|
+
| `hyv verify <original> <candidate> <profile.json>` | Original, candidate, profile | Verification JSON and exit code | You need the candidate gate. |
|
|
183
|
+
| `hyv patterns` | None | Ruleset JSON | You need the exact enabled rules. |
|
|
184
|
+
|
|
185
|
+
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.
|
|
186
|
+
|
|
187
|
+
## Project map
|
|
188
|
+
|
|
189
|
+
| Path | Responsibility |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| `src/contracts.ts` | Profiles, findings, reports, analysis, and verification data shapes. |
|
|
192
|
+
| `src/text.ts` | Sentence, paragraph, word, and basic statistics helpers. |
|
|
193
|
+
| `src/voice-dna.ts` | Builds profiles and runs VoiceDNA checks. |
|
|
194
|
+
| `src/ai-editor.ts` | Owns the versioned deterministic editorial rules. |
|
|
195
|
+
| `src/pipeline.ts` | Combines pass states, makes briefs, and verifies candidates. |
|
|
196
|
+
| `src/cli.ts` | Local file and standard-input command adapter. |
|
|
197
|
+
| `src/pipeline.test.ts` | Contract and regression tests. |
|
|
198
|
+
| `CONTRIBUTING.md` | Public-safety rules and the contributor model. |
|
|
199
|
+
| `scripts/release-audit.mjs` | Checks source files for credential and network markers. |
|
|
200
|
+
|
|
201
|
+
`pipeline.ts` is the sole composition point. It combines pass states and preserves each engine’s separate score.
|
|
202
|
+
|
|
203
|
+
## Privacy and data rights
|
|
204
|
+
|
|
205
|
+
The runtime uses files on your machine. Samples, drafts, profiles, candidates, feedback history, embeddings, and client data stay there.
|
|
206
|
+
|
|
207
|
+
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.
|
|
208
|
+
|
|
209
|
+
See the [privacy guide](https://github.com/shashank-sn/holdyourvoice/wiki/Privacy-and-Data-Rights) for maintainer and contributor boundaries.
|
|
210
|
+
|
|
211
|
+
## Benchmarks and claims
|
|
212
|
+
|
|
213
|
+
The repository preserves historical product material in [docs/BENCHMARKS.md](docs/BENCHMARKS.md), with related public articles about the [Voice Memory Composer](https://holdyourvoice.com/blog/voice-memory-composer) and [Hold Your Voice vs GPT-5.6 writing](https://holdyourvoice.com/blog/hold-your-voice-vs-gpt-5-6-writing).
|
|
214
|
+
|
|
215
|
+
Treat those as dated reference material. A reproducible benchmark needs rights-cleared data, frozen settings, a published rubric, separate dimensions, and visible limitations. The [benchmark guide](https://github.com/shashank-sn/holdyourvoice/wiki/Benchmarks-and-Research) explains the standard.
|
|
216
|
+
|
|
217
|
+
## Documentation
|
|
218
|
+
|
|
219
|
+
| Read this | When you need |
|
|
220
|
+
| --- | --- |
|
|
221
|
+
| [The complete Wiki](https://github.com/shashank-sn/holdyourvoice/wiki) | Product, workflow, and contributor documentation. |
|
|
222
|
+
| [Thesis](docs/THESIS.md) | The design argument for two independent engines. |
|
|
223
|
+
| [Architecture](docs/ARCHITECTURE.md) | Source boundaries and extension rules. |
|
|
224
|
+
| [Prompt contract](docs/PROMPT-CONTRACT.md) | The tier order and editing constraints. |
|
|
225
|
+
| [Pattern taxonomy](docs/PATTERN-TAXONOMY.md) | The catalog/executable-rule boundary. |
|
|
226
|
+
| [Support](SUPPORT.md) | Funding without a feature gate. |
|
|
227
|
+
|
|
228
|
+
## Contribute
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npm test
|
|
232
|
+
npm run check:release
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Keep changes narrow. Add tests when code behavior changes. Separate current executable behavior, editorial guidance, historical research, and proposals. Keep private, client, secret, and unlicensed material out of issues, fixtures, tests, and documentation.
|
|
236
|
+
|
|
237
|
+
## npm releases
|
|
238
|
+
|
|
239
|
+
`@holdyourvoice/hyv` is published automatically after a change to the package source reaches `main`. The workflow publishes only when the version in `package.json` is not already on npm, so bump that version in the same pull request as a release-worthy change. It runs the tests and release audit before publishing, then verifies that npm reports the package as MIT licensed.
|
|
240
|
+
|
|
241
|
+
## Support
|
|
242
|
+
|
|
243
|
+
Hold Your Voice stays fully open source. Read the [funding policy](FUNDING.md) or [sponsor maintenance on GitHub](https://github.com/sponsors/shashank-sn) with a one-time or recurring sponsorship. Sponsorship funds maintenance while the feature set and local privacy contract remain the same for everyone.
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
[MIT](LICENSE). You can use, modify, and distribute the code under its terms. Third-party writing and data retain their own rights.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { sentences } from './text.js';
|
|
2
|
+
export const RULESET_VERSION = '2026.08.04.1';
|
|
3
|
+
export const rules = [
|
|
4
|
+
['ai.delve', 'red', /\bdelve\b/i, 'Generic AI verb.', 'Use a concrete verb.'], ['ai.leverage', 'red', /\bleverage\b/i, 'Business-jargon default.', 'Say what is being used.'], ['ai.tapestry', 'red', /\btapestry\b/i, 'Abstract metaphor without content.', 'Name the thing.'], ['ai.holistic', 'red', /\bholistic\b/i, 'Vague qualifier.', 'Describe the actual approach.'], ['ai.robust', 'yellow', /\brobust\b/i, 'Claims strength without evidence.', 'State what makes it strong.'], ['ai.landscape', 'yellow', /\blandscape\b/i, 'Generic context word.', 'Name the field.'], ['ai.game-changer', 'red', /\bgame[ -]changer\b/i, 'Promotional cliche.', 'Explain what changed.'], ['ai.formulaic-connector', 'yellow', /\b(firstly|secondly|moreover|furthermore|in conclusion)\b/i, 'Formulaic connector.', 'State the next point directly.'], ['ai.hedging', 'yellow', /\b(arguably|perhaps|it is worth noting|some might say)\b/i, 'Hedge hides the claim.', 'Make or remove the claim.'], ['ai.signpost', 'yellow', /\b(this is why|this is how|here.?s why)\b/i, 'Meta-signpost.', 'Lead with the observation.'], ['ai.not-just', 'red', /\bnot just\b.{0,80}\bbut\b/i, 'Binary persuasion template.', 'State the point plainly.'], ['ai.truth-setup', 'yellow', /\b(the hard|the brutal|the uncomfortable) truth\b/i, 'Manufactured revelation.', 'Name the fact.'], ['ai.em-dash', 'yellow', /—/, 'Dramatic em-dash use.', 'Use a sentence break when needed.'], ['ai.question-hook', 'yellow', /^(have you|do you|what if|why do|how do)\b/i, 'Question opener instead of a concrete start.', 'Open from an observation.'], ['ai.abstract-cluster', 'yellow', /\b(alignment|authenticity|clarity|strategy|value)\b.*\b(alignment|authenticity|clarity|strategy|value)\b/i, 'Abstract nouns pile up.', 'Use concrete nouns and actions.']
|
|
5
|
+
].map(([id, severity, expression, reason, suggestion]) => ({ id: id, severity: severity, expression: expression, reason: reason, suggestion: suggestion }));
|
|
6
|
+
export function analyzeAiEditor(text) { const findings = []; for (const s of sentences(text))
|
|
7
|
+
for (const r of rules)
|
|
8
|
+
if (r.expression.test(s.text))
|
|
9
|
+
findings.push({ engine: 'ai_editor', id: r.id, severity: r.severity, sentence: s.index, excerpt: s.text, reason: r.reason, suggestion: r.suggestion }); const reds = findings.filter(f => f.severity === 'red').length, score = Math.max(0, 100 - reds * 18 - (findings.length - reds) * 6); return { engine: 'ai_editor', version: RULESET_VERSION, score, passed: reds === 0, findings }; }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { analyzeAiEditor, rules } from './ai-editor.js';
|
|
4
|
+
test('publishes executable rules with stable IDs and repair directions', () => {
|
|
5
|
+
assert.ok(rules.length > 0);
|
|
6
|
+
for (const rule of rules) {
|
|
7
|
+
assert.match(rule.id, /^ai\./);
|
|
8
|
+
assert.ok(rule.reason.length > 0);
|
|
9
|
+
assert.ok(rule.suggestion.length > 0);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
test('detects every executable rule against its exact sentence', () => {
|
|
13
|
+
const examples = [
|
|
14
|
+
['ai.delve', 'we will delve into it.'],
|
|
15
|
+
['ai.leverage', 'we leverage the existing logs.'],
|
|
16
|
+
['ai.tapestry', 'the tapestry explains the work.'],
|
|
17
|
+
['ai.holistic', 'a holistic review starts today.'],
|
|
18
|
+
['ai.robust', 'robust evidence supports the claim.'],
|
|
19
|
+
['ai.landscape', 'the market landscape changed.'],
|
|
20
|
+
['ai.game-changer', 'this is a game-changer.'],
|
|
21
|
+
['ai.formulaic-connector', 'Firstly, check the invoice.'],
|
|
22
|
+
['ai.hedging', 'Perhaps the invoice is late.'],
|
|
23
|
+
['ai.signpost', 'This is why the invoice matters.'],
|
|
24
|
+
['ai.not-just', 'this is not just fast but reliable.'],
|
|
25
|
+
['ai.truth-setup', 'the hard truth is in the logs.'],
|
|
26
|
+
['ai.em-dash', 'the logs failed — retry later.'],
|
|
27
|
+
['ai.question-hook', 'Have you checked the logs?'],
|
|
28
|
+
['ai.abstract-cluster', 'alignment and clarity are missing.'],
|
|
29
|
+
];
|
|
30
|
+
for (const [id, example] of examples) {
|
|
31
|
+
const report = analyzeAiEditor(example);
|
|
32
|
+
assert.deepEqual(report.findings.map((finding) => [finding.id, finding.sentence]), [[id, 1]], id);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
test('keeps a counterexample for every executable rule', () => {
|
|
36
|
+
const counterexamples = [
|
|
37
|
+
['ai.delve', 'we inspect the logs.'],
|
|
38
|
+
['ai.leverage', 'we use the existing logs.'],
|
|
39
|
+
['ai.tapestry', 'the report explains the work.'],
|
|
40
|
+
['ai.holistic', 'the review covers the named files.'],
|
|
41
|
+
['ai.robust', 'the evidence includes three dated reports.'],
|
|
42
|
+
['ai.landscape', 'the market changed after the price cut.'],
|
|
43
|
+
['ai.game-changer', 'the release removed a manual step.'],
|
|
44
|
+
['ai.formulaic-connector', 'next, check the invoice.'],
|
|
45
|
+
['ai.hedging', 'the invoice is late.'],
|
|
46
|
+
['ai.signpost', 'the invoice matters because it is overdue.'],
|
|
47
|
+
['ai.not-just', 'the service is fast and reliable.'],
|
|
48
|
+
['ai.truth-setup', 'the logs show the service failed.'],
|
|
49
|
+
['ai.em-dash', 'the logs failed; retry later.'],
|
|
50
|
+
['ai.question-hook', 'the reviewer asked, have you checked the logs?'],
|
|
51
|
+
['ai.abstract-cluster', 'the editor checked the contract and sent the invoice.'],
|
|
52
|
+
];
|
|
53
|
+
for (const [id, example] of counterexamples) {
|
|
54
|
+
assert.equal(analyzeAiEditor(example).findings.some((finding) => finding.id === id), false, id);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
test('keeps yellow findings as review cues rather than release blockers', () => {
|
|
58
|
+
const report = analyzeAiEditor('Firstly, the editor checked the invoice.');
|
|
59
|
+
assert.ok(report.findings.every((finding) => finding.severity === 'yellow'));
|
|
60
|
+
assert.equal(report.passed, true);
|
|
61
|
+
});
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { rules, RULESET_VERSION } from './ai-editor.js';
|
|
4
|
+
import { analyze, rewritePrompt, verify } from './pipeline.js';
|
|
5
|
+
import { buildProfile } from './voice-dna.js';
|
|
6
|
+
const usage = 'Commands: profile, analyze, rewrite-prompt, verify, patterns';
|
|
7
|
+
function input(path) {
|
|
8
|
+
return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
|
|
9
|
+
}
|
|
10
|
+
function isNumberRecord(value) {
|
|
11
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype)
|
|
12
|
+
return false;
|
|
13
|
+
return Object.values(value).every((item) => typeof item === 'number' && Number.isFinite(item));
|
|
14
|
+
}
|
|
15
|
+
function isPunctuation(value) {
|
|
16
|
+
const marks = ['!', '?', ';', ':', '—'];
|
|
17
|
+
return isNumberRecord(value) && Object.values(value).every((item) => item >= 0) && Object.keys(value).length === marks.length && marks.every((mark) => mark in value);
|
|
18
|
+
}
|
|
19
|
+
function isMetrics(value) {
|
|
20
|
+
if (!value || typeof value !== 'object')
|
|
21
|
+
return false;
|
|
22
|
+
const metrics = value;
|
|
23
|
+
const numbers = [metrics.sentenceLength, metrics.sentenceVariation, metrics.rhythm, metrics.paragraphLength, metrics.lexicalDensity, metrics.questionRate];
|
|
24
|
+
const stringArrays = [metrics.sentenceStructure, metrics.openingMoves, metrics.vocabulary, metrics.transitions];
|
|
25
|
+
return numbers.every((item) => typeof item === 'number' && Number.isFinite(item) && item >= 0)
|
|
26
|
+
&& typeof metrics.lexicalDensity === 'number' && metrics.lexicalDensity <= 1
|
|
27
|
+
&& typeof metrics.questionRate === 'number' && metrics.questionRate <= 1
|
|
28
|
+
&& ['first_person', 'second_person', 'third_person', 'mixed'].includes(metrics.pointOfView ?? '')
|
|
29
|
+
&& ['lowercase', 'standard', 'mixed'].includes(metrics.caseStyle ?? '')
|
|
30
|
+
&& stringArrays.every((items) => Array.isArray(items) && items.every((item) => typeof item === 'string'))
|
|
31
|
+
&& isPunctuation(metrics.punctuation);
|
|
32
|
+
}
|
|
33
|
+
function readProfile(path) {
|
|
34
|
+
const value = JSON.parse(input(path));
|
|
35
|
+
if (!value || typeof value !== 'object')
|
|
36
|
+
throw new Error('Profile must be a JSON object.');
|
|
37
|
+
const profile = value;
|
|
38
|
+
if (profile.version !== '2' || typeof profile.sampleCount !== 'number' || !Number.isInteger(profile.sampleCount) || profile.sampleCount < 2 || !isMetrics(profile.metrics) || !Array.isArray(profile.avoid) || !profile.avoid.every((item) => typeof item === 'string' && item.trim().length > 0)) {
|
|
39
|
+
throw new Error('Profile is not a valid Hold Your Voice version 2 profile. Rebuild it with the profile command.');
|
|
40
|
+
}
|
|
41
|
+
return profile;
|
|
42
|
+
}
|
|
43
|
+
function json(value) {
|
|
44
|
+
console.log(JSON.stringify(value, null, 2));
|
|
45
|
+
}
|
|
46
|
+
function profileArguments(args) {
|
|
47
|
+
const [output, ...rest] = args;
|
|
48
|
+
const samples = [];
|
|
49
|
+
const avoid = [];
|
|
50
|
+
for (const argument of rest) {
|
|
51
|
+
if (argument.startsWith('--avoid=')) {
|
|
52
|
+
const phrase = argument.slice('--avoid='.length).trim();
|
|
53
|
+
if (!phrase)
|
|
54
|
+
throw new Error('Avoid phrases must use --avoid=phrase.');
|
|
55
|
+
avoid.push(phrase);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
samples.push(argument);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!output || samples.length < 2)
|
|
62
|
+
throw new Error('Usage: hyv profile profile.json sample-a.md sample-b.md [sample-c.md] [--avoid=phrase]');
|
|
63
|
+
return { output, samples, avoid };
|
|
64
|
+
}
|
|
65
|
+
export function runCli(args) {
|
|
66
|
+
const [command, ...rest] = args;
|
|
67
|
+
if (command === 'profile') {
|
|
68
|
+
const { output, samples, avoid } = profileArguments(rest);
|
|
69
|
+
writeFileSync(output, `${JSON.stringify(buildProfile(samples.map(input), avoid), null, 2)}\n`);
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
if (command === 'analyze') {
|
|
73
|
+
const [draft, profilePath] = rest;
|
|
74
|
+
if (!draft || !profilePath)
|
|
75
|
+
throw new Error('Usage: hyv analyze draft.md profile.json');
|
|
76
|
+
json(analyze(input(draft), readProfile(profilePath)));
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (command === 'rewrite-prompt') {
|
|
80
|
+
const [draft, profilePath] = rest;
|
|
81
|
+
if (!draft || !profilePath)
|
|
82
|
+
throw new Error('Usage: hyv rewrite-prompt draft.md profile.json');
|
|
83
|
+
console.log(rewritePrompt(input(draft), readProfile(profilePath)));
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (command === 'verify') {
|
|
87
|
+
const [original, candidate, profilePath] = rest;
|
|
88
|
+
if (!original || !candidate || !profilePath)
|
|
89
|
+
throw new Error('Usage: hyv verify original.md candidate.md profile.json');
|
|
90
|
+
const result = verify(input(original), input(candidate), readProfile(profilePath));
|
|
91
|
+
json(result);
|
|
92
|
+
return result.passed ? 0 : 2;
|
|
93
|
+
}
|
|
94
|
+
if (command === 'patterns') {
|
|
95
|
+
json({ version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) });
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`${usage}.`);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
process.exitCode = runCli(process.argv.slice(2));
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
}
|
package/dist/cli.test.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
const cli = new URL('./cli.js', import.meta.url).pathname;
|
|
8
|
+
function run(...args) {
|
|
9
|
+
return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8' });
|
|
10
|
+
}
|
|
11
|
+
test('creates an explicit local avoid list and exposes the ruleset', () => {
|
|
12
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
13
|
+
try {
|
|
14
|
+
const first = join(directory, 'first.md');
|
|
15
|
+
const second = join(directory, 'second.md');
|
|
16
|
+
const profile = join(directory, 'profile.json');
|
|
17
|
+
writeFileSync(first, 'i write plainly. i name the work.');
|
|
18
|
+
writeFileSync(second, 'i keep the mechanism clear. i avoid filler.');
|
|
19
|
+
const created = run('profile', profile, first, second, '--avoid=unlock');
|
|
20
|
+
assert.equal(created.status, 0, created.stderr);
|
|
21
|
+
assert.deepEqual(JSON.parse(readFileSync(profile, 'utf8')).avoid, ['unlock']);
|
|
22
|
+
const patterns = run('patterns');
|
|
23
|
+
assert.equal(patterns.status, 0, patterns.stderr);
|
|
24
|
+
assert.ok(JSON.parse(patterns.stdout).rules.every((rule) => rule.id && rule.severity && rule.reason && rule.suggestion));
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
rmSync(directory, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
|
|
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 original = join(directory, 'original.md');
|
|
37
|
+
const candidate = join(directory, 'candidate.md');
|
|
38
|
+
writeFileSync(first, 'i write plainly. i name the work.');
|
|
39
|
+
writeFileSync(second, 'i keep the mechanism clear. i avoid filler.');
|
|
40
|
+
writeFileSync(original, 'i name the work.');
|
|
41
|
+
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);
|
|
44
|
+
assert.equal(verification.status, 2);
|
|
45
|
+
assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'original', 'passed', 'preservationScore', 'regressions', 'version']);
|
|
46
|
+
assert.equal(run('unknown-command').status, 1);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
rmSync(directory, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
test('rejects a malformed hand-edited profile before analysis', () => {
|
|
53
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
54
|
+
try {
|
|
55
|
+
const draft = join(directory, 'draft.md');
|
|
56
|
+
const profile = join(directory, 'profile.json');
|
|
57
|
+
writeFileSync(draft, 'i name the work.');
|
|
58
|
+
writeFileSync(profile, JSON.stringify({ version: '2', sampleCount: 2, metrics: {}, avoid: [1] }));
|
|
59
|
+
const result = run('analyze', draft, profile);
|
|
60
|
+
assert.equal(result.status, 1);
|
|
61
|
+
assert.match(result.stderr, /not a valid Hold Your Voice version 2 profile/);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
rmSync(directory, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
test('rejects malformed profile enum values and punctuation', () => {
|
|
68
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
69
|
+
try {
|
|
70
|
+
const draft = join(directory, 'draft.md');
|
|
71
|
+
const profile = join(directory, 'profile.json');
|
|
72
|
+
writeFileSync(draft, 'i name the work.');
|
|
73
|
+
writeFileSync(profile, JSON.stringify({
|
|
74
|
+
version: '2',
|
|
75
|
+
sampleCount: 2,
|
|
76
|
+
metrics: {
|
|
77
|
+
sentenceLength: 4,
|
|
78
|
+
sentenceVariation: 1,
|
|
79
|
+
sentenceStructure: [],
|
|
80
|
+
rhythm: 1,
|
|
81
|
+
paragraphLength: 1,
|
|
82
|
+
openingMoves: [],
|
|
83
|
+
vocabulary: [],
|
|
84
|
+
lexicalDensity: 0.5,
|
|
85
|
+
pointOfView: 'fourth_person',
|
|
86
|
+
punctuation: [],
|
|
87
|
+
caseStyle: 'titlecase',
|
|
88
|
+
questionRate: 0,
|
|
89
|
+
transitions: [],
|
|
90
|
+
},
|
|
91
|
+
avoid: [''],
|
|
92
|
+
}));
|
|
93
|
+
assert.equal(run('analyze', draft, profile).status, 1);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
rmSync(directory, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
test('rejects hand-edited metrics outside their semantic bounds', () => {
|
|
100
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
101
|
+
try {
|
|
102
|
+
const first = join(directory, 'first.md');
|
|
103
|
+
const second = join(directory, 'second.md');
|
|
104
|
+
const profile = join(directory, 'profile.json');
|
|
105
|
+
const draft = join(directory, 'draft.md');
|
|
106
|
+
writeFileSync(first, 'i write plainly.');
|
|
107
|
+
writeFileSync(second, 'i name the work.');
|
|
108
|
+
writeFileSync(draft, 'i name the work.');
|
|
109
|
+
assert.equal(run('profile', profile, first, second).status, 0);
|
|
110
|
+
const malformed = JSON.parse(readFileSync(profile, 'utf8'));
|
|
111
|
+
malformed.sampleCount = 2.5;
|
|
112
|
+
malformed.metrics.questionRate = 1.2;
|
|
113
|
+
writeFileSync(profile, JSON.stringify(malformed));
|
|
114
|
+
assert.equal(run('analyze', draft, profile).status, 1);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
rmSync(directory, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|