@holdyourvoice/hyv 3.1.1 → 3.3.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.
Files changed (53) hide show
  1. package/Readme.md +76 -17
  2. package/dist/ai-editor-rules.js +151 -0
  3. package/dist/ai-editor.js +104 -8
  4. package/dist/ai-editor.test.js +135 -22
  5. package/dist/approval-capability.js +111 -0
  6. package/dist/approval-capability.test.js +52 -0
  7. package/dist/approval-context.js +54 -0
  8. package/dist/approval-context.test.js +38 -0
  9. package/dist/benchmark.js +232 -0
  10. package/dist/benchmark.test.js +328 -0
  11. package/dist/canonical-json.js +123 -0
  12. package/dist/canonical-json.test.js +24 -0
  13. package/dist/cli.js +359 -21
  14. package/dist/cli.test.js +275 -7
  15. package/dist/copy-spec.js +35 -8
  16. package/dist/editorial-packs.js +25 -1
  17. package/dist/editorial-packs.test.js +45 -0
  18. package/dist/hygiene.js +91 -0
  19. package/dist/hygiene.test.js +73 -0
  20. package/dist/judgment-task.js +171 -0
  21. package/dist/judgment-task.test.js +162 -0
  22. package/dist/learning.js +240 -100
  23. package/dist/learning.test.js +203 -3
  24. package/dist/lifecycle-adapter.js +75 -0
  25. package/dist/lifecycle-adapter.test.js +56 -0
  26. package/dist/mcp-tools.js +110 -9
  27. package/dist/mcp-tools.test.js +188 -10
  28. package/dist/mcp.js +228 -9
  29. package/dist/mcp.test.js +248 -12
  30. package/dist/pipeline.js +81 -15
  31. package/dist/pipeline.test.js +94 -2
  32. package/dist/preservation.js +89 -0
  33. package/dist/preservation.test.js +22 -0
  34. package/dist/profile.js +87 -0
  35. package/dist/profile.test.js +114 -0
  36. package/dist/rebuild-task.js +226 -0
  37. package/dist/rebuild-task.test.js +179 -0
  38. package/dist/release-audit.test.js +144 -2
  39. package/dist/rewrite-task.js +136 -16
  40. package/dist/rewrite-task.test.js +72 -4
  41. package/dist/rule-reconciliation.test.js +50 -0
  42. package/dist/semantic-review.js +176 -7
  43. package/dist/semantic-review.test.js +98 -14
  44. package/dist/stage1-dry-run.test.js +39 -0
  45. package/dist/stage1-evaluation.js +579 -0
  46. package/dist/stage1-evaluation.test.js +184 -0
  47. package/dist/stage1-human-packet.test.js +102 -0
  48. package/dist/stage1-schema-contract.test.js +95 -0
  49. package/dist/stage2-human-packet.test.js +81 -0
  50. package/dist/version.js +1 -0
  51. package/dist/voice-dna.js +53 -1
  52. package/dist/voice-dna.test.js +79 -1
  53. package/package.json +2 -2
package/dist/cli.test.js CHANGED
@@ -1,12 +1,30 @@
1
1
  import assert from 'node:assert/strict';
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, mkdirSync, 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
+ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
6
7
  import test from 'node:test';
8
+ import { patternsForMcp } from './mcp-tools.js';
9
+ import { canonicalJson } from './canonical-json.js';
7
10
  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 });
11
+ function run(args, env = process.env, input) {
12
+ return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env, input });
13
+ }
14
+ function installedContextEnvironment(root, context) {
15
+ const home = join(root, 'home');
16
+ const config = join(home, '.config', 'holdyourvoice');
17
+ mkdirSync(config, { recursive: true, mode: 0o700 });
18
+ const contextPath = join(config, 'approval-context.json');
19
+ writeFileSync(contextPath, canonicalJson(context), { mode: 0o600 });
20
+ chmodSync(contextPath, 0o600);
21
+ const fakeOs = join(root, 'fake-os.mjs');
22
+ const hooks = join(root, 'hooks.mjs');
23
+ const register = join(root, 'register.mjs');
24
+ writeFileSync(fakeOs, `import * as actual from 'node:os'; export const userInfo = () => ({ ...actual.userInfo(), homedir: process.env.HYV_TEST_HOME });\n`);
25
+ writeFileSync(hooks, `export async function resolve(specifier, context, nextResolve) { if (specifier === 'node:os' && context.parentURL?.endsWith('/approval-context.js')) return { url: new URL('./fake-os.mjs', import.meta.url).href, shortCircuit: true }; return nextResolve(specifier, context); }\n`);
26
+ writeFileSync(register, `import { register } from 'node:module'; register(new URL('./hooks.mjs', import.meta.url));\n`);
27
+ return { ...process.env, NODE_NO_WARNINGS: '1', NODE_OPTIONS: `--import=${register}`, HYV_TEST_HOME: home };
10
28
  }
11
29
  test('creates an explicit local avoid list and exposes the ruleset', () => {
12
30
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
@@ -27,6 +45,15 @@ test('creates an explicit local avoid list and exposes the ruleset', () => {
27
45
  rmSync(directory, { recursive: true, force: true });
28
46
  }
29
47
  });
48
+ test('publishes the same normalized reconciled catalog and version through CLI and MCP', () => {
49
+ const result = run(['patterns']);
50
+ assert.equal(result.status, 0, result.stderr);
51
+ const cliCatalog = JSON.parse(result.stdout);
52
+ const mcpCatalog = patternsForMcp();
53
+ assert.equal(cliCatalog.version, '3.2.0-reconciled.1');
54
+ assert.equal(cliCatalog.rules.length, 148);
55
+ assert.deepEqual(cliCatalog, mcpCatalog);
56
+ });
30
57
  test('runs contextual analysis and batch analysis without changing the profile contract', () => {
31
58
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
32
59
  try {
@@ -45,6 +72,7 @@ test('runs contextual analysis and batch analysis without changing the profile c
45
72
  assert.equal(run(['profile', profile, first, second]).status, 0);
46
73
  const contextual = JSON.parse(run(['analyze', draft, profile, brief]).stdout);
47
74
  assert.equal(contextual.editorial.findings[0].id, 'editorial.social.generic-opener');
75
+ assert.equal(contextual.hygiene.suspiciousCount, 0);
48
76
  const batch = JSON.parse(run(['batch-analyze', draft, duplicate]).stdout);
49
77
  assert.equal(batch.findings.length, 2);
50
78
  assert.equal(run(['prepare-rewrite', draft, profile, task, brief]).status, 0);
@@ -54,6 +82,66 @@ test('runs contextual analysis and batch analysis without changing the profile c
54
82
  rmSync(directory, { recursive: true, force: true });
55
83
  }
56
84
  });
85
+ test('inspects and conservatively fixes Unicode hygiene without overwriting either file', () => {
86
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
87
+ try {
88
+ const draft = join(directory, 'draft.md');
89
+ const cleaned = join(directory, 'draft.cleaned.md');
90
+ const original = `\uFEFFkeep\u200Bthis\u00A0space\u200D`;
91
+ writeFileSync(draft, original);
92
+ const inspected = run(['hygiene', draft]);
93
+ assert.equal(inspected.status, 0, inspected.stderr);
94
+ const report = JSON.parse(inspected.stdout);
95
+ assert.equal(report.suspiciousCount, 4);
96
+ assert.equal(report.fixableCount, 1);
97
+ const fixed = run(['hygiene', draft, '--fix']);
98
+ assert.equal(fixed.status, 0, fixed.stderr);
99
+ const receipt = JSON.parse(fixed.stdout);
100
+ assert.equal(receipt.outputPath, cleaned);
101
+ assert.equal(receipt.changed, true);
102
+ assert.equal(receipt.changes.length, 1);
103
+ assert.equal(readFileSync(draft, 'utf8'), original);
104
+ assert.equal(readFileSync(cleaned, 'utf8'), `keep\u200Bthis\u00A0space\u200D`);
105
+ const custom = join(directory, 'review-copy.md');
106
+ const customFixed = run(['hygiene', draft, '--fix', `--output=${custom}`]);
107
+ assert.equal(customFixed.status, 0, customFixed.stderr);
108
+ assert.equal(JSON.parse(customFixed.stdout).outputPath, custom);
109
+ assert.equal(readFileSync(custom, 'utf8'), `keep\u200Bthis\u00A0space\u200D`);
110
+ const samePath = run(['hygiene', draft, '--fix', `--output=${draft}`]);
111
+ assert.equal(samePath.status, 1);
112
+ assert.match(samePath.stderr, /must differ from the input path/);
113
+ assert.equal(readFileSync(draft, 'utf8'), original);
114
+ const refused = run(['hygiene', draft, '--fix']);
115
+ assert.equal(refused.status, 1);
116
+ assert.match(refused.stderr, /already exists/);
117
+ assert.equal(readdirSync(directory).some((name) => name.startsWith('.hyv-hygiene-')), false);
118
+ }
119
+ finally {
120
+ rmSync(directory, { recursive: true, force: true });
121
+ }
122
+ });
123
+ test('inspects stdin and refuses to clean it without a preservable input file', () => {
124
+ const inspected = run(['hygiene', '-'], process.env, 'one\u200Btwo');
125
+ assert.equal(inspected.status, 0, inspected.stderr);
126
+ assert.equal(JSON.parse(inspected.stdout).suspiciousCount, 1);
127
+ const refused = run(['hygiene', '-', '--fix'], process.env, 'one\u200Btwo');
128
+ assert.equal(refused.status, 1);
129
+ assert.match(refused.stderr, /requires a file path/);
130
+ });
131
+ test('gates final output from any producer without a voice profile', () => {
132
+ const clean = run(['final-check', '-'], process.env, 'exact output\n');
133
+ assert.equal(clean.status, 0, clean.stderr);
134
+ assert.equal(clean.stdout, 'exact output\n');
135
+ assert.equal(clean.stderr, '');
136
+ const bom = run(['final-check', '-'], process.env, '\uFEFFexact output');
137
+ assert.equal(bom.status, 0, bom.stderr);
138
+ assert.equal(bom.stdout, 'exact output');
139
+ assert.match(bom.stderr, /U\+FEFF/);
140
+ const unresolved = run(['final-check', '-'], process.env, 'Thai\u200Bboundary');
141
+ assert.equal(unresolved.status, 2);
142
+ assert.equal(unresolved.stdout, '');
143
+ assert.match(unresolved.stderr, /U\+200B/);
144
+ });
57
145
  test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
58
146
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
59
147
  try {
@@ -126,6 +214,96 @@ test('prepares and applies the same constrained rewrite task without a provider
126
214
  rmSync(directory, { recursive: true, force: true });
127
215
  }
128
216
  });
217
+ test('prepares and reduces pre-edit judgment envelopes through CLI', () => {
218
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-judgment-'));
219
+ try {
220
+ const first = join(directory, 'first.md');
221
+ const second = join(directory, 'second.md');
222
+ const profile = join(directory, 'profile.json');
223
+ const draft = join(directory, 'draft.md');
224
+ writeFileSync(first, 'I write plainly. I name the work.');
225
+ writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
226
+ writeFileSync(draft, 'I leverage the answer.');
227
+ assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
228
+ const envelopes = ['triage', 'argument', 'form'].map((kind) => {
229
+ const taskPath = join(directory, `${kind}.json`);
230
+ assert.equal(run(['prepare-judgment', 'pre-edit', kind, draft, profile, taskPath]).status, 0);
231
+ const task = JSON.parse(readFileSync(taskPath, 'utf8'));
232
+ const envelopePath = join(directory, `${kind}-envelope.json`);
233
+ writeFileSync(envelopePath, JSON.stringify({
234
+ version: '1', stage: 'pre-edit', judgmentType: kind, taskFingerprint: task.taskFingerprint,
235
+ bindings: { ...task.bindings, evaluatorId: 'writer.1' }, findings: [], decision: 'SHIP',
236
+ }));
237
+ return envelopePath;
238
+ });
239
+ const result = run(['reduce-judgment', ...envelopes]);
240
+ assert.equal(result.status, 0, result.stderr);
241
+ assert.equal(JSON.parse(result.stdout).decision, 'SHIP');
242
+ }
243
+ finally {
244
+ rmSync(directory, { recursive: true, force: true });
245
+ }
246
+ });
247
+ test('prepares, submits, and inspects a normal lifecycle through CLI canonical envelopes', () => {
248
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-lifecycle-'));
249
+ try {
250
+ const deterministicBase = { version: '1', verificationKind: 'standard', passed: true, analysisVersion: '2', rulesetVersion: '3.2.0', preservationMetricVersion: 'legacy-set-v1', preservationScore: 100, sourceHash: '4'.repeat(64), candidateHash: '5'.repeat(64), profileId: 'founder.primary', profileRevisionDigest: '6'.repeat(64), regressionKeys: [] };
251
+ const deterministic = { ...deterministicBase, artifactFingerprint: createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(deterministicBase)}`).digest('hex') };
252
+ const binding = { rewriteTaskFingerprint: '1'.repeat(64), rewriteResponseFingerprint: '2'.repeat(64), deterministicArtifactFingerprint: deterministic.artifactFingerprint, sourceHash: deterministic.sourceHash, candidateHash: deterministic.candidateHash, profileId: deterministic.profileId, profileRevisionDigest: deterministic.profileRevisionDigest, rulesetVersion: deterministic.rulesetVersion, schemaVersion: '1' };
253
+ const receipt = { version: '1', taskFingerprint: binding.rewriteTaskFingerprint, responseFingerprint: binding.rewriteResponseFingerprint, adapterIds: [], replacementSentenceIds: [1] };
254
+ const paths = Object.fromEntries(['deterministic', 'binding', 'receipt', 'violations', 'output', 'artifact', 'task', 'verdict'].map((name) => [name, join(directory, `${name}.json`)]));
255
+ writeFileSync(paths.deterministic, JSON.stringify(deterministic));
256
+ writeFileSync(paths.binding, JSON.stringify(binding));
257
+ writeFileSync(paths.receipt, JSON.stringify(receipt));
258
+ writeFileSync(paths.violations, JSON.stringify(['action_change']));
259
+ const prepared = run(['lifecycle', 'prepare-semantic', paths.deterministic, paths.binding, paths.receipt, 'normal', paths.violations, paths.output]);
260
+ assert.equal(prepared.status, 0, prepared.stderr);
261
+ const envelope = JSON.parse(prepared.stdout);
262
+ assert.equal(envelope.artifact.status, 'needs_semantic_review');
263
+ assert.deepEqual(JSON.parse(readFileSync(paths.output, 'utf8')), envelope);
264
+ writeFileSync(paths.artifact, JSON.stringify(envelope.artifact));
265
+ writeFileSync(paths.task, JSON.stringify(envelope.task));
266
+ writeFileSync(paths.verdict, JSON.stringify({ approved: true, violations: [] }));
267
+ const context = { now: 0, trustStore: { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [] }, authorizedSemanticEvaluatorIds: { normal: ['reviewer-1'], highAssurance: [] }, authorizedHumanFinalizerIds: ['human-1'] };
268
+ const submitted = run(['lifecycle', 'submit-verdict', paths.artifact, paths.task, 'reviewer-1', paths.verdict], installedContextEnvironment(directory, context));
269
+ assert.equal(submitted.status, 0, submitted.stderr);
270
+ assert.equal(JSON.parse(submitted.stdout).status, 'ready_for_human_review');
271
+ const inspected = run(['lifecycle', 'inspect', paths.artifact]);
272
+ assert.equal(inspected.status, 0, inspected.stderr);
273
+ assert.equal(JSON.parse(inspected.stdout).status, 'needs_semantic_review');
274
+ assert.doesNotMatch(inspected.stdout, /sourceHash|candidateHash|verdicts/);
275
+ assert.equal(run(['lifecycle', 'prepare-semantic', paths.deterministic, paths.binding, paths.receipt, 'high_assurance', paths.violations, join(directory, 'high.json')]).status, 1);
276
+ }
277
+ finally {
278
+ rmSync(directory, { recursive: true, force: true });
279
+ }
280
+ });
281
+ test('accepts only one bounded capability transport and treats a file named stdin literally', () => {
282
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-capability-'));
283
+ try {
284
+ const capability = JSON.stringify({ payload: 'secret-payload', signature: 'secret-signature' });
285
+ const literal = join(directory, 'stdin');
286
+ writeFileSync(literal, capability, { mode: 0o600 });
287
+ const literalResult = spawnSync(process.execPath, [cli, 'lifecycle', 'validate-final-approval', 'missing.json', '--capability-file', 'stdin'], { cwd: directory, encoding: 'utf8' });
288
+ assert.equal(literalResult.status, 1);
289
+ assert.doesNotMatch(literalResult.stderr, /Capability file is unavailable or unsafe|secret-payload|secret-signature/);
290
+ chmodSync(literal, 0o644);
291
+ const unsafe = spawnSync(process.execPath, [cli, 'lifecycle', 'validate-final-approval', 'missing.json', '--capability-file', 'stdin'], { cwd: directory, encoding: 'utf8' });
292
+ assert.equal(unsafe.status, 1);
293
+ assert.match(unsafe.stderr, /Capability file is unavailable or unsafe/);
294
+ assert.doesNotMatch(unsafe.stderr, /secret-payload|secret-signature/);
295
+ const duplicate = run(['lifecycle', 'validate-final-approval', 'missing.json', '--capability-stdin', '--capability-file', literal], process.env, capability);
296
+ assert.equal(duplicate.status, 1);
297
+ assert.match(duplicate.stderr, /Choose one capability source/);
298
+ assert.doesNotMatch(duplicate.stderr, /secret-payload|secret-signature/);
299
+ const oversized = run(['lifecycle', 'validate-final-approval', 'missing.json', '--capability-stdin'], process.env, 'x'.repeat(1024 * 1024 + 1));
300
+ assert.equal(oversized.status, 1);
301
+ assert.match(oversized.stderr, /exceeds the byte limit/);
302
+ }
303
+ finally {
304
+ rmSync(directory, { recursive: true, force: true });
305
+ }
306
+ });
129
307
  test('rejects a malformed hand-edited profile before analysis', () => {
130
308
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
131
309
  try {
@@ -194,7 +372,7 @@ test('rejects hand-edited metrics outside their semantic bounds', () => {
194
372
  rmSync(directory, { recursive: true, force: true });
195
373
  }
196
374
  });
197
- test('learns from a successful local verification by default and exposes local controls', () => {
375
+ test('keeps verification read-only and exposes learning only through explicit controls', () => {
198
376
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
199
377
  try {
200
378
  const first = join(directory, 'first.md');
@@ -211,11 +389,10 @@ test('learns from a successful local verification by default and exposes local c
211
389
  assert.equal(run(['verify', original, candidate, profile], env).status, 0);
212
390
  const brief = run(['rewrite-prompt', candidate, profile], env);
213
391
  assert.equal(brief.status, 0, brief.stderr);
214
- assert.match(brief.stdout, /Learned local preferences/);
215
- assert.match(brief.stdout, /ai\\_editor\/ai\.leverage/);
392
+ assert.doesNotMatch(brief.stdout, /Learned local preferences/);
216
393
  const learned = run(['learning', 'show', profile], env);
217
394
  assert.equal(learned.status, 0, learned.stderr);
218
- assert.ok(JSON.parse(learned.stdout).preferences.some((item) => item.text.includes('ai_editor/ai.leverage')));
395
+ assert.deepEqual(JSON.parse(learned.stdout).preferences, []);
219
396
  assert.equal(run(['learning', 'clear', profile], env).status, 0);
220
397
  assert.deepEqual(JSON.parse(run(['learning', 'show', profile], env).stdout).preferences, []);
221
398
  }
@@ -223,3 +400,94 @@ test('learns from a successful local verification by default and exposes local c
223
400
  rmSync(directory, { recursive: true, force: true });
224
401
  }
225
402
  });
403
+ test('records and inspects text-free learning metadata through the CLI', () => {
404
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-learning-'));
405
+ try {
406
+ const first = join(directory, 'first.md');
407
+ const second = join(directory, 'second.md');
408
+ const profile = join(directory, 'profile.json');
409
+ const env = { ...process.env, HYV_HOME: join(directory, 'state') };
410
+ writeFileSync(first, 'I write plainly. I name the work.');
411
+ writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
412
+ assert.equal(run(['profile', profile, first, second]).status, 0);
413
+ const recorded = run(['learning', 'record', profile, 'Keep the mechanism concrete.', '--authority=founder', '--provenance=editor-review', '--weight=2', '--compatibility=exact', '--mutation-id=cli-1'], env);
414
+ assert.equal(recorded.status, 0, recorded.stderr);
415
+ assert.equal(JSON.parse(recorded.stdout).mutationId, 'cli-1');
416
+ assert.equal(JSON.parse(run(['learning', 'record', profile, 'Different instruction.', '--mutation-id=cli-1'], env).stdout).status, 'conflict');
417
+ const inspected = run(['learning', 'inspect', profile], env);
418
+ assert.equal(inspected.status, 0, inspected.stderr);
419
+ assert.equal(JSON.parse(inspected.stdout)[0].authority, 'founder');
420
+ assert.doesNotMatch(inspected.stdout, /Keep the mechanism concrete/);
421
+ assert.equal(run(['learning', 'ratify', profile, JSON.parse(recorded.stdout).eventId], env).status, 1);
422
+ assert.equal(run(['learning', 'record', profile, 'Boundary.', `--mutation-id=${'m'.repeat(200)}`, `--provenance=${'p'.repeat(500)}`], env).status, 0);
423
+ assert.equal(run(['learning', 'record', profile, 'Too long.', `--mutation-id=${'m'.repeat(201)}`], env).status, 1);
424
+ assert.equal(run(['learning', 'record', profile, 'Too long.', `--provenance=${'p'.repeat(501)}`], env).status, 1);
425
+ assert.equal(run(['learning', 'inspect', profile, '--mutation-id=nope'], env).status, 1);
426
+ assert.equal(run(['learning', 'clear', profile, '--authority=team'], env).status, 1);
427
+ }
428
+ finally {
429
+ rmSync(directory, { recursive: true, force: true });
430
+ }
431
+ });
432
+ test('prepares and applies an authorized rebuild through CLI', () => {
433
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-rebuild-'));
434
+ try {
435
+ const first = join(directory, 'first.md');
436
+ const second = join(directory, 'second.md');
437
+ const profile = join(directory, 'profile.json');
438
+ const draft = join(directory, 'draft.md');
439
+ const spec = join(directory, 'copy-spec.json');
440
+ const reduction = join(directory, 'reduction.json');
441
+ const task = join(directory, 'rebuild-task.json');
442
+ const response = join(directory, 'response.json');
443
+ const capability = join(directory, 'capability.json');
444
+ writeFileSync(first, 'I write plainly. I name the work.');
445
+ writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
446
+ writeFileSync(draft, 'I leverage the answer. The launch is on 14 August.');
447
+ 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, 7 August.' }] }));
448
+ assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
449
+ const envelopes = ['triage', 'argument', 'form'].map((kind) => {
450
+ const taskPath = join(directory, `${kind}.json`);
451
+ assert.equal(run(['prepare-judgment', 'pre-edit', kind, draft, profile, taskPath]).status, 0);
452
+ const prepared = JSON.parse(readFileSync(taskPath, 'utf8'));
453
+ const envelopePath = join(directory, `${kind}-envelope.json`);
454
+ writeFileSync(envelopePath, JSON.stringify({
455
+ version: '1', stage: 'pre-edit', judgmentType: kind, taskFingerprint: prepared.taskFingerprint,
456
+ bindings: { ...prepared.bindings, evaluatorId: 'writer.1' }, findings: [], decision: kind === 'argument' ? 'REBUILD' : 'SHIP',
457
+ }));
458
+ return envelopePath;
459
+ });
460
+ const reduced = run(['reduce-judgment', ...envelopes]);
461
+ assert.equal(reduced.status, 0, reduced.stderr);
462
+ writeFileSync(reduction, reduced.stdout);
463
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
464
+ const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'host.example', keyId: 'key-1', publicKeySpki: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), status: 'active' }] };
465
+ const context = { now: 0, trustStore, authorizedSemanticEvaluatorIds: { normal: [], highAssurance: [] }, authorizedHumanFinalizerIds: [] };
466
+ const env = installedContextEnvironment(directory, context);
467
+ const sourceHash = createHash('sha256').update(readFileSync(draft)).digest('hex');
468
+ const profileValue = JSON.parse(readFileSync(profile, 'utf8'));
469
+ const identity = `legacy-v2:${createHash('sha256').update(canonicalJson(profileValue)).digest('hex')}`;
470
+ const claims = {
471
+ version: '1', purpose: 'hyv.rebuild-authorization', issuer: 'host.example', audience: '@holdyourvoice/hyv',
472
+ subjectArtifactFingerprint: JSON.parse(reduced.stdout).recommendationFingerprint, sourceHash, candidateHash: sourceHash,
473
+ profileId: identity, profileRevisionDigest: identity, keyId: 'key-1', issuedAt: Math.floor(Date.now() / 1000) - 1,
474
+ notBefore: Math.floor(Date.now() / 1000) - 1, expiresAt: Math.floor(Date.now() / 1000) + 120, nonce: 'cli-rebuild',
475
+ };
476
+ const payload = Buffer.from(canonicalJson(claims));
477
+ writeFileSync(capability, canonicalJson({ payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') }));
478
+ chmodSync(capability, 0o600);
479
+ const prepared = run(['prepare-rebuild', draft, profile, reduction, spec, task, '--capability-file', capability], env);
480
+ assert.equal(prepared.status, 0, prepared.stderr);
481
+ writeFileSync(response, JSON.stringify({
482
+ version: '1', mode: 'REBUILD', taskFingerprint: JSON.parse(readFileSync(task, 'utf8')).fingerprint,
483
+ candidate: 'Ship planning now treats one calendar fact as fixed. The launch is on 14 August. Every other sentence in this note is new operational language for the release desk.',
484
+ }));
485
+ const applied = run(['apply-rebuild', task, response, profile, '--capability-file', capability], env);
486
+ assert.equal(applied.status, 2, applied.stderr);
487
+ assert.equal(JSON.parse(applied.stdout).status, 'needs_semantic_review');
488
+ assert.equal(run(['apply-rebuild', task, response, profile], env).status, 1);
489
+ }
490
+ finally {
491
+ rmSync(directory, { recursive: true, force: true });
492
+ }
493
+ });
package/dist/copy-spec.js CHANGED
@@ -2,6 +2,15 @@ import { sentences } from './text.js';
2
2
  function normalized(value) {
3
3
  return value.toLowerCase().replace(/\s+/g, ' ').trim();
4
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
+ }
5
14
  function isText(value, limit) {
6
15
  return typeof value === 'string' && value.trim().length > 0 && value.length <= limit;
7
16
  }
@@ -11,7 +20,8 @@ function isClaim(value) {
11
20
  const claim = value;
12
21
  return isText(claim.id, 100) && /^[A-Za-z0-9._-]+$/.test(claim.id)
13
22
  && isText(claim.text, 2_000) && isText(claim.evidence, 4_000)
14
- && (claim.mutable === undefined || typeof claim.mutable === 'boolean');
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)));
15
25
  }
16
26
  export function parseCopySpec(value) {
17
27
  if (!value || typeof value !== 'object')
@@ -26,17 +36,34 @@ export function parseCopySpec(value) {
26
36
  return spec;
27
37
  }
28
38
  export function verifyClaims(candidate, spec) {
29
- const draftSentences = sentences(candidate);
39
+ const draftSentences = sentences(candidate).map((sentence) => ({ ...sentence, normalizedText: normalized(sentence.text) }));
30
40
  const normalizedCandidate = normalized(candidate);
31
41
  const sentenceClaims = {};
32
42
  const failures = [];
33
43
  for (const claim of spec.claims) {
34
- const claimText = normalized(claim.text);
35
- const matching = draftSentences.filter((sentence) => normalized(sentence.text).includes(claimText));
36
- for (const sentence of matching)
37
- (sentenceClaims[sentence.index] ??= []).push(claim.id);
38
- if (!claim.mutable && matching.length === 0) {
39
- failures.push({ id: claim.id, code: 'missing_immutable_claim', message: `Immutable claim ${claim.id} is absent or changed.`, evidence: claim.evidence });
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 });
40
67
  }
41
68
  }
42
69
  for (const claim of spec.prohibitedClaims ?? []) {
@@ -1,11 +1,18 @@
1
1
  import { paragraphs, sentences, words } from './text.js';
2
2
  const formats = ['general', 'social', 'deck', 'outreach', 'blog', 'audit', 'website'];
3
+ const evidenceStatuses = ['primary', 'attributed', 'internal', 'unverified'];
3
4
  function isText(value, limit) {
4
5
  return typeof value === 'string' && value.trim().length > 0 && value.length <= limit;
5
6
  }
6
7
  function isTerms(value) {
7
8
  return Array.isArray(value) && value.length <= 100 && value.every((term) => isText(term, 200));
8
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
+ }
9
16
  export function parseWritingBrief(value) {
10
17
  if (!value || typeof value !== 'object' || Array.isArray(value))
11
18
  throw new Error('WritingBrief must be a JSON object.');
@@ -14,17 +21,34 @@ export function parseWritingBrief(value) {
14
21
  || (brief.readerKnowsAuthor !== undefined && typeof brief.readerKnowsAuthor !== 'boolean')
15
22
  || (brief.vocabulary !== undefined && !isTerms(brief.vocabulary))
16
23
  || (brief.prohibitedTerms !== undefined && !isTerms(brief.prohibitedTerms))
17
- || (brief.title !== undefined && !isText(brief.title, 500))) {
24
+ || (brief.title !== undefined && !isText(brief.title, 500))
25
+ || (brief.evidenceStatus !== undefined && !evidenceStatuses.includes(brief.evidenceStatus))
26
+ || (brief.argumentMap !== undefined && !isArgumentMap(brief.argumentMap))) {
18
27
  throw new Error('WritingBrief needs version "1", audience, intent, a known format, and optional bounded context fields.');
19
28
  }
20
29
  return brief;
21
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
+ }
22
34
  function finding(id, severity, sentence, excerpt, reason, suggestion) {
23
35
  return { engine: 'editorial', id, severity, sentence, excerpt, reason, suggestion };
24
36
  }
25
37
  function formatFindings(text, draftSentences, brief) {
26
38
  const findings = [];
27
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
+ }
28
52
  if (brief.format === 'social') {
29
53
  for (const sentence of draftSentences) {
30
54
  if (/^(a pattern|a theme|something) i (keep )?(seeing|noticing)\b/i.test(sentence.text)) {
@@ -46,4 +46,49 @@ test('detects exact repeated openings and endings across a batch without judging
46
46
  test('rejects malformed writing briefs before they activate editorial checks', () => {
47
47
  assert.throws(() => parseWritingBrief({ version: '1', audience: '', intent: 'write', format: 'social' }), /WritingBrief/);
48
48
  assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'unknown' }), /WritingBrief/);
49
+ assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'social', evidenceStatus: 'unknown' }), /WritingBrief/);
50
+ assert.throws(() => parseWritingBrief({ version: '1', audience: 'founders', intent: 'write', format: 'social', argumentMap: { observation: 'A', mechanism: 'B', consequence: 'C' } }), /WritingBrief/);
51
+ });
52
+ test('adds opt-in evidence and reader-value review cues without blocking publication', () => {
53
+ const brief = parseWritingBrief({
54
+ version: '1',
55
+ audience: 'operators',
56
+ intent: 'explain a reliability cost',
57
+ format: 'social',
58
+ evidenceStatus: 'unverified',
59
+ argumentMap: {
60
+ observation: 'A worker failed.',
61
+ mechanism: 'The cache was lost.',
62
+ consequence: 'The request restarts.',
63
+ readerValue: 'Avoid the cold restart cost.',
64
+ },
65
+ });
66
+ const report = analyzeEditorial('A worker failed. The request restarts.', brief);
67
+ assert.equal(report.passed, true);
68
+ assert.deepEqual(report.findings.map((finding) => finding.id), [
69
+ 'editorial.evidence.unverified',
70
+ 'editorial.argument-map.reader-value-missing',
71
+ ]);
72
+ });
73
+ test('does not flag an argument map when the draft carries the reader value', () => {
74
+ const brief = parseWritingBrief({
75
+ version: '1',
76
+ audience: 'operators',
77
+ intent: 'explain a reliability cost',
78
+ format: 'social',
79
+ argumentMap: {
80
+ observation: 'A worker failed.',
81
+ mechanism: 'The cache was lost.',
82
+ consequence: 'The request restarts.',
83
+ readerValue: 'Avoid the cold restart cost.',
84
+ },
85
+ });
86
+ assert.deepEqual(analyzeEditorial('A worker failed. Avoid the cold restart cost.', brief).findings, []);
87
+ });
88
+ test('keeps the reader-value cue when only one generic term overlaps', () => {
89
+ const brief = parseWritingBrief({
90
+ version: '1', audience: 'operators', intent: 'explain a reliability cost', format: 'social',
91
+ argumentMap: { observation: 'A worker failed.', mechanism: 'The cache was lost.', consequence: 'The request restarts.', readerValue: 'Avoid the cold restart cost.' },
92
+ });
93
+ assert.ok(analyzeEditorial('A worker failed. We avoid a delay.', brief).findings.some((item) => item.id === 'editorial.argument-map.reader-value-missing'));
49
94
  });
@@ -0,0 +1,91 @@
1
+ const CHARACTER_POLICIES = new Map([
2
+ [0x180e, { kind: 'zero_width', label: 'Mongolian vowel separator', fix: 'none' }],
3
+ [0x200b, { kind: 'zero_width', label: 'Zero width space', fix: 'none' }],
4
+ [0x200c, { kind: 'zero_width', label: 'Zero width non-joiner', fix: 'none' }],
5
+ [0x200d, { kind: 'zero_width', label: 'Zero width joiner', fix: 'none' }],
6
+ [0x2060, { kind: 'zero_width', label: 'Word joiner', fix: 'none' }],
7
+ [0xfeff, { kind: 'zero_width', label: 'Byte order mark / zero width no-break space', fix: 'none' }],
8
+ ]);
9
+ for (const [codepoint, label] of [
10
+ [0x061c, 'Arabic letter mark'], [0x200e, 'Left-to-right mark'], [0x200f, 'Right-to-left mark'],
11
+ [0x202a, 'Left-to-right embedding'], [0x202b, 'Right-to-left embedding'], [0x202c, 'Pop directional formatting'],
12
+ [0x202d, 'Left-to-right override'], [0x202e, 'Right-to-left override'], [0x2066, 'Left-to-right isolate'],
13
+ [0x2067, 'Right-to-left isolate'], [0x2068, 'First strong isolate'], [0x2069, 'Pop directional isolate'],
14
+ ])
15
+ CHARACTER_POLICIES.set(codepoint, { kind: 'bidi', label, fix: 'none' });
16
+ for (const [codepoint, label] of [
17
+ [0x00a0, 'No-break space'], [0x1680, 'Ogham space mark'], [0x2000, 'En quad'], [0x2001, 'Em quad'],
18
+ [0x2002, 'En space'], [0x2003, 'Em space'], [0x2004, 'Three-per-em space'], [0x2005, 'Four-per-em space'],
19
+ [0x2006, 'Six-per-em space'], [0x2007, 'Figure space'], [0x2008, 'Punctuation space'], [0x2009, 'Thin space'],
20
+ [0x200a, 'Hair space'], [0x202f, 'Narrow no-break space'], [0x205f, 'Medium mathematical space'], [0x3000, 'Ideographic space'],
21
+ ])
22
+ CHARACTER_POLICIES.set(codepoint, { kind: 'unusual_space', label, fix: 'none' });
23
+ function formattedCodepoint(codepoint) {
24
+ return `U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`;
25
+ }
26
+ function classification(codepoint) {
27
+ return CHARACTER_POLICIES.get(codepoint) ?? (codepoint >= 0xe0001 && codepoint <= 0xe007f
28
+ ? { kind: 'tag', label: 'Unicode tag character', fix: 'none' }
29
+ : undefined);
30
+ }
31
+ function policyAt(codepoint, offset) {
32
+ const policy = classification(codepoint);
33
+ return codepoint === 0xfeff && offset === 0 && policy ? { ...policy, fix: 'remove' } : policy;
34
+ }
35
+ function scanHygiene(text, clean) {
36
+ const grouped = new Map();
37
+ const cleanedParts = [];
38
+ const changes = [];
39
+ let unchangedStart = 0;
40
+ for (let offset = 0; offset < text.length;) {
41
+ const codepoint = text.codePointAt(offset);
42
+ const character = String.fromCodePoint(codepoint);
43
+ const found = policyAt(codepoint, offset);
44
+ if (found) {
45
+ const key = `${codepoint}:${found.fix}`;
46
+ const hit = grouped.get(key) ?? { ...found, codepoint, offsets: [] };
47
+ hit.offsets.push(offset);
48
+ grouped.set(key, hit);
49
+ if (clean && found.fix !== 'none') {
50
+ cleanedParts.push(text.slice(unchangedStart, offset));
51
+ changes.push({ offset, codepoint: formattedCodepoint(codepoint), action: 'removed' });
52
+ unchangedStart = offset + character.length;
53
+ }
54
+ }
55
+ offset += character.length;
56
+ }
57
+ const hits = [...grouped.values()].sort((left, right) => left.codepoint - right.codepoint || left.offsets[0] - right.offsets[0]).map((hit) => {
58
+ const { codepoint } = hit;
59
+ const base = { codepoint: formattedCodepoint(codepoint), label: hit.label, kind: hit.kind, count: hit.offsets.length, offsets: hit.offsets };
60
+ return { ...base, fix: hit.fix };
61
+ });
62
+ const report = {
63
+ version: '1',
64
+ length: text.length,
65
+ suspiciousCount: hits.reduce((total, hit) => total + hit.count, 0),
66
+ fixableCount: hits.filter((hit) => hit.fix !== 'none').reduce((total, hit) => total + hit.count, 0),
67
+ hits,
68
+ };
69
+ if (cleanedParts.length)
70
+ cleanedParts.push(text.slice(unchangedStart));
71
+ return { report, cleaned: cleanedParts.length ? cleanedParts.join('') : text, changes };
72
+ }
73
+ export function inspectHygiene(text) {
74
+ return scanHygiene(text, false).report;
75
+ }
76
+ export function hygieneSourceFindings(text) {
77
+ return inspectHygiene(text).hits.flatMap((hit) => hit.offsets.map((start) => {
78
+ const character = String.fromCodePoint(text.codePointAt(start));
79
+ return { kind: 'hygiene', start, end: start + character.length, codepoint: hit.codepoint, eligible: hit.fix === 'remove' };
80
+ }));
81
+ }
82
+ export function cleanHygiene(text) {
83
+ const result = scanHygiene(text, true);
84
+ return { ...result, changed: result.changes.length > 0 };
85
+ }
86
+ export function finalOutputCheck(text) {
87
+ const cleaned = cleanHygiene(text);
88
+ const remaining = inspectHygiene(cleaned.cleaned);
89
+ const base = { version: '1', changed: cleaned.changed, changes: cleaned.changes, input: cleaned.report, remaining };
90
+ return remaining.suspiciousCount === 0 ? { ...base, accepted: true, output: cleaned.cleaned } : { ...base, accepted: false };
91
+ }