@holdyourvoice/hyv 3.6.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/Readme.md +91 -96
  2. package/dist/ai-editor-rules.js +1 -0
  3. package/dist/ai-editor.js +19 -13
  4. package/dist/analysis.js +33 -0
  5. package/dist/cli/agents.js +15 -35
  6. package/dist/cli/io.js +41 -37
  7. package/dist/copy-spec.js +6 -6
  8. package/dist/editorial-packs.js +4 -3
  9. package/dist/fact-linter.js +45 -27
  10. package/dist/hidden-text.js +6 -6
  11. package/dist/hold-your-voice.mcpb +0 -0
  12. package/dist/internal.js +8 -0
  13. package/dist/judgment-task.js +10 -14
  14. package/dist/learning.js +66 -67
  15. package/dist/lifecycle-adapter.js +2 -5
  16. package/dist/local-eval.js +16 -31
  17. package/dist/mcp-server.js +272 -0
  18. package/dist/mcp-tools.js +2 -2
  19. package/dist/mcp.js +2 -261
  20. package/dist/pipeline.js +9 -85
  21. package/dist/profile-compose.js +18 -11
  22. package/dist/profile-score.js +6 -6
  23. package/dist/profile.js +17 -19
  24. package/dist/provenance-status.js +1 -3
  25. package/dist/rebuild-task.js +14 -25
  26. package/dist/recomposition.js +18 -13
  27. package/dist/rewrite-prompt.js +82 -0
  28. package/dist/rewrite-task.js +66 -65
  29. package/dist/rule-allowances.js +6 -10
  30. package/dist/semantic-review.js +71 -47
  31. package/dist/stage1-evaluation.js +29 -14
  32. package/dist/strict-quality.js +10 -18
  33. package/dist/version.js +1 -1
  34. package/dist/voice-dna.js +34 -21
  35. package/dist/writing-examples.js +2 -7
  36. package/package.json +6 -3
  37. package/skills/hyv-prepare-judgment/SKILL.md +4 -0
  38. package/dist/agents/catalog.test.js +0 -60
  39. package/dist/agents/emit.test.js +0 -64
  40. package/dist/agents/load.test.js +0 -149
  41. package/dist/ai-editor.test.js +0 -265
  42. package/dist/approval-capability.test.js +0 -52
  43. package/dist/approval-context.test.js +0 -38
  44. package/dist/backtest.test.js +0 -20
  45. package/dist/benchmark.test.js +0 -328
  46. package/dist/canonical-json.test.js +0 -24
  47. package/dist/cli/context.test.js +0 -55
  48. package/dist/cli.test.js +0 -731
  49. package/dist/editorial-packs.test.js +0 -94
  50. package/dist/fact-linter.test.js +0 -85
  51. package/dist/hidden-text.test.js +0 -26
  52. package/dist/hygiene.test.js +0 -83
  53. package/dist/judgment-task.test.js +0 -162
  54. package/dist/learning.test.js +0 -325
  55. package/dist/lifecycle-adapter.test.js +0 -56
  56. package/dist/local-eval.test.js +0 -20
  57. package/dist/logic-linter-corpus.test.js +0 -22
  58. package/dist/logic-linter.test.js +0 -39
  59. package/dist/mcp-tools.test.js +0 -286
  60. package/dist/mcp.test.js +0 -312
  61. package/dist/mirror-refs.test.js +0 -63
  62. package/dist/pipeline.test.js +0 -247
  63. package/dist/preservation.test.js +0 -22
  64. package/dist/production-gates.test.js +0 -34
  65. package/dist/profile-compose.test.js +0 -32
  66. package/dist/profile-score.test.js +0 -22
  67. package/dist/profile-watch.test.js +0 -23
  68. package/dist/profile.test.js +0 -141
  69. package/dist/provenance-status.test.js +0 -22
  70. package/dist/rebuild-task.test.js +0 -206
  71. package/dist/recomposition.test.js +0 -34
  72. package/dist/release-audit.test.js +0 -292
  73. package/dist/rewrite-task.test.js +0 -166
  74. package/dist/rule-allowances.test.js +0 -17
  75. package/dist/rule-reconciliation.test.js +0 -50
  76. package/dist/sample-ingest.test.js +0 -52
  77. package/dist/semantic-review.test.js +0 -101
  78. package/dist/stage1-dry-run.test.js +0 -39
  79. package/dist/stage1-evaluation.test.js +0 -184
  80. package/dist/stage1-human-packet.test.js +0 -102
  81. package/dist/stage1-schema-contract.test.js +0 -95
  82. package/dist/stage2-human-packet.test.js +0 -81
  83. package/dist/strict-quality.test.js +0 -62
  84. package/dist/text-provenance.feature.test.js +0 -45
  85. package/dist/text.test.js +0 -16
  86. package/dist/voice-dna.test.js +0 -121
  87. package/dist/writing-examples.test.js +0 -35
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@holdyourvoice/hyv",
3
- "version": "3.6.1",
4
- "description": "A local-first dual-engine writing gate that protects voice and catches generic AI patterns.",
3
+ "version": "4.0.0",
4
+ "description": "Local writing checks, voice profiles, and verified editing workflows for humans and agents.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "hyv": "dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
+ "!dist/**/*.test.js",
11
12
  "skills",
12
13
  "Readme.md",
13
14
  "LICENSE"
@@ -17,6 +18,7 @@
17
18
  "bundle:claude": "esbuild src/mcp.ts --bundle --platform=node --format=esm --target=node20 --outfile=mcpb/server/index.js",
18
19
  "build:claude": "npm run build && npm run bundle:claude",
19
20
  "pack:claude": "npm run build:claude && node scripts/pack-claude.mjs",
21
+ "pack:chatgpt-plugin": "node scripts/pack-chatgpt-plugin.mjs",
20
22
  "scorecard": "node scripts/public-scorecard.mjs benchmarks",
21
23
  "validate:rule-contributions": "node scripts/validate-rule-contributions.mjs",
22
24
  "stage1:evaluate": "node scripts/evaluate-rewrite-benchmark.mjs",
@@ -25,7 +27,8 @@
25
27
  "stage2:human-packet": "npm run build && node scripts/run-stage2-human-packet.mjs",
26
28
  "test": "npm run build && node --test \"dist/**/*.test.js\"",
27
29
  "check:release": "node scripts/release-audit.mjs",
28
- "prepack": "npm run check:release && npm test && npm run pack:claude"
30
+ "prepack": "npm run check:release && npm test && npm run pack:claude",
31
+ "check:compatibility": "node scripts/verify-compatibility.mjs"
29
32
  },
30
33
  "engines": {
31
34
  "node": ">=20"
@@ -19,6 +19,10 @@ hyv prepare-judgment pre-edit|post-candidate kind draft.md profile.json task.jso
19
19
  - Commands that write use an explicit output path. Confirm that path before running the command.
20
20
  - This agent describes how to invoke the command and what it returns. It does not change command behavior or exit codes.
21
21
 
22
+ ## Word economy review
23
+
24
+ For a post-candidate form judgment, review whether every word earns its place before final verification and delivery. Name the phrase and sentence range, propose a cut, and explain why meaning, evidence, clarity, and voice survive it. Preserve necessary uncertainty, attribution, emphasis, rhythm, and transitions. Do not use a word-count target. Record unresolved cuts as findings and use ESCALATE; do not return CLEAR merely because the candidate is shorter. Report concerns outside the authorized edit scope without changing text. The local command prepares the task; it does not perform this editorial judgment.
25
+
22
26
  ## Handoff
23
27
 
24
28
  Run `hyv prepare-judgment` directly to execute the operation. Follow-on agents: hyv-reduce-judgment.
@@ -1,60 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { resolveHost, listHosts, runtime } from './catalog.js';
4
- import { AGENT_SCHEMA_VERSION } from './types.js';
5
- function minimalDescriptor(id, permissions) {
6
- return {
7
- schema_version: AGENT_SCHEMA_VERSION,
8
- id,
9
- title: id,
10
- description: 'test',
11
- instruction_file: 'SKILL.md',
12
- role: 'Tester',
13
- workflow_phase: 'test',
14
- input: { required: ['draft'], optional: [] },
15
- output: { required: ['report'], optional: [] },
16
- evidence_requirements: ['evidence'],
17
- permissions,
18
- stop_conditions: ['stop'],
19
- tool_free_mode: { available: true, behavior: 'report', unavailable_statuses: ['NOT_AVAILABLE'] },
20
- handoff_to: [],
21
- };
22
- }
23
- test('resolves known hosts and falls back to generic for unknown ids', () => {
24
- assert.equal(resolveHost('codex').id, 'codex');
25
- assert.equal(resolveHost('future-ide').id, 'generic');
26
- assert.equal(resolveHost('future-ide').native_skills, false);
27
- assert.equal(resolveHost('future-ide').cli, true);
28
- });
29
- test('catalog has unique host ids and includes generic', () => {
30
- const hosts = listHosts();
31
- const ids = hosts.map((host) => host.id);
32
- assert.ok(ids.includes('generic'));
33
- assert.equal(new Set(ids).size, ids.length);
34
- });
35
- test('runtime marks unsupported permissions unavailable and switches to prompt-only', () => {
36
- const descriptor = minimalDescriptor('hyv-verify', ['read_repository', 'network', 'subagents']);
37
- const resolved = runtime(descriptor, 'generic');
38
- assert.deepEqual(resolved.available_capabilities, []);
39
- assert.deepEqual(resolved.unavailable_capabilities, [
40
- { capability: 'read_repository', status: 'NOT_AVAILABLE' },
41
- { capability: 'network', status: 'NOT_AVAILABLE' },
42
- { capability: 'subagents', status: 'NOT_AVAILABLE' },
43
- ]);
44
- assert.equal(resolved.execution_mode, 'prompt-only');
45
- });
46
- test('runtime uses native mode when the host supports every permission', () => {
47
- const descriptor = minimalDescriptor('hyv-verify', ['read_repository', 'execute_commands']);
48
- const resolved = runtime(descriptor, 'codex');
49
- assert.deepEqual(resolved.available_capabilities, ['read_repository', 'execute_commands']);
50
- assert.deepEqual(resolved.unavailable_capabilities, []);
51
- assert.equal(resolved.execution_mode, 'native');
52
- });
53
- test('network is never available on any host', () => {
54
- for (const host of listHosts()) {
55
- const resolved = runtime(minimalDescriptor('hyv-verify', ['network']), host.id);
56
- assert.deepEqual(resolved.available_capabilities, []);
57
- assert.deepEqual(resolved.unavailable_capabilities, [{ capability: 'network', status: 'NOT_AVAILABLE' }]);
58
- assert.equal(resolved.execution_mode, 'prompt-only');
59
- }
60
- });
@@ -1,64 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { describe, emitJson, emitPrompt } from './emit.js';
4
- import { AGENT_SCHEMA_VERSION } from './types.js';
5
- function makePackage(id) {
6
- return {
7
- directory: '/pkg',
8
- instructions: 'Usage: hyv verify original.md candidate.md profile.json',
9
- descriptor: {
10
- schema_version: AGENT_SCHEMA_VERSION,
11
- id,
12
- title: 'hyv-verify',
13
- description: 'verify a candidate',
14
- instruction_file: 'SKILL.md',
15
- role: 'Verifier',
16
- workflow_phase: 'verify',
17
- input: { required: ['original', 'candidate'], optional: ['writing brief'] },
18
- output: { required: ['verification report'], optional: [] },
19
- evidence_requirements: ['preserve revision evidence'],
20
- permissions: ['read_repository', 'network'],
21
- stop_conditions: ['stop when evidence is stale'],
22
- tool_free_mode: {
23
- available: true,
24
- behavior: 'report the decision and exact unavailable capability',
25
- unavailable_statuses: ['NOT_AVAILABLE', 'NOT_CONFIGURED', 'NOT_RUN', 'STALE', 'ERROR'],
26
- },
27
- handoff_to: ['hyv-lifecycle'],
28
- },
29
- };
30
- }
31
- test('describe resolves a host and returns a runtime descriptor', () => {
32
- const resolved = describe(makePackage('hyv-verify'), 'codex');
33
- assert.equal(resolved.agent.id, 'hyv-verify');
34
- assert.equal(resolved.host.id, 'codex');
35
- assert.ok(resolved.available_capabilities.includes('read_repository'));
36
- assert.deepEqual(resolved.unavailable_capabilities, [{ capability: 'network', status: 'NOT_AVAILABLE' }]);
37
- assert.equal(resolved.execution_mode, 'prompt-only');
38
- });
39
- test('emitJson renders a valid JSON runtime descriptor', () => {
40
- const body = emitJson(makePackage('hyv-verify'), 'generic');
41
- const parsed = JSON.parse(body);
42
- assert.equal(parsed.agent.id, 'hyv-verify');
43
- assert.equal(parsed.host.id, 'generic');
44
- assert.ok(Array.isArray(parsed.unavailable_capabilities));
45
- assert.deepEqual(parsed.unavailable_capabilities, [
46
- { capability: 'read_repository', status: 'NOT_AVAILABLE' },
47
- { capability: 'network', status: 'NOT_AVAILABLE' },
48
- ]);
49
- });
50
- test('emitPrompt includes instructions, capability boundary, and required statuses', () => {
51
- const prompt = emitPrompt(makePackage('hyv-verify'), 'codex');
52
- assert.match(prompt, /# hyv-verify/);
53
- assert.match(prompt, /Role: Verifier/);
54
- assert.match(prompt, /Usage: hyv verify original\.md candidate\.md profile\.json/);
55
- assert.match(prompt, /Available: read_repository/);
56
- assert.match(prompt, /Unavailable: network \(NOT_AVAILABLE\)/);
57
- assert.match(prompt, /NOT_AVAILABLE, NOT_CONFIGURED, NOT_RUN, STALE, ERROR/);
58
- assert.match(prompt, /Next agents: hyv-lifecycle/);
59
- });
60
- test('emitPrompt reports no available capabilities for a fully unavailable host', () => {
61
- const prompt = emitPrompt(makePackage('hyv-verify'), 'generic');
62
- assert.match(prompt, /Available: none/);
63
- assert.match(prompt, /Execution mode: prompt-only/);
64
- });
@@ -1,149 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { loadAll, loadAllFrom, validateAll, validateId, sortedIds } from './load.js';
7
- import { AGENT_SCHEMA_VERSION } from './types.js';
8
- function makePackage(dir, id, overrides = {}) {
9
- const packageDir = join(dir, 'skills', id);
10
- mkdirSync(join(packageDir, 'agents'), { recursive: true });
11
- const descriptor = {
12
- schema_version: AGENT_SCHEMA_VERSION,
13
- id,
14
- title: id,
15
- description: 'test agent',
16
- instruction_file: 'SKILL.md',
17
- role: 'Tester',
18
- workflow_phase: 'test',
19
- input: { required: ['draft'], optional: [] },
20
- output: { required: ['report'], optional: [] },
21
- evidence_requirements: ['preserve evidence'],
22
- permissions: ['read_repository'],
23
- stop_conditions: ['stop when evidence missing'],
24
- tool_free_mode: {
25
- available: true,
26
- behavior: 'report NOT_AVAILABLE',
27
- unavailable_statuses: ['NOT_AVAILABLE', 'NOT_RUN'],
28
- },
29
- handoff_to: [],
30
- ...overrides,
31
- };
32
- writeFileSync(join(packageDir, 'agent.json'), JSON.stringify(descriptor));
33
- writeFileSync(join(packageDir, 'SKILL.md'), `# ${id}\n\nUsage: hyv ${id} draft.md\n`);
34
- writeFileSync(join(packageDir, 'agents', 'openai.yaml'), `interface:\n display_name: "${id}"\n short_description: "test agent"\n default_prompt: "Use ${id}"\n`);
35
- return packageDir;
36
- }
37
- function makeTempRoot() {
38
- return mkdtempSync(join(tmpdir(), 'hyv-agents-'));
39
- }
40
- test('loads all valid packages and rejects unknown schema version', () => {
41
- const root = makeTempRoot();
42
- try {
43
- const first = makePackage(root, 'hyv-alpha');
44
- makePackage(root, 'hyv-beta', { handoff_to: ['hyv-alpha'] });
45
- const packages = loadAllFrom(root);
46
- assert.deepEqual(sortedIds(packages), ['hyv-alpha', 'hyv-beta']);
47
- assert.equal(packages.get('hyv-alpha').directory, first);
48
- assert.match(packages.get('hyv-alpha').instructions, /^# hyv-alpha/);
49
- }
50
- finally {
51
- rmSync(root, { recursive: true, force: true });
52
- }
53
- });
54
- test('rejects a package whose id does not match its directory name', () => {
55
- const root = makeTempRoot();
56
- try {
57
- makePackage(root, 'hyv-alpha', { id: 'hyv-mismatch' });
58
- assert.throws(() => loadAllFrom(root), /id must match skill directory/);
59
- }
60
- finally {
61
- rmSync(root, { recursive: true, force: true });
62
- }
63
- });
64
- test('rejects a handoff target that does not exist', () => {
65
- const root = makeTempRoot();
66
- try {
67
- makePackage(root, 'hyv-alpha', { handoff_to: ['hyv-ghost'] });
68
- assert.throws(() => loadAllFrom(root), /hands off to unknown agent/);
69
- }
70
- finally {
71
- rmSync(root, { recursive: true, force: true });
72
- }
73
- });
74
- test('rejects an unsupported permission and an unsupported status', () => {
75
- const root = makeTempRoot();
76
- try {
77
- makePackage(root, 'hyv-alpha', { permissions: ['definitely-not-real'] });
78
- assert.throws(() => loadAllFrom(root), /unsupported permission/);
79
- rmSync(root, { recursive: true, force: true });
80
- const root2 = makeTempRoot();
81
- makePackage(root2, 'hyv-beta', {
82
- tool_free_mode: { available: true, behavior: 'x', unavailable_statuses: ['NOPE'] },
83
- });
84
- assert.throws(() => loadAllFrom(root2), /tool_free_mode requires/);
85
- }
86
- finally {
87
- rmSync(root, { recursive: true, force: true });
88
- }
89
- });
90
- test('validateAll and validateId report PASS or unknown id', () => {
91
- const root = makeTempRoot();
92
- try {
93
- makePackage(root, 'hyv-alpha');
94
- const packages = loadAllFrom(root);
95
- validateAll(packages);
96
- validateId(packages, 'hyv-alpha');
97
- assert.throws(() => validateId(packages, 'hyv-missing'), /unknown agent/);
98
- }
99
- finally {
100
- rmSync(root, { recursive: true, force: true });
101
- }
102
- });
103
- test('rejects a malformed agent.json', () => {
104
- const root = makeTempRoot();
105
- try {
106
- const packageDir = join(root, 'skills', 'hyv-alpha');
107
- mkdirSync(join(packageDir, 'agents'), { recursive: true });
108
- writeFileSync(join(packageDir, 'agent.json'), '{ not json');
109
- writeFileSync(join(packageDir, 'SKILL.md'), '# x');
110
- assert.throws(() => loadAllFrom(root), /not valid JSON/);
111
- }
112
- finally {
113
- rmSync(root, { recursive: true, force: true });
114
- }
115
- });
116
- test('rejects unknown contract fields and invalid OpenAI interface metadata', () => {
117
- const root = makeTempRoot();
118
- try {
119
- makePackage(root, 'hyv-alpha', { unsupported: true });
120
- assert.throws(() => loadAllFrom(root), /supported contract fields/);
121
- rmSync(root, { recursive: true, force: true });
122
- const root2 = makeTempRoot();
123
- const packageDir = makePackage(root2, 'hyv-beta');
124
- rmSync(join(packageDir, 'agents', 'openai.yaml'));
125
- assert.throws(() => loadAllFrom(root2), /openai\.yaml/);
126
- rmSync(root2, { recursive: true, force: true });
127
- const root3 = makeTempRoot();
128
- const invalidPackageDir = makePackage(root3, 'hyv-gamma');
129
- writeFileSync(join(invalidPackageDir, 'agents', 'openai.yaml'), 'interface:\n display_name: "unterminated\n short_description: "test"\n default_prompt: "Use hyv-gamma"\n');
130
- assert.throws(() => loadAllFrom(root3), /supported interface metadata/);
131
- rmSync(root3, { recursive: true, force: true });
132
- }
133
- finally {
134
- rmSync(root, { recursive: true, force: true });
135
- }
136
- });
137
- test('published packages declare command execution and required writes', () => {
138
- const packages = loadAll();
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
- for (const [id, pkg] of packages) {
141
- assert.ok(pkg.descriptor.permissions.includes('execute_commands'), `${id} must require command execution`);
142
- assert.equal(pkg.descriptor.permissions.includes('write_repository'), writers.has(id), `${id} write permission mismatch`);
143
- }
144
- });
145
- test('published instructions document command-specific argument requirements', () => {
146
- const packages = loadAll();
147
- assert.match(packages.get('hyv-reduce-judgment').instructions, /hyv reduce-judgment envelope\.json envelope\.json envelope\.json \[envelope\.json\.\.\.\]/);
148
- assert.match(packages.get('hyv-learning').instructions, /record-approved/);
149
- });
@@ -1,265 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { analyzeAiEditor, maskNonProse, RULESET_VERSION, rules, serializedRules } from './ai-editor.js';
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';
7
- test('publishes executable rules with stable IDs and repair directions', () => {
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');
12
- assert.equal(new Set(rules.map((rule) => rule.id)).size, rules.length);
13
- for (const rule of rules) {
14
- assert.match(rule.id, /^(ai|formula|hedge|struct|punct|bait|cringe|insider|ogilvy|format)\./);
15
- assert.ok(rule.reason.length > 0);
16
- assert.ok(rule.suggestion.length > 0);
17
- assert.equal(rule.expression.global, false, rule.id);
18
- assert.equal(rule.expression.sticky, false, rule.id);
19
- }
20
- });
21
- function profileWithPolicies(rulePolicy) {
22
- return {
23
- version: '3', id: 'founder.test', revision: 1, revisionDigest: '0'.repeat(64), sampleCount: 2,
24
- metrics: { sentenceLength: 5, sentenceVariation: 1, sentenceStructure: [], rhythm: 1, paragraphLength: 1, openingMoves: [], vocabulary: [], lexicalDensity: 0.5, pointOfView: 'mixed', punctuation: {}, caseStyle: 'mixed', questionRate: 0, transitions: [] },
25
- avoid: [], provenance: { source: 'test', rights: 'test', createdAt: '2026-08-13T00:00:00.000Z' }, rulePolicy,
26
- fingerprint: { contractionRate: 0, sentenceLengthDistribution: { short: 1, medium: 0, long: 0 }, bulletRate: 0, enDashRate: 0 },
27
- tolerances: { contractionRate: { absolute: 0, calibrated: false }, sentenceLengthDistribution: { absolute: 0, calibrated: false }, bulletRate: { absolute: 0, calibrated: false }, enDashRate: { absolute: 0, calibrated: false } },
28
- metricFixtures: { contractionRate: ['test'], sentenceLengthDistribution: ['test'], bulletRate: ['test'], enDashRate: ['test'] },
29
- };
30
- }
31
- test('applies all four v3 policy states after matching and preserves catalog order', () => {
32
- const report = analyzeAiEditor('Firstly, perhaps we leverage a holistic plan.', profileWithPolicies({
33
- 'formula.firstly': 'blocking',
34
- 'hedge.perhaps': 'advisory',
35
- 'ai.leverage': 'judgment-required',
36
- 'ai.holistic': 'disabled',
37
- }));
38
- assert.deepEqual(report.findings.map((finding) => [finding.id, finding.appliedPolicy, finding.severity]), [
39
- ['ai.leverage', 'judgment-required', 'yellow'],
40
- ['formula.firstly', 'blocking', 'red'],
41
- ['hedge.perhaps', 'advisory', 'yellow'],
42
- ]);
43
- assert.equal(report.passed, false);
44
- });
45
- test('fails closed when a v3 policy names a rule outside the catalog', () => {
46
- assert.throws(() => analyzeAiEditor('Plain text.', profileWithPolicies({ 'ai.missing': 'blocking' })), /unknown rule ID/);
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
- });
81
- test('uses reconciled defaults for v2 profiles and suppresses inherited duplicate emissions', () => {
82
- const report = analyzeAiEditor("It's worth noting: in other words, I think the same plan. Better results.");
83
- assert.equal(report.findings.some((finding) => finding.id === 'hedge.worth-noting'), false);
84
- assert.equal(report.findings.some((finding) => finding.id === 'struct.in-other-words'), false);
85
- assert.equal(report.findings.some((finding) => finding.id === 'hedge.i-think'), false);
86
- assert.equal(report.findings.some((finding) => finding.id === 'struct.same-better'), false);
87
- assert.ok(report.findings.every((finding) => finding.appliedPolicy !== undefined));
88
- });
89
- test('treats bare red vocabulary as pending judgment and clear sincerity or dashes as blocking', () => {
90
- const vocabulary = analyzeAiEditor('We leverage the existing scheduler.');
91
- assert.deepEqual(vocabulary.findings.find((finding) => finding.id === 'ai.leverage')?.appliedPolicy, 'judgment-required');
92
- assert.equal(vocabulary.passed, true);
93
- const blocked = analyzeAiEditor('To be honest, the scheduler failed — twice.');
94
- assert.ok(blocked.findings.some((finding) => finding.id === 'formula.performative-sincerity' && finding.appliedPolicy === 'blocking'));
95
- assert.ok(blocked.findings.some((finding) => finding.id === 'punct.em-dash' && finding.appliedPolicy === 'blocking'));
96
- assert.equal(blocked.passed, false);
97
- const advisory = analyzeAiEditor('Honestly, the scheduler failed twice.');
98
- assert.ok(advisory.findings.some((finding) => finding.id === 'hedge.performative-sincerity-adverb' && finding.appliedPolicy === 'advisory'));
99
- assert.equal(advisory.passed, true);
100
- });
101
- test('only applies the question-hook policy to document sentence one', () => {
102
- assert.ok(analyzeAiEditor('Have you checked the invoice? It is overdue.').findings.some((finding) => finding.id === 'ai.question-hook'));
103
- assert.equal(analyzeAiEditor('The invoice is overdue. Have you checked it?').findings.some((finding) => finding.id === 'ai.question-hook'), false);
104
- });
105
- test('detects representative rules from every inherited rule family', () => {
106
- const examples = [
107
- ['ai.delve', 'we will delve into it.'],
108
- ['ai.leverage', 'we leverage the existing logs.'],
109
- ['ai.tapestry', 'the tapestry explains the work.'],
110
- ['ai.holistic', 'a holistic review starts today.'],
111
- ['ai.robust', 'robust evidence supports the claim.'],
112
- ['ai.landscape', 'the market landscape changed.'],
113
- ['ai.game-changer', 'this is a game-changer.'],
114
- ['formula.firstly', 'Firstly, check the invoice.'],
115
- ['hedge.perhaps', 'Perhaps the invoice is late.'],
116
- ['struct.this-is-why', 'This is why the invoice matters.'],
117
- ['struct.not-just-but-also', 'this is not just fast but reliable.'],
118
- ['struct.rhetorical-truth', 'the hard truth is in the logs.'],
119
- ['punct.em-dash', 'the logs failed — retry later.'],
120
- ['bait.let-that-sink', 'Let that sink in.'],
121
- ['cringe.10x', 'The change delivered a 10x result.'],
122
- ['insider.nobody-tells', 'What nobody tells you is in the report.'],
123
- ['ogilvy.bandwidth', 'We lack the bandwidth this week.'],
124
- ];
125
- for (const [id, example] of examples) {
126
- const report = analyzeAiEditor(example);
127
- assert.ok(report.findings.some((finding) => finding.id === id && finding.sentence === 1), id);
128
- }
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
- });
186
- test('keeps counterexamples for representative inherited rules', () => {
187
- const counterexamples = [
188
- ['ai.delve', 'we inspect the logs.'],
189
- ['ai.leverage', 'we use the existing logs.'],
190
- ['ai.tapestry', 'the report explains the work.'],
191
- ['ai.holistic', 'the review covers the named files.'],
192
- ['ai.robust', 'the evidence includes three dated reports.'],
193
- ['ai.landscape', 'the market changed after the price cut.'],
194
- ['ai.game-changer', 'the release removed a manual step.'],
195
- ['formula.firstly', 'next, check the invoice.'],
196
- ['hedge.perhaps', 'the invoice is late.'],
197
- ['struct.this-is-why', 'the invoice matters because it is overdue.'],
198
- ['struct.not-just-but-also', 'the service is fast and reliable.'],
199
- ['struct.rhetorical-truth', 'the logs show the service failed.'],
200
- ['punct.em-dash', 'the logs failed; retry later.'],
201
- ['bait.let-that-sink', 'The invoice is overdue.'],
202
- ['cringe.10x', 'The result rose from 2 to 20.'],
203
- ['insider.nobody-tells', 'The report explains the missing step.'],
204
- ['ogilvy.bandwidth', 'We lack time this week.'],
205
- ];
206
- for (const [id, example] of counterexamples) {
207
- assert.equal(analyzeAiEditor(example).findings.some((finding) => finding.id === id), false, id);
208
- }
209
- });
210
- test('keeps yellow findings as review cues rather than release blockers', () => {
211
- const report = analyzeAiEditor('Firstly, the editor checked the invoice.');
212
- assert.ok(report.findings.every((finding) => finding.severity === 'yellow'));
213
- assert.equal(report.passed, true);
214
- });
215
- test('restores the benchmark signals from the 2.9.24 executable catalog', () => {
216
- const report = analyzeAiEditor("This work is meaningful. Here's the part nobody is talking about. The change delivered a 10x result.");
217
- assert.deepEqual(report.findings.map((finding) => [finding.id, finding.sentence]), [
218
- ['ai.meaningful', 1],
219
- ['struct.heres-where', 2],
220
- ['cringe.10x', 3],
221
- ]);
222
- });
223
- test('returns the same findings across repeated sentence and line analysis', () => {
224
- for (const text of ['We leverage logs. We leverage traces.', 'No demos. No decks. No distractions. Same team. Better results.']) {
225
- assert.deepEqual(analyzeAiEditor(text), analyzeAiEditor(text));
226
- }
227
- });
228
- test('reports the same rule in multiple sentences', () => {
229
- const report = analyzeAiEditor('We leverage logs. We leverage traces.');
230
- assert.deepEqual(report.findings.filter((finding) => finding.id === 'ai.leverage').map((finding) => finding.sentence), [1, 2]);
231
- });
232
- test('executes inherited cross-sentence rules and maps them to the first sentence', () => {
233
- const report = analyzeAiEditor('No demos. No decks. No distractions. Same team. Better results.');
234
- assert.deepEqual(report.findings
235
- .filter((finding) => finding.id === 'struct.negation-cascade' || finding.id === 'struct.same-better')
236
- .map((finding) => [finding.id, finding.sentence]), [['struct.negation-cascade', 1]]);
237
- });
238
- test('preserves inherited physical-line matching and line-start anchors', () => {
239
- const sameLine = analyzeAiEditor("This isn't positioning. This is proof. Forget vanity metrics. You need retention.");
240
- assert.ok(sameLine.findings.some((finding) => finding.id === 'struct.this-isnt-x-this-is-y'));
241
- assert.ok(sameLine.findings.some((finding) => finding.id === 'struct.forget-x'));
242
- assert.equal(analyzeAiEditor("This isn't positioning.\nThis is proof.").findings.some((finding) => finding.id === 'struct.this-isnt-x-this-is-y'), false);
243
- assert.equal(analyzeAiEditor('The logs failed. Of course we can retry.').findings.some((finding) => finding.id === 'cringe.of-course'), false);
244
- assert.ok(analyzeAiEditor('The logs failed.\nOf course we can retry.').findings.some((finding) => finding.id === 'cringe.of-course'));
245
- });
246
- test('retains the current question-hook and abstract-cluster detectors', () => {
247
- assert.ok(analyzeAiEditor('Have you checked the invoice?').findings.some((finding) => finding.id === 'ai.question-hook'));
248
- assert.ok(analyzeAiEditor('Clarity and strategy are missing.').findings.some((finding) => finding.id === 'ai.abstract-cluster'));
249
- });
250
- test('serializes reconstructable regular expressions and explicit scopes', () => {
251
- const catalog = serializedRules();
252
- assert.equal(catalog.length, 182);
253
- assert.ok(catalog.every((rule) => rule.scope === 'sentence' || rule.scope === 'line' || rule.scope === 'document'));
254
- const meaningful = catalog.find((rule) => rule.id === 'ai.meaningful');
255
- assert.ok(meaningful);
256
- assert.equal(new RegExp(meaningful.expression.source, meaningful.expression.flags).test('Meaningful work.'), true);
257
- });
258
- test('suppresses intentional inherited overlaps before scoring', () => {
259
- const report = analyzeAiEditor('In other words, use logs.');
260
- assert.deepEqual(report.findings.map((finding) => finding.id), ['formula.in-other-words']);
261
- assert.equal(report.score, 82);
262
- });
263
- test('returns zero AI findings for clean input', () => {
264
- assert.deepEqual(analyzeAiEditor('The launch starts Tuesday. The owner signed the release checklist.').findings, []);
265
- });
@@ -1,52 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { generateKeyPairSync, sign } from 'node:crypto';
3
- import test from 'node:test';
4
- import { canonicalJsonBytes } from './canonical-json.js';
5
- import { verifyApprovalCapability } from './approval-capability.js';
6
- const binding = {
7
- rewriteTaskFingerprint: '1'.repeat(64), rewriteResponseFingerprint: '2'.repeat(64), deterministicArtifactFingerprint: '3'.repeat(64),
8
- sourceHash: '4'.repeat(64), candidateHash: '5'.repeat(64), profileId: 'founder.primary', profileRevisionDigest: '6'.repeat(64),
9
- rulesetVersion: '3.2.0', schemaVersion: '1',
10
- };
11
- const { publicKey, privateKey } = generateKeyPairSync('ed25519');
12
- const publicKeySpki = publicKey.export({ format: 'der', type: 'spki' }).toString('base64url');
13
- const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'host.example', keyId: 'key-1', publicKeySpki, status: 'active' }] };
14
- function envelope(overrides = {}, signer = privateKey) {
15
- const claims = {
16
- version: '1', purpose: 'hyv.final-approval', issuer: 'host.example', audience: '@holdyourvoice/hyv',
17
- subjectArtifactFingerprint: '7'.repeat(64), sourceHash: binding.sourceHash, candidateHash: binding.candidateHash,
18
- profileId: binding.profileId, profileRevisionDigest: binding.profileRevisionDigest, keyId: 'key-1',
19
- issuedAt: 100, notBefore: 100, expiresAt: 200, nonce: 'nonce-1', ...overrides,
20
- };
21
- const payload = canonicalJsonBytes(claims);
22
- return { payload: payload.toString('base64url'), signature: sign(null, payload, signer).toString('base64url') };
23
- }
24
- test('verifies one canonical bound Ed25519 final-approval capability', () => {
25
- const result = verifyApprovalCapability(envelope(), trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' });
26
- assert.equal(result.ok, true);
27
- if (result.ok)
28
- assert.match(result.capabilityFingerprint, /^[a-f0-9]{64}$/);
29
- });
30
- test('fails closed for purpose, binding, trust, time, signature, and canonical encoding', () => {
31
- assert.deepEqual(verifyApprovalCapability(envelope({ purpose: 'hyv.rebuild-authorization' }), trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'wrong_purpose' });
32
- assert.deepEqual(verifyApprovalCapability(envelope({ candidateHash: '8'.repeat(64) }), trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'binding_mismatch' });
33
- assert.deepEqual(verifyApprovalCapability(envelope(), { ...trustStore, keys: [{ ...trustStore.keys[0], status: 'revoked' }] }, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'revoked_key' });
34
- assert.deepEqual(verifyApprovalCapability(envelope(), trustStore, { now: 201, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'expired' });
35
- const forged = envelope();
36
- const forgedBytes = Buffer.from(forged.signature, 'base64url');
37
- forgedBytes[0] = forgedBytes[0] ^ 1;
38
- forged.signature = forgedBytes.toString('base64url');
39
- assert.deepEqual(verifyApprovalCapability(forged, trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'invalid_signature' });
40
- const nonCanonical = envelope();
41
- nonCanonical.payload = Buffer.from(` ${Buffer.from(nonCanonical.payload, 'base64url').toString('utf8')}`).toString('base64url');
42
- assert.deepEqual(verifyApprovalCapability(nonCanonical, trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'non_canonical' });
43
- });
44
- test('rejects malformed envelopes and invalid trust stores without returning secret material', () => {
45
- const result = verifyApprovalCapability({ payload: `${envelope().payload}=`, signature: envelope().signature }, trustStore, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' });
46
- assert.deepEqual(result, { ok: false, error: 'invalid_encoding' });
47
- assert.doesNotMatch(JSON.stringify(result), /nonce-1|signature|publicKeySpki/);
48
- assert.deepEqual(verifyApprovalCapability(envelope(), { ...trustStore, keys: [...trustStore.keys, trustStore.keys[0]] }, { now: 150, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'invalid_schema' });
49
- });
50
- test('rejects an invalid host clock', () => {
51
- assert.deepEqual(verifyApprovalCapability(envelope(), trustStore, { now: Number.NaN, expectedSubjectArtifactFingerprint: '7'.repeat(64), binding, expectedPurpose: 'hyv.final-approval' }), { ok: false, error: 'invalid_schema' });
52
- });