@holdyourvoice/hyv 3.4.4 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +7 -0
- package/dist/agents/load.test.js +1 -1
- package/dist/ai-editor-rules.js +34 -0
- package/dist/ai-editor.js +120 -9
- package/dist/ai-editor.test.js +99 -8
- package/dist/ai-shadow-fixtures.js +7 -0
- package/dist/ai-shadow-generator.js +17 -0
- package/dist/backtest.js +16 -0
- package/dist/backtest.test.js +20 -0
- package/dist/cli.js +225 -8
- package/dist/cli.test.js +102 -5
- package/dist/editorial-packs.js +1 -0
- package/dist/hold-your-voice.mcpb +0 -0
- package/dist/local-eval.js +98 -0
- package/dist/local-eval.test.js +20 -0
- package/dist/mcp-tools.js +22 -2
- package/dist/mcp-tools.test.js +33 -1
- package/dist/mcp.js +35 -4
- package/dist/mcp.test.js +2 -2
- package/dist/pipeline.js +5 -4
- package/dist/pipeline.test.js +12 -0
- package/dist/profile-compose.js +97 -0
- package/dist/profile-compose.test.js +32 -0
- package/dist/profile-score.js +79 -0
- package/dist/profile-score.test.js +22 -0
- package/dist/profile-watch.js +34 -0
- package/dist/profile-watch.test.js +23 -0
- package/dist/profile.js +33 -2
- package/dist/profile.test.js +27 -0
- package/dist/rebuild-task.test.js +1 -1
- package/dist/rule-allowances.js +27 -0
- package/dist/rule-allowances.test.js +17 -0
- package/dist/sample-ingest.js +94 -0
- package/dist/sample-ingest.test.js +52 -0
- package/dist/strict-quality.js +64 -0
- package/dist/strict-quality.test.js +62 -0
- package/dist/version.js +1 -1
- package/dist/voice-dna.js +33 -1
- package/dist/voice-dna.test.js +12 -1
- package/dist/writing-examples.js +83 -0
- package/dist/writing-examples.test.js +35 -0
- package/package.json +56 -11
- package/skills/hyv-analyze/SKILL.md +3 -1
- package/skills/hyv-analyze/agent.json +2 -1
- package/skills/hyv-backtest/SKILL.md +14 -0
- package/skills/hyv-backtest/agent.json +16 -0
- package/skills/hyv-backtest/agents/openai.yaml +4 -0
- package/skills/hyv-evaluate-local/SKILL.md +14 -0
- package/skills/hyv-evaluate-local/agent.json +16 -0
- package/skills/hyv-evaluate-local/agents/openai.yaml +4 -0
- package/skills/hyv-final-check/SKILL.md +2 -0
- package/skills/hyv-find-writing-examples/SKILL.md +10 -0
- package/skills/hyv-find-writing-examples/agent.json +16 -0
- package/skills/hyv-find-writing-examples/agents/openai.yaml +4 -0
- package/skills/hyv-ingest/SKILL.md +31 -0
- package/skills/hyv-ingest/agent.json +28 -0
- package/skills/hyv-ingest/agents/openai.yaml +4 -0
- package/skills/hyv-patterns/SKILL.md +2 -0
- package/skills/hyv-profile/SKILL.md +2 -0
- package/skills/hyv-score/SKILL.md +26 -0
- package/skills/hyv-score/agent.json +28 -0
- package/skills/hyv-score/agents/openai.yaml +4 -0
- package/skills/hyv-strict-check/SKILL.md +26 -0
- package/skills/hyv-strict-check/agent.json +33 -0
- package/skills/hyv-strict-check/agents/openai.yaml +4 -0
- package/skills/hyv-verify/SKILL.md +1 -1
- package/skills/hyv-verify/agent.json +1 -0
package/Readme.md
CHANGED
|
@@ -81,6 +81,8 @@ npm install --global @holdyourvoice/hyv
|
|
|
81
81
|
|
|
82
82
|
For a one-off command, replace `hyv` with `npx @holdyourvoice/hyv`.
|
|
83
83
|
|
|
84
|
+
For Humanizer's complementary editor skill, install it separately with `npx skills add blader/humanizer`. It is not a HYV dependency: HYV's local AI Editor keeps its own versioned rules and final-output gate.
|
|
85
|
+
|
|
84
86
|
## basic workflow
|
|
85
87
|
|
|
86
88
|
1. Build a local profile from your samples.
|
|
@@ -124,7 +126,12 @@ producer | hyv final-check -
|
|
|
124
126
|
| Command | Purpose |
|
|
125
127
|
| --- | --- |
|
|
126
128
|
| `hyv profile <profile.json> <sample...>` | Build a local profile from two or more samples. |
|
|
129
|
+
| `hyv profile v3 <profile.json> --id=writer.channel --channel=email <sample...>` | Build a signed channel-specific Profile v3; optional tone is advisory metadata. |
|
|
130
|
+
| `hyv profile compose --ratio 70:30 <profile...>` | Locally compose two or more Profile v3 metrics without creating writing. |
|
|
131
|
+
| `hyv score <draft> <profile-v3.json> <heldout...>` | Measure the draft against a separate held-out local writing range; may abstain. |
|
|
132
|
+
| `hyv ingest <source> <export> --owner=owner --output=/absolute/dir` | Create redacted, owner-authorized Gmail or Telegram samples and a text-free receipt. |
|
|
127
133
|
| `hyv analyze <draft> <profile.json>` | Run VoiceDNA, AI Editor, and hygiene checks. |
|
|
134
|
+
| `hyv strict-check <draft> <profile-v3.json> <sample...>` | Run the opt-in strict local quality gate. It requires a calibrated V3 profile built from five or more samples, five non-duplicate samples with a consistent visible format and 1,500 words total, and returns `strict-ready`, `needs-human-review`, or `blocked`. |
|
|
128
135
|
| `hyv hygiene <draft> [--fix]` | Inspect hidden Unicode or write a conservative cleaned copy. |
|
|
129
136
|
| `hyv inspect-hidden-text <draft> [policy.json]` | Inspect hidden text with an optional policy. |
|
|
130
137
|
| `hyv apply-hidden-text-policy <draft> <policy.json> <output>` | Apply approved hidden-text removals. |
|
package/dist/agents/load.test.js
CHANGED
|
@@ -136,7 +136,7 @@ test('rejects unknown contract fields and invalid OpenAI interface metadata', ()
|
|
|
136
136
|
});
|
|
137
137
|
test('published packages declare command execution and required writes', () => {
|
|
138
138
|
const packages = loadAll();
|
|
139
|
-
const writers = new Set(['hyv-profile', 'hyv-hygiene', 'hyv-apply-hidden-text-policy', 'hyv-prepare-rewrite', 'hyv-prepare-judgment', 'hyv-prepare-rebuild', 'hyv-rebuild-writer-request', 'hyv-lifecycle', 'hyv-learning']);
|
|
139
|
+
const writers = new Set(['hyv-profile', 'hyv-ingest', 'hyv-hygiene', 'hyv-apply-hidden-text-policy', 'hyv-prepare-rewrite', 'hyv-prepare-judgment', 'hyv-prepare-rebuild', 'hyv-rebuild-writer-request', 'hyv-lifecycle', 'hyv-learning']);
|
|
140
140
|
for (const [id, pkg] of packages) {
|
|
141
141
|
assert.ok(pkg.descriptor.permissions.includes('execute_commands'), `${id} must require command execution`);
|
|
142
142
|
assert.equal(pkg.descriptor.permissions.includes('write_repository'), writers.has(id), `${id} write permission mismatch`);
|
package/dist/ai-editor-rules.js
CHANGED
|
@@ -148,4 +148,38 @@ export const rules = [
|
|
|
148
148
|
{ id: "hedge.performative-sincerity-adverb", severity: "yellow", expression: /\b(?:honestly|genuinely|truly|frankly|actually)\b/i, reason: "The adverb performs sincerity instead of adding evidence.", suggestion: "cut the adverb or replace it with the evidence" },
|
|
149
149
|
{ id: "ai.question-hook", severity: "yellow", expression: /^(?:have you|do you|what if|why do|how do)\b/i, reason: "A question opener delays the concrete observation.", suggestion: "Open from an observation." },
|
|
150
150
|
{ id: "ai.abstract-cluster", severity: "yellow", expression: /\b(?:alignment|authenticity|clarity|strategy|value)\b.*\b(?:alignment|authenticity|clarity|strategy|value)\b/i, reason: "Abstract nouns pile up without a mechanism.", suggestion: "Use concrete nouns and actions." },
|
|
151
|
+
{ id: "ai.inflated-significance", severity: "yellow", expression: /\b(?:marks?|marking)\s+(?:a\s+)?(?:pivotal|defining|landmark)\s+(?:moment|chapter|milestone)\b/i, reason: "The sentence announces historical importance without showing it.", suggestion: "Name the specific change, date, or consequence." },
|
|
152
|
+
{ id: "ai.notability-name-drop", severity: "yellow", expression: /\b(?:renowned|legendary|iconic)\s+(?:[A-Z][a-z]+\s+){1,3}(?:writer|scientist|scholar|artist|thinker)\b/, reason: "Prestige framing can stand in for the relevant work.", suggestion: "Name the work or contribution that matters here." },
|
|
153
|
+
{ id: "ai.shallow-participle-analysis", severity: "yellow", expression: /\b(?:highlighting|underscoring|reflecting)\s+(?:its|their|the)\s+(?:importance|significance|impact)\b/i, reason: "The participle announces analysis without supplying it.", suggestion: "State the specific implication or evidence." },
|
|
154
|
+
{ id: "ai.promotional-scene-setting", severity: "yellow", expression: /\b(?:in\s+)?(?:today['’]?s|the)\s+(?:rapidly\s+)?(?:changing|dynamic)\s+(?:business|digital|technology|market)\s+landscape\b/i, reason: "Generic scene-setting delays the actual subject.", suggestion: "Open with the concrete change or condition." },
|
|
155
|
+
{ id: "ai.vague-attribution", severity: "yellow", expression: /\b(?:experts|critics|observers|many)\s+(?:say|note|believe|argue)\b/i, reason: "The source of the claim is too vague to evaluate.", suggestion: "Name the source, evidence, or make the claim directly." },
|
|
156
|
+
{ id: "ai.challenges-outlook", severity: "yellow", expression: /\b(?:despite|while)\s+(?:these|the)\s+(?:challenges|obstacles).{0,80}\b(?:future|outlook)\b/i, reason: "A stock challenges-and-outlook turn can replace a concrete conclusion.", suggestion: "Name the remaining constraint and the next supported step." },
|
|
157
|
+
{ id: "ai.copula-avoidance", severity: "yellow", expression: /\b(?:serving|representing|offering)\s+as\s+(?:a|an)\s+(?:testament|reminder|symbol)\b/i, reason: "The construction avoids a direct claim.", suggestion: "Use a direct subject, verb, and supported claim." },
|
|
158
|
+
{ id: "ai.false-range", severity: "yellow", expression: /\bfrom\s+(?:the\s+)?(?:big\s+bang|ancient\s+times)\s+to\s+(?:dark\s+matter|the\s+modern\s+era)\b/i, reason: "A sweeping range can simulate scope without connecting the endpoints.", suggestion: "Name the specific connected topics instead." },
|
|
159
|
+
{ id: "ai.actorless-claim", severity: "yellow", expression: /\bit\s+is\s+(?:important|clear|worth\s+noting)\s+that\b/i, reason: "The claim has no accountable actor or evidence.", suggestion: "Name who knows this and why, or state the fact directly." },
|
|
160
|
+
{ id: "ai.chatbot-offer", severity: "yellow", expression: /\b(?:let me know if you(?:'d| would) like|i(?:'m| am) happy to help with anything else)\b/i, reason: "A generic assistant offer adds no useful next step.", suggestion: "Offer one concrete next action only when it is relevant." },
|
|
161
|
+
{ id: "ai.knowledge-limit-disclaimer", severity: "yellow", expression: /\b(?:as an ai|my knowledge (?:cutoff|is limited)|i do not have access to)\b/i, reason: "A model disclaimer does not advance the reader's task.", suggestion: "State the verified limit or omit the boilerplate." },
|
|
162
|
+
{ id: "ai.agreement-preamble", severity: "yellow", expression: /^\s*(?:absolutely|certainly|indeed)[,!]?\s+/i, reason: "An agreement preamble delays the answer.", suggestion: "Start with the answer or evidence.", scope: "line" },
|
|
163
|
+
{ id: "ai.qualifier-stack", severity: "yellow", expression: /\b(?:very|really|quite|highly)\s+(?:important|significant|valuable|effective)\b/i, reason: "Stacked qualifiers ask for emphasis instead of supplying evidence.", suggestion: "Replace the qualifier with the measurable consequence." },
|
|
164
|
+
{ id: "ai.generic-positive-ending", severity: "yellow", expression: /\b(?:the future is bright|the possibilities are endless|only time will tell)\b/i, reason: "A generic ending avoids a specific conclusion.", suggestion: "End on the concrete decision, constraint, or next step." },
|
|
165
|
+
{ id: "ai.at-its-core", severity: "yellow", expression: /\bat\s+(?:its|the)\s+core\b/i, reason: "The phrase announces depth instead of making the underlying claim.", suggestion: "State the underlying claim directly." },
|
|
166
|
+
{ id: "ai.section-announcement", severity: "yellow", expression: /\b(?:let'?s|let us)\s+(?:now\s+)?(?:explore|look at|turn to)\b/i, reason: "The sentence announces the next point instead of making it.", suggestion: "Start with the point itself." },
|
|
167
|
+
{ id: "ai.historical-implementation-aside", severity: "yellow", expression: /\b(?:once|formerly)\s+known\s+as\b.{0,80}\b(?:now|today)\b/i, reason: "A historical aside can add trivia without advancing the argument.", suggestion: "Keep it only when the name change explains the current claim." },
|
|
168
|
+
{ id: "ai.unraised-objection", severity: "yellow", expression: /\b(?:some\s+might\s+argue|one\s+could\s+argue)\b/i, reason: "The sentence raises an unnamed objection that the reader did not make.", suggestion: "Address a real, specific objection with evidence or remove it." },
|
|
169
|
+
{ id: "ai.fake-alternative", severity: "yellow", expression: /\b(?:whether\s+you\s+choose|regardless\s+of\s+whether)\b/i, reason: "The sentence presents a false choice instead of the actual decision.", suggestion: "Name the real options and the trade-off." },
|
|
170
|
+
{ id: "format.bold-bullet-label", severity: "yellow", expression: /^\s*(?:[-*+]|\d+[.)])\s+\*{1,2}[^*\n]{1,80}:\*{1,2}\s*/u, reason: "A bold label can turn a list into template-like mini-headings.", suggestion: "Use a plain list item unless the label carries real navigation value.", scope: "line" },
|
|
171
|
+
{ id: "format.title-case-heading", severity: "yellow", expression: /^#{1,6}\s+(?:[A-Z][a-z]+\s+){2,}[A-Z][a-z]+\s*$/u, reason: "A title-case heading can create generic template texture.", suggestion: "Use sentence case or a more specific heading.", scope: "line" },
|
|
172
|
+
{ id: "format.emoji-heading", severity: "yellow", expression: /^#{1,6}\s+\p{Extended_Pictographic}/u, reason: "An emoji heading can add template-like decoration without meaning.", suggestion: "Use a precise text heading.", scope: "line" },
|
|
173
|
+
{ id: "format.curly-quotes", severity: "yellow", expression: /[“”]/u, reason: "Decorative curly quotation marks can be a house style rather than evidence of a writing problem.", suggestion: "Keep the house style only when the selected profile demonstrates it; otherwise use the document’s existing quote style." },
|
|
174
|
+
{ id: "format.hyphenated-modifier-stack", severity: "yellow", expression: /\b(?:\p{L}+-){2,}\p{L}+\b/u, reason: "A stack of hyphenated modifiers can make prose sound manufactured.", suggestion: "Use a direct noun phrase or split the claim.", },
|
|
175
|
+
{ id: "ai.forced-triplet", severity: "yellow", expression: /\b(?:[\p{L}-]+\s*,\s*){2}(?:and\s+)?[\p{L}-]+\b/u, reason: "A neat three-item construction can read like generated cadence when it carries no real taxonomy.", suggestion: "Keep the list only when the three items are necessary and distinct." },
|
|
176
|
+
{ id: "ai.repeated-sentence-opening", severity: "yellow", expression: /\b\p{L}+/u, reason: "Three consecutive sentences opening the same way can create mechanical cadence.", suggestion: "Vary the openings or combine the sentences when the repetition adds no emphasis.", scope: "document" },
|
|
177
|
+
{ id: "format.bold-density", severity: "yellow", expression: /\*\*[^*\n]+\*\*/u, reason: "Repeated bold fragments can make prose read like a template instead of a document.", suggestion: "Keep bold only for real navigation or remove decorative emphasis.", scope: "document" },
|
|
178
|
+
{ id: "format.repeated-heading-body", severity: "yellow", expression: /^#{1,6}\s+(.+)$/mu, reason: "Repeating a heading verbatim in its body can add generated padding.", suggestion: "Start the body with the evidence or explanation instead of restating the heading.", scope: "document" },
|
|
179
|
+
{ id: "ai.clipped-fragment-run", severity: "yellow", expression: /\b\p{L}+/u, reason: "A run of clipped fragments can manufacture punchy rhythm without adding information.", suggestion: "Join the fragments into a complete sentence or keep only the one that carries the point.", scope: "document" },
|
|
180
|
+
{ id: "ai.formulaic-aphorism", severity: "yellow", expression: /\b(?:less is more|quality over quantity|the devil is in the details|actions speak louder than words)\b/i, reason: "A stock saying can stand in for the actual observation.", suggestion: "State the concrete lesson from this situation." },
|
|
181
|
+
{ id: "ai.fake-candid-opener", severity: "yellow", expression: /^\s*i(?:'m| am) (?:going to )?(?:be )?(?:honest|real)\b/i, reason: "A performed candid opening can add authority without evidence.", suggestion: "Open with the concrete observation or claim." },
|
|
182
|
+
{ id: "ai.metric-theater", severity: "yellow", expression: /\b(?:\d{1,2}:\d{2}\s*(?:a\.?(?:m)?\.?|p\.?(?:m)?\.?)|\d+(?:\.\d+)?x\s*(?:roi|return|growth|lift))\b/i, reason: "Hyper-specific numbers can simulate evidence when no source accompanies them.", suggestion: "Cite the measurement and source, or use only the detail the claim needs." },
|
|
183
|
+
{ id: "ai.jargon-stack", severity: "yellow", expression: /\b(?:synergy|leverage|alignment|ecosystem|framework|paradigm|stakeholder|scalable|holistic)\b/i, reason: "Several business abstractions in one sentence can hide the mechanism.", suggestion: "Replace the stack with the people, action, system, and result.", scope: "document" },
|
|
184
|
+
{ id: "ai.sentence-length-cluster", severity: "yellow", expression: /\b\p{L}+/u, reason: "A long run of similarly sized medium sentences can sound mechanically paced.", suggestion: "Vary length where it improves the natural rhythm of the argument.", scope: "document" },
|
|
151
185
|
];
|
package/dist/ai-editor.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { rules } from './ai-editor-rules.js';
|
|
2
2
|
import { sentences } from './text.js';
|
|
3
3
|
export { rules } from './ai-editor-rules.js';
|
|
4
|
-
export const RULESET_VERSION = '3.
|
|
5
|
-
const sentenceRules = rules.filter((rule) => rule.scope
|
|
4
|
+
export const RULESET_VERSION = '3.5.0-local.3';
|
|
5
|
+
const sentenceRules = rules.filter((rule) => rule.scope === undefined || rule.scope === 'sentence');
|
|
6
6
|
const lineRules = rules.filter((rule) => rule.scope === 'line');
|
|
7
|
+
const documentRules = rules.filter((rule) => rule.scope === 'document');
|
|
7
8
|
const ruleOrder = new Map(rules.map((rule, index) => [rule.id, index]));
|
|
8
9
|
const ruleIds = new Set(rules.map((rule) => rule.id));
|
|
9
10
|
const policyStates = new Set(['blocking', 'advisory', 'judgment-required', 'disabled']);
|
|
@@ -33,9 +34,65 @@ function policiesFor(profile) {
|
|
|
33
34
|
throw new Error(`Profile rulePolicy contains invalid state for rule ID: ${id}`);
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
|
-
return new Map(rules.map((rule) =>
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
return new Map(rules.map((rule) => {
|
|
38
|
+
const explicit = profile?.version === '3' ? profile.rulePolicy[rule.id] : undefined;
|
|
39
|
+
if (explicit)
|
|
40
|
+
return [rule.id, explicit];
|
|
41
|
+
if (profile?.version === '3' && profile.ruleAllowances?.[rule.id])
|
|
42
|
+
return [rule.id, 'disabled'];
|
|
43
|
+
return [rule.id, defaultPolicy(rule.id, rule.severity)];
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
function maskRange(characters, start, end) {
|
|
47
|
+
for (let index = start; index < end; index += 1)
|
|
48
|
+
if (characters[index] !== '\n')
|
|
49
|
+
characters[index] = ' ';
|
|
50
|
+
}
|
|
51
|
+
/** Masks Markdown regions that are not author prose while preserving offsets. */
|
|
52
|
+
export function maskNonProse(text) {
|
|
53
|
+
// String offsets elsewhere in HYV are UTF-16 code-unit offsets, so this
|
|
54
|
+
// buffer must use the same indexing even when prose contains emoji.
|
|
55
|
+
const characters = text.split('');
|
|
56
|
+
const lines = text.split('\n');
|
|
57
|
+
let offset = 0;
|
|
58
|
+
let fence;
|
|
59
|
+
let frontmatter = lines[0]?.trim() === '---';
|
|
60
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
61
|
+
const line = lines[lineIndex];
|
|
62
|
+
const fenceMarker = line.match(/^\s*(\x60{3,}|~{3,})/u)?.[1];
|
|
63
|
+
if (frontmatter) {
|
|
64
|
+
maskRange(characters, offset, offset + line.length);
|
|
65
|
+
if (lineIndex > 0 && /^(?:---|\.\.\.)\s*$/u.test(line))
|
|
66
|
+
frontmatter = false;
|
|
67
|
+
offset += line.length + 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (fence) {
|
|
71
|
+
maskRange(characters, offset, offset + line.length);
|
|
72
|
+
if (fenceMarker && fenceMarker[0] === fence[0] && fenceMarker.length >= fence.length)
|
|
73
|
+
fence = undefined;
|
|
74
|
+
offset += line.length + 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (fenceMarker) {
|
|
78
|
+
maskRange(characters, offset, offset + line.length);
|
|
79
|
+
fence = fenceMarker;
|
|
80
|
+
offset += line.length + 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
for (const match of line.matchAll(/\x60[^\x60\n]*\x60|\]\((?:\\.|[^)\n])*\)/gu)) {
|
|
84
|
+
const start = offset + match.index;
|
|
85
|
+
const value = match[0];
|
|
86
|
+
if (value.startsWith(']('))
|
|
87
|
+
maskRange(characters, start + 1, start + value.length);
|
|
88
|
+
else
|
|
89
|
+
maskRange(characters, start, start + value.length);
|
|
90
|
+
}
|
|
91
|
+
for (const match of line.matchAll(/\bhttps?:\/\/[^\s<>)]+/gu))
|
|
92
|
+
maskRange(characters, offset + match.index, offset + match.index + match[0].length);
|
|
93
|
+
offset += line.length + 1;
|
|
94
|
+
}
|
|
95
|
+
return characters.join('');
|
|
39
96
|
}
|
|
40
97
|
export function serializedRules() {
|
|
41
98
|
return rules.map((rule) => ({
|
|
@@ -47,12 +104,63 @@ export function serializedRules() {
|
|
|
47
104
|
scope: rule.scope ?? 'sentence',
|
|
48
105
|
}));
|
|
49
106
|
}
|
|
107
|
+
function documentFinding(rule, sentence) {
|
|
108
|
+
return { engine: 'ai_editor', id: rule.id, severity: rule.severity, sentence: sentence.index, excerpt: sentence.text, reason: rule.reason, suggestion: rule.suggestion };
|
|
109
|
+
}
|
|
110
|
+
function documentMatches(rule, prose, mapped) {
|
|
111
|
+
const at = (index) => mapped[index] ? [documentFinding(rule, mapped[index])] : [];
|
|
112
|
+
if (rule.id === 'ai.repeated-sentence-opening') {
|
|
113
|
+
for (let index = 0; index + 2 < mapped.length; index += 1) {
|
|
114
|
+
const opening = mapped[index].text.match(/^\s*(\p{L}+)/u)?.[1]?.toLocaleLowerCase();
|
|
115
|
+
if (opening && [1, 2].every((offset) => mapped[index + offset].text.match(/^\s*(\p{L}+)/u)?.[1]?.toLocaleLowerCase() === opening))
|
|
116
|
+
return at(index);
|
|
117
|
+
}
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
if (rule.id === 'format.bold-density') {
|
|
121
|
+
const line = prose.split('\n').findIndex((value) => (value.match(/\*\*[^*\n]+\*\*/gu) ?? []).length >= 2);
|
|
122
|
+
if (line >= 0)
|
|
123
|
+
return at(mapped.findIndex((sentence) => sentence.start >= prose.split('\n').slice(0, line).join('\n').length));
|
|
124
|
+
const total = (prose.match(/\*\*[^*\n]+\*\*/gu) ?? []).length;
|
|
125
|
+
return total >= 4 ? at(0) : [];
|
|
126
|
+
}
|
|
127
|
+
if (rule.id === 'format.repeated-heading-body') {
|
|
128
|
+
for (const heading of prose.matchAll(/^#{1,6}\s+(.+)$/gmu)) {
|
|
129
|
+
const title = heading[1].trim().replace(/[.*_`]/g, '');
|
|
130
|
+
if (title.length >= 4 && prose.indexOf(title, heading.index + heading[0].length) >= 0)
|
|
131
|
+
return at(mapped.findIndex((sentence) => sentence.start >= heading.index));
|
|
132
|
+
}
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
if (rule.id === 'ai.clipped-fragment-run') {
|
|
136
|
+
for (let index = 0; index + 2 < mapped.length; index += 1)
|
|
137
|
+
if ([0, 1, 2].every((offset) => (mapped[index + offset].text.match(/\p{L}+/gu) ?? []).length <= 4))
|
|
138
|
+
return at(index);
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
if (rule.id === 'ai.jargon-stack') {
|
|
142
|
+
const jargon = /\b(?:synergy|leverage|alignment|ecosystem|framework|paradigm|stakeholder|scalable|holistic)\b/giu;
|
|
143
|
+
const index = mapped.findIndex((sentence) => (sentence.text.match(jargon) ?? []).length >= 3);
|
|
144
|
+
return index >= 0 ? at(index) : [];
|
|
145
|
+
}
|
|
146
|
+
if (rule.id === 'ai.sentence-length-cluster') {
|
|
147
|
+
for (let index = 0; index + 3 < mapped.length; index += 1)
|
|
148
|
+
if ([0, 1, 2, 3].every((offset) => {
|
|
149
|
+
const count = (mapped[index + offset].text.match(/\p{L}+/gu) ?? []).length;
|
|
150
|
+
return count >= 15 && count <= 20;
|
|
151
|
+
}))
|
|
152
|
+
return at(index);
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
50
157
|
export function analyzeAiEditor(text, profile) {
|
|
51
158
|
const matched = [];
|
|
52
|
-
const
|
|
159
|
+
const prose = maskNonProse(text);
|
|
160
|
+
const mapped = sentences(prose).map((sentence) => ({ ...sentence, text: text.slice(sentence.start, sentence.end) }));
|
|
53
161
|
for (const sentence of mapped) {
|
|
54
162
|
for (const rule of sentenceRules) {
|
|
55
|
-
if (rule.expression.test(sentence.
|
|
163
|
+
if (rule.expression.test(prose.slice(sentence.start, sentence.end))) {
|
|
56
164
|
matched.push({
|
|
57
165
|
engine: 'ai_editor',
|
|
58
166
|
id: rule.id,
|
|
@@ -66,13 +174,14 @@ export function analyzeAiEditor(text, profile) {
|
|
|
66
174
|
}
|
|
67
175
|
}
|
|
68
176
|
let lineStart = 0;
|
|
69
|
-
for (const line of
|
|
177
|
+
for (const line of prose.split('\n')) {
|
|
70
178
|
for (const rule of lineRules) {
|
|
71
179
|
const result = rule.expression.exec(line);
|
|
72
180
|
if (!result)
|
|
73
181
|
continue;
|
|
74
182
|
const matchStart = lineStart + result.index;
|
|
75
|
-
const sentence = mapped.find((candidate) => candidate.start <= matchStart && matchStart < candidate.end)
|
|
183
|
+
const sentence = mapped.find((candidate) => candidate.start <= matchStart && matchStart < candidate.end)
|
|
184
|
+
?? mapped.find((candidate) => candidate.start >= lineStart && candidate.start < lineStart + line.length);
|
|
76
185
|
if (!sentence)
|
|
77
186
|
continue;
|
|
78
187
|
matched.push({
|
|
@@ -87,6 +196,8 @@ export function analyzeAiEditor(text, profile) {
|
|
|
87
196
|
}
|
|
88
197
|
lineStart += line.length + 1;
|
|
89
198
|
}
|
|
199
|
+
for (const rule of documentRules)
|
|
200
|
+
matched.push(...documentMatches(rule, prose, mapped));
|
|
90
201
|
matched.sort((left, right) => left.sentence - right.sentence || (ruleOrder.get(left.id) ?? 0) - (ruleOrder.get(right.id) ?? 0));
|
|
91
202
|
const policies = policiesFor(profile);
|
|
92
203
|
const findings = matched.flatMap((finding) => {
|
package/dist/ai-editor.test.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { analyzeAiEditor, RULESET_VERSION, rules, serializedRules } from './ai-editor.js';
|
|
3
|
+
import { analyzeAiEditor, maskNonProse, RULESET_VERSION, rules, serializedRules } from './ai-editor.js';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
|
+
import { AI_SHADOW_FAIL_SET_V1 } from './ai-shadow-fixtures.js';
|
|
6
|
+
import { generateAiShadowFailSetV1 } from './ai-shadow-generator.js';
|
|
5
7
|
test('publishes executable rules with stable IDs and repair directions', () => {
|
|
6
|
-
assert.equal(RULESET_VERSION, '3.
|
|
7
|
-
assert.equal(rules.length,
|
|
8
|
-
assert.equal(createHash('sha256').update(JSON.stringify(rules.map((rule) => rule.id))).digest('hex'), '
|
|
9
|
-
assert.equal(createHash('sha256').update(JSON.stringify(serializedRules())).digest('hex'), '
|
|
8
|
+
assert.equal(RULESET_VERSION, '3.5.0-local.3');
|
|
9
|
+
assert.equal(rules.length, 182);
|
|
10
|
+
assert.equal(createHash('sha256').update(JSON.stringify(rules.map((rule) => rule.id))).digest('hex'), '172563cd3f55b363c895bb97c37f636dd718253799c369f329bb943dad416f16');
|
|
11
|
+
assert.equal(createHash('sha256').update(JSON.stringify(serializedRules())).digest('hex'), '542534f1223d65e7ae7d4c3da8ba22bd31b4ddae2dfe399f7672ca9e6a9cee6b');
|
|
10
12
|
assert.equal(new Set(rules.map((rule) => rule.id)).size, rules.length);
|
|
11
13
|
for (const rule of rules) {
|
|
12
|
-
assert.match(rule.id, /^(ai|formula|hedge|struct|punct|bait|cringe|insider|ogilvy)\./);
|
|
14
|
+
assert.match(rule.id, /^(ai|formula|hedge|struct|punct|bait|cringe|insider|ogilvy|format)\./);
|
|
13
15
|
assert.ok(rule.reason.length > 0);
|
|
14
16
|
assert.ok(rule.suggestion.length > 0);
|
|
15
17
|
assert.equal(rule.expression.global, false, rule.id);
|
|
@@ -43,6 +45,39 @@ test('applies all four v3 policy states after matching and preserves catalog ord
|
|
|
43
45
|
test('fails closed when a v3 policy names a rule outside the catalog', () => {
|
|
44
46
|
assert.throws(() => analyzeAiEditor('Plain text.', profileWithPolicies({ 'ai.missing': 'blocking' })), /unknown rule ID/);
|
|
45
47
|
});
|
|
48
|
+
test('allows a sample-derived punctuation exception only when no explicit policy exists', () => {
|
|
49
|
+
const profile = profileWithPolicies({});
|
|
50
|
+
profile.ruleAllowances = { 'punct.em-dash': { sampleCount: 2, evidenceDigest: 'a'.repeat(64) } };
|
|
51
|
+
assert.equal(analyzeAiEditor('The scheduler failed — retry later.', profile).findings.some((finding) => finding.id === 'punct.em-dash'), false);
|
|
52
|
+
profile.rulePolicy['punct.em-dash'] = 'blocking';
|
|
53
|
+
assert.equal(analyzeAiEditor('The scheduler failed — retry later.', profile).findings.find((finding) => finding.id === 'punct.em-dash')?.appliedPolicy, 'blocking');
|
|
54
|
+
});
|
|
55
|
+
test('keeps non-prose regions out of AI Editor while scanning adjacent prose', () => {
|
|
56
|
+
const tick = String.fromCharCode(96);
|
|
57
|
+
const fence = tick.repeat(3);
|
|
58
|
+
const draft = [
|
|
59
|
+
'---',
|
|
60
|
+
'title: We leverage the launch',
|
|
61
|
+
'---',
|
|
62
|
+
'We leverage the launch.',
|
|
63
|
+
fence + 'ts',
|
|
64
|
+
'const message = "We leverage logs";',
|
|
65
|
+
fence,
|
|
66
|
+
'Use ' + tick + 'we leverage logs' + tick + ' only as an example.',
|
|
67
|
+
'[A link](https://example.com/we-leverage) stays useful.',
|
|
68
|
+
].join('\n');
|
|
69
|
+
const report = analyzeAiEditor(draft);
|
|
70
|
+
assert.deepEqual(report.findings.filter((finding) => finding.id === 'ai.leverage').map((finding) => finding.sentence), [1]);
|
|
71
|
+
assert.equal(maskNonProse(draft).includes('https://example.com/we-leverage'), false);
|
|
72
|
+
});
|
|
73
|
+
test('preserves protected-region offsets after a supplementary Unicode character', () => {
|
|
74
|
+
const tick = String.fromCharCode(96);
|
|
75
|
+
const draft = '🚀 We leverage the launch. Use ' + tick + 'we leverage logs' + tick + ' as an example.';
|
|
76
|
+
const report = analyzeAiEditor(draft);
|
|
77
|
+
assert.deepEqual(report.findings.filter((finding) => finding.id === 'ai.leverage').map((finding) => [finding.sentence, finding.excerpt]), [
|
|
78
|
+
[1, '🚀 We leverage the launch.'],
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
46
81
|
test('uses reconciled defaults for v2 profiles and suppresses inherited duplicate emissions', () => {
|
|
47
82
|
const report = analyzeAiEditor("It's worth noting: in other words, I think the same plan. Better results.");
|
|
48
83
|
assert.equal(report.findings.some((finding) => finding.id === 'hedge.worth-noting'), false);
|
|
@@ -92,6 +127,62 @@ test('detects representative rules from every inherited rule family', () => {
|
|
|
92
127
|
assert.ok(report.findings.some((finding) => finding.id === id && finding.sentence === 1), id);
|
|
93
128
|
}
|
|
94
129
|
});
|
|
130
|
+
test('covers the narrow 3.5 pattern additions with an editorial example for each rule', () => {
|
|
131
|
+
const examples = [
|
|
132
|
+
['ai.inflated-significance', 'This marks a pivotal moment for the project.'],
|
|
133
|
+
['ai.notability-name-drop', 'The renowned Ada Lovelace writer changed the field.'],
|
|
134
|
+
['ai.shallow-participle-analysis', 'The result matters, highlighting its importance.'],
|
|
135
|
+
['ai.promotional-scene-setting', 'In today’s changing technology landscape, the work starts.'],
|
|
136
|
+
['ai.vague-attribution', 'Experts say the change will help.'],
|
|
137
|
+
['ai.challenges-outlook', 'Despite these challenges, the future remains open.'],
|
|
138
|
+
['ai.copula-avoidance', 'The launch is serving as a testament to patience.'],
|
|
139
|
+
['ai.false-range', 'The course runs from the Big Bang to dark matter.'],
|
|
140
|
+
['ai.actorless-claim', 'It is important that we check the source.'],
|
|
141
|
+
['ai.chatbot-offer', 'Let me know if you would like another draft.'],
|
|
142
|
+
['ai.knowledge-limit-disclaimer', 'As an AI, I cannot verify that.'],
|
|
143
|
+
['ai.agreement-preamble', 'Absolutely, the invoice is overdue.'],
|
|
144
|
+
['ai.qualifier-stack', 'This is very important for the launch.'],
|
|
145
|
+
['ai.generic-positive-ending', 'The possibilities are endless.'],
|
|
146
|
+
['ai.at-its-core', 'At its core, the work is a queue.'],
|
|
147
|
+
['ai.section-announcement', 'Let us now explore the next step.'],
|
|
148
|
+
['ai.historical-implementation-aside', 'The tool was once known as Alpha and now has a new name.'],
|
|
149
|
+
['ai.unraised-objection', 'Some might argue that the queue is unnecessary.'],
|
|
150
|
+
['ai.fake-alternative', 'Whether you choose email or chat, start today.'],
|
|
151
|
+
['format.bold-bullet-label', '- **Decision:** Ship Tuesday.'],
|
|
152
|
+
['format.title-case-heading', '# Generic Title Case Heading'],
|
|
153
|
+
['format.emoji-heading', '# 🚀 Launch plan'],
|
|
154
|
+
['format.curly-quotes', 'The operator wrote “ship Tuesday” in the release note.'],
|
|
155
|
+
['format.hyphenated-modifier-stack', 'Use a high-trust-low-friction process.'],
|
|
156
|
+
];
|
|
157
|
+
for (const [id, example] of examples)
|
|
158
|
+
assert.ok(analyzeAiEditor(example).findings.some((finding) => finding.id === id), id);
|
|
159
|
+
});
|
|
160
|
+
test('keeps bounded Humanizer and Ghostwriter document cues advisory', () => {
|
|
161
|
+
const examples = [
|
|
162
|
+
['ai.forced-triplet', 'The template promises speed, scale, and alignment.'],
|
|
163
|
+
['ai.repeated-sentence-opening', 'We checked the logs. We checked the queue. We checked the retry.'],
|
|
164
|
+
['format.bold-density', '**Plan** stays visible. **Owner** checks it. **Proof** ships. **Next** is Tuesday.'],
|
|
165
|
+
['format.repeated-heading-body', '# Release plan\n\nRelease plan explains the verified rollback.'],
|
|
166
|
+
['ai.clipped-fragment-run', 'No demos. No decks. No distractions. The owner checked the logs.'],
|
|
167
|
+
['ai.formulaic-aphorism', 'Quality over quantity is the whole lesson.'],
|
|
168
|
+
['ai.fake-candid-opener', "I'm going to be honest: the queue failed."],
|
|
169
|
+
['ai.metric-theater', 'At 3:47 AM, the slide promised a 23.6x ROI.'],
|
|
170
|
+
['ai.jargon-stack', 'The scalable ecosystem needs alignment, leverage, and a holistic framework.'],
|
|
171
|
+
['ai.sentence-length-cluster', 'The release owner carefully checks each visible rollback instruction before the scheduled production deployment window opens this morning. The release operator carefully records each visible rollback instruction before the scheduled production deployment window opens this morning. The release reviewer carefully reviews each visible rollback instruction before the scheduled production deployment window opens this morning. The release manager carefully confirms each visible rollback instruction before the scheduled production deployment window opens this morning.'],
|
|
172
|
+
];
|
|
173
|
+
for (const [id, example] of examples) {
|
|
174
|
+
const finding = analyzeAiEditor(example).findings.find((item) => item.id === id);
|
|
175
|
+
assert.ok(finding, id);
|
|
176
|
+
assert.equal(finding.appliedPolicy, 'advisory', id);
|
|
177
|
+
}
|
|
178
|
+
assert.equal(analyzeAiEditor('Three source IDs appear in the manifest.').findings.some((finding) => finding.id === 'ai.forced-triplet'), false);
|
|
179
|
+
assert.equal(analyzeAiEditor('Owners checked the queue. Operators checked the log. Reviewers checked the proof.').findings.some((finding) => finding.id === 'ai.repeated-sentence-opening'), false);
|
|
180
|
+
});
|
|
181
|
+
test('keeps the frozen synthetic AI-shadow fail set generated in CI and executable without runtime generation', () => {
|
|
182
|
+
assert.deepEqual(generateAiShadowFailSetV1(), AI_SHADOW_FAIL_SET_V1);
|
|
183
|
+
for (const fixture of AI_SHADOW_FAIL_SET_V1)
|
|
184
|
+
assert.ok(analyzeAiEditor(fixture.text).findings.some((finding) => finding.id === fixture.rule), fixture.id);
|
|
185
|
+
});
|
|
95
186
|
test('keeps counterexamples for representative inherited rules', () => {
|
|
96
187
|
const counterexamples = [
|
|
97
188
|
['ai.delve', 'we inspect the logs.'],
|
|
@@ -158,8 +249,8 @@ test('retains the current question-hook and abstract-cluster detectors', () => {
|
|
|
158
249
|
});
|
|
159
250
|
test('serializes reconstructable regular expressions and explicit scopes', () => {
|
|
160
251
|
const catalog = serializedRules();
|
|
161
|
-
assert.equal(catalog.length,
|
|
162
|
-
assert.ok(catalog.every((rule) => rule.scope === 'sentence' || rule.scope === 'line'));
|
|
252
|
+
assert.equal(catalog.length, 182);
|
|
253
|
+
assert.ok(catalog.every((rule) => rule.scope === 'sentence' || rule.scope === 'line' || rule.scope === 'document'));
|
|
163
254
|
const meaningful = catalog.find((rule) => rule.id === 'ai.meaningful');
|
|
164
255
|
assert.ok(meaningful);
|
|
165
256
|
assert.equal(new RegExp(meaningful.expression.source, meaningful.expression.flags).test('Meaningful work.'), true);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Frozen, invented AI-tell fixtures. Runtime never generates or fetches them. */
|
|
2
|
+
export const AI_SHADOW_FAIL_SET_V1 = [
|
|
3
|
+
{ id: 'metric-theater', text: 'At 3:47 AM, the slide promised a 23.6x ROI.', rule: 'ai.metric-theater' },
|
|
4
|
+
{ id: 'jargon-stack', text: 'The scalable ecosystem needs alignment, leverage, and a holistic framework.', rule: 'ai.jargon-stack' },
|
|
5
|
+
{ id: 'forced-triplet', text: 'The template promises speed, scale, and alignment.', rule: 'ai.forced-triplet' },
|
|
6
|
+
{ id: 'clipped-run', text: 'No demos. No decks. No distractions. The owner checked the logs.', rule: 'ai.clipped-fragment-run' },
|
|
7
|
+
];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function injectTell(seed, tell) {
|
|
2
|
+
switch (tell) {
|
|
3
|
+
case 'metric-theater': return { id: tell, text: `At 3:47 AM, ${seed} promised a 23.6x ROI.`, rule: 'ai.metric-theater' };
|
|
4
|
+
case 'jargon-stack': return { id: tell, text: `The scalable ${seed} needs alignment, leverage, and a holistic framework.`, rule: 'ai.jargon-stack' };
|
|
5
|
+
case 'forced-triplet': return { id: tell, text: `The ${seed} promises speed, scale, and alignment.`, rule: 'ai.forced-triplet' };
|
|
6
|
+
case 'clipped-run': return { id: tell, text: `No demos. No decks. No distractions. The ${seed} checked the logs.`, rule: 'ai.clipped-fragment-run' };
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** Generates the reviewed synthetic fail-set candidate bytes deterministically. */
|
|
10
|
+
export function generateAiShadowFailSetV1() {
|
|
11
|
+
return [
|
|
12
|
+
injectTell('the slide', 'metric-theater'),
|
|
13
|
+
injectTell('ecosystem', 'jargon-stack'),
|
|
14
|
+
injectTell('template', 'forced-triplet'),
|
|
15
|
+
injectTell('owner', 'clipped-run'),
|
|
16
|
+
];
|
|
17
|
+
}
|
package/dist/backtest.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { analyze } from './pipeline.js';
|
|
2
|
+
import { comparePreservation } from './preservation.js';
|
|
3
|
+
import { scoreHeldoutProfile } from './profile-score.js';
|
|
4
|
+
import { sha256 } from './internal.js';
|
|
5
|
+
function textFreeAiEditor(report) {
|
|
6
|
+
return { ...report, findings: report.findings.map(({ excerpt: _excerpt, ...finding }) => finding) };
|
|
7
|
+
}
|
|
8
|
+
/** Scores a caller-supplied candidate without generating from or returning the held-out target. */
|
|
9
|
+
export function evaluateIsolatedBacktest(context, target, candidate, profile, heldoutSamples) {
|
|
10
|
+
return {
|
|
11
|
+
version: '1', contextDigest: sha256(context), targetDigest: sha256(target), candidateDigest: sha256(candidate),
|
|
12
|
+
preservation: comparePreservation(target, candidate).legacySet,
|
|
13
|
+
aiEditor: textFreeAiEditor(analyze(candidate, profile).aiEditor),
|
|
14
|
+
heldout: scoreHeldoutProfile(candidate, profile, heldoutSamples),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { evaluateIsolatedBacktest } from './backtest.js';
|
|
4
|
+
import { buildProfile } from './voice-dna.js';
|
|
5
|
+
test('keeps an isolated backtest generation-free and text-free in its report', () => {
|
|
6
|
+
const samples = [
|
|
7
|
+
'I name the constraint first and explain the mechanism with direct words for the person doing the work. The owner checks each source before the release moves into the production queue.',
|
|
8
|
+
'The owner checks the source before the release moves into the production queue and records the evidence for later review. I keep the next step clear for the person handling the release.',
|
|
9
|
+
'I keep evidence visible and choose one next step for the person doing the work before the deployment window opens. The operator records the trade-off and names the owner for the rollback.',
|
|
10
|
+
];
|
|
11
|
+
const target = 'The owner checks the source before the release and records the rollback evidence for the operator doing the production work.';
|
|
12
|
+
const candidate = 'The owner checks the source before the release and uses leverage for the operator doing the production work while the team records each rollback decision in the release log.';
|
|
13
|
+
const report = evaluateIsolatedBacktest('Reply to the rollout question with the owner and check.', target, candidate, buildProfile(samples), samples);
|
|
14
|
+
assert.equal(report.version, '1');
|
|
15
|
+
assert.ok(report.preservation.score < 100);
|
|
16
|
+
assert.equal(report.heldout.selfSimilarity?.ceiling, 100);
|
|
17
|
+
assert.equal(JSON.stringify(report).includes(target), false);
|
|
18
|
+
assert.equal(JSON.stringify(report).includes(candidate), false);
|
|
19
|
+
assert.equal(JSON.stringify(report).includes('uses leverage for the operator'), false);
|
|
20
|
+
});
|