@holdyourvoice/hyv 3.1.0 → 3.2.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/dist/cli.js CHANGED
@@ -1,17 +1,55 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync } from 'node:fs';
3
- import { rules, RULESET_VERSION } from './ai-editor.js';
2
+ import { linkSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, extname, join, resolve } from 'node:path';
4
+ import { RULESET_VERSION, serializedRules } from './ai-editor.js';
5
+ import { parseCopySpec } from './copy-spec.js';
6
+ import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
4
7
  import { addLearningInstruction, clearLearning, composeLearning, profileFingerprint, recordVerifiedCandidate } from './learning.js';
5
- import { analyze, rewritePrompt, verify } from './pipeline.js';
8
+ import { cleanHygiene, finalOutputCheck, inspectHygiene } from './hygiene.js';
9
+ import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
6
10
  import { parseProfile } from './profile.js';
11
+ import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
7
12
  import { buildProfile } from './voice-dna.js';
8
- const usage = 'Commands: profile, analyze, rewrite-prompt, verify, learning, patterns, mcp';
13
+ const usage = 'Commands: profile, analyze, hygiene, final-check, batch-analyze, rewrite-prompt, prepare-rewrite, apply-rewrite, verify, verify-spec, learning, patterns, mcp';
9
14
  function input(path) {
10
15
  return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
11
16
  }
12
17
  function readProfile(path) {
13
18
  return parseProfile(JSON.parse(input(path)));
14
19
  }
20
+ function readBrief(path) {
21
+ return path ? parseWritingBrief(JSON.parse(input(path))) : undefined;
22
+ }
23
+ function prepareContext(paths) {
24
+ let copySpec;
25
+ let writingBrief;
26
+ for (const path of paths) {
27
+ const value = JSON.parse(input(path));
28
+ try {
29
+ const parsed = parseCopySpec(value);
30
+ if (copySpec)
31
+ throw new Error('Prepare-rewrite accepts at most one CopySpec.');
32
+ copySpec = parsed;
33
+ continue;
34
+ }
35
+ catch (error) {
36
+ if (error instanceof Error && error.message === 'Prepare-rewrite accepts at most one CopySpec.')
37
+ throw error;
38
+ }
39
+ try {
40
+ const parsed = parseWritingBrief(value);
41
+ if (writingBrief)
42
+ throw new Error('Prepare-rewrite accepts at most one WritingBrief.');
43
+ writingBrief = parsed;
44
+ }
45
+ catch (error) {
46
+ if (error instanceof Error && error.message === 'Prepare-rewrite accepts at most one WritingBrief.')
47
+ throw error;
48
+ throw new Error(`Expected a valid CopySpec or WritingBrief at ${path}.`);
49
+ }
50
+ }
51
+ return { copySpec, writingBrief };
52
+ }
15
53
  function json(value) {
16
54
  console.log(JSON.stringify(value, null, 2));
17
55
  }
@@ -34,6 +72,58 @@ function profileArguments(args) {
34
72
  throw new Error('Usage: hyv profile profile.json sample-a.md sample-b.md [sample-c.md] [--avoid=phrase]');
35
73
  return { output, samples, avoid };
36
74
  }
75
+ function cleanedPath(path) {
76
+ const extension = extname(path);
77
+ const stem = extension ? path.slice(0, -extension.length) : path;
78
+ return `${stem}.cleaned${extension}`;
79
+ }
80
+ function hygieneArguments(args) {
81
+ const [path, ...options] = args;
82
+ if (!path)
83
+ throw new Error('Usage: hyv hygiene draft.md [--fix] [--output=cleaned.md]');
84
+ let fix = false;
85
+ let output;
86
+ for (const option of options) {
87
+ if (option === '--fix')
88
+ fix = true;
89
+ else if (option.startsWith('--output='))
90
+ output = option.slice('--output='.length).trim();
91
+ else
92
+ throw new Error('Usage: hyv hygiene draft.md [--fix] [--output=cleaned.md]');
93
+ }
94
+ if (output !== undefined && (!output || !fix))
95
+ throw new Error('--output requires --fix and a non-empty path.');
96
+ return { path, fix, ...(output ? { output } : {}) };
97
+ }
98
+ function writeNewFileAtomically(path, text) {
99
+ const temporaryDirectory = mkdtempSync(join(dirname(resolve(path)), '.hyv-hygiene-'));
100
+ const temporaryPath = join(temporaryDirectory, 'cleaned');
101
+ let primaryError;
102
+ try {
103
+ writeFileSync(temporaryPath, text, 'utf8');
104
+ try {
105
+ linkSync(temporaryPath, path);
106
+ }
107
+ catch (error) {
108
+ const code = error.code;
109
+ if (!['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV'].includes(code ?? ''))
110
+ throw error;
111
+ throw new Error(`Atomic hygiene output is not supported by this filesystem: ${path}`);
112
+ }
113
+ }
114
+ catch (error) {
115
+ primaryError = error.code === 'EEXIST' ? new Error(`Hygiene output already exists: ${path}`) : error;
116
+ }
117
+ try {
118
+ rmSync(temporaryDirectory, { recursive: true, force: true });
119
+ }
120
+ catch (error) {
121
+ if (!primaryError)
122
+ console.error(`Warning: output was published, but temporary-file cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
123
+ }
124
+ if (primaryError)
125
+ throw primaryError;
126
+ }
37
127
  export async function runCli(args) {
38
128
  const [command, ...rest] = args;
39
129
  if (command === 'profile') {
@@ -42,34 +132,104 @@ export async function runCli(args) {
42
132
  return 0;
43
133
  }
44
134
  if (command === 'analyze') {
45
- const [draft, profilePath] = rest;
135
+ const [draft, profilePath, briefPath] = rest;
46
136
  if (!draft || !profilePath)
47
- throw new Error('Usage: hyv analyze draft.md profile.json');
48
- json(analyze(input(draft), readProfile(profilePath)));
137
+ throw new Error('Usage: hyv analyze draft.md profile.json [writing-brief.json]');
138
+ json(analyze(input(draft), readProfile(profilePath), readBrief(briefPath)));
139
+ return 0;
140
+ }
141
+ if (command === 'hygiene') {
142
+ const { path, fix, output } = hygieneArguments(rest);
143
+ if (fix && path === '-')
144
+ throw new Error('hyv hygiene --fix requires a file path so the original can be preserved.');
145
+ const text = input(path);
146
+ if (!fix) {
147
+ json(inspectHygiene(text));
148
+ return 0;
149
+ }
150
+ const outputPath = output ?? cleanedPath(path);
151
+ if (resolve(outputPath) === resolve(path))
152
+ throw new Error('Hygiene output must differ from the input path.');
153
+ const result = cleanHygiene(text);
154
+ writeNewFileAtomically(outputPath, result.cleaned);
155
+ json({ ...result.report, changed: result.changed, changes: result.changes, outputPath });
156
+ return 0;
157
+ }
158
+ if (command === 'final-check') {
159
+ const [path, ...options] = rest;
160
+ if (!path || options.length)
161
+ throw new Error('Usage: hyv final-check <path|->');
162
+ const result = finalOutputCheck(input(path));
163
+ if (!result.accepted) {
164
+ console.error(JSON.stringify(result, null, 2));
165
+ return 2;
166
+ }
167
+ if (result.changed)
168
+ console.error(JSON.stringify({ changed: true, changes: result.changes }, null, 2));
169
+ process.stdout.write(result.output);
170
+ return 0;
171
+ }
172
+ if (command === 'batch-analyze') {
173
+ if (rest.length < 2)
174
+ throw new Error('Usage: hyv batch-analyze draft-a.md draft-b.md [draft-c.md]');
175
+ json(analyzeBatch(rest.map(input)));
49
176
  return 0;
50
177
  }
51
178
  if (command === 'rewrite-prompt') {
52
- const [draft, profilePath] = rest;
179
+ const [draft, profilePath, briefPath] = rest;
53
180
  if (!draft || !profilePath)
54
- throw new Error('Usage: hyv rewrite-prompt draft.md profile.json');
181
+ throw new Error('Usage: hyv rewrite-prompt draft.md profile.json [writing-brief.json]');
55
182
  const profile = readProfile(profilePath);
56
- console.log(rewritePrompt(input(draft), profile, composeLearning(profile)));
183
+ console.log(rewritePrompt(input(draft), profile, composeLearning(profile), readBrief(briefPath)));
57
184
  return 0;
58
185
  }
186
+ if (command === 'prepare-rewrite') {
187
+ const [draft, profilePath, output, ...contextPaths] = rest;
188
+ if (!draft || !profilePath || !output)
189
+ throw new Error('Usage: hyv prepare-rewrite draft.md profile.json task.json [copy-spec.json] [writing-brief.json]');
190
+ const context = prepareContext(contextPaths);
191
+ const task = prepareRewriteTask(input(draft), readProfile(profilePath), context.copySpec, context.writingBrief);
192
+ writeFileSync(output, `${JSON.stringify(task, null, 2)}\n`);
193
+ json({ version: task.version, fingerprint: task.fingerprint, eligibleSentenceIds: task.eligibleSentenceIds });
194
+ return 0;
195
+ }
196
+ if (command === 'apply-rewrite') {
197
+ const [taskPath, responsePath, profilePath] = rest;
198
+ if (!taskPath || !responsePath || !profilePath)
199
+ throw new Error('Usage: hyv apply-rewrite task.json response.json profile.json');
200
+ const result = evaluateRewriteResponse(parseRewriteTask(JSON.parse(input(taskPath))), input(responsePath), readProfile(profilePath));
201
+ json(result);
202
+ return result.status === 'accepted' ? 0 : 2;
203
+ }
59
204
  if (command === 'verify') {
60
- const [original, candidate, profilePath] = rest;
205
+ const [original, candidate, profilePath, briefPath] = rest;
61
206
  if (!original || !candidate || !profilePath)
62
- throw new Error('Usage: hyv verify original.md candidate.md profile.json');
207
+ throw new Error('Usage: hyv verify original.md candidate.md profile.json [writing-brief.json]');
63
208
  const profile = readProfile(profilePath);
64
209
  const originalText = input(original);
65
210
  const candidateText = input(candidate);
66
- const result = verify(originalText, candidateText, profile);
211
+ const result = verify(originalText, candidateText, profile, readBrief(briefPath));
67
212
  const learning = recordVerifiedCandidate(profile, result, candidateText);
68
213
  if (learning === 'write_failed')
69
214
  console.error('Warning: verification passed, but local learning could not be saved.');
70
215
  json(result);
71
216
  return result.passed ? 0 : 2;
72
217
  }
218
+ if (command === 'verify-spec') {
219
+ const [original, candidate, profilePath, specPath, briefPath] = rest;
220
+ if (!original || !candidate || !profilePath || !specPath)
221
+ throw new Error('Usage: hyv verify-spec original.md candidate.md profile.json copy-spec.json [writing-brief.json]');
222
+ const profile = readProfile(profilePath);
223
+ const candidateText = input(candidate);
224
+ const result = verifyWithCopySpec(input(original), candidateText, profile, parseCopySpec(JSON.parse(input(specPath))), readBrief(briefPath));
225
+ if (result.passed) {
226
+ const learning = recordVerifiedCandidate(profile, result, candidateText);
227
+ if (learning === 'write_failed')
228
+ console.error('Warning: verification passed, but local learning could not be saved.');
229
+ }
230
+ json(result);
231
+ return result.passed ? 0 : 2;
232
+ }
73
233
  if (command === 'learning') {
74
234
  const [action, profilePath, ...instruction] = rest;
75
235
  if (!action || !profilePath)
@@ -93,7 +253,7 @@ export async function runCli(args) {
93
253
  throw new Error('Usage: hyv learning <show|add|clear> profile.json [instruction]');
94
254
  }
95
255
  if (command === 'patterns') {
96
- json({ version: RULESET_VERSION, rules: rules.map(({ expression, ...rule }) => ({ ...rule, expression: expression.source })) });
256
+ json({ version: RULESET_VERSION, rules: serializedRules() });
97
257
  return 0;
98
258
  }
99
259
  if (command === 'mcp') {
package/dist/cli.test.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { spawnSync } from 'node:child_process';
6
6
  import test from 'node:test';
7
+ import { patternsForMcp } from './mcp-tools.js';
7
8
  const cli = new URL('./cli.js', import.meta.url).pathname;
8
- function run(args, env = process.env) {
9
- return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env });
9
+ function run(args, env = process.env, input) {
10
+ return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env, input });
10
11
  }
11
12
  test('creates an explicit local avoid list and exposes the ruleset', () => {
12
13
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
@@ -27,6 +28,103 @@ test('creates an explicit local avoid list and exposes the ruleset', () => {
27
28
  rmSync(directory, { recursive: true, force: true });
28
29
  }
29
30
  });
31
+ test('publishes the same normalized 145-rule catalog and version through CLI and MCP', () => {
32
+ const result = run(['patterns']);
33
+ assert.equal(result.status, 0, result.stderr);
34
+ const cliCatalog = JSON.parse(result.stdout);
35
+ const mcpCatalog = patternsForMcp();
36
+ assert.equal(cliCatalog.version, '2.9.24-static.2');
37
+ assert.equal(cliCatalog.rules.length, 145);
38
+ assert.deepEqual(cliCatalog, mcpCatalog);
39
+ });
40
+ test('runs contextual analysis and batch analysis without changing the profile contract', () => {
41
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
42
+ try {
43
+ const first = join(directory, 'first.md');
44
+ const second = join(directory, 'second.md');
45
+ const profile = join(directory, 'profile.json');
46
+ const brief = join(directory, 'brief.json');
47
+ const draft = join(directory, 'draft.md');
48
+ const duplicate = join(directory, 'duplicate.md');
49
+ const task = join(directory, 'task.json');
50
+ writeFileSync(first, 'I write plainly. I name the work.');
51
+ writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
52
+ writeFileSync(brief, JSON.stringify({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' }));
53
+ writeFileSync(draft, 'A pattern I keep seeing in founder posts is vague advice.');
54
+ writeFileSync(duplicate, 'A pattern I keep seeing in founder posts is vague advice.');
55
+ assert.equal(run(['profile', profile, first, second]).status, 0);
56
+ const contextual = JSON.parse(run(['analyze', draft, profile, brief]).stdout);
57
+ assert.equal(contextual.editorial.findings[0].id, 'editorial.social.generic-opener');
58
+ assert.equal(contextual.hygiene.suspiciousCount, 0);
59
+ const batch = JSON.parse(run(['batch-analyze', draft, duplicate]).stdout);
60
+ assert.equal(batch.findings.length, 2);
61
+ assert.equal(run(['prepare-rewrite', draft, profile, task, brief]).status, 0);
62
+ assert.equal(JSON.parse(readFileSync(task, 'utf8')).writingBrief.format, 'social');
63
+ }
64
+ finally {
65
+ rmSync(directory, { recursive: true, force: true });
66
+ }
67
+ });
68
+ test('inspects and conservatively fixes Unicode hygiene without overwriting either file', () => {
69
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
70
+ try {
71
+ const draft = join(directory, 'draft.md');
72
+ const cleaned = join(directory, 'draft.cleaned.md');
73
+ const original = `\uFEFFkeep\u200Bthis\u00A0space\u200D`;
74
+ writeFileSync(draft, original);
75
+ const inspected = run(['hygiene', draft]);
76
+ assert.equal(inspected.status, 0, inspected.stderr);
77
+ const report = JSON.parse(inspected.stdout);
78
+ assert.equal(report.suspiciousCount, 4);
79
+ assert.equal(report.fixableCount, 1);
80
+ const fixed = run(['hygiene', draft, '--fix']);
81
+ assert.equal(fixed.status, 0, fixed.stderr);
82
+ const receipt = JSON.parse(fixed.stdout);
83
+ assert.equal(receipt.outputPath, cleaned);
84
+ assert.equal(receipt.changed, true);
85
+ assert.equal(receipt.changes.length, 1);
86
+ assert.equal(readFileSync(draft, 'utf8'), original);
87
+ assert.equal(readFileSync(cleaned, 'utf8'), `keep\u200Bthis\u00A0space\u200D`);
88
+ const custom = join(directory, 'review-copy.md');
89
+ const customFixed = run(['hygiene', draft, '--fix', `--output=${custom}`]);
90
+ assert.equal(customFixed.status, 0, customFixed.stderr);
91
+ assert.equal(JSON.parse(customFixed.stdout).outputPath, custom);
92
+ assert.equal(readFileSync(custom, 'utf8'), `keep\u200Bthis\u00A0space\u200D`);
93
+ const samePath = run(['hygiene', draft, '--fix', `--output=${draft}`]);
94
+ assert.equal(samePath.status, 1);
95
+ assert.match(samePath.stderr, /must differ from the input path/);
96
+ assert.equal(readFileSync(draft, 'utf8'), original);
97
+ const refused = run(['hygiene', draft, '--fix']);
98
+ assert.equal(refused.status, 1);
99
+ assert.match(refused.stderr, /already exists/);
100
+ assert.equal(readdirSync(directory).some((name) => name.startsWith('.hyv-hygiene-')), false);
101
+ }
102
+ finally {
103
+ rmSync(directory, { recursive: true, force: true });
104
+ }
105
+ });
106
+ test('inspects stdin and refuses to clean it without a preservable input file', () => {
107
+ const inspected = run(['hygiene', '-'], process.env, 'one\u200Btwo');
108
+ assert.equal(inspected.status, 0, inspected.stderr);
109
+ assert.equal(JSON.parse(inspected.stdout).suspiciousCount, 1);
110
+ const refused = run(['hygiene', '-', '--fix'], process.env, 'one\u200Btwo');
111
+ assert.equal(refused.status, 1);
112
+ assert.match(refused.stderr, /requires a file path/);
113
+ });
114
+ test('gates final output from any producer without a voice profile', () => {
115
+ const clean = run(['final-check', '-'], process.env, 'exact output\n');
116
+ assert.equal(clean.status, 0, clean.stderr);
117
+ assert.equal(clean.stdout, 'exact output\n');
118
+ assert.equal(clean.stderr, '');
119
+ const bom = run(['final-check', '-'], process.env, '\uFEFFexact output');
120
+ assert.equal(bom.status, 0, bom.stderr);
121
+ assert.equal(bom.stdout, 'exact output');
122
+ assert.match(bom.stderr, /U\+FEFF/);
123
+ const unresolved = run(['final-check', '-'], process.env, 'Thai\u200Bboundary');
124
+ assert.equal(unresolved.status, 2);
125
+ assert.equal(unresolved.stdout, '');
126
+ assert.match(unresolved.stderr, /U\+200B/);
127
+ });
30
128
  test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
31
129
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
32
130
  try {
@@ -50,6 +148,55 @@ test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
50
148
  rmSync(directory, { recursive: true, force: true });
51
149
  }
52
150
  });
151
+ test('fails the CopySpec gate when a locked claim changes', () => {
152
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
153
+ try {
154
+ const first = join(directory, 'first.md');
155
+ const second = join(directory, 'second.md');
156
+ const profile = join(directory, 'profile.json');
157
+ const original = join(directory, 'original.md');
158
+ const candidate = join(directory, 'candidate.md');
159
+ const spec = join(directory, 'copy-spec.json');
160
+ writeFileSync(first, 'I write plainly. I name the work.');
161
+ writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
162
+ writeFileSync(original, 'The launch is on 14 August.');
163
+ writeFileSync(candidate, 'The launch is next month.');
164
+ 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.' }] }));
165
+ assert.equal(run(['profile', profile, first, second]).status, 0);
166
+ const result = run(['verify-spec', original, candidate, profile, spec]);
167
+ assert.equal(result.status, 2);
168
+ assert.equal(JSON.parse(result.stdout).claims.failures[0].code, 'missing_immutable_claim');
169
+ }
170
+ finally {
171
+ rmSync(directory, { recursive: true, force: true });
172
+ }
173
+ });
174
+ test('prepares and applies the same constrained rewrite task without a provider call', () => {
175
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
176
+ try {
177
+ const first = join(directory, 'first.md');
178
+ const second = join(directory, 'second.md');
179
+ const profile = join(directory, 'profile.json');
180
+ const draft = join(directory, 'draft.md');
181
+ const task = join(directory, 'task.json');
182
+ const response = join(directory, 'response.json');
183
+ writeFileSync(first, 'I write plainly. I name the work.');
184
+ writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
185
+ writeFileSync(draft, 'I leverage the answer with useful detail and clear mechanism.');
186
+ assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
187
+ assert.equal(run(['prepare-rewrite', draft, profile, task]).status, 0);
188
+ const prepared = JSON.parse(readFileSync(task, 'utf8'));
189
+ writeFileSync(response, JSON.stringify({ version: '1', taskFingerprint: prepared.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer with useful detail and clear mechanism.' }] }));
190
+ const result = run(['apply-rewrite', task, response, profile]);
191
+ assert.equal(result.status, 2, result.stderr);
192
+ const applied = JSON.parse(result.stdout);
193
+ assert.equal(applied.status, 'needs_semantic_review');
194
+ assert.equal(applied.candidate, 'I use the answer with useful detail and clear mechanism.');
195
+ }
196
+ finally {
197
+ rmSync(directory, { recursive: true, force: true });
198
+ }
199
+ });
53
200
  test('rejects a malformed hand-edited profile before analysis', () => {
54
201
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
55
202
  try {
@@ -0,0 +1,75 @@
1
+ import { sentences } from './text.js';
2
+ function normalized(value) {
3
+ return value.toLowerCase().replace(/\s+/g, ' ').trim();
4
+ }
5
+ function escaped(value) {
6
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ }
8
+ function atomMatches(value, atom) {
9
+ return new RegExp(`(?<![\\p{L}\\p{N}\\p{M}])${escaped(atom)}(?![\\p{L}\\p{N}\\p{M}])`, 'u').test(value);
10
+ }
11
+ function isAtom(value) {
12
+ return isText(value, 500) && /[\p{L}\p{N}]/u.test(normalized(value));
13
+ }
14
+ function isText(value, limit) {
15
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= limit;
16
+ }
17
+ function isClaim(value) {
18
+ if (!value || typeof value !== 'object')
19
+ return false;
20
+ const claim = value;
21
+ return isText(claim.id, 100) && /^[A-Za-z0-9._-]+$/.test(claim.id)
22
+ && isText(claim.text, 2_000) && isText(claim.evidence, 4_000)
23
+ && (claim.mutable === undefined || typeof claim.mutable === 'boolean')
24
+ && (claim.atoms === undefined || (Array.isArray(claim.atoms) && claim.atoms.length > 0 && claim.atoms.length <= 20 && claim.atoms.every(isAtom)));
25
+ }
26
+ export function parseCopySpec(value) {
27
+ if (!value || typeof value !== 'object')
28
+ throw new Error('CopySpec must be a JSON object.');
29
+ const spec = value;
30
+ if (spec.version !== '1' || !isText(spec.audience, 500) || !isText(spec.intent, 500) || !isText(spec.channel, 100)
31
+ || !Array.isArray(spec.claims) || spec.claims.length === 0 || spec.claims.length > 100 || !spec.claims.every(isClaim)
32
+ || new Set(spec.claims.map((claim) => claim.id)).size !== spec.claims.length
33
+ || (spec.prohibitedClaims !== undefined && (!Array.isArray(spec.prohibitedClaims) || spec.prohibitedClaims.length > 100 || !spec.prohibitedClaims.every((claim) => isText(claim, 2_000))))) {
34
+ throw new Error('CopySpec is not valid. It needs version "1", audience, intent, channel, unique claims with text and evidence, and optional prohibitedClaims.');
35
+ }
36
+ return spec;
37
+ }
38
+ export function verifyClaims(candidate, spec) {
39
+ const draftSentences = sentences(candidate).map((sentence) => ({ ...sentence, normalizedText: normalized(sentence.text) }));
40
+ const normalizedCandidate = normalized(candidate);
41
+ const sentenceClaims = {};
42
+ const failures = [];
43
+ for (const claim of spec.claims) {
44
+ if (claim.atoms?.length) {
45
+ const atoms = claim.atoms.map(normalized);
46
+ const presentAtoms = new Set();
47
+ for (const sentence of draftSentences) {
48
+ if (atoms.some((atom) => atomMatches(sentence.normalizedText, atom))) {
49
+ for (const atom of atoms)
50
+ if (atomMatches(sentence.normalizedText, atom))
51
+ presentAtoms.add(atom);
52
+ (sentenceClaims[sentence.index] ??= []).push(claim.id);
53
+ }
54
+ }
55
+ const missingAtoms = atoms.filter((atom) => !presentAtoms.has(atom));
56
+ if (!claim.mutable && missingAtoms.length) {
57
+ failures.push({ id: claim.id, code: 'missing_immutable_atom', message: `Immutable claim ${claim.id} is missing atomic facts: ${missingAtoms.join(', ')}.`, evidence: claim.evidence });
58
+ }
59
+ }
60
+ else {
61
+ const claimText = normalized(claim.text);
62
+ const matching = draftSentences.filter((sentence) => sentence.normalizedText.includes(claimText));
63
+ for (const sentence of matching)
64
+ (sentenceClaims[sentence.index] ??= []).push(claim.id);
65
+ if (!claim.mutable && matching.length === 0)
66
+ failures.push({ id: claim.id, code: 'missing_immutable_claim', message: `Immutable claim ${claim.id} is absent or changed.`, evidence: claim.evidence });
67
+ }
68
+ }
69
+ for (const claim of spec.prohibitedClaims ?? []) {
70
+ if (normalizedCandidate.includes(normalized(claim))) {
71
+ failures.push({ id: claim, code: 'prohibited_claim', message: `Prohibited claim appears in the candidate: ${claim}` });
72
+ }
73
+ }
74
+ return { passed: failures.length === 0, failures, sentenceClaims };
75
+ }
@@ -0,0 +1,126 @@
1
+ import { paragraphs, sentences, words } from './text.js';
2
+ const formats = ['general', 'social', 'deck', 'outreach', 'blog', 'audit', 'website'];
3
+ const evidenceStatuses = ['primary', 'attributed', 'internal', 'unverified'];
4
+ function isText(value, limit) {
5
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= limit;
6
+ }
7
+ function isTerms(value) {
8
+ return Array.isArray(value) && value.length <= 100 && value.every((term) => isText(term, 200));
9
+ }
10
+ function isArgumentMap(value) {
11
+ if (!value || typeof value !== 'object' || Array.isArray(value))
12
+ return false;
13
+ const map = value;
14
+ return isText(map.observation, 500) && isText(map.mechanism, 500) && isText(map.consequence, 500) && isText(map.readerValue, 500);
15
+ }
16
+ export function parseWritingBrief(value) {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value))
18
+ throw new Error('WritingBrief must be a JSON object.');
19
+ const brief = value;
20
+ if (brief.version !== '1' || !isText(brief.audience, 500) || !isText(brief.intent, 500) || !formats.includes(brief.format)
21
+ || (brief.readerKnowsAuthor !== undefined && typeof brief.readerKnowsAuthor !== 'boolean')
22
+ || (brief.vocabulary !== undefined && !isTerms(brief.vocabulary))
23
+ || (brief.prohibitedTerms !== undefined && !isTerms(brief.prohibitedTerms))
24
+ || (brief.title !== undefined && !isText(brief.title, 500))
25
+ || (brief.evidenceStatus !== undefined && !evidenceStatuses.includes(brief.evidenceStatus))
26
+ || (brief.argumentMap !== undefined && !isArgumentMap(brief.argumentMap))) {
27
+ throw new Error('WritingBrief needs version "1", audience, intent, a known format, and optional bounded context fields.');
28
+ }
29
+ return brief;
30
+ }
31
+ function meaningfulTerms(value) {
32
+ return [...new Set(words(value.toLowerCase()).filter((word) => word.length > 3 && !['that', 'this', 'with', 'from', 'your', 'when', 'what', 'into', 'their'].includes(word)))];
33
+ }
34
+ function finding(id, severity, sentence, excerpt, reason, suggestion) {
35
+ return { engine: 'editorial', id, severity, sentence, excerpt, reason, suggestion };
36
+ }
37
+ function formatFindings(text, draftSentences, brief) {
38
+ const findings = [];
39
+ const first = draftSentences[0];
40
+ if (brief.evidenceStatus === 'unverified' && first) {
41
+ findings.push(finding('editorial.evidence.unverified', 'yellow', first.index, first.text, 'The brief marks the source state as unverified.', 'Keep attribution explicit and verify the source before treating the claim as established.'));
42
+ }
43
+ if (brief.argumentMap && first) {
44
+ const expected = meaningfulTerms(brief.argumentMap.readerValue);
45
+ const draftWords = new Set(words(text.toLowerCase()));
46
+ const matchedTerms = expected.filter((term) => draftWords.has(term)).length;
47
+ const requiredTerms = expected.length === 1 ? 1 : Math.min(2, expected.length);
48
+ if (expected.length && matchedTerms < requiredTerms) {
49
+ findings.push(finding('editorial.argument-map.reader-value-missing', 'yellow', first.index, first.text, 'The draft does not carry a concrete reader-value cue from the brief.', 'Connect the observation to the operational consequence the reader can act on.'));
50
+ }
51
+ }
52
+ if (brief.format === 'social') {
53
+ for (const sentence of draftSentences) {
54
+ if (/^(a pattern|a theme|something) i (keep )?(seeing|noticing)\b/i.test(sentence.text)) {
55
+ 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.'));
56
+ }
57
+ }
58
+ const draftParagraphs = paragraphs(text);
59
+ const allSingleSentence = draftParagraphs.length >= 3 && draftParagraphs.every((paragraph) => sentences(paragraph).length === 1);
60
+ if (allSingleSentence && first)
61
+ 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.'));
62
+ }
63
+ if (brief.format === 'deck') {
64
+ if (first && /^we\b/i.test(first.text))
65
+ 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.'));
66
+ if (brief.title && /^\s*\d/.test(brief.title))
67
+ 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.'));
68
+ }
69
+ if (brief.format === 'outreach') {
70
+ for (const sentence of draftSentences) {
71
+ if (/\b(would you be open to|does that sound interesting|what do you think)\??$/i.test(sentence.text)) {
72
+ 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.'));
73
+ }
74
+ }
75
+ }
76
+ return findings;
77
+ }
78
+ export function analyzeEditorial(text, brief) {
79
+ const draftSentences = sentences(text);
80
+ const findings = formatFindings(text, draftSentences, brief);
81
+ const prohibitedTerms = (brief.prohibitedTerms ?? []).map((term) => [term, normalizedWords(term)]).filter(([, term]) => term);
82
+ for (const sentence of draftSentences) {
83
+ const normalizedSentence = normalizedWords(sentence.text);
84
+ for (const [term, normalizedTerm] of prohibitedTerms) {
85
+ if (normalizedTerm && ` ${normalizedSentence} `.includes(` ${normalizedTerm} `)) {
86
+ findings.push(finding('editorial.prohibited-term', 'red', sentence.index, sentence.text, `Uses prohibited term: ${term}.`, 'Remove the term or replace it with approved wording.'));
87
+ }
88
+ }
89
+ }
90
+ const red = findings.filter((item) => item.severity === 'red').length;
91
+ const yellow = findings.length - red;
92
+ return { engine: 'editorial', version: '1', score: Math.max(0, 100 - red * 25 - yellow * 6), passed: red === 0, findings };
93
+ }
94
+ function normalizedWords(text) {
95
+ return words(text.toLowerCase()).join(' ');
96
+ }
97
+ function normalizedBoundary(text) {
98
+ if (!text)
99
+ return undefined;
100
+ const normalized = normalizedWords(text);
101
+ return normalized || undefined;
102
+ }
103
+ function duplicateBoundary(id, boundaries, reason, suggestion) {
104
+ const groups = new Map();
105
+ boundaries.forEach((boundary, index) => {
106
+ if (boundary) {
107
+ const indexes = groups.get(boundary) ?? [];
108
+ indexes.push(index + 1);
109
+ groups.set(boundary, indexes);
110
+ }
111
+ });
112
+ return [...groups.values()].filter((indexes) => indexes.length > 1).map((draftIndexes) => ({ id, severity: 'yellow', draftIndexes, reason, suggestion }));
113
+ }
114
+ export function analyzeBatch(drafts) {
115
+ if (drafts.length < 2 || drafts.length > 100 || drafts.some((draft) => !isText(draft, 100_000)))
116
+ throw new Error('Batch analysis needs 2 to 100 non-empty drafts.');
117
+ const parsed = drafts.map(sentences);
118
+ return {
119
+ version: '1',
120
+ findings: [
121
+ ...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.'),
122
+ ...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.'),
123
+ ],
124
+ passed: true,
125
+ };
126
+ }