@holdyourvoice/hyv 3.2.0 → 3.3.1
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 +51 -11
- package/dist/ai-editor-rules.js +5 -2
- package/dist/ai-editor.js +52 -9
- package/dist/ai-editor.test.js +62 -10
- package/dist/approval-capability.js +111 -0
- package/dist/approval-capability.test.js +52 -0
- package/dist/approval-context.js +54 -0
- package/dist/approval-context.test.js +38 -0
- package/dist/benchmark.js +232 -0
- package/dist/benchmark.test.js +328 -0
- package/dist/canonical-json.js +123 -0
- package/dist/canonical-json.test.js +24 -0
- package/dist/cli.js +272 -19
- package/dist/cli.test.js +205 -8
- package/dist/hygiene.js +6 -0
- package/dist/hygiene.test.js +7 -1
- package/dist/judgment-task.js +171 -0
- package/dist/judgment-task.test.js +162 -0
- package/dist/learning.js +240 -100
- package/dist/learning.test.js +203 -3
- package/dist/lifecycle-adapter.js +75 -0
- package/dist/lifecycle-adapter.test.js +56 -0
- package/dist/mcp-tools.js +101 -7
- package/dist/mcp-tools.test.js +156 -6
- package/dist/mcp.js +213 -6
- package/dist/mcp.test.js +210 -11
- package/dist/pipeline.js +78 -14
- package/dist/pipeline.test.js +36 -2
- package/dist/preservation.js +89 -0
- package/dist/preservation.test.js +22 -0
- package/dist/profile.js +87 -0
- package/dist/profile.test.js +114 -0
- package/dist/rebuild-task.js +226 -0
- package/dist/rebuild-task.test.js +179 -0
- package/dist/release-audit.test.js +111 -2
- package/dist/rewrite-task.js +136 -16
- package/dist/rewrite-task.test.js +62 -7
- package/dist/rule-reconciliation.test.js +50 -0
- package/dist/semantic-review.js +176 -7
- package/dist/semantic-review.test.js +98 -14
- package/dist/stage1-dry-run.test.js +39 -0
- package/dist/stage1-evaluation.js +579 -0
- package/dist/stage1-evaluation.test.js +184 -0
- package/dist/stage1-human-packet.test.js +102 -0
- package/dist/stage1-schema-contract.test.js +95 -0
- package/dist/stage2-human-packet.test.js +81 -0
- package/dist/version.js +1 -1
- package/dist/voice-dna.js +53 -1
- package/dist/voice-dna.test.js +79 -1
- package/package.json +2 -2
package/dist/cli.test.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
|
6
7
|
import test from 'node:test';
|
|
7
8
|
import { patternsForMcp } from './mcp-tools.js';
|
|
9
|
+
import { canonicalJson } from './canonical-json.js';
|
|
8
10
|
const cli = new URL('./cli.js', import.meta.url).pathname;
|
|
9
11
|
function run(args, env = process.env, input) {
|
|
10
12
|
return spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', env, input });
|
|
11
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
|
+
}
|
|
12
29
|
test('creates an explicit local avoid list and exposes the ruleset', () => {
|
|
13
30
|
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
14
31
|
try {
|
|
@@ -28,13 +45,13 @@ test('creates an explicit local avoid list and exposes the ruleset', () => {
|
|
|
28
45
|
rmSync(directory, { recursive: true, force: true });
|
|
29
46
|
}
|
|
30
47
|
});
|
|
31
|
-
test('publishes the same normalized
|
|
48
|
+
test('publishes the same normalized reconciled catalog and version through CLI and MCP', () => {
|
|
32
49
|
const result = run(['patterns']);
|
|
33
50
|
assert.equal(result.status, 0, result.stderr);
|
|
34
51
|
const cliCatalog = JSON.parse(result.stdout);
|
|
35
52
|
const mcpCatalog = patternsForMcp();
|
|
36
|
-
assert.equal(cliCatalog.version, '2.
|
|
37
|
-
assert.equal(cliCatalog.rules.length,
|
|
53
|
+
assert.equal(cliCatalog.version, '3.2.0-reconciled.1');
|
|
54
|
+
assert.equal(cliCatalog.rules.length, 148);
|
|
38
55
|
assert.deepEqual(cliCatalog, mcpCatalog);
|
|
39
56
|
});
|
|
40
57
|
test('runs contextual analysis and batch analysis without changing the profile contract', () => {
|
|
@@ -197,6 +214,96 @@ test('prepares and applies the same constrained rewrite task without a provider
|
|
|
197
214
|
rmSync(directory, { recursive: true, force: true });
|
|
198
215
|
}
|
|
199
216
|
});
|
|
217
|
+
test('prepares and reduces pre-edit judgment envelopes through CLI', () => {
|
|
218
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-judgment-'));
|
|
219
|
+
try {
|
|
220
|
+
const first = join(directory, 'first.md');
|
|
221
|
+
const second = join(directory, 'second.md');
|
|
222
|
+
const profile = join(directory, 'profile.json');
|
|
223
|
+
const draft = join(directory, 'draft.md');
|
|
224
|
+
writeFileSync(first, 'I write plainly. I name the work.');
|
|
225
|
+
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
226
|
+
writeFileSync(draft, 'I leverage the answer.');
|
|
227
|
+
assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
|
|
228
|
+
const envelopes = ['triage', 'argument', 'form'].map((kind) => {
|
|
229
|
+
const taskPath = join(directory, `${kind}.json`);
|
|
230
|
+
assert.equal(run(['prepare-judgment', 'pre-edit', kind, draft, profile, taskPath]).status, 0);
|
|
231
|
+
const task = JSON.parse(readFileSync(taskPath, 'utf8'));
|
|
232
|
+
const envelopePath = join(directory, `${kind}-envelope.json`);
|
|
233
|
+
writeFileSync(envelopePath, JSON.stringify({
|
|
234
|
+
version: '1', stage: 'pre-edit', judgmentType: kind, taskFingerprint: task.taskFingerprint,
|
|
235
|
+
bindings: { ...task.bindings, evaluatorId: 'writer.1' }, findings: [], decision: 'SHIP',
|
|
236
|
+
}));
|
|
237
|
+
return envelopePath;
|
|
238
|
+
});
|
|
239
|
+
const result = run(['reduce-judgment', ...envelopes]);
|
|
240
|
+
assert.equal(result.status, 0, result.stderr);
|
|
241
|
+
assert.equal(JSON.parse(result.stdout).decision, 'SHIP');
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
rmSync(directory, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
test('prepares, submits, and inspects a normal lifecycle through CLI canonical envelopes', () => {
|
|
248
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-lifecycle-'));
|
|
249
|
+
try {
|
|
250
|
+
const deterministicBase = { version: '1', verificationKind: 'standard', passed: true, analysisVersion: '2', rulesetVersion: '3.2.0', preservationMetricVersion: 'legacy-set-v1', preservationScore: 100, sourceHash: '4'.repeat(64), candidateHash: '5'.repeat(64), profileId: 'founder.primary', profileRevisionDigest: '6'.repeat(64), regressionKeys: [] };
|
|
251
|
+
const deterministic = { ...deterministicBase, artifactFingerprint: createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(deterministicBase)}`).digest('hex') };
|
|
252
|
+
const binding = { rewriteTaskFingerprint: '1'.repeat(64), rewriteResponseFingerprint: '2'.repeat(64), deterministicArtifactFingerprint: deterministic.artifactFingerprint, sourceHash: deterministic.sourceHash, candidateHash: deterministic.candidateHash, profileId: deterministic.profileId, profileRevisionDigest: deterministic.profileRevisionDigest, rulesetVersion: deterministic.rulesetVersion, schemaVersion: '1' };
|
|
253
|
+
const receipt = { version: '1', taskFingerprint: binding.rewriteTaskFingerprint, responseFingerprint: binding.rewriteResponseFingerprint, adapterIds: [], replacementSentenceIds: [1] };
|
|
254
|
+
const paths = Object.fromEntries(['deterministic', 'binding', 'receipt', 'violations', 'output', 'artifact', 'task', 'verdict'].map((name) => [name, join(directory, `${name}.json`)]));
|
|
255
|
+
writeFileSync(paths.deterministic, JSON.stringify(deterministic));
|
|
256
|
+
writeFileSync(paths.binding, JSON.stringify(binding));
|
|
257
|
+
writeFileSync(paths.receipt, JSON.stringify(receipt));
|
|
258
|
+
writeFileSync(paths.violations, JSON.stringify(['action_change']));
|
|
259
|
+
const prepared = run(['lifecycle', 'prepare-semantic', paths.deterministic, paths.binding, paths.receipt, 'normal', paths.violations, paths.output]);
|
|
260
|
+
assert.equal(prepared.status, 0, prepared.stderr);
|
|
261
|
+
const envelope = JSON.parse(prepared.stdout);
|
|
262
|
+
assert.equal(envelope.artifact.status, 'needs_semantic_review');
|
|
263
|
+
assert.deepEqual(JSON.parse(readFileSync(paths.output, 'utf8')), envelope);
|
|
264
|
+
writeFileSync(paths.artifact, JSON.stringify(envelope.artifact));
|
|
265
|
+
writeFileSync(paths.task, JSON.stringify(envelope.task));
|
|
266
|
+
writeFileSync(paths.verdict, JSON.stringify({ approved: true, violations: [] }));
|
|
267
|
+
const context = { now: 0, trustStore: { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [] }, authorizedSemanticEvaluatorIds: { normal: ['reviewer-1'], highAssurance: [] }, authorizedHumanFinalizerIds: ['human-1'] };
|
|
268
|
+
const submitted = run(['lifecycle', 'submit-verdict', paths.artifact, paths.task, 'reviewer-1', paths.verdict], installedContextEnvironment(directory, context));
|
|
269
|
+
assert.equal(submitted.status, 0, submitted.stderr);
|
|
270
|
+
assert.equal(JSON.parse(submitted.stdout).status, 'ready_for_human_review');
|
|
271
|
+
const inspected = run(['lifecycle', 'inspect', paths.artifact]);
|
|
272
|
+
assert.equal(inspected.status, 0, inspected.stderr);
|
|
273
|
+
assert.equal(JSON.parse(inspected.stdout).status, 'needs_semantic_review');
|
|
274
|
+
assert.doesNotMatch(inspected.stdout, /sourceHash|candidateHash|verdicts/);
|
|
275
|
+
assert.equal(run(['lifecycle', 'prepare-semantic', paths.deterministic, paths.binding, paths.receipt, 'high_assurance', paths.violations, join(directory, 'high.json')]).status, 1);
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
rmSync(directory, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
test('accepts only one bounded capability transport and treats a file named stdin literally', () => {
|
|
282
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-capability-'));
|
|
283
|
+
try {
|
|
284
|
+
const capability = JSON.stringify({ payload: 'secret-payload', signature: 'secret-signature' });
|
|
285
|
+
const literal = join(directory, 'stdin');
|
|
286
|
+
writeFileSync(literal, capability, { mode: 0o600 });
|
|
287
|
+
const literalResult = spawnSync(process.execPath, [cli, 'lifecycle', 'validate-final-approval', 'missing.json', '--capability-file', 'stdin'], { cwd: directory, encoding: 'utf8' });
|
|
288
|
+
assert.equal(literalResult.status, 1);
|
|
289
|
+
assert.doesNotMatch(literalResult.stderr, /Capability file is unavailable or unsafe|secret-payload|secret-signature/);
|
|
290
|
+
chmodSync(literal, 0o644);
|
|
291
|
+
const unsafe = spawnSync(process.execPath, [cli, 'lifecycle', 'validate-final-approval', 'missing.json', '--capability-file', 'stdin'], { cwd: directory, encoding: 'utf8' });
|
|
292
|
+
assert.equal(unsafe.status, 1);
|
|
293
|
+
assert.match(unsafe.stderr, /Capability file is unavailable or unsafe/);
|
|
294
|
+
assert.doesNotMatch(unsafe.stderr, /secret-payload|secret-signature/);
|
|
295
|
+
const duplicate = run(['lifecycle', 'validate-final-approval', 'missing.json', '--capability-stdin', '--capability-file', literal], process.env, capability);
|
|
296
|
+
assert.equal(duplicate.status, 1);
|
|
297
|
+
assert.match(duplicate.stderr, /Choose one capability source/);
|
|
298
|
+
assert.doesNotMatch(duplicate.stderr, /secret-payload|secret-signature/);
|
|
299
|
+
const oversized = run(['lifecycle', 'validate-final-approval', 'missing.json', '--capability-stdin'], process.env, 'x'.repeat(1024 * 1024 + 1));
|
|
300
|
+
assert.equal(oversized.status, 1);
|
|
301
|
+
assert.match(oversized.stderr, /exceeds the byte limit/);
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
rmSync(directory, { recursive: true, force: true });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
200
307
|
test('rejects a malformed hand-edited profile before analysis', () => {
|
|
201
308
|
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
202
309
|
try {
|
|
@@ -265,7 +372,7 @@ test('rejects hand-edited metrics outside their semantic bounds', () => {
|
|
|
265
372
|
rmSync(directory, { recursive: true, force: true });
|
|
266
373
|
}
|
|
267
374
|
});
|
|
268
|
-
test('
|
|
375
|
+
test('keeps verification read-only and exposes learning only through explicit controls', () => {
|
|
269
376
|
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
270
377
|
try {
|
|
271
378
|
const first = join(directory, 'first.md');
|
|
@@ -282,11 +389,10 @@ test('learns from a successful local verification by default and exposes local c
|
|
|
282
389
|
assert.equal(run(['verify', original, candidate, profile], env).status, 0);
|
|
283
390
|
const brief = run(['rewrite-prompt', candidate, profile], env);
|
|
284
391
|
assert.equal(brief.status, 0, brief.stderr);
|
|
285
|
-
assert.
|
|
286
|
-
assert.match(brief.stdout, /ai\\_editor\/ai\.leverage/);
|
|
392
|
+
assert.doesNotMatch(brief.stdout, /Learned local preferences/);
|
|
287
393
|
const learned = run(['learning', 'show', profile], env);
|
|
288
394
|
assert.equal(learned.status, 0, learned.stderr);
|
|
289
|
-
assert.
|
|
395
|
+
assert.deepEqual(JSON.parse(learned.stdout).preferences, []);
|
|
290
396
|
assert.equal(run(['learning', 'clear', profile], env).status, 0);
|
|
291
397
|
assert.deepEqual(JSON.parse(run(['learning', 'show', profile], env).stdout).preferences, []);
|
|
292
398
|
}
|
|
@@ -294,3 +400,94 @@ test('learns from a successful local verification by default and exposes local c
|
|
|
294
400
|
rmSync(directory, { recursive: true, force: true });
|
|
295
401
|
}
|
|
296
402
|
});
|
|
403
|
+
test('records and inspects text-free learning metadata through the CLI', () => {
|
|
404
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-learning-'));
|
|
405
|
+
try {
|
|
406
|
+
const first = join(directory, 'first.md');
|
|
407
|
+
const second = join(directory, 'second.md');
|
|
408
|
+
const profile = join(directory, 'profile.json');
|
|
409
|
+
const env = { ...process.env, HYV_HOME: join(directory, 'state') };
|
|
410
|
+
writeFileSync(first, 'I write plainly. I name the work.');
|
|
411
|
+
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
412
|
+
assert.equal(run(['profile', profile, first, second]).status, 0);
|
|
413
|
+
const recorded = run(['learning', 'record', profile, 'Keep the mechanism concrete.', '--authority=founder', '--provenance=editor-review', '--weight=2', '--compatibility=exact', '--mutation-id=cli-1'], env);
|
|
414
|
+
assert.equal(recorded.status, 0, recorded.stderr);
|
|
415
|
+
assert.equal(JSON.parse(recorded.stdout).mutationId, 'cli-1');
|
|
416
|
+
assert.equal(JSON.parse(run(['learning', 'record', profile, 'Different instruction.', '--mutation-id=cli-1'], env).stdout).status, 'conflict');
|
|
417
|
+
const inspected = run(['learning', 'inspect', profile], env);
|
|
418
|
+
assert.equal(inspected.status, 0, inspected.stderr);
|
|
419
|
+
assert.equal(JSON.parse(inspected.stdout)[0].authority, 'founder');
|
|
420
|
+
assert.doesNotMatch(inspected.stdout, /Keep the mechanism concrete/);
|
|
421
|
+
assert.equal(run(['learning', 'ratify', profile, JSON.parse(recorded.stdout).eventId], env).status, 1);
|
|
422
|
+
assert.equal(run(['learning', 'record', profile, 'Boundary.', `--mutation-id=${'m'.repeat(200)}`, `--provenance=${'p'.repeat(500)}`], env).status, 0);
|
|
423
|
+
assert.equal(run(['learning', 'record', profile, 'Too long.', `--mutation-id=${'m'.repeat(201)}`], env).status, 1);
|
|
424
|
+
assert.equal(run(['learning', 'record', profile, 'Too long.', `--provenance=${'p'.repeat(501)}`], env).status, 1);
|
|
425
|
+
assert.equal(run(['learning', 'inspect', profile, '--mutation-id=nope'], env).status, 1);
|
|
426
|
+
assert.equal(run(['learning', 'clear', profile, '--authority=team'], env).status, 1);
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
rmSync(directory, { recursive: true, force: true });
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
test('prepares and applies an authorized rebuild through CLI', () => {
|
|
433
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-rebuild-'));
|
|
434
|
+
try {
|
|
435
|
+
const first = join(directory, 'first.md');
|
|
436
|
+
const second = join(directory, 'second.md');
|
|
437
|
+
const profile = join(directory, 'profile.json');
|
|
438
|
+
const draft = join(directory, 'draft.md');
|
|
439
|
+
const spec = join(directory, 'copy-spec.json');
|
|
440
|
+
const reduction = join(directory, 'reduction.json');
|
|
441
|
+
const task = join(directory, 'rebuild-task.json');
|
|
442
|
+
const response = join(directory, 'response.json');
|
|
443
|
+
const capability = join(directory, 'capability.json');
|
|
444
|
+
writeFileSync(first, 'I write plainly. I name the work.');
|
|
445
|
+
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
446
|
+
writeFileSync(draft, 'I leverage the answer. The launch is on 14 August.');
|
|
447
|
+
writeFileSync(spec, JSON.stringify({ version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar, 7 August.' }] }));
|
|
448
|
+
assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
|
|
449
|
+
const envelopes = ['triage', 'argument', 'form'].map((kind) => {
|
|
450
|
+
const taskPath = join(directory, `${kind}.json`);
|
|
451
|
+
assert.equal(run(['prepare-judgment', 'pre-edit', kind, draft, profile, taskPath]).status, 0);
|
|
452
|
+
const prepared = JSON.parse(readFileSync(taskPath, 'utf8'));
|
|
453
|
+
const envelopePath = join(directory, `${kind}-envelope.json`);
|
|
454
|
+
writeFileSync(envelopePath, JSON.stringify({
|
|
455
|
+
version: '1', stage: 'pre-edit', judgmentType: kind, taskFingerprint: prepared.taskFingerprint,
|
|
456
|
+
bindings: { ...prepared.bindings, evaluatorId: 'writer.1' }, findings: [], decision: kind === 'argument' ? 'REBUILD' : 'SHIP',
|
|
457
|
+
}));
|
|
458
|
+
return envelopePath;
|
|
459
|
+
});
|
|
460
|
+
const reduced = run(['reduce-judgment', ...envelopes]);
|
|
461
|
+
assert.equal(reduced.status, 0, reduced.stderr);
|
|
462
|
+
writeFileSync(reduction, reduced.stdout);
|
|
463
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
464
|
+
const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'host.example', keyId: 'key-1', publicKeySpki: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), status: 'active' }] };
|
|
465
|
+
const context = { now: 0, trustStore, authorizedSemanticEvaluatorIds: { normal: [], highAssurance: [] }, authorizedHumanFinalizerIds: [] };
|
|
466
|
+
const env = installedContextEnvironment(directory, context);
|
|
467
|
+
const sourceHash = createHash('sha256').update(readFileSync(draft)).digest('hex');
|
|
468
|
+
const profileValue = JSON.parse(readFileSync(profile, 'utf8'));
|
|
469
|
+
const identity = `legacy-v2:${createHash('sha256').update(canonicalJson(profileValue)).digest('hex')}`;
|
|
470
|
+
const claims = {
|
|
471
|
+
version: '1', purpose: 'hyv.rebuild-authorization', issuer: 'host.example', audience: '@holdyourvoice/hyv',
|
|
472
|
+
subjectArtifactFingerprint: JSON.parse(reduced.stdout).recommendationFingerprint, sourceHash, candidateHash: sourceHash,
|
|
473
|
+
profileId: identity, profileRevisionDigest: identity, keyId: 'key-1', issuedAt: Math.floor(Date.now() / 1000) - 1,
|
|
474
|
+
notBefore: Math.floor(Date.now() / 1000) - 1, expiresAt: Math.floor(Date.now() / 1000) + 120, nonce: 'cli-rebuild',
|
|
475
|
+
};
|
|
476
|
+
const payload = Buffer.from(canonicalJson(claims));
|
|
477
|
+
writeFileSync(capability, canonicalJson({ payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') }));
|
|
478
|
+
chmodSync(capability, 0o600);
|
|
479
|
+
const prepared = run(['prepare-rebuild', draft, profile, reduction, spec, task, '--capability-file', capability], env);
|
|
480
|
+
assert.equal(prepared.status, 0, prepared.stderr);
|
|
481
|
+
writeFileSync(response, JSON.stringify({
|
|
482
|
+
version: '1', mode: 'REBUILD', taskFingerprint: JSON.parse(readFileSync(task, 'utf8')).fingerprint,
|
|
483
|
+
candidate: 'Ship planning now treats one calendar fact as fixed. The launch is on 14 August. Every other sentence in this note is new operational language for the release desk.',
|
|
484
|
+
}));
|
|
485
|
+
const applied = run(['apply-rebuild', task, response, profile, '--capability-file', capability], env);
|
|
486
|
+
assert.equal(applied.status, 2, applied.stderr);
|
|
487
|
+
assert.equal(JSON.parse(applied.stdout).status, 'needs_semantic_review');
|
|
488
|
+
assert.equal(run(['apply-rebuild', task, response, profile], env).status, 1);
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
rmSync(directory, { recursive: true, force: true });
|
|
492
|
+
}
|
|
493
|
+
});
|
package/dist/hygiene.js
CHANGED
|
@@ -73,6 +73,12 @@ function scanHygiene(text, clean) {
|
|
|
73
73
|
export function inspectHygiene(text) {
|
|
74
74
|
return scanHygiene(text, false).report;
|
|
75
75
|
}
|
|
76
|
+
export function hygieneSourceFindings(text) {
|
|
77
|
+
return inspectHygiene(text).hits.flatMap((hit) => hit.offsets.map((start) => {
|
|
78
|
+
const character = String.fromCodePoint(text.codePointAt(start));
|
|
79
|
+
return { kind: 'hygiene', start, end: start + character.length, codepoint: hit.codepoint, eligible: hit.fix === 'remove' };
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
76
82
|
export function cleanHygiene(text) {
|
|
77
83
|
const result = scanHygiene(text, true);
|
|
78
84
|
return { ...result, changed: result.changes.length > 0 };
|
package/dist/hygiene.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { cleanHygiene, finalOutputCheck, inspectHygiene } from './hygiene.js';
|
|
3
|
+
import { cleanHygiene, finalOutputCheck, hygieneSourceFindings, inspectHygiene } from './hygiene.js';
|
|
4
4
|
test('reports zero-width, bidi, tag, and unusual-space characters with exact offsets', () => {
|
|
5
5
|
const text = `one\u200Btwo\u202Ethree\u{E0001}\u00A0four`;
|
|
6
6
|
const report = inspectHygiene(text);
|
|
@@ -31,6 +31,12 @@ test('leaves clean text byte-for-byte unchanged', () => {
|
|
|
31
31
|
assert.deepEqual(result.changes, []);
|
|
32
32
|
assert.deepEqual(result.report.hits, []);
|
|
33
33
|
});
|
|
34
|
+
test('projects eligible hygiene hits as source-offset findings', () => {
|
|
35
|
+
const findings = hygieneSourceFindings('\uFEFFplain');
|
|
36
|
+
assert.equal(findings.length, 1);
|
|
37
|
+
assert.equal(findings[0]?.start, 0);
|
|
38
|
+
assert.equal(findings[0]?.eligible, true);
|
|
39
|
+
});
|
|
34
40
|
test('groups repeated report-only hits and preserves supplementary characters', () => {
|
|
35
41
|
const text = `😀\u200Bword\u200B`;
|
|
36
42
|
const result = cleanHygiene(text);
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from './canonical-json.js';
|
|
3
|
+
import { sentences } from './text.js';
|
|
4
|
+
import { HYV_VERSION } from './version.js';
|
|
5
|
+
const PRE_EDIT_KINDS = ['triage', 'argument', 'form'];
|
|
6
|
+
const POST_CANDIDATE_KINDS = ['argument', 'polarity', 'form', 'flatness', 'semantic'];
|
|
7
|
+
function digest(value) {
|
|
8
|
+
return createHash('sha256').update(value).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
function digestCanonical(value) {
|
|
11
|
+
return digest(canonicalJson(value));
|
|
12
|
+
}
|
|
13
|
+
function profileIdentity(profile) {
|
|
14
|
+
if (profile.version === '3')
|
|
15
|
+
return { profileId: profile.id, profileRevisionDigest: profile.revisionDigest };
|
|
16
|
+
const legacy = `legacy-v2:${digestCanonical(profile)}`;
|
|
17
|
+
return { profileId: legacy, profileRevisionDigest: legacy };
|
|
18
|
+
}
|
|
19
|
+
function fingerprintTask(task) {
|
|
20
|
+
return digest(`hyv:judgment-task:v1\0${canonicalJson(task)}`);
|
|
21
|
+
}
|
|
22
|
+
function sentenceIds(text) {
|
|
23
|
+
return sentences(text).map((sentence) => sentence.index);
|
|
24
|
+
}
|
|
25
|
+
function isRange(value) {
|
|
26
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
27
|
+
&& Number.isInteger(value.startSentenceId)
|
|
28
|
+
&& Number.isInteger(value.endSentenceId)
|
|
29
|
+
&& value.startSentenceId >= 1
|
|
30
|
+
&& value.endSentenceId >= value.startSentenceId;
|
|
31
|
+
}
|
|
32
|
+
function rangesContiguous(ranges) {
|
|
33
|
+
return ranges.every((range) => range.endSentenceId >= range.startSentenceId);
|
|
34
|
+
}
|
|
35
|
+
export function parseJudgmentEnvelope(value) {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
37
|
+
throw new Error('Judgment envelope must be an object.');
|
|
38
|
+
const envelope = value;
|
|
39
|
+
if (envelope.version !== '1')
|
|
40
|
+
throw new Error('Judgment envelope version must be "1".');
|
|
41
|
+
if (envelope.stage !== 'pre-edit' && envelope.stage !== 'post-candidate')
|
|
42
|
+
throw new Error('Judgment stage is invalid.');
|
|
43
|
+
if (typeof envelope.judgmentType !== 'string' || typeof envelope.taskFingerprint !== 'string' || envelope.taskFingerprint.length !== 64) {
|
|
44
|
+
throw new Error('Judgment envelope is missing a bound task.');
|
|
45
|
+
}
|
|
46
|
+
if (!envelope.bindings || typeof envelope.bindings !== 'object' || typeof envelope.bindings.sourceHash !== 'string' || typeof envelope.bindings.evaluatorId !== 'string') {
|
|
47
|
+
throw new Error('Judgment envelope is missing bindings.');
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(envelope.findings) || typeof envelope.decision !== 'string')
|
|
50
|
+
throw new Error('Judgment envelope is missing findings or a decision.');
|
|
51
|
+
for (const [index, finding] of envelope.findings.entries()) {
|
|
52
|
+
if (!finding || typeof finding !== 'object' || typeof finding.kind !== 'string')
|
|
53
|
+
throw new Error(`Finding ${index} is invalid.`);
|
|
54
|
+
if (finding.ranges && (!Array.isArray(finding.ranges) || !finding.ranges.every(isRange)))
|
|
55
|
+
throw new Error(`Finding ${index} ranges are invalid.`);
|
|
56
|
+
}
|
|
57
|
+
if (envelope.editScope && (!Array.isArray(envelope.editScope.ranges) || !envelope.editScope.ranges.every(isRange))) {
|
|
58
|
+
throw new Error('Edit scope ranges are invalid.');
|
|
59
|
+
}
|
|
60
|
+
return envelope;
|
|
61
|
+
}
|
|
62
|
+
function prepareTask(stage, judgmentType, draft, profile, candidate) {
|
|
63
|
+
const identity = profileIdentity(profile);
|
|
64
|
+
const allowedDecisions = stage === 'pre-edit' ? ['SHIP', 'EDIT', 'REBUILD'] : ['CLEAR', 'ESCALATE', 'REBUILD'];
|
|
65
|
+
const base = {
|
|
66
|
+
version: '1',
|
|
67
|
+
stage,
|
|
68
|
+
judgmentType,
|
|
69
|
+
...(stage === 'pre-edit' ? { draft } : { draft, candidate }),
|
|
70
|
+
bindings: {
|
|
71
|
+
sourceHash: digest(draft),
|
|
72
|
+
...(candidate !== undefined ? { candidateHash: digest(candidate) } : {}),
|
|
73
|
+
...identity,
|
|
74
|
+
rulesetVersion: HYV_VERSION,
|
|
75
|
+
evidenceScope: { sentenceIds: sentenceIds(candidate ?? draft) },
|
|
76
|
+
},
|
|
77
|
+
allowedDecisions,
|
|
78
|
+
};
|
|
79
|
+
return { ...base, taskFingerprint: fingerprintTask(base) };
|
|
80
|
+
}
|
|
81
|
+
export function preparePreEditJudgment(draft, profile, kind) {
|
|
82
|
+
return prepareTask('pre-edit', kind, draft, profile);
|
|
83
|
+
}
|
|
84
|
+
export function preparePostCandidateJudgment(draft, candidate, profile, kind) {
|
|
85
|
+
return prepareTask('post-candidate', kind, draft, profile, candidate);
|
|
86
|
+
}
|
|
87
|
+
export function bindJudgmentEnvelope(task, envelope) {
|
|
88
|
+
const parsed = parseJudgmentEnvelope(envelope);
|
|
89
|
+
if (parsed.taskFingerprint !== task.taskFingerprint)
|
|
90
|
+
throw new Error('Judgment envelope task fingerprint does not match.');
|
|
91
|
+
if (parsed.stage !== task.stage || parsed.judgmentType !== task.judgmentType)
|
|
92
|
+
throw new Error('Judgment envelope type does not match the task.');
|
|
93
|
+
if (parsed.bindings.sourceHash !== task.bindings.sourceHash)
|
|
94
|
+
throw new Error('Judgment envelope source hash does not match.');
|
|
95
|
+
if (task.bindings.candidateHash && parsed.bindings.candidateHash !== task.bindings.candidateHash)
|
|
96
|
+
throw new Error('Judgment envelope candidate hash does not match.');
|
|
97
|
+
if (parsed.bindings.profileId !== task.bindings.profileId || parsed.bindings.profileRevisionDigest !== task.bindings.profileRevisionDigest) {
|
|
98
|
+
throw new Error('Judgment envelope profile binding does not match.');
|
|
99
|
+
}
|
|
100
|
+
if (parsed.bindings.rulesetVersion !== task.bindings.rulesetVersion)
|
|
101
|
+
throw new Error('Judgment envelope ruleset does not match.');
|
|
102
|
+
if (canonicalJson(parsed.bindings.evidenceScope) !== canonicalJson(task.bindings.evidenceScope))
|
|
103
|
+
throw new Error('Judgment envelope evidence scope does not match.');
|
|
104
|
+
if (!task.allowedDecisions.includes(parsed.decision))
|
|
105
|
+
throw new Error('Judgment decision is not allowed at this stage.');
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
function namedRanges(findings) {
|
|
109
|
+
return findings.flatMap((finding) => finding.ranges ?? []);
|
|
110
|
+
}
|
|
111
|
+
function fingerprintReduction(decision, editScope, reason) {
|
|
112
|
+
return digest(`hyv:pre-edit-reduction:v1\0${canonicalJson({ decision, editScope, ...(reason ? { reason } : {}) })}`);
|
|
113
|
+
}
|
|
114
|
+
export function fingerprintPreEditReduction(reduction) {
|
|
115
|
+
return fingerprintReduction(reduction.decision, reduction.editScope, reduction.reason);
|
|
116
|
+
}
|
|
117
|
+
function reduced(decision, editScope, reason) {
|
|
118
|
+
return { decision, editScope, ...(reason ? { reason } : {}), recommendationFingerprint: fingerprintReduction(decision, editScope, reason) };
|
|
119
|
+
}
|
|
120
|
+
export function reducePreEdit(envelopes) {
|
|
121
|
+
if (envelopes.length !== PRE_EDIT_KINDS.length)
|
|
122
|
+
throw new Error('Pre-edit reduction requires triage, argument, and form envelopes.');
|
|
123
|
+
const kinds = new Set(envelopes.map((envelope) => envelope.judgmentType));
|
|
124
|
+
if (PRE_EDIT_KINDS.some((kind) => !kinds.has(kind)))
|
|
125
|
+
throw new Error('Pre-edit reduction requires triage, argument, and form envelopes.');
|
|
126
|
+
if (envelopes.some((envelope) => envelope.stage !== 'pre-edit'))
|
|
127
|
+
throw new Error('Pre-edit reduction rejects post-candidate envelopes.');
|
|
128
|
+
const argument = envelopes.find((envelope) => envelope.judgmentType === 'argument');
|
|
129
|
+
if (argument.findings.some((finding) => finding.unbounded) || argument.decision === 'REBUILD') {
|
|
130
|
+
return reduced('REBUILD', { ranges: [] }, argument.findings.some((finding) => finding.unbounded) ? 'unbounded_argument_failure' : undefined);
|
|
131
|
+
}
|
|
132
|
+
if (envelopes.some((envelope) => envelope.decision === 'REBUILD')) {
|
|
133
|
+
return reduced('REBUILD', { ranges: [] });
|
|
134
|
+
}
|
|
135
|
+
const ranges = envelopes.flatMap((envelope) => envelope.editScope?.ranges ?? namedRanges(envelope.findings));
|
|
136
|
+
if (!rangesContiguous(ranges) || ranges.some((range) => range.endSentenceId < range.startSentenceId)) {
|
|
137
|
+
throw new Error('Edit scope must name contiguous sentence ranges.');
|
|
138
|
+
}
|
|
139
|
+
if (envelopes.every((envelope) => envelope.decision === 'SHIP') && ranges.length === 0) {
|
|
140
|
+
return reduced('SHIP', { ranges: [] });
|
|
141
|
+
}
|
|
142
|
+
if (ranges.length === 0)
|
|
143
|
+
throw new Error('Paragraph-level findings cannot unlock text unless they name contiguous sentence ranges.');
|
|
144
|
+
return reduced('EDIT', { ranges });
|
|
145
|
+
}
|
|
146
|
+
export function reducePostCandidate(envelopes) {
|
|
147
|
+
if (envelopes.length !== POST_CANDIDATE_KINDS.length)
|
|
148
|
+
throw new Error('Post-candidate reduction requires argument, polarity, form, flatness, and semantic envelopes.');
|
|
149
|
+
const kinds = new Set(envelopes.map((envelope) => envelope.judgmentType));
|
|
150
|
+
if (POST_CANDIDATE_KINDS.some((kind) => !kinds.has(kind)))
|
|
151
|
+
throw new Error('Post-candidate reduction requires argument, polarity, form, flatness, and semantic envelopes.');
|
|
152
|
+
if (envelopes.some((envelope) => envelope.stage !== 'post-candidate'))
|
|
153
|
+
throw new Error('Post-candidate reduction rejects pre-edit envelopes.');
|
|
154
|
+
if (envelopes.some((envelope) => envelope.decision === 'REBUILD'))
|
|
155
|
+
return { decision: 'REBUILD' };
|
|
156
|
+
if (envelopes.some((envelope) => envelope.decision === 'ESCALATE'))
|
|
157
|
+
return { decision: 'ESCALATE' };
|
|
158
|
+
if (!envelopes.every((envelope) => envelope.decision === 'CLEAR'))
|
|
159
|
+
throw new Error('Post-candidate envelopes must CLEAR, ESCALATE, or REBUILD.');
|
|
160
|
+
return { decision: 'CLEAR' };
|
|
161
|
+
}
|
|
162
|
+
export function authorizedSentenceIds(reduction) {
|
|
163
|
+
if (reduction.decision !== 'EDIT')
|
|
164
|
+
return [];
|
|
165
|
+
const ids = new Set();
|
|
166
|
+
for (const range of reduction.editScope.ranges) {
|
|
167
|
+
for (let id = range.startSentenceId; id <= range.endSentenceId; id += 1)
|
|
168
|
+
ids.add(id);
|
|
169
|
+
}
|
|
170
|
+
return [...ids].sort((left, right) => left - right);
|
|
171
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { bindJudgmentEnvelope, preparePostCandidateJudgment, preparePreEditJudgment, reducePostCandidate, reducePreEdit } from './judgment-task.js';
|
|
4
|
+
import { applyRewriteResponse, applyShip, prepareRewriteTask } from './rewrite-task.js';
|
|
5
|
+
import { hygieneSourceFindings } from './hygiene.js';
|
|
6
|
+
import { buildProfile } from './voice-dna.js';
|
|
7
|
+
const profile = buildProfile([
|
|
8
|
+
'I write clear notes. I keep the mechanism visible.',
|
|
9
|
+
'I name the trade-off. Then I make the next step plain.',
|
|
10
|
+
], ['leverage']);
|
|
11
|
+
function envelope(task, decision, extra = {}) {
|
|
12
|
+
return {
|
|
13
|
+
version: '1',
|
|
14
|
+
stage: task.stage,
|
|
15
|
+
judgmentType: task.judgmentType,
|
|
16
|
+
taskFingerprint: task.taskFingerprint,
|
|
17
|
+
bindings: { ...task.bindings, evaluatorId: 'writer.1' },
|
|
18
|
+
findings: [],
|
|
19
|
+
decision,
|
|
20
|
+
...extra,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
test('pre-edit findings select SHIP, bounded EDIT, or REBUILD', () => {
|
|
24
|
+
const draft = 'I leverage the answer. The launch is on 14 August.';
|
|
25
|
+
const triage = preparePreEditJudgment(draft, profile, 'triage');
|
|
26
|
+
const argument = preparePreEditJudgment(draft, profile, 'argument');
|
|
27
|
+
const form = preparePreEditJudgment(draft, profile, 'form');
|
|
28
|
+
const ship = reducePreEdit([
|
|
29
|
+
bindJudgmentEnvelope(triage, envelope(triage, 'SHIP')),
|
|
30
|
+
bindJudgmentEnvelope(argument, envelope(argument, 'SHIP')),
|
|
31
|
+
bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
|
|
32
|
+
]);
|
|
33
|
+
assert.equal(ship.decision, 'SHIP');
|
|
34
|
+
const edit = reducePreEdit([
|
|
35
|
+
bindJudgmentEnvelope(triage, envelope(triage, 'EDIT', { editScope: { ranges: [{ startSentenceId: 1, endSentenceId: 1 }] } })),
|
|
36
|
+
bindJudgmentEnvelope(argument, envelope(argument, 'SHIP')),
|
|
37
|
+
bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
|
|
38
|
+
]);
|
|
39
|
+
assert.equal(edit.decision, 'EDIT');
|
|
40
|
+
assert.deepEqual(edit.editScope.ranges, [{ startSentenceId: 1, endSentenceId: 1 }]);
|
|
41
|
+
const rebuild = reducePreEdit([
|
|
42
|
+
bindJudgmentEnvelope(triage, envelope(triage, 'SHIP')),
|
|
43
|
+
bindJudgmentEnvelope(argument, envelope(argument, 'REBUILD')),
|
|
44
|
+
bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
|
|
45
|
+
]);
|
|
46
|
+
assert.equal(rebuild.decision, 'REBUILD');
|
|
47
|
+
assert.match(rebuild.recommendationFingerprint, /^[a-f0-9]{64}$/);
|
|
48
|
+
});
|
|
49
|
+
test('unbounded argument failure can recommend only rebuild', () => {
|
|
50
|
+
const draft = 'I leverage the answer. The launch is on 14 August.';
|
|
51
|
+
const triage = preparePreEditJudgment(draft, profile, 'triage');
|
|
52
|
+
const argument = preparePreEditJudgment(draft, profile, 'argument');
|
|
53
|
+
const form = preparePreEditJudgment(draft, profile, 'form');
|
|
54
|
+
const reduced = reducePreEdit([
|
|
55
|
+
bindJudgmentEnvelope(triage, envelope(triage, 'EDIT', { editScope: { ranges: [{ startSentenceId: 1, endSentenceId: 1 }] } })),
|
|
56
|
+
bindJudgmentEnvelope(argument, envelope(argument, 'EDIT', { findings: [{ kind: 'argument', unbounded: true }] })),
|
|
57
|
+
bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
|
|
58
|
+
]);
|
|
59
|
+
assert.equal(reduced.decision, 'REBUILD');
|
|
60
|
+
assert.equal(reduced.reason, 'unbounded_argument_failure');
|
|
61
|
+
});
|
|
62
|
+
test('paragraph-level findings cannot unlock text without named ranges', () => {
|
|
63
|
+
const draft = 'I leverage the answer. The launch is on 14 August.';
|
|
64
|
+
const triage = preparePreEditJudgment(draft, profile, 'triage');
|
|
65
|
+
const argument = preparePreEditJudgment(draft, profile, 'argument');
|
|
66
|
+
const form = preparePreEditJudgment(draft, profile, 'form');
|
|
67
|
+
assert.throws(() => reducePreEdit([
|
|
68
|
+
bindJudgmentEnvelope(triage, envelope(triage, 'EDIT')),
|
|
69
|
+
bindJudgmentEnvelope(argument, envelope(argument, 'SHIP')),
|
|
70
|
+
bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
|
|
71
|
+
]), /contiguous sentence ranges/);
|
|
72
|
+
});
|
|
73
|
+
test('deleting one eligible sentence or merging two adjacent sentences succeeds', () => {
|
|
74
|
+
const draft = 'I leverage the answer. I leverage the second point. The launch is on 14 August.';
|
|
75
|
+
const task = prepareRewriteTask(draft, profile, undefined, undefined, [1, 2]);
|
|
76
|
+
const deleted = applyRewriteResponse(task, {
|
|
77
|
+
version: '2',
|
|
78
|
+
taskFingerprint: task.fingerprint,
|
|
79
|
+
operations: [{ startSentenceId: 1, endSentenceId: 1, text: '' }],
|
|
80
|
+
});
|
|
81
|
+
assert.equal(deleted.status, 'accepted');
|
|
82
|
+
assert.equal(deleted.candidate, ' I leverage the second point. The launch is on 14 August.');
|
|
83
|
+
const merged = applyRewriteResponse(task, {
|
|
84
|
+
version: '2',
|
|
85
|
+
taskFingerprint: task.fingerprint,
|
|
86
|
+
operations: [{ startSentenceId: 1, endSentenceId: 2, text: 'I use both points.' }],
|
|
87
|
+
});
|
|
88
|
+
assert.equal(merged.status, 'accepted');
|
|
89
|
+
assert.equal(merged.candidate, 'I use both points. The launch is on 14 August.');
|
|
90
|
+
});
|
|
91
|
+
test('overlapping, noncontiguous, out-of-order, or partly locked ranges fail before candidate construction', () => {
|
|
92
|
+
const draft = 'I leverage the answer. The launch is on 14 August. I keep the mechanism visible.';
|
|
93
|
+
const task = prepareRewriteTask(draft, profile);
|
|
94
|
+
const overlap = applyRewriteResponse(task, {
|
|
95
|
+
version: '2',
|
|
96
|
+
taskFingerprint: task.fingerprint,
|
|
97
|
+
operations: [
|
|
98
|
+
{ startSentenceId: 1, endSentenceId: 1, text: 'I use the answer.' },
|
|
99
|
+
{ startSentenceId: 1, endSentenceId: 1, text: 'I choose the answer.' },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
assert.equal(overlap.status, 'repairable');
|
|
103
|
+
assert.equal(overlap.candidate, undefined);
|
|
104
|
+
assert.equal(overlap.failures[0]?.code, 'overlapping_range');
|
|
105
|
+
const locked = applyRewriteResponse(task, {
|
|
106
|
+
version: '2',
|
|
107
|
+
taskFingerprint: task.fingerprint,
|
|
108
|
+
operations: [{ startSentenceId: 1, endSentenceId: 2, text: 'I use the answer. The launch is on 14 August.' }],
|
|
109
|
+
});
|
|
110
|
+
assert.equal(locked.status, 'repairable');
|
|
111
|
+
assert.equal(locked.failures[0]?.code, 'partly_locked_range');
|
|
112
|
+
});
|
|
113
|
+
test('SHIP returns original bytes without a model response body', () => {
|
|
114
|
+
const draft = 'I leverage the answer. The launch is on 14 August.';
|
|
115
|
+
const task = prepareRewriteTask(draft, profile);
|
|
116
|
+
const shipped = applyShip(task);
|
|
117
|
+
assert.equal(shipped.status, 'accepted');
|
|
118
|
+
assert.equal(shipped.candidate, draft);
|
|
119
|
+
assert.equal(shipped.receipt.mode, 'SHIP');
|
|
120
|
+
const viaResponse = applyRewriteResponse(task, { version: '1', mode: 'SHIP', taskFingerprint: task.fingerprint });
|
|
121
|
+
assert.equal(viaResponse.candidate, draft);
|
|
122
|
+
});
|
|
123
|
+
test('hygiene changes occur only through eligible source-offset findings', () => {
|
|
124
|
+
const draft = `\uFEFFI leverage the answer.`;
|
|
125
|
+
const findings = hygieneSourceFindings(draft);
|
|
126
|
+
assert.equal(findings[0]?.eligible, true);
|
|
127
|
+
const task = prepareRewriteTask(draft, profile);
|
|
128
|
+
const rejected = applyRewriteResponse(task, {
|
|
129
|
+
version: '2',
|
|
130
|
+
taskFingerprint: task.fingerprint,
|
|
131
|
+
operations: [],
|
|
132
|
+
hygieneOperations: [{ start: 1, end: 2, text: '' }],
|
|
133
|
+
});
|
|
134
|
+
assert.equal(rejected.status, 'repairable');
|
|
135
|
+
assert.equal(rejected.failures[0]?.code, 'ineligible_hygiene_offset');
|
|
136
|
+
const cleaned = applyRewriteResponse(task, {
|
|
137
|
+
version: '2',
|
|
138
|
+
taskFingerprint: task.fingerprint,
|
|
139
|
+
operations: [{ startSentenceId: 1, endSentenceId: 1, text: 'I use the answer.' }],
|
|
140
|
+
hygieneOperations: [{ start: findings[0].start, end: findings[0].end, text: '' }],
|
|
141
|
+
});
|
|
142
|
+
assert.equal(cleaned.status, 'accepted');
|
|
143
|
+
assert.equal(cleaned.candidate, 'I use the answer.');
|
|
144
|
+
});
|
|
145
|
+
test('post-candidate reduction requires the full judgment set', () => {
|
|
146
|
+
const draft = 'I write clear notes.';
|
|
147
|
+
const candidate = 'I write clear notes.';
|
|
148
|
+
const kinds = ['argument', 'polarity', 'form', 'flatness', 'semantic'];
|
|
149
|
+
const envelopes = kinds.map((kind) => {
|
|
150
|
+
const task = preparePostCandidateJudgment(draft, candidate, profile, kind);
|
|
151
|
+
return bindJudgmentEnvelope(task, {
|
|
152
|
+
version: '1',
|
|
153
|
+
stage: 'post-candidate',
|
|
154
|
+
judgmentType: kind,
|
|
155
|
+
taskFingerprint: task.taskFingerprint,
|
|
156
|
+
bindings: { ...task.bindings, evaluatorId: 'writer.1' },
|
|
157
|
+
findings: [],
|
|
158
|
+
decision: 'CLEAR',
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
assert.equal(reducePostCandidate(envelopes).decision, 'CLEAR');
|
|
162
|
+
});
|