@holdyourvoice/hyv 3.6.0 → 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.
- package/Readme.md +93 -154
- package/dist/ai-editor-rules.js +1 -0
- package/dist/ai-editor.js +23 -33
- package/dist/analysis.js +33 -0
- package/dist/cli/agents.js +88 -0
- package/dist/cli/checks.js +226 -0
- package/dist/cli/io.js +141 -0
- package/dist/cli/lifecycle.js +162 -0
- package/dist/cli/profiles.js +246 -0
- package/dist/cli/rewriting.js +111 -0
- package/dist/cli.js +5 -974
- package/dist/copy-spec.js +6 -6
- package/dist/editorial-packs.js +4 -3
- package/dist/fact-linter.js +45 -27
- package/dist/hidden-text.js +6 -6
- package/dist/hold-your-voice.mcpb +0 -0
- package/dist/internal.js +8 -0
- package/dist/judgment-task.js +10 -14
- package/dist/learning.js +77 -71
- package/dist/lifecycle-adapter.js +2 -5
- package/dist/local-eval.js +16 -31
- package/dist/mcp-server.js +272 -0
- package/dist/mcp-tools.js +10 -19
- package/dist/mcp.js +2 -267
- package/dist/pipeline.js +18 -105
- package/dist/profile-compose.js +18 -11
- package/dist/profile-score.js +6 -6
- package/dist/profile.js +17 -19
- package/dist/provenance-status.js +1 -3
- package/dist/rebuild-task.js +18 -52
- package/dist/recomposition.js +18 -13
- package/dist/rewrite-prompt.js +82 -0
- package/dist/rewrite-response.js +27 -0
- package/dist/rewrite-task.js +73 -98
- package/dist/rule-allowances.js +6 -10
- package/dist/semantic-review.js +71 -47
- package/dist/stage1-evaluation.js +29 -14
- package/dist/strict-quality.js +10 -18
- package/dist/version.js +1 -1
- package/dist/voice-dna.js +34 -21
- package/dist/writing-examples.js +2 -7
- package/package.json +6 -3
- package/skills/hyv-prepare-judgment/SKILL.md +4 -0
- package/dist/agents/catalog.test.js +0 -60
- package/dist/agents/emit.test.js +0 -64
- package/dist/agents/load.test.js +0 -149
- package/dist/ai-editor.test.js +0 -265
- package/dist/approval-capability.test.js +0 -52
- package/dist/approval-context.test.js +0 -38
- package/dist/backtest.test.js +0 -20
- package/dist/benchmark.test.js +0 -328
- package/dist/canonical-json.test.js +0 -24
- package/dist/cli.test.js +0 -731
- package/dist/editorial-packs.test.js +0 -94
- package/dist/fact-linter.test.js +0 -85
- package/dist/hidden-text.test.js +0 -26
- package/dist/hygiene.test.js +0 -83
- package/dist/judgment-task.test.js +0 -162
- package/dist/learning.test.js +0 -325
- package/dist/lifecycle-adapter.test.js +0 -56
- package/dist/local-eval.test.js +0 -20
- package/dist/logic-linter-corpus.test.js +0 -22
- package/dist/logic-linter.test.js +0 -39
- package/dist/mcp-tools.test.js +0 -286
- package/dist/mcp.test.js +0 -312
- package/dist/mirror-refs.test.js +0 -63
- package/dist/pipeline.test.js +0 -247
- package/dist/preservation.test.js +0 -22
- package/dist/production-gates.test.js +0 -34
- package/dist/profile-compose.test.js +0 -32
- package/dist/profile-score.test.js +0 -22
- package/dist/profile-watch.test.js +0 -23
- package/dist/profile.test.js +0 -141
- package/dist/provenance-status.test.js +0 -22
- package/dist/rebuild-task.test.js +0 -206
- package/dist/recomposition.test.js +0 -34
- package/dist/release-audit.test.js +0 -292
- package/dist/rewrite-task.test.js +0 -166
- package/dist/rule-allowances.test.js +0 -17
- package/dist/rule-reconciliation.test.js +0 -50
- package/dist/sample-ingest.test.js +0 -52
- package/dist/semantic-review.test.js +0 -101
- package/dist/stage1-dry-run.test.js +0 -39
- package/dist/stage1-evaluation.test.js +0 -184
- package/dist/stage1-human-packet.test.js +0 -102
- package/dist/stage1-schema-contract.test.js +0 -95
- package/dist/stage2-human-packet.test.js +0 -81
- package/dist/strict-quality.test.js +0 -62
- package/dist/text-provenance.feature.test.js +0 -45
- package/dist/text.test.js +0 -16
- package/dist/voice-dna.test.js +0 -121
- package/dist/writing-examples.test.js +0 -35
package/dist/cli.test.js
DELETED
|
@@ -1,731 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
-
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
|
7
|
-
import test from 'node:test';
|
|
8
|
-
import { patternsForMcp } from './mcp-tools.js';
|
|
9
|
-
import { canonicalJson } from './canonical-json.js';
|
|
10
|
-
const cli = new URL('./cli.js', import.meta.url).pathname;
|
|
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 };
|
|
28
|
-
}
|
|
29
|
-
test('creates an explicit local avoid list and exposes the ruleset', () => {
|
|
30
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
31
|
-
try {
|
|
32
|
-
const first = join(directory, 'first.md');
|
|
33
|
-
const second = join(directory, 'second.md');
|
|
34
|
-
const profile = join(directory, 'profile.json');
|
|
35
|
-
writeFileSync(first, 'i write plainly. i name the work.');
|
|
36
|
-
writeFileSync(second, 'i keep the mechanism clear. i avoid filler.');
|
|
37
|
-
const created = run(['profile', profile, first, second, '--avoid=unlock']);
|
|
38
|
-
assert.equal(created.status, 0, created.stderr);
|
|
39
|
-
assert.deepEqual(JSON.parse(readFileSync(profile, 'utf8')).avoid, ['unlock']);
|
|
40
|
-
const patterns = run(['patterns']);
|
|
41
|
-
assert.equal(patterns.status, 0, patterns.stderr);
|
|
42
|
-
assert.ok(JSON.parse(patterns.stdout).rules.every((rule) => rule.id && rule.severity && rule.reason && rule.suggestion));
|
|
43
|
-
}
|
|
44
|
-
finally {
|
|
45
|
-
rmSync(directory, { recursive: true, force: true });
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
test('builds a channel-specific Profile v3 from local samples', () => {
|
|
49
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-profile-v3-cli-'));
|
|
50
|
-
try {
|
|
51
|
-
const first = join(directory, 'first.md');
|
|
52
|
-
const second = join(directory, 'second.md');
|
|
53
|
-
const profile = join(directory, 'profile.json');
|
|
54
|
-
writeFileSync(first, 'I write directly about the release. The owner checks the evidence.');
|
|
55
|
-
writeFileSync(second, 'I keep the mechanism visible. The next step stays clear.');
|
|
56
|
-
const result = run(['profile', 'v3', profile, '--id=founder.email', '--channel=email', '--tone=0.4,0.7,0.6,0.3,0.5', first, second]);
|
|
57
|
-
assert.equal(result.status, 0, result.stderr);
|
|
58
|
-
const parsed = JSON.parse(readFileSync(profile, 'utf8'));
|
|
59
|
-
assert.equal(parsed.version, '3');
|
|
60
|
-
assert.equal(parsed.channel, 'email');
|
|
61
|
-
assert.equal(parsed.tone.warmth, 0.6);
|
|
62
|
-
}
|
|
63
|
-
finally {
|
|
64
|
-
rmSync(directory, { recursive: true, force: true });
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
test('publishes the same normalized reconciled catalog and version through CLI and MCP', () => {
|
|
68
|
-
const result = run(['patterns']);
|
|
69
|
-
assert.equal(result.status, 0, result.stderr);
|
|
70
|
-
const cliCatalog = JSON.parse(result.stdout);
|
|
71
|
-
const mcpCatalog = patternsForMcp();
|
|
72
|
-
assert.equal(cliCatalog.version, '3.5.0-local.3');
|
|
73
|
-
assert.equal(cliCatalog.rules.length, 182);
|
|
74
|
-
assert.deepEqual(cliCatalog, mcpCatalog);
|
|
75
|
-
});
|
|
76
|
-
test('runs contextual analysis and batch analysis without changing the profile contract', () => {
|
|
77
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
78
|
-
try {
|
|
79
|
-
const first = join(directory, 'first.md');
|
|
80
|
-
const second = join(directory, 'second.md');
|
|
81
|
-
const profile = join(directory, 'profile.json');
|
|
82
|
-
const brief = join(directory, 'brief.json');
|
|
83
|
-
const draft = join(directory, 'draft.md');
|
|
84
|
-
const duplicate = join(directory, 'duplicate.md');
|
|
85
|
-
const task = join(directory, 'task.json');
|
|
86
|
-
writeFileSync(first, 'I write plainly. I name the work.');
|
|
87
|
-
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
88
|
-
writeFileSync(brief, JSON.stringify({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' }));
|
|
89
|
-
writeFileSync(draft, 'A pattern I keep seeing in founder posts is vague advice.');
|
|
90
|
-
writeFileSync(duplicate, 'A pattern I keep seeing in founder posts is vague advice.');
|
|
91
|
-
assert.equal(run(['profile', profile, first, second]).status, 0);
|
|
92
|
-
const contextual = JSON.parse(run(['analyze', draft, profile, brief]).stdout);
|
|
93
|
-
assert.equal(contextual.editorial.findings[0].id, 'editorial.social.generic-opener');
|
|
94
|
-
assert.equal(contextual.hygiene.suspiciousCount, 0);
|
|
95
|
-
const batch = JSON.parse(run(['batch-analyze', draft, duplicate]).stdout);
|
|
96
|
-
assert.equal(batch.findings.length, 2);
|
|
97
|
-
assert.equal(run(['prepare-rewrite', draft, profile, task, brief]).status, 0);
|
|
98
|
-
assert.equal(JSON.parse(readFileSync(task, 'utf8')).writingBrief.format, 'social');
|
|
99
|
-
}
|
|
100
|
-
finally {
|
|
101
|
-
rmSync(directory, { recursive: true, force: true });
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
test('scores a candidate only against explicit held-out local samples', () => {
|
|
105
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-score-cli-'));
|
|
106
|
-
try {
|
|
107
|
-
const samples = Array.from({ length: 3 }, (_, index) => join(directory, 'sample-' + index + '.md'));
|
|
108
|
-
const profile = join(directory, 'profile.json');
|
|
109
|
-
const draft = join(directory, 'draft.md');
|
|
110
|
-
const text = [
|
|
111
|
-
'I write a direct note about the launch. The owner checks the evidence before we ship. The next step stays clear and small. The report remains useful.',
|
|
112
|
-
'I name the trade-off before I make the decision. We keep the mechanism visible for the person doing the work. The release has one owner. The report stays useful.',
|
|
113
|
-
'I start from evidence in the issue. Then I explain the constraint and choose a concrete next step. The team checks the result. The report stays useful.',
|
|
114
|
-
];
|
|
115
|
-
for (const [index, sample] of samples.entries())
|
|
116
|
-
writeFileSync(sample, text[index]);
|
|
117
|
-
writeFileSync(draft, 'I name the evidence, explain the trade-off, and choose a next step. The owner checks the work before release. The report stays useful for the team.');
|
|
118
|
-
assert.equal(run(['profile', profile, ...samples]).status, 0);
|
|
119
|
-
const result = run(['score', draft, profile, ...samples]);
|
|
120
|
-
assert.equal(result.status, 0, result.stderr);
|
|
121
|
-
const score = JSON.parse(result.stdout);
|
|
122
|
-
assert.equal(score.version, '1');
|
|
123
|
-
assert.equal(score.selfSimilarity.ceiling, 100);
|
|
124
|
-
assert.equal(Object.keys(score.components).length, 13);
|
|
125
|
-
}
|
|
126
|
-
finally {
|
|
127
|
-
rmSync(directory, { recursive: true, force: true });
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
test('ingests an explicit Gmail export only to an absolute directory outside a Git checkout', () => {
|
|
131
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-ingest-cli-'));
|
|
132
|
-
try {
|
|
133
|
-
const source = join(directory, 'sent.mbox');
|
|
134
|
-
writeFileSync(source, [
|
|
135
|
-
'From sender@example.com Mon Jan 1 00:00:00 2026',
|
|
136
|
-
'From: Owner <owner@example.com>',
|
|
137
|
-
'',
|
|
138
|
-
'Reach owner@example.com before we ship.',
|
|
139
|
-
].join('\n'));
|
|
140
|
-
const result = run(['ingest', 'gmail-sent-mbox', source, '--owner=Owner <owner@example.com>', '--output=' + directory]);
|
|
141
|
-
assert.equal(result.status, 0, result.stderr);
|
|
142
|
-
const receipt = JSON.parse(result.stdout);
|
|
143
|
-
assert.equal(receipt.samplesAccepted, 1);
|
|
144
|
-
assert.equal(JSON.stringify(receipt).includes('owner@example.com'), false);
|
|
145
|
-
assert.equal(readFileSync(join(directory, 'samples.jsonl'), 'utf8').includes('REDACTED:EMAIL'), true);
|
|
146
|
-
assert.equal(run(['ingest', 'gmail-sent-mbox', source, '--owner=Owner <owner@example.com>', '--output=' + process.cwd()]).status, 1);
|
|
147
|
-
const linkedOutput = join(directory, 'linked-output');
|
|
148
|
-
symlinkSync(process.cwd(), linkedOutput);
|
|
149
|
-
const linked = run(['ingest', 'gmail-sent-mbox', source, '--owner=Owner <owner@example.com>', '--output=' + linkedOutput]);
|
|
150
|
-
assert.equal(linked.status, 1);
|
|
151
|
-
assert.match(linked.stderr, /non-symlink directory/);
|
|
152
|
-
}
|
|
153
|
-
finally {
|
|
154
|
-
rmSync(directory, { recursive: true, force: true });
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
test('exposes strict quality as an opt-in v3-only CLI gate', () => {
|
|
158
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-strict-cli-'));
|
|
159
|
-
try {
|
|
160
|
-
const profile = join(directory, 'profile.json');
|
|
161
|
-
const draft = join(directory, 'draft.md');
|
|
162
|
-
const samples = Array.from({ length: 5 }, (_, index) => join(directory, `sample-${index}.md`));
|
|
163
|
-
for (const [index, sample] of samples.entries())
|
|
164
|
-
writeFileSync(sample, `i write sample ${index}. `.repeat(300));
|
|
165
|
-
writeFileSync(profile, JSON.stringify({ version: '2', sampleCount: 2, metrics: { sentenceLength: 5, sentenceVariation: 1, sentenceStructure: [], rhythm: 1, paragraphLength: 1, openingMoves: [], vocabulary: [], lexicalDensity: 0.5, pointOfView: 'mixed', punctuation: { '!': 0, '?': 0, ';': 0, ':': 0, '—': 0 }, caseStyle: 'mixed', questionRate: 0, transitions: [] }, avoid: [] }));
|
|
166
|
-
writeFileSync(draft, 'The launch starts Tuesday.');
|
|
167
|
-
const result = run(['strict-check', draft, profile, ...samples]);
|
|
168
|
-
assert.equal(result.status, 2, result.stderr);
|
|
169
|
-
assert.equal(JSON.parse(result.stdout).disposition, 'blocked');
|
|
170
|
-
assert.equal(JSON.parse(result.stdout).findings[0].id, 'strict.profile.version');
|
|
171
|
-
}
|
|
172
|
-
finally {
|
|
173
|
-
rmSync(directory, { recursive: true, force: true });
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
test('inspects and conservatively fixes Unicode hygiene without overwriting either file', () => {
|
|
177
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
178
|
-
try {
|
|
179
|
-
const draft = join(directory, 'draft.md');
|
|
180
|
-
const cleaned = join(directory, 'draft.cleaned.md');
|
|
181
|
-
const original = `\uFEFFkeep\u200Bthis\u00A0space\u200D`;
|
|
182
|
-
writeFileSync(draft, original);
|
|
183
|
-
const inspected = run(['hygiene', draft]);
|
|
184
|
-
assert.equal(inspected.status, 0, inspected.stderr);
|
|
185
|
-
const report = JSON.parse(inspected.stdout);
|
|
186
|
-
assert.equal(report.suspiciousCount, 4);
|
|
187
|
-
assert.equal(report.fixableCount, 1);
|
|
188
|
-
const fixed = run(['hygiene', draft, '--fix']);
|
|
189
|
-
assert.equal(fixed.status, 0, fixed.stderr);
|
|
190
|
-
const receipt = JSON.parse(fixed.stdout);
|
|
191
|
-
assert.equal(receipt.outputPath, cleaned);
|
|
192
|
-
assert.equal(receipt.changed, true);
|
|
193
|
-
assert.equal(receipt.changes.length, 1);
|
|
194
|
-
assert.equal(readFileSync(draft, 'utf8'), original);
|
|
195
|
-
assert.equal(readFileSync(cleaned, 'utf8'), `keep\u200Bthis\u00A0space\u200D`);
|
|
196
|
-
const custom = join(directory, 'review-copy.md');
|
|
197
|
-
const customFixed = run(['hygiene', draft, '--fix', `--output=${custom}`]);
|
|
198
|
-
assert.equal(customFixed.status, 0, customFixed.stderr);
|
|
199
|
-
assert.equal(JSON.parse(customFixed.stdout).outputPath, custom);
|
|
200
|
-
assert.equal(readFileSync(custom, 'utf8'), `keep\u200Bthis\u00A0space\u200D`);
|
|
201
|
-
const samePath = run(['hygiene', draft, '--fix', `--output=${draft}`]);
|
|
202
|
-
assert.equal(samePath.status, 1);
|
|
203
|
-
assert.match(samePath.stderr, /must differ from the input path/);
|
|
204
|
-
assert.equal(readFileSync(draft, 'utf8'), original);
|
|
205
|
-
const refused = run(['hygiene', draft, '--fix']);
|
|
206
|
-
assert.equal(refused.status, 1);
|
|
207
|
-
assert.match(refused.stderr, /already exists/);
|
|
208
|
-
assert.equal(readdirSync(directory).some((name) => name.startsWith('.hyv-hygiene-')), false);
|
|
209
|
-
}
|
|
210
|
-
finally {
|
|
211
|
-
rmSync(directory, { recursive: true, force: true });
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
test('inspects stdin and refuses to clean it without a preservable input file', () => {
|
|
215
|
-
const inspected = run(['hygiene', '-'], process.env, 'one\u200Btwo');
|
|
216
|
-
assert.equal(inspected.status, 0, inspected.stderr);
|
|
217
|
-
assert.equal(JSON.parse(inspected.stdout).suspiciousCount, 1);
|
|
218
|
-
const refused = run(['hygiene', '-', '--fix'], process.env, 'one\u200Btwo');
|
|
219
|
-
assert.equal(refused.status, 1);
|
|
220
|
-
assert.match(refused.stderr, /requires a file path/);
|
|
221
|
-
});
|
|
222
|
-
test('applies the explicit hidden-text policy through CLI without touching review-only Unicode', () => {
|
|
223
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-hidden-text-'));
|
|
224
|
-
try {
|
|
225
|
-
const draft = join(directory, 'draft.md');
|
|
226
|
-
const policy = join(directory, 'hidden-text-policy.json');
|
|
227
|
-
const output = join(directory, 'draft.sanitized.md');
|
|
228
|
-
const original = 'before\u0007middle\uFEFFafter\u200D';
|
|
229
|
-
writeFileSync(draft, original);
|
|
230
|
-
writeFileSync(policy, JSON.stringify({ version: '1', name: 'minimal-text-control-cleanup', approvedRemovals: ['ascii_control', 'mid_document_bom'], acknowledgement: 'Removes only listed non-semantic controls; all other findings remain review-only.' }));
|
|
231
|
-
const inspected = run(['inspect-hidden-text', draft, policy]);
|
|
232
|
-
assert.equal(inspected.status, 0, inspected.stderr);
|
|
233
|
-
assert.deepEqual(JSON.parse(inspected.stdout).proposedChanges.map((change) => change.codepoint), ['U+0007', 'U+FEFF']);
|
|
234
|
-
const applied = run(['apply-hidden-text-policy', draft, policy, output]);
|
|
235
|
-
assert.equal(applied.status, 0, applied.stderr);
|
|
236
|
-
const receipt = JSON.parse(applied.stdout);
|
|
237
|
-
assert.equal(receipt.idempotent, true);
|
|
238
|
-
assert.equal(readFileSync(draft, 'utf8'), original);
|
|
239
|
-
assert.equal(readFileSync(output, 'utf8'), 'beforemiddleafter\u200D');
|
|
240
|
-
assert.equal(receipt.remaining[0].codepoint, 'U+200D');
|
|
241
|
-
}
|
|
242
|
-
finally {
|
|
243
|
-
rmSync(directory, { recursive: true, force: true });
|
|
244
|
-
}
|
|
245
|
-
});
|
|
246
|
-
test('gates final output from any producer without a voice profile', () => {
|
|
247
|
-
const clean = run(['final-check', '-'], process.env, 'exact output\n');
|
|
248
|
-
assert.equal(clean.status, 0, clean.stderr);
|
|
249
|
-
assert.equal(clean.stdout, 'exact output\n');
|
|
250
|
-
assert.equal(clean.stderr, '');
|
|
251
|
-
const bom = run(['final-check', '-'], process.env, '\uFEFFexact output');
|
|
252
|
-
assert.equal(bom.status, 0, bom.stderr);
|
|
253
|
-
assert.equal(bom.stdout, 'exact output');
|
|
254
|
-
assert.match(bom.stderr, /U\+FEFF/);
|
|
255
|
-
const unresolved = run(['final-check', '-'], process.env, 'Thai\u200Bboundary');
|
|
256
|
-
assert.equal(unresolved.status, 2);
|
|
257
|
-
assert.equal(unresolved.stdout, '');
|
|
258
|
-
assert.match(unresolved.stderr, /U\+200B/);
|
|
259
|
-
});
|
|
260
|
-
test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
|
|
261
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
262
|
-
try {
|
|
263
|
-
const first = join(directory, 'first.md');
|
|
264
|
-
const second = join(directory, 'second.md');
|
|
265
|
-
const profile = join(directory, 'profile.json');
|
|
266
|
-
const original = join(directory, 'original.md');
|
|
267
|
-
const candidate = join(directory, 'candidate.md');
|
|
268
|
-
writeFileSync(first, 'i write plainly. i name the work.');
|
|
269
|
-
writeFileSync(second, 'i keep the mechanism clear. i avoid filler.');
|
|
270
|
-
writeFileSync(original, 'i name the work.');
|
|
271
|
-
writeFileSync(candidate, 'i unlock the answer.');
|
|
272
|
-
assert.equal(run(['profile', profile, first, second, '--avoid=unlock']).status, 0);
|
|
273
|
-
const verification = run(['verify', original, candidate, profile]);
|
|
274
|
-
assert.equal(verification.status, 2);
|
|
275
|
-
assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'finalOutput', 'logicLint', 'original', 'passed', 'preservationScore', 'regressions', 'strictFindings', 'version']);
|
|
276
|
-
assert.equal(run(['unknown-command']).status, 1);
|
|
277
|
-
assert.equal(run(['mcp', 'unexpected']).status, 1);
|
|
278
|
-
}
|
|
279
|
-
finally {
|
|
280
|
-
rmSync(directory, { recursive: true, force: true });
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
test('runs the logic linter as a standalone final gate', () => {
|
|
284
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-logic-lint-'));
|
|
285
|
-
try {
|
|
286
|
-
const draft = join(directory, 'draft.md');
|
|
287
|
-
writeFileSync(draft, 'The release checklist names rollback ownership. Each owner signs before deployment. Espresso machines use a dual boiler for stable temperature control. The checklist catches missing rollback steps.');
|
|
288
|
-
const result = run(['logic-lint', draft]);
|
|
289
|
-
assert.equal(result.status, 2, result.stderr);
|
|
290
|
-
assert.equal(JSON.parse(result.stdout).findings[0].kind, 'topic_drift');
|
|
291
|
-
}
|
|
292
|
-
finally {
|
|
293
|
-
rmSync(directory, { recursive: true, force: true });
|
|
294
|
-
}
|
|
295
|
-
});
|
|
296
|
-
test('fails the CopySpec gate when a locked claim changes', () => {
|
|
297
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
298
|
-
try {
|
|
299
|
-
const first = join(directory, 'first.md');
|
|
300
|
-
const second = join(directory, 'second.md');
|
|
301
|
-
const profile = join(directory, 'profile.json');
|
|
302
|
-
const original = join(directory, 'original.md');
|
|
303
|
-
const candidate = join(directory, 'candidate.md');
|
|
304
|
-
const spec = join(directory, 'copy-spec.json');
|
|
305
|
-
writeFileSync(first, 'I write plainly. I name the work.');
|
|
306
|
-
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
307
|
-
writeFileSync(original, 'The launch is on 14 August.');
|
|
308
|
-
writeFileSync(candidate, 'The launch is next month.');
|
|
309
|
-
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.' }] }));
|
|
310
|
-
assert.equal(run(['profile', profile, first, second]).status, 0);
|
|
311
|
-
const result = run(['verify-spec', original, candidate, profile, spec]);
|
|
312
|
-
assert.equal(result.status, 2);
|
|
313
|
-
assert.equal(JSON.parse(result.stdout).claims.failures[0].code, 'missing_immutable_claim');
|
|
314
|
-
}
|
|
315
|
-
finally {
|
|
316
|
-
rmSync(directory, { recursive: true, force: true });
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
test('reports fact consistency as JSON or compact text and only fails strict mode on errors', () => {
|
|
320
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-fact-lint-'));
|
|
321
|
-
try {
|
|
322
|
-
const source = join(directory, 'source.md');
|
|
323
|
-
const draft = join(directory, 'draft.md');
|
|
324
|
-
writeFileSync(source, 'Atlas launched on 14 August 2026 and exports CSV reports.');
|
|
325
|
-
writeFileSync(draft, 'Atlas launched on 15 August 2026 and exports CSV reports.');
|
|
326
|
-
const report = run(['fact-lint', draft, `--source=release:${source}`]);
|
|
327
|
-
assert.equal(report.status, 0, report.stderr);
|
|
328
|
-
assert.equal(JSON.parse(report.stdout).findings[0].kind, 'date_drift');
|
|
329
|
-
assert.equal(run(['fact-lint', draft, `--source=release:${source}`, '--strict']).status, 2);
|
|
330
|
-
const compact = run(['fact-lint', draft, `--source=release:${source}`, '--human']);
|
|
331
|
-
assert.equal(compact.status, 0, compact.stderr);
|
|
332
|
-
assert.match(compact.stdout, /date_drift/);
|
|
333
|
-
}
|
|
334
|
-
finally {
|
|
335
|
-
rmSync(directory, { recursive: true, force: true });
|
|
336
|
-
}
|
|
337
|
-
});
|
|
338
|
-
test('prepares and applies the same constrained rewrite task without a provider call', () => {
|
|
339
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
340
|
-
try {
|
|
341
|
-
const first = join(directory, 'first.md');
|
|
342
|
-
const second = join(directory, 'second.md');
|
|
343
|
-
const profile = join(directory, 'profile.json');
|
|
344
|
-
const draft = join(directory, 'draft.md');
|
|
345
|
-
const task = join(directory, 'task.json');
|
|
346
|
-
const response = join(directory, 'response.json');
|
|
347
|
-
writeFileSync(first, 'I write plainly. I name the work.');
|
|
348
|
-
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
349
|
-
writeFileSync(draft, 'I leverage the answer with useful detail and clear mechanism.');
|
|
350
|
-
assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
|
|
351
|
-
assert.equal(run(['prepare-rewrite', draft, profile, task]).status, 0);
|
|
352
|
-
const prepared = JSON.parse(readFileSync(task, 'utf8'));
|
|
353
|
-
writeFileSync(response, JSON.stringify({ version: '1', taskFingerprint: prepared.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer with useful detail and clear mechanism.' }] }));
|
|
354
|
-
const result = run(['apply-rewrite', task, response, profile]);
|
|
355
|
-
assert.equal(result.status, 2, result.stderr);
|
|
356
|
-
const applied = JSON.parse(result.stdout);
|
|
357
|
-
assert.equal(applied.status, 'needs_semantic_review');
|
|
358
|
-
assert.equal(applied.candidate, 'I use the answer with useful detail and clear mechanism.');
|
|
359
|
-
}
|
|
360
|
-
finally {
|
|
361
|
-
rmSync(directory, { recursive: true, force: true });
|
|
362
|
-
}
|
|
363
|
-
});
|
|
364
|
-
test('prepares and reduces pre-edit judgment envelopes through CLI', () => {
|
|
365
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-judgment-'));
|
|
366
|
-
try {
|
|
367
|
-
const first = join(directory, 'first.md');
|
|
368
|
-
const second = join(directory, 'second.md');
|
|
369
|
-
const profile = join(directory, 'profile.json');
|
|
370
|
-
const draft = join(directory, 'draft.md');
|
|
371
|
-
writeFileSync(first, 'I write plainly. I name the work.');
|
|
372
|
-
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
373
|
-
writeFileSync(draft, 'I leverage the answer.');
|
|
374
|
-
assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
|
|
375
|
-
const envelopes = ['triage', 'argument', 'form'].map((kind) => {
|
|
376
|
-
const taskPath = join(directory, `${kind}.json`);
|
|
377
|
-
assert.equal(run(['prepare-judgment', 'pre-edit', kind, draft, profile, taskPath]).status, 0);
|
|
378
|
-
const task = JSON.parse(readFileSync(taskPath, 'utf8'));
|
|
379
|
-
const envelopePath = join(directory, `${kind}-envelope.json`);
|
|
380
|
-
writeFileSync(envelopePath, JSON.stringify({
|
|
381
|
-
version: '1', stage: 'pre-edit', judgmentType: kind, taskFingerprint: task.taskFingerprint,
|
|
382
|
-
bindings: { ...task.bindings, evaluatorId: 'writer.1' }, findings: [], decision: 'SHIP',
|
|
383
|
-
}));
|
|
384
|
-
return envelopePath;
|
|
385
|
-
});
|
|
386
|
-
const result = run(['reduce-judgment', ...envelopes]);
|
|
387
|
-
assert.equal(result.status, 0, result.stderr);
|
|
388
|
-
assert.equal(JSON.parse(result.stdout).decision, 'SHIP');
|
|
389
|
-
}
|
|
390
|
-
finally {
|
|
391
|
-
rmSync(directory, { recursive: true, force: true });
|
|
392
|
-
}
|
|
393
|
-
});
|
|
394
|
-
test('prepares, submits, and inspects a normal lifecycle through CLI canonical envelopes', () => {
|
|
395
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-lifecycle-'));
|
|
396
|
-
try {
|
|
397
|
-
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: [] };
|
|
398
|
-
const deterministic = { ...deterministicBase, artifactFingerprint: createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(deterministicBase)}`).digest('hex') };
|
|
399
|
-
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' };
|
|
400
|
-
const receipt = { version: '1', taskFingerprint: binding.rewriteTaskFingerprint, responseFingerprint: binding.rewriteResponseFingerprint, adapterIds: [], replacementSentenceIds: [1] };
|
|
401
|
-
const paths = Object.fromEntries(['deterministic', 'binding', 'receipt', 'violations', 'output', 'artifact', 'task', 'verdict'].map((name) => [name, join(directory, `${name}.json`)]));
|
|
402
|
-
writeFileSync(paths.deterministic, JSON.stringify(deterministic));
|
|
403
|
-
writeFileSync(paths.binding, JSON.stringify(binding));
|
|
404
|
-
writeFileSync(paths.receipt, JSON.stringify(receipt));
|
|
405
|
-
writeFileSync(paths.violations, JSON.stringify(['action_change']));
|
|
406
|
-
const prepared = run(['lifecycle', 'prepare-semantic', paths.deterministic, paths.binding, paths.receipt, 'normal', paths.violations, paths.output]);
|
|
407
|
-
assert.equal(prepared.status, 0, prepared.stderr);
|
|
408
|
-
const envelope = JSON.parse(prepared.stdout);
|
|
409
|
-
assert.equal(envelope.artifact.status, 'needs_semantic_review');
|
|
410
|
-
assert.deepEqual(JSON.parse(readFileSync(paths.output, 'utf8')), envelope);
|
|
411
|
-
writeFileSync(paths.artifact, JSON.stringify(envelope.artifact));
|
|
412
|
-
writeFileSync(paths.task, JSON.stringify(envelope.task));
|
|
413
|
-
writeFileSync(paths.verdict, JSON.stringify({ approved: true, violations: [] }));
|
|
414
|
-
const context = { now: 0, trustStore: { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [] }, authorizedSemanticEvaluatorIds: { normal: ['reviewer-1'], highAssurance: [] }, authorizedHumanFinalizerIds: ['human-1'] };
|
|
415
|
-
const submitted = run(['lifecycle', 'submit-verdict', paths.artifact, paths.task, 'reviewer-1', paths.verdict], installedContextEnvironment(directory, context));
|
|
416
|
-
assert.equal(submitted.status, 0, submitted.stderr);
|
|
417
|
-
assert.equal(JSON.parse(submitted.stdout).status, 'ready_for_human_review');
|
|
418
|
-
const inspected = run(['lifecycle', 'inspect', paths.artifact]);
|
|
419
|
-
assert.equal(inspected.status, 0, inspected.stderr);
|
|
420
|
-
assert.equal(JSON.parse(inspected.stdout).status, 'needs_semantic_review');
|
|
421
|
-
assert.doesNotMatch(inspected.stdout, /sourceHash|candidateHash|verdicts/);
|
|
422
|
-
assert.equal(run(['lifecycle', 'prepare-semantic', paths.deterministic, paths.binding, paths.receipt, 'high_assurance', paths.violations, join(directory, 'high.json')]).status, 1);
|
|
423
|
-
}
|
|
424
|
-
finally {
|
|
425
|
-
rmSync(directory, { recursive: true, force: true });
|
|
426
|
-
}
|
|
427
|
-
});
|
|
428
|
-
test('accepts only one bounded capability transport and treats a file named stdin literally', () => {
|
|
429
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-capability-'));
|
|
430
|
-
try {
|
|
431
|
-
const capability = JSON.stringify({ payload: 'secret-payload', signature: 'secret-signature' });
|
|
432
|
-
const literal = join(directory, 'stdin');
|
|
433
|
-
writeFileSync(literal, capability, { mode: 0o600 });
|
|
434
|
-
const literalResult = spawnSync(process.execPath, [cli, 'lifecycle', 'validate-final-approval', 'missing.json', '--capability-file', 'stdin'], { cwd: directory, encoding: 'utf8' });
|
|
435
|
-
assert.equal(literalResult.status, 1);
|
|
436
|
-
assert.doesNotMatch(literalResult.stderr, /Capability file is unavailable or unsafe|secret-payload|secret-signature/);
|
|
437
|
-
chmodSync(literal, 0o644);
|
|
438
|
-
const unsafe = spawnSync(process.execPath, [cli, 'lifecycle', 'validate-final-approval', 'missing.json', '--capability-file', 'stdin'], { cwd: directory, encoding: 'utf8' });
|
|
439
|
-
assert.equal(unsafe.status, 1);
|
|
440
|
-
assert.match(unsafe.stderr, /Capability file is unavailable or unsafe/);
|
|
441
|
-
assert.doesNotMatch(unsafe.stderr, /secret-payload|secret-signature/);
|
|
442
|
-
const duplicate = run(['lifecycle', 'validate-final-approval', 'missing.json', '--capability-stdin', '--capability-file', literal], process.env, capability);
|
|
443
|
-
assert.equal(duplicate.status, 1);
|
|
444
|
-
assert.match(duplicate.stderr, /Choose one capability source/);
|
|
445
|
-
assert.doesNotMatch(duplicate.stderr, /secret-payload|secret-signature/);
|
|
446
|
-
const oversized = run(['lifecycle', 'validate-final-approval', 'missing.json', '--capability-stdin'], process.env, 'x'.repeat(1024 * 1024 + 1));
|
|
447
|
-
assert.equal(oversized.status, 1);
|
|
448
|
-
assert.match(oversized.stderr, /exceeds the byte limit/);
|
|
449
|
-
}
|
|
450
|
-
finally {
|
|
451
|
-
rmSync(directory, { recursive: true, force: true });
|
|
452
|
-
}
|
|
453
|
-
});
|
|
454
|
-
test('rejects a malformed hand-edited profile before analysis', () => {
|
|
455
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
456
|
-
try {
|
|
457
|
-
const draft = join(directory, 'draft.md');
|
|
458
|
-
const profile = join(directory, 'profile.json');
|
|
459
|
-
writeFileSync(draft, 'i name the work.');
|
|
460
|
-
writeFileSync(profile, JSON.stringify({ version: '2', sampleCount: 2, metrics: {}, avoid: [1] }));
|
|
461
|
-
const result = run(['analyze', draft, profile]);
|
|
462
|
-
assert.equal(result.status, 1);
|
|
463
|
-
assert.match(result.stderr, /not a valid Hold Your Voice version 2 profile/);
|
|
464
|
-
}
|
|
465
|
-
finally {
|
|
466
|
-
rmSync(directory, { recursive: true, force: true });
|
|
467
|
-
}
|
|
468
|
-
});
|
|
469
|
-
test('rejects malformed profile enum values and punctuation', () => {
|
|
470
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
471
|
-
try {
|
|
472
|
-
const draft = join(directory, 'draft.md');
|
|
473
|
-
const profile = join(directory, 'profile.json');
|
|
474
|
-
writeFileSync(draft, 'i name the work.');
|
|
475
|
-
writeFileSync(profile, JSON.stringify({
|
|
476
|
-
version: '2',
|
|
477
|
-
sampleCount: 2,
|
|
478
|
-
metrics: {
|
|
479
|
-
sentenceLength: 4,
|
|
480
|
-
sentenceVariation: 1,
|
|
481
|
-
sentenceStructure: [],
|
|
482
|
-
rhythm: 1,
|
|
483
|
-
paragraphLength: 1,
|
|
484
|
-
openingMoves: [],
|
|
485
|
-
vocabulary: [],
|
|
486
|
-
lexicalDensity: 0.5,
|
|
487
|
-
pointOfView: 'fourth_person',
|
|
488
|
-
punctuation: [],
|
|
489
|
-
caseStyle: 'titlecase',
|
|
490
|
-
questionRate: 0,
|
|
491
|
-
transitions: [],
|
|
492
|
-
},
|
|
493
|
-
avoid: [''],
|
|
494
|
-
}));
|
|
495
|
-
assert.equal(run(['analyze', draft, profile]).status, 1);
|
|
496
|
-
}
|
|
497
|
-
finally {
|
|
498
|
-
rmSync(directory, { recursive: true, force: true });
|
|
499
|
-
}
|
|
500
|
-
});
|
|
501
|
-
test('rejects hand-edited metrics outside their semantic bounds', () => {
|
|
502
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
503
|
-
try {
|
|
504
|
-
const first = join(directory, 'first.md');
|
|
505
|
-
const second = join(directory, 'second.md');
|
|
506
|
-
const profile = join(directory, 'profile.json');
|
|
507
|
-
const draft = join(directory, 'draft.md');
|
|
508
|
-
writeFileSync(first, 'i write plainly.');
|
|
509
|
-
writeFileSync(second, 'i name the work.');
|
|
510
|
-
writeFileSync(draft, 'i name the work.');
|
|
511
|
-
assert.equal(run(['profile', profile, first, second]).status, 0);
|
|
512
|
-
const malformed = JSON.parse(readFileSync(profile, 'utf8'));
|
|
513
|
-
malformed.sampleCount = 2.5;
|
|
514
|
-
malformed.metrics.questionRate = 1.2;
|
|
515
|
-
writeFileSync(profile, JSON.stringify(malformed));
|
|
516
|
-
assert.equal(run(['analyze', draft, profile]).status, 1);
|
|
517
|
-
}
|
|
518
|
-
finally {
|
|
519
|
-
rmSync(directory, { recursive: true, force: true });
|
|
520
|
-
}
|
|
521
|
-
});
|
|
522
|
-
test('keeps verification read-only and exposes learning only through explicit controls', () => {
|
|
523
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
524
|
-
try {
|
|
525
|
-
const first = join(directory, 'first.md');
|
|
526
|
-
const second = join(directory, 'second.md');
|
|
527
|
-
const profile = join(directory, 'profile.json');
|
|
528
|
-
const original = join(directory, 'original.md');
|
|
529
|
-
const candidate = join(directory, 'candidate.md');
|
|
530
|
-
const env = { ...process.env, HYV_HOME: join(directory, 'state') };
|
|
531
|
-
writeFileSync(first, 'I write plainly. I name the work.');
|
|
532
|
-
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
533
|
-
writeFileSync(original, 'I leverage the answer with useful detail and clear mechanism.');
|
|
534
|
-
writeFileSync(candidate, 'I use the answer with useful detail and clear mechanism.');
|
|
535
|
-
assert.equal(run(['profile', profile, first, second, '--avoid=leverage'], env).status, 0);
|
|
536
|
-
assert.equal(run(['verify', original, candidate, profile], env).status, 0);
|
|
537
|
-
const brief = run(['rewrite-prompt', candidate, profile], env);
|
|
538
|
-
assert.equal(brief.status, 0, brief.stderr);
|
|
539
|
-
assert.doesNotMatch(brief.stdout, /Learned local preferences/);
|
|
540
|
-
const learned = run(['learning', 'show', profile], env);
|
|
541
|
-
assert.equal(learned.status, 0, learned.stderr);
|
|
542
|
-
assert.deepEqual(JSON.parse(learned.stdout).preferences, []);
|
|
543
|
-
assert.equal(run(['learning', 'clear', profile], env).status, 0);
|
|
544
|
-
assert.deepEqual(JSON.parse(run(['learning', 'show', profile], env).stdout).preferences, []);
|
|
545
|
-
}
|
|
546
|
-
finally {
|
|
547
|
-
rmSync(directory, { recursive: true, force: true });
|
|
548
|
-
}
|
|
549
|
-
});
|
|
550
|
-
test('records and inspects text-free learning metadata through the CLI', () => {
|
|
551
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-learning-'));
|
|
552
|
-
try {
|
|
553
|
-
const first = join(directory, 'first.md');
|
|
554
|
-
const second = join(directory, 'second.md');
|
|
555
|
-
const profile = join(directory, 'profile.json');
|
|
556
|
-
const env = { ...process.env, HYV_HOME: join(directory, 'state') };
|
|
557
|
-
writeFileSync(first, 'I write plainly. I name the work.');
|
|
558
|
-
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
559
|
-
assert.equal(run(['profile', profile, first, second]).status, 0);
|
|
560
|
-
const recorded = run(['learning', 'record', profile, 'Keep the mechanism concrete.', '--authority=founder', '--provenance=editor-review', '--weight=2', '--compatibility=exact', '--mutation-id=cli-1'], env);
|
|
561
|
-
assert.equal(recorded.status, 0, recorded.stderr);
|
|
562
|
-
assert.equal(JSON.parse(recorded.stdout).mutationId, 'cli-1');
|
|
563
|
-
assert.equal(JSON.parse(run(['learning', 'record', profile, 'Different instruction.', '--mutation-id=cli-1'], env).stdout).status, 'conflict');
|
|
564
|
-
const inspected = run(['learning', 'inspect', profile], env);
|
|
565
|
-
assert.equal(inspected.status, 0, inspected.stderr);
|
|
566
|
-
assert.equal(JSON.parse(inspected.stdout)[0].authority, 'founder');
|
|
567
|
-
assert.doesNotMatch(inspected.stdout, /Keep the mechanism concrete/);
|
|
568
|
-
assert.equal(run(['learning', 'ratify', profile, JSON.parse(recorded.stdout).eventId], env).status, 1);
|
|
569
|
-
assert.equal(run(['learning', 'record', profile, 'Boundary.', `--mutation-id=${'m'.repeat(200)}`, `--provenance=${'p'.repeat(500)}`], env).status, 0);
|
|
570
|
-
assert.equal(run(['learning', 'record', profile, 'Too long.', `--mutation-id=${'m'.repeat(201)}`], env).status, 1);
|
|
571
|
-
assert.equal(run(['learning', 'record', profile, 'Too long.', `--provenance=${'p'.repeat(501)}`], env).status, 1);
|
|
572
|
-
assert.equal(run(['learning', 'inspect', profile, '--mutation-id=nope'], env).status, 1);
|
|
573
|
-
assert.equal(run(['learning', 'clear', profile, '--authority=team'], env).status, 1);
|
|
574
|
-
}
|
|
575
|
-
finally {
|
|
576
|
-
rmSync(directory, { recursive: true, force: true });
|
|
577
|
-
}
|
|
578
|
-
});
|
|
579
|
-
test('prepares and applies an authorized rebuild through CLI', () => {
|
|
580
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-rebuild-'));
|
|
581
|
-
try {
|
|
582
|
-
const first = join(directory, 'first.md');
|
|
583
|
-
const second = join(directory, 'second.md');
|
|
584
|
-
const profile = join(directory, 'profile.json');
|
|
585
|
-
const draft = join(directory, 'draft.md');
|
|
586
|
-
const spec = join(directory, 'copy-spec.json');
|
|
587
|
-
const policy = join(directory, 'recomposition-policy.json');
|
|
588
|
-
const reduction = join(directory, 'reduction.json');
|
|
589
|
-
const task = join(directory, 'rebuild-task.json');
|
|
590
|
-
const response = join(directory, 'response.json');
|
|
591
|
-
const capability = join(directory, 'capability.json');
|
|
592
|
-
const writerRequest = join(directory, 'writer-request.json');
|
|
593
|
-
writeFileSync(first, 'I write plainly. I name the work.');
|
|
594
|
-
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
595
|
-
writeFileSync(draft, 'I leverage the answer. The launch is on 14 August.');
|
|
596
|
-
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.' }] }));
|
|
597
|
-
writeFileSync(policy, JSON.stringify({ version: '1', mode: 'meaning-first', lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0, maxLongestSharedRunTokens: 4 }, acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.' }));
|
|
598
|
-
assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
|
|
599
|
-
const envelopes = ['triage', 'argument', 'form'].map((kind) => {
|
|
600
|
-
const taskPath = join(directory, `${kind}.json`);
|
|
601
|
-
assert.equal(run(['prepare-judgment', 'pre-edit', kind, draft, profile, taskPath]).status, 0);
|
|
602
|
-
const prepared = JSON.parse(readFileSync(taskPath, 'utf8'));
|
|
603
|
-
const envelopePath = join(directory, `${kind}-envelope.json`);
|
|
604
|
-
writeFileSync(envelopePath, JSON.stringify({
|
|
605
|
-
version: '1', stage: 'pre-edit', judgmentType: kind, taskFingerprint: prepared.taskFingerprint,
|
|
606
|
-
bindings: { ...prepared.bindings, evaluatorId: 'writer.1' }, findings: [], decision: kind === 'argument' ? 'REBUILD' : 'SHIP',
|
|
607
|
-
}));
|
|
608
|
-
return envelopePath;
|
|
609
|
-
});
|
|
610
|
-
const reduced = run(['reduce-judgment', ...envelopes]);
|
|
611
|
-
assert.equal(reduced.status, 0, reduced.stderr);
|
|
612
|
-
writeFileSync(reduction, reduced.stdout);
|
|
613
|
-
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
614
|
-
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' }] };
|
|
615
|
-
const context = { now: 0, trustStore, authorizedSemanticEvaluatorIds: { normal: [], highAssurance: [] }, authorizedHumanFinalizerIds: [] };
|
|
616
|
-
const env = installedContextEnvironment(directory, context);
|
|
617
|
-
const sourceHash = createHash('sha256').update(readFileSync(draft)).digest('hex');
|
|
618
|
-
const profileValue = JSON.parse(readFileSync(profile, 'utf8'));
|
|
619
|
-
const identity = `legacy-v2:${createHash('sha256').update(canonicalJson(profileValue)).digest('hex')}`;
|
|
620
|
-
const claims = {
|
|
621
|
-
version: '1', purpose: 'hyv.rebuild-authorization', issuer: 'host.example', audience: '@holdyourvoice/hyv',
|
|
622
|
-
subjectArtifactFingerprint: JSON.parse(reduced.stdout).recommendationFingerprint, sourceHash, candidateHash: sourceHash,
|
|
623
|
-
profileId: identity, profileRevisionDigest: identity, keyId: 'key-1', issuedAt: Math.floor(Date.now() / 1000) - 1,
|
|
624
|
-
notBefore: Math.floor(Date.now() / 1000) - 1, expiresAt: Math.floor(Date.now() / 1000) + 120, nonce: 'cli-rebuild',
|
|
625
|
-
};
|
|
626
|
-
const payload = Buffer.from(canonicalJson(claims));
|
|
627
|
-
writeFileSync(capability, canonicalJson({ payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') }));
|
|
628
|
-
chmodSync(capability, 0o600);
|
|
629
|
-
const prepared = run(['prepare-rebuild', draft, profile, reduction, spec, task, '--recomposition-policy', policy, '--capability-file', capability], env);
|
|
630
|
-
assert.equal(prepared.status, 0, prepared.stderr);
|
|
631
|
-
assert.match(JSON.parse(prepared.stdout).recompositionPolicy.acknowledgement, /not detect/);
|
|
632
|
-
assert.doesNotMatch(JSON.parse(readFileSync(task, 'utf8')).prompt, /I leverage the answer/);
|
|
633
|
-
const writer = run(['rebuild-writer-request', task, writerRequest]);
|
|
634
|
-
assert.equal(writer.status, 0, writer.stderr);
|
|
635
|
-
assert.doesNotMatch(readFileSync(writerRequest, 'utf8'), /I leverage the answer|capability|profile/i);
|
|
636
|
-
writeFileSync(response, JSON.stringify({
|
|
637
|
-
version: '1', mode: 'REBUILD', taskFingerprint: JSON.parse(readFileSync(task, 'utf8')).fingerprint,
|
|
638
|
-
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.',
|
|
639
|
-
}));
|
|
640
|
-
const applied = run(['apply-rebuild', task, response, profile, '--capability-file', capability], env);
|
|
641
|
-
assert.equal(applied.status, 2, applied.stderr);
|
|
642
|
-
assert.equal(JSON.parse(applied.stdout).status, 'needs_semantic_review');
|
|
643
|
-
assert.equal(JSON.parse(applied.stdout).receipt.lexicalResidual.passed, true);
|
|
644
|
-
assert.equal(run(['apply-rebuild', task, response, profile], env).status, 1);
|
|
645
|
-
}
|
|
646
|
-
finally {
|
|
647
|
-
rmSync(directory, { recursive: true, force: true });
|
|
648
|
-
}
|
|
649
|
-
});
|
|
650
|
-
test('agent list enumerates every command as one portable package', () => {
|
|
651
|
-
const result = run(['agent', 'list']);
|
|
652
|
-
assert.equal(result.status, 0, result.stderr);
|
|
653
|
-
const entries = JSON.parse(result.stdout);
|
|
654
|
-
assert.equal(entries.length, 29);
|
|
655
|
-
const ids = entries.map((entry) => entry.id);
|
|
656
|
-
assert.equal(new Set(ids).size, ids.length);
|
|
657
|
-
for (const id of ['hyv-profile', 'hyv-score', 'hyv-ingest', 'hyv-backtest', 'hyv-evaluate-local', 'hyv-find-writing-examples', 'hyv-analyze', 'hyv-verify', 'hyv-mcp', 'hyv-patterns', 'hyv-strict-check']) {
|
|
658
|
-
assert.ok(ids.includes(id), `missing ${id}`);
|
|
659
|
-
}
|
|
660
|
-
const strict = JSON.parse(run(['agent', 'describe', 'hyv-strict-check']).stdout).agent;
|
|
661
|
-
const analyze = JSON.parse(run(['agent', 'describe', 'hyv-analyze']).stdout).agent;
|
|
662
|
-
const verify = JSON.parse(run(['agent', 'describe', 'hyv-verify']).stdout).agent;
|
|
663
|
-
assert.deepEqual(strict.handoff_to, ['hyv-final-check']);
|
|
664
|
-
assert.ok(analyze.handoff_to.includes('hyv-strict-check'));
|
|
665
|
-
assert.ok(verify.handoff_to.includes('hyv-strict-check'));
|
|
666
|
-
for (const entry of entries) {
|
|
667
|
-
assert.ok(entry.role);
|
|
668
|
-
assert.ok(entry.workflow_phase);
|
|
669
|
-
assert.ok(entry.description);
|
|
670
|
-
}
|
|
671
|
-
});
|
|
672
|
-
test('agent validate passes over the full package tree and rejects unknown ids', () => {
|
|
673
|
-
const result = run(['agent', 'validate']);
|
|
674
|
-
assert.equal(result.status, 0, result.stderr);
|
|
675
|
-
assert.equal(JSON.parse(result.stdout).status, 'PASS');
|
|
676
|
-
assert.equal(run(['agent', 'validate', 'hyv-verify']).status, 0);
|
|
677
|
-
const unknown = run(['agent', 'validate', 'hyv-does-not-exist']);
|
|
678
|
-
assert.equal(unknown.status, 1);
|
|
679
|
-
assert.match(unknown.stderr, /unknown agent/);
|
|
680
|
-
});
|
|
681
|
-
test('agent describe resolves a host and reports the runtime descriptor', () => {
|
|
682
|
-
const result = run(['agent', 'describe', 'hyv-verify', '--host', 'codex']);
|
|
683
|
-
assert.equal(result.status, 0, result.stderr);
|
|
684
|
-
const descriptor = JSON.parse(result.stdout);
|
|
685
|
-
assert.equal(descriptor.agent.id, 'hyv-verify');
|
|
686
|
-
assert.equal(descriptor.host.id, 'codex');
|
|
687
|
-
assert.ok(descriptor.available_capabilities.includes('read_repository'));
|
|
688
|
-
assert.ok(Array.isArray(descriptor.unavailable_capabilities));
|
|
689
|
-
});
|
|
690
|
-
test('agent emit writes a prompt or json contract to stdout or a named file', () => {
|
|
691
|
-
const prompt = run(['agent', 'emit', 'hyv-verify', '--mode=prompt', '--host=generic']);
|
|
692
|
-
assert.equal(prompt.status, 0, prompt.stderr);
|
|
693
|
-
assert.match(prompt.stdout, /# hyv-verify/);
|
|
694
|
-
assert.match(prompt.stdout, /Required input: original path, candidate path, profile path/);
|
|
695
|
-
const jsonOut = run(['agent', 'emit', 'hyv-verify', '--mode', 'json', '--host', 'generic']);
|
|
696
|
-
assert.equal(jsonOut.status, 0, jsonOut.stderr);
|
|
697
|
-
assert.equal(JSON.parse(jsonOut.stdout).agent.id, 'hyv-verify');
|
|
698
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-agent-'));
|
|
699
|
-
try {
|
|
700
|
-
const target = join(directory, 'verify.json');
|
|
701
|
-
const emitted = run(['agent', 'emit', 'hyv-verify', '--mode', 'json', '--output', target]);
|
|
702
|
-
assert.equal(emitted.status, 0, emitted.stderr);
|
|
703
|
-
assert.equal(JSON.parse(readFileSync(target, 'utf8')).agent.id, 'hyv-verify');
|
|
704
|
-
}
|
|
705
|
-
finally {
|
|
706
|
-
rmSync(directory, { recursive: true, force: true });
|
|
707
|
-
}
|
|
708
|
-
});
|
|
709
|
-
test('agent commands locate the installed package tree outside a repository', () => {
|
|
710
|
-
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-agent-cwd-'));
|
|
711
|
-
try {
|
|
712
|
-
mkdirSync(join(directory, 'skills'));
|
|
713
|
-
const result = spawnSync(process.execPath, [cli, 'agent', 'validate'], { cwd: directory, encoding: 'utf8' });
|
|
714
|
-
assert.equal(result.status, 0, result.stderr);
|
|
715
|
-
assert.equal(JSON.parse(result.stdout).status, 'PASS');
|
|
716
|
-
}
|
|
717
|
-
finally {
|
|
718
|
-
rmSync(directory, { recursive: true, force: true });
|
|
719
|
-
}
|
|
720
|
-
});
|
|
721
|
-
test('agent usage errors return exit 1 and never mutate', () => {
|
|
722
|
-
assert.equal(run(['agent']).status, 1);
|
|
723
|
-
assert.equal(run(['agent', 'emit', 'hyv-verify']).status, 1);
|
|
724
|
-
assert.equal(run(['agent', 'emit', 'hyv-verify', '--mode', 'bad']).status, 1);
|
|
725
|
-
assert.equal(run(['agent', 'describe']).status, 1);
|
|
726
|
-
assert.equal(run(['agent', 'describe', 'hyv-verify', '--mode', 'json']).status, 1);
|
|
727
|
-
assert.equal(run(['agent', 'describe', 'hyv-verify', '--output', 'ignored.json']).status, 1);
|
|
728
|
-
assert.equal(run(['agent', 'list', '--host', 'codex']).status, 1);
|
|
729
|
-
assert.equal(run(['agent', 'list', 'extra']).status, 1);
|
|
730
|
-
assert.equal(run(['agent', 'nope']).status, 1);
|
|
731
|
-
});
|