@holdyourvoice/hyv 3.1.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/Readme.md +76 -17
  2. package/dist/ai-editor-rules.js +151 -0
  3. package/dist/ai-editor.js +104 -8
  4. package/dist/ai-editor.test.js +135 -22
  5. package/dist/approval-capability.js +111 -0
  6. package/dist/approval-capability.test.js +52 -0
  7. package/dist/approval-context.js +54 -0
  8. package/dist/approval-context.test.js +38 -0
  9. package/dist/benchmark.js +232 -0
  10. package/dist/benchmark.test.js +328 -0
  11. package/dist/canonical-json.js +123 -0
  12. package/dist/canonical-json.test.js +24 -0
  13. package/dist/cli.js +359 -21
  14. package/dist/cli.test.js +275 -7
  15. package/dist/copy-spec.js +35 -8
  16. package/dist/editorial-packs.js +25 -1
  17. package/dist/editorial-packs.test.js +45 -0
  18. package/dist/hygiene.js +91 -0
  19. package/dist/hygiene.test.js +73 -0
  20. package/dist/judgment-task.js +171 -0
  21. package/dist/judgment-task.test.js +162 -0
  22. package/dist/learning.js +240 -100
  23. package/dist/learning.test.js +203 -3
  24. package/dist/lifecycle-adapter.js +75 -0
  25. package/dist/lifecycle-adapter.test.js +56 -0
  26. package/dist/mcp-tools.js +110 -9
  27. package/dist/mcp-tools.test.js +188 -10
  28. package/dist/mcp.js +228 -9
  29. package/dist/mcp.test.js +248 -12
  30. package/dist/pipeline.js +81 -15
  31. package/dist/pipeline.test.js +94 -2
  32. package/dist/preservation.js +89 -0
  33. package/dist/preservation.test.js +22 -0
  34. package/dist/profile.js +87 -0
  35. package/dist/profile.test.js +114 -0
  36. package/dist/rebuild-task.js +226 -0
  37. package/dist/rebuild-task.test.js +179 -0
  38. package/dist/release-audit.test.js +144 -2
  39. package/dist/rewrite-task.js +136 -16
  40. package/dist/rewrite-task.test.js +72 -4
  41. package/dist/rule-reconciliation.test.js +50 -0
  42. package/dist/semantic-review.js +176 -7
  43. package/dist/semantic-review.test.js +98 -14
  44. package/dist/stage1-dry-run.test.js +39 -0
  45. package/dist/stage1-evaluation.js +579 -0
  46. package/dist/stage1-evaluation.test.js +184 -0
  47. package/dist/stage1-human-packet.test.js +102 -0
  48. package/dist/stage1-schema-contract.test.js +95 -0
  49. package/dist/stage2-human-packet.test.js +81 -0
  50. package/dist/version.js +1 -0
  51. package/dist/voice-dna.js +53 -1
  52. package/dist/voice-dna.test.js +79 -1
  53. package/package.json +2 -2
@@ -0,0 +1,179 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
3
+ import test from 'node:test';
4
+ import { canonicalJson, canonicalJsonBytes } from './canonical-json.js';
5
+ import { applyRewriteResponse, prepareRewriteTask } from './rewrite-task.js';
6
+ import { applyRebuildResponse, evaluateRebuildResponse, parseRebuildTask, prepareRebuildTask } from './rebuild-task.js';
7
+ import { bindJudgmentEnvelope, preparePreEditJudgment, reducePreEdit } from './judgment-task.js';
8
+ import { verifyDeterministically, verifyRebuildWithCopySpec } from './pipeline.js';
9
+ import { prepareLifecycle, recordApprovedLearning } from './lifecycle-adapter.js';
10
+ import { buildProfile } from './voice-dna.js';
11
+ import { HYV_VERSION } from './version.js';
12
+ const profile = buildProfile([
13
+ 'I write clear notes. I keep the mechanism visible.',
14
+ 'I name the trade-off. Then I make the next step plain.',
15
+ ], ['leverage']);
16
+ const draft = 'I leverage the answer. The launch is on 14 August.';
17
+ const rebuilt = '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.';
18
+ const copySpec = {
19
+ version: '1',
20
+ audience: 'operators',
21
+ intent: 'explain',
22
+ channel: 'email',
23
+ claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar, 7 August.' }],
24
+ };
25
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
26
+ const trustStore = {
27
+ version: '1',
28
+ audience: '@holdyourvoice/hyv',
29
+ maxCapabilityLifetimeSeconds: 300,
30
+ keys: [{ issuer: 'host.example', keyId: 'key-1', publicKeySpki: publicKey.export({ format: 'der', type: 'spki' }).toString('base64url'), status: 'active' }],
31
+ };
32
+ function envelope(task, decision, extra = {}) {
33
+ return {
34
+ version: '1',
35
+ stage: task.stage,
36
+ judgmentType: task.judgmentType,
37
+ taskFingerprint: task.taskFingerprint,
38
+ bindings: { ...task.bindings, evaluatorId: 'writer.1' },
39
+ findings: [],
40
+ decision,
41
+ ...extra,
42
+ };
43
+ }
44
+ function rebuildRecommendation(text = draft) {
45
+ const triage = preparePreEditJudgment(text, profile, 'triage');
46
+ const argument = preparePreEditJudgment(text, profile, 'argument');
47
+ const form = preparePreEditJudgment(text, profile, 'form');
48
+ return reducePreEdit([
49
+ bindJudgmentEnvelope(triage, envelope(triage, 'SHIP')),
50
+ bindJudgmentEnvelope(argument, envelope(argument, 'REBUILD')),
51
+ bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
52
+ ]);
53
+ }
54
+ function capability(reduction, overrides = {}, source = draft) {
55
+ const sourceHash = createHash('sha256').update(source).digest('hex');
56
+ const identity = `legacy-v2:${createHash('sha256').update(canonicalJson(profile)).digest('hex')}`;
57
+ const claims = {
58
+ version: '1',
59
+ purpose: 'hyv.rebuild-authorization',
60
+ issuer: 'host.example',
61
+ audience: '@holdyourvoice/hyv',
62
+ subjectArtifactFingerprint: reduction.recommendationFingerprint,
63
+ sourceHash,
64
+ candidateHash: sourceHash,
65
+ profileId: identity,
66
+ profileRevisionDigest: identity,
67
+ keyId: 'key-1',
68
+ issuedAt: 100,
69
+ notBefore: 100,
70
+ expiresAt: 200,
71
+ nonce: 'nonce-rebuild',
72
+ ...overrides,
73
+ };
74
+ const payload = canonicalJsonBytes(claims);
75
+ return { payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') };
76
+ }
77
+ function evaluate(task, raw, reduction, boundProfile = profile) {
78
+ return evaluateRebuildResponse(task, raw, boundProfile, capability(reduction), trustStore, 150);
79
+ }
80
+ test('pre-edit reductions carry a stable recommendation fingerprint', () => {
81
+ const left = rebuildRecommendation();
82
+ const right = rebuildRecommendation();
83
+ assert.equal(left.decision, 'REBUILD');
84
+ assert.match(left.recommendationFingerprint, /^[a-f0-9]{64}$/);
85
+ assert.equal(left.recommendationFingerprint, right.recommendationFingerprint);
86
+ });
87
+ test('a caller cannot self-select rebuild or submit a forged authorization', () => {
88
+ const reduction = rebuildRecommendation();
89
+ const edit = { ...reduction, decision: 'EDIT' };
90
+ assert.throws(() => prepareRebuildTask(draft, profile, edit, copySpec, capability(reduction), trustStore, 150), /upstream REBUILD recommendation/);
91
+ assert.throws(() => prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { purpose: 'hyv.final-approval' }), trustStore, 150), /Rebuild authorization is invalid/);
92
+ assert.throws(() => prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { nonce: 'stale' }), { ...trustStore, keys: [{ ...trustStore.keys[0], status: 'revoked' }] }, 150), /Rebuild authorization is invalid/);
93
+ assert.throws(() => prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { sourceHash: '8'.repeat(64) }), trustStore, 150), /Rebuild authorization is invalid/);
94
+ assert.throws(() => prepareRebuildTask(draft, profile, { ...reduction, recommendationFingerprint: 'a'.repeat(64) }, copySpec, capability(reduction), trustStore, 150), /upstream REBUILD recommendation/);
95
+ });
96
+ test('missing CopySpec blocks rebuild before a candidate is evaluated', () => {
97
+ const reduction = rebuildRecommendation();
98
+ assert.throws(() => prepareRebuildTask(draft, profile, reduction, { ...copySpec, claims: [] }, capability(reduction), trustStore, 150), /CopySpec/);
99
+ });
100
+ test('edit and rebuild responses are mutually incompatible', () => {
101
+ const reduction = rebuildRecommendation();
102
+ const rebuildTask = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
103
+ const editTask = prepareRewriteTask(draft, profile);
104
+ const rebuildOnEdit = applyRewriteResponse(editTask, { version: '1', mode: 'REBUILD', taskFingerprint: editTask.fingerprint, candidate: rebuilt });
105
+ assert.equal(rebuildOnEdit.status, 'repairable');
106
+ assert.equal(rebuildOnEdit.failures[0]?.code, 'rebuild_response_on_edit_task');
107
+ const replacementsOnRebuild = applyRebuildResponse(rebuildTask, { version: '1', taskFingerprint: rebuildTask.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer.' }] });
108
+ assert.equal(replacementsOnRebuild.status, 'repairable');
109
+ assert.equal(replacementsOnRebuild.failures[0]?.code, 'edit_response_on_rebuild_task');
110
+ const unsigned = applyRebuildResponse(rebuildTask, { version: '1', mode: 'REBUILD', taskFingerprint: rebuildTask.fingerprint, candidate: rebuilt });
111
+ assert.equal(unsigned.status, 'accepted');
112
+ assert.equal(unsigned.receipt.authorizationFingerprint, undefined);
113
+ assert.equal(unsigned.receipt.preservationBypass, undefined);
114
+ });
115
+ test('authorized rebuild allows low lexical survival while claims and hygiene still block', () => {
116
+ const reduction = rebuildRecommendation();
117
+ const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
118
+ const passed = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: rebuilt }, reduction);
119
+ assert.equal(passed.status, 'needs_semantic_review');
120
+ assert.ok((passed.verification?.preservationScore ?? 100) < 70);
121
+ assert.equal(passed.receipt.mode, 'REBUILD');
122
+ assert.equal(passed.receipt.preservationBypass, true);
123
+ assert.ok(Array.isArray(passed.receipt.replacementSentenceIds));
124
+ assert.ok((passed.receipt.replacementSentenceIds?.length ?? 0) > 0);
125
+ assert.equal(passed.deterministicArtifact?.verificationKind, 'rebuild');
126
+ assert.equal(prepareLifecycle(passed.deterministicArtifact, passed.lifecycleBinding, passed.receipt, 'normal', ['action_change']).artifact.status, 'needs_semantic_review');
127
+ assert.equal(verifyRebuildWithCopySpec(draft, rebuilt, profile, copySpec).passed, true);
128
+ const missingClaim = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: 'Operators should delay the launch indefinitely.' }, reduction);
129
+ assert.equal(missingClaim.status, 'needs_escalation');
130
+ const hygiene = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: `${rebuilt}\u200B` }, reduction);
131
+ assert.equal(hygiene.status, 'needs_escalation');
132
+ });
133
+ test('rebuild disagreement cannot record accepted learning', () => {
134
+ const reduction = rebuildRecommendation();
135
+ const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
136
+ const failed = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: 'Operators should delay the launch indefinitely.' }, reduction);
137
+ assert.equal(failed.status, 'needs_escalation');
138
+ const evaluated = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: rebuilt }, reduction);
139
+ assert.equal(evaluated.status, 'needs_semantic_review');
140
+ const standard = verifyDeterministically(draft, rebuilt, profile, copySpec);
141
+ assert.notEqual(standard.artifact.artifactFingerprint, evaluated.deterministicArtifact?.artifactFingerprint);
142
+ assert.throws(() => recordApprovedLearning({
143
+ ready: { version: '1', status: 'ready_for_human_review', artifactFingerprint: '1'.repeat(64), transitionFingerprint: '2'.repeat(64), binding: evaluated.lifecycleBinding, semanticPolicy: 'normal', semanticTaskFingerprint: '3'.repeat(64), semanticEvidenceScopeFingerprint: '4'.repeat(64), verdictFingerprints: [] },
144
+ approved: { version: '1', status: 'approved', artifactFingerprint: '5'.repeat(64), transitionFingerprint: '6'.repeat(64), binding: evaluated.lifecycleBinding, semanticPolicy: 'normal', semanticTaskFingerprint: '3'.repeat(64), semanticEvidenceScopeFingerprint: '4'.repeat(64), verdictFingerprints: [] },
145
+ decision: { evaluatorId: 'human.1', decision: 'approve' },
146
+ capability: capability(reduction),
147
+ source: draft,
148
+ candidate: rebuilt,
149
+ profile,
150
+ context: { now: 150, trustStore, authorizedSemanticEvaluatorIds: { normal: [], highAssurance: [] }, authorizedHumanFinalizerIds: ['human.1'] },
151
+ copySpec,
152
+ }), /Invalid lifecycle artifact|Approved learning is not authorized|does not match deterministic verification/);
153
+ });
154
+ test('CLI and MCP rebuild helpers share fingerprints', () => {
155
+ const reduction = rebuildRecommendation();
156
+ const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
157
+ const parsed = parseRebuildTask(JSON.parse(JSON.stringify(task)));
158
+ assert.equal(parsed.fingerprint, task.fingerprint);
159
+ assert.equal(parsed.authorizationFingerprint, task.authorizationFingerprint);
160
+ assert.match(task.prompt, /whole-document candidate/);
161
+ const briefTask = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { nonce: 'nonce-brief' }), trustStore, 150, {
162
+ version: '1', audience: 'operators', intent: 'explain', format: 'outreach',
163
+ });
164
+ assert.match(briefTask.prompt, /# WritingBrief/);
165
+ assert.equal(HYV_VERSION, '3.3.0');
166
+ });
167
+ test('apply rejects forged tasks, missing capability, and substituted profiles', () => {
168
+ const reduction = rebuildRecommendation();
169
+ const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
170
+ const response = { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: rebuilt };
171
+ const { fingerprint: _ignored, ...base } = task;
172
+ const forgedBase = { ...base, authorizationFingerprint: 'a'.repeat(64) };
173
+ const forged = { ...forgedBase, fingerprint: createHash('sha256').update(canonicalJson(forgedBase)).digest('hex') };
174
+ assert.equal(parseRebuildTask(forged).fingerprint, forged.fingerprint);
175
+ assert.throws(() => evaluateRebuildResponse(forged, { ...response, taskFingerprint: forged.fingerprint }, profile, capability(reduction), trustStore, 150), /Rebuild authorization is invalid/);
176
+ assert.throws(() => evaluateRebuildResponse(task, response, profile, {}, trustStore, 150), /Rebuild authorization is invalid/);
177
+ const other = buildProfile(['I speak in a different register altogether.', 'I keep every sentence longer than the first profile would.'], ['mechanism']);
178
+ assert.throws(() => evaluate(task, response, reduction, other), /Rebuild profile binding does not match this task/);
179
+ });
@@ -5,17 +5,38 @@ import { dirname, join } from 'node:path';
5
5
  import { execFileSync, spawnSync } from 'node:child_process';
6
6
  import test from 'node:test';
7
7
  const audit = new URL('../scripts/release-audit.mjs', import.meta.url).pathname;
8
+ const stage1Files = [
9
+ 'scripts/evaluate-rewrite-benchmark.mjs',
10
+ 'scripts/run-stage1-dry-run.mjs',
11
+ 'scripts/run-stage1-human-packet.mjs',
12
+ 'scripts/run-stage2-human-packet.mjs',
13
+ 'benchmarks/schema/protocol-manifest.v1.schema.json',
14
+ 'benchmarks/schema/run-event.v1.schema.json',
15
+ 'benchmarks/schema/blind-packet.v1.schema.json',
16
+ 'benchmarks/schema/blind-mapping.v1.schema.json',
17
+ 'benchmarks/schema/reviewer-record.v1.schema.json',
18
+ 'benchmarks/schema/ratings-seal.v1.schema.json',
19
+ 'benchmarks/schema/aggregate-report.v1.schema.json',
20
+ 'benchmarks/schema/checkpoint-disposition.v1.schema.json',
21
+ ];
8
22
  function fixture(files) {
9
23
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-audit-'));
10
24
  execFileSync('git', ['init', '--quiet'], { cwd: directory });
11
25
  const defaults = {
12
- 'package.json': JSON.stringify({ license: 'MIT', files: ['LICENSE'] }),
26
+ 'package.json': JSON.stringify({ name: 'audit-fixture', license: 'MIT', files: ['dist', 'Readme.md', 'LICENSE'], scripts: { 'stage1:evaluate': 'node scripts/evaluate-rewrite-benchmark.mjs', 'stage1:dry-run': 'npm run build && node scripts/run-stage1-dry-run.mjs', 'stage1:human-packet': 'npm run build && node scripts/run-stage1-human-packet.mjs', 'stage2:human-packet': 'npm run build && node scripts/run-stage2-human-packet.mjs' }, version: '1.0.0', type: 'module', bin: { hyv: 'dist/cli.js' }, engines: { node: '>=20' } }),
13
27
  'mcpb/manifest.json': JSON.stringify({ version: '1.0.0' }),
28
+ 'claude-plugin/.claude-plugin/plugin.json': JSON.stringify({ version: '1.0.0' }),
29
+ '.claude-plugin/marketplace.json': JSON.stringify({ plugins: [{ name: 'hold-your-voice', version: '1.0.0' }] }),
30
+ 'claude-plugin/.mcp.json': JSON.stringify({ mcpServers: { 'hold-your-voice': { args: ['--package=@holdyourvoice/hyv@1.0.0'] } } }),
31
+ 'Readme.md': '# public',
14
32
  LICENSE: [
15
33
  'Permission is hereby granted, free of charge, to any person obtaining a copy',
16
34
  'The above copyright notice and this permission notice shall be included in all',
17
35
  'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND',
18
36
  ].join('\n'),
37
+ 'src/version.ts': "export const HYV_VERSION = '1.0.0';",
38
+ 'src/stage1-evaluation.ts': "const baseline = '4e6269121d551c008a34db73077e1e4fea41b3f9'; const stage1 = '550ea24f652291dca13757fdbd2f0fa0b5e3f621';",
39
+ ...Object.fromEntries(stage1Files.map((file) => [file, '{}'])),
19
40
  };
20
41
  for (const [file, text] of Object.entries({ ...defaults, ...files })) {
21
42
  const path = join(directory, file);
@@ -25,6 +46,34 @@ function fixture(files) {
25
46
  execFileSync('git', ['add', 'README.md'], { cwd: directory });
26
47
  return directory;
27
48
  }
49
+ test('accepts the complete public package contract', () => {
50
+ const directory = fixture({ 'README.md': '# public' });
51
+ try {
52
+ const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
53
+ assert.equal(result.status, 0, result.stderr);
54
+ assert.match(result.stdout, /release audit passed/);
55
+ }
56
+ finally {
57
+ rmSync(directory, { recursive: true, force: true });
58
+ }
59
+ });
60
+ test('requires exact Stage 1 scripts even when the checkpoint files remain', () => {
61
+ const directory = fixture({
62
+ 'README.md': '# public',
63
+ 'package.json': JSON.stringify({ name: 'audit-fixture', license: 'MIT', files: ['dist', 'Readme.md', 'LICENSE'], scripts: { 'stage1:evaluate': 'node changed.mjs' }, version: '1.0.0', type: 'module', bin: { hyv: 'dist/cli.js' }, engines: { node: '>=20' } }),
64
+ });
65
+ try {
66
+ const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
67
+ assert.notEqual(result.status, 0);
68
+ assert.match(result.stderr, /Stage 1 script contract has drifted: stage1:evaluate/);
69
+ assert.match(result.stderr, /Stage 1 script contract has drifted: stage1:dry-run/);
70
+ assert.match(result.stderr, /Stage 1 script contract has drifted: stage1:human-packet/);
71
+ assert.match(result.stderr, /Stage 1 script contract has drifted: stage2:human-packet/);
72
+ }
73
+ finally {
74
+ rmSync(directory, { recursive: true, force: true });
75
+ }
76
+ });
28
77
  test('rejects unquoted credentials in an untracked source file', () => {
29
78
  const directory = fixture({ 'README.md': '# public', 'src/unsafe.ts': ['const ', 'API_KEY', '=topsecret;'].join('') });
30
79
  try {
@@ -39,7 +88,7 @@ test('rejects unquoted credentials in an untracked source file', () => {
39
88
  test('requires the Claude extension version to match npm', () => {
40
89
  const directory = fixture({
41
90
  'README.md': '# public',
42
- 'package.json': JSON.stringify({ license: 'MIT', files: ['LICENSE'], version: '1.0.0' }),
91
+ 'package.json': JSON.stringify({ name: 'audit-fixture', license: 'MIT', files: ['dist', 'Readme.md', 'LICENSE'], version: '1.0.0', type: 'module', bin: { hyv: 'dist/cli.js' }, engines: { node: '>=20' } }),
43
92
  'mcpb/manifest.json': JSON.stringify({ version: '1.0.1' }),
44
93
  });
45
94
  try {
@@ -51,3 +100,96 @@ test('requires the Claude extension version to match npm', () => {
51
100
  rmSync(directory, { recursive: true, force: true });
52
101
  }
53
102
  });
103
+ test('requires every Claude package surface to match npm', () => {
104
+ const directory = fixture({
105
+ 'README.md': '# public',
106
+ 'claude-plugin/.claude-plugin/plugin.json': JSON.stringify({ version: '0.9.0' }),
107
+ '.claude-plugin/marketplace.json': JSON.stringify({ plugins: [{ name: 'other', version: '1.0.0' }, { name: 'hold-your-voice', version: '0.9.0' }] }),
108
+ 'claude-plugin/.mcp.json': JSON.stringify({ mcpServers: { 'hold-your-voice': { args: ['--package=@holdyourvoice/hyv@0.9.0'] } } }),
109
+ });
110
+ try {
111
+ const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
112
+ assert.notEqual(result.status, 0);
113
+ assert.match(result.stderr, /Claude plugin version must match package\.json/);
114
+ assert.match(result.stderr, /Claude marketplace version must match package\.json/);
115
+ assert.match(result.stderr, /Claude plugin package pin must match package\.json/);
116
+ }
117
+ finally {
118
+ rmSync(directory, { recursive: true, force: true });
119
+ }
120
+ });
121
+ test('requires the MCP runtime version to match npm', () => {
122
+ const directory = fixture({ 'README.md': '# public', 'src/version.ts': "export const HYV_VERSION = '0.9.0';" });
123
+ try {
124
+ const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
125
+ assert.notEqual(result.status, 0);
126
+ assert.match(result.stderr, /MCP runtime version must match package\.json/);
127
+ }
128
+ finally {
129
+ rmSync(directory, { recursive: true, force: true });
130
+ }
131
+ });
132
+ test('requires the npm package runtime contract', () => {
133
+ const directory = fixture({
134
+ 'README.md': '# public',
135
+ 'package.json': JSON.stringify({
136
+ name: 'audit-fixture',
137
+ license: 'MIT',
138
+ files: ['LICENSE'],
139
+ version: '1.0.0',
140
+ type: 'commonjs',
141
+ bin: { hyv: 'src/cli.ts' },
142
+ engines: { node: '>=18' },
143
+ }),
144
+ });
145
+ try {
146
+ const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
147
+ assert.notEqual(result.status, 0);
148
+ assert.match(result.stderr, /npm package must include dist/);
149
+ assert.match(result.stderr, /npm package must include Readme\.md/);
150
+ assert.match(result.stderr, /npm package must use the ESM runtime contract/);
151
+ assert.match(result.stderr, /npm hyv binary must point to dist\/cli\.js/);
152
+ assert.match(result.stderr, /npm package must require Node 20 or newer/);
153
+ }
154
+ finally {
155
+ rmSync(directory, { recursive: true, force: true });
156
+ }
157
+ });
158
+ test('rejects every package manifest path outside the public allowlist', () => {
159
+ const directory = fixture({
160
+ 'README.md': '# public',
161
+ 'package.json': JSON.stringify({
162
+ name: 'audit-fixture',
163
+ license: 'MIT',
164
+ files: ['dist', 'Readme.md', 'LICENSE', 'docs', 'benchmarks/private', 'profiles'],
165
+ version: '1.0.0',
166
+ type: 'module',
167
+ bin: { hyv: 'dist/cli.js' },
168
+ engines: { node: '>=20' },
169
+ }),
170
+ });
171
+ try {
172
+ const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
173
+ assert.notEqual(result.status, 0);
174
+ assert.match(result.stderr, /npm package exposes an unexpected path: docs/);
175
+ assert.match(result.stderr, /npm package exposes an unexpected path: benchmarks\/private/);
176
+ assert.match(result.stderr, /npm package exposes an unexpected path: profiles/);
177
+ }
178
+ finally {
179
+ rmSync(directory, { recursive: true, force: true });
180
+ }
181
+ });
182
+ test('rejects a forbidden nested file from the actual npm package list', () => {
183
+ const directory = fixture({
184
+ 'README.md': '# public',
185
+ 'dist/profiles/private.json': '{"secret":true}',
186
+ });
187
+ try {
188
+ const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
189
+ assert.notEqual(result.status, 0);
190
+ assert.match(result.stderr, /npm package contains an unexpected file: dist\/profiles\/private\.json/);
191
+ }
192
+ finally {
193
+ rmSync(directory, { recursive: true, force: true });
194
+ }
195
+ });
@@ -1,12 +1,14 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from './canonical-json.js';
2
3
  import { parseWritingBrief } from './editorial-packs.js';
3
- import { rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
4
+ import { hygieneSourceFindings } from './hygiene.js';
5
+ import { analyze, deriveEditScope, renderRewritePrompt, verifyDeterministically } from './pipeline.js';
4
6
  import { sentences } from './text.js';
5
7
  const MAX_RESPONSE_BYTES = 100_000;
6
8
  const MAX_REPLACEMENTS = 100;
7
9
  const MAX_REPLACEMENT_CHARACTERS = 10_000;
8
10
  function fingerprint(value) {
9
- return createHash('sha256').update(JSON.stringify(value)).digest('hex');
11
+ return createHash('sha256').update(typeof value === 'string' ? value : canonicalJson(value)).digest('hex');
10
12
  }
11
13
  function failure(code, message, path) {
12
14
  return { code, message, ...(path ? { path } : {}) };
@@ -34,10 +36,40 @@ function parseResponse(value) {
34
36
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
35
37
  return failure('invalid_response_shape', 'Response must be an object.');
36
38
  const response = raw;
37
- if (response.version !== '1')
38
- return failure('invalid_response_version', 'Response version must be "1".', 'version');
39
39
  if (typeof response.taskFingerprint !== 'string' || response.taskFingerprint.length !== 64)
40
40
  return failure('invalid_response_shape', 'Response must include the task fingerprint.', 'taskFingerprint');
41
+ if (response.mode === 'REBUILD')
42
+ return failure('rebuild_response_on_edit_task', 'Rebuild responses cannot satisfy edit tasks.', 'mode');
43
+ if (response.mode === 'SHIP' && response.version === '1') {
44
+ return { version: '1', mode: 'SHIP', taskFingerprint: response.taskFingerprint };
45
+ }
46
+ if (response.version === '2') {
47
+ if (!Array.isArray(response.operations))
48
+ return failure('invalid_response_shape', 'Version 2 responses require an operations array.', 'operations');
49
+ if (response.operations.length > MAX_REPLACEMENTS)
50
+ return failure('invalid_response_shape', `Response may include at most ${MAX_REPLACEMENTS} replacements.`, 'operations');
51
+ for (const [index, operation] of response.operations.entries()) {
52
+ if (!operation || typeof operation !== 'object' || !Number.isInteger(operation.startSentenceId) || !Number.isInteger(operation.endSentenceId) || typeof operation.text !== 'string') {
53
+ return failure('invalid_response_shape', 'Every operation requires integer startSentenceId, endSentenceId, and string text.', `operations[${index}]`);
54
+ }
55
+ if (operation.endSentenceId < operation.startSentenceId)
56
+ return failure('noncontiguous_range', 'A range must be inclusive and contiguous.', `operations[${index}]`);
57
+ if (operation.text.length > MAX_REPLACEMENT_CHARACTERS)
58
+ return failure('invalid_replacement_text', `Replacement text must contain at most ${MAX_REPLACEMENT_CHARACTERS} characters.`, `operations[${index}].text`);
59
+ }
60
+ if (response.hygieneOperations !== undefined) {
61
+ if (!Array.isArray(response.hygieneOperations))
62
+ return failure('invalid_response_shape', 'Hygiene operations must be an array.', 'hygieneOperations');
63
+ for (const [index, operation] of response.hygieneOperations.entries()) {
64
+ if (!operation || !Number.isInteger(operation.start) || !Number.isInteger(operation.end) || typeof operation.text !== 'string' || operation.end < operation.start) {
65
+ return failure('invalid_response_shape', 'Every hygiene operation requires integer start, end, and string text.', `hygieneOperations[${index}]`);
66
+ }
67
+ }
68
+ }
69
+ return response;
70
+ }
71
+ if (response.version !== '1')
72
+ return failure('invalid_response_version', 'Response version must be "1" or "2".', 'version');
41
73
  if (!Array.isArray(response.replacements))
42
74
  return failure('invalid_response_shape', 'Response replacements must be an array.', 'replacements');
43
75
  if (response.replacements.length > MAX_REPLACEMENTS)
@@ -74,18 +106,17 @@ function repairFencedJson(value) {
74
106
  const match = value.match(/^```json\s*\n([\s\S]*?)\n```\s*$/i);
75
107
  return match ? { value: match[1], adapterId: 'fenced_json_v1' } : { value };
76
108
  }
77
- export function prepareRewriteTask(draft, profile, copySpec, writingBrief) {
78
- const analysis = rewritePrompt(draft, profile, [], writingBrief);
109
+ export function prepareRewriteTask(draft, profile, copySpec, writingBrief, authorizedSentenceIds = []) {
110
+ const result = analyze(draft, profile, writingBrief);
111
+ const prompt = renderRewritePrompt(draft, profile, result, [], writingBrief);
79
112
  const mapped = sentences(draft);
80
- const eligibleSentenceIds = new Set([
81
- ...analysis.matchAll(/^- Sentence (\d+) \[/gm),
82
- ].map((match) => Number(match[1])));
113
+ const eligibleSentenceIds = new Set([...deriveEditScope(result).eligibleSentenceIds, ...authorizedSentenceIds]);
83
114
  const taskBase = {
84
115
  version: '1',
85
116
  draft,
86
117
  sentences: mapped.map((sentence) => ({ id: sentence.index, text: sentence.text, eligible: eligibleSentenceIds.has(sentence.index) })),
87
118
  eligibleSentenceIds: [...eligibleSentenceIds].sort((left, right) => left - right),
88
- prompt: analysis,
119
+ prompt,
89
120
  ...(copySpec ? { copySpec } : {}),
90
121
  ...(writingBrief ? { writingBrief } : {}),
91
122
  };
@@ -106,7 +137,22 @@ export function parseRewriteTask(value) {
106
137
  return task;
107
138
  }
108
139
  function rejected(task, raw, failures, adapterIds = []) {
109
- return { status: 'repairable', failures, receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds } };
140
+ return { status: 'repairable', failures, receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds, replacementSentenceIds: [] } };
141
+ }
142
+ export function applyShip(task) {
143
+ return {
144
+ status: 'accepted',
145
+ candidate: task.draft,
146
+ failures: [],
147
+ receipt: {
148
+ version: '1',
149
+ taskFingerprint: task.fingerprint,
150
+ responseFingerprint: fingerprint({ version: '1', mode: 'SHIP', taskFingerprint: task.fingerprint }),
151
+ adapterIds: [],
152
+ replacementSentenceIds: [],
153
+ mode: 'SHIP',
154
+ },
155
+ };
110
156
  }
111
157
  export function applyRewriteResponse(task, raw) {
112
158
  const source = typeof raw === 'string' ? parseJson(raw) : raw;
@@ -119,6 +165,14 @@ export function applyRewriteResponse(task, raw) {
119
165
  return rejected(task, raw, [response], adapterIds);
120
166
  if (response.taskFingerprint !== task.fingerprint)
121
167
  return rejected(task, raw, [failure('task_fingerprint_mismatch', 'Response task fingerprint does not match this task.', 'taskFingerprint')], adapterIds);
168
+ if ('mode' in response && response.mode === 'SHIP') {
169
+ const shipped = applyShip(task);
170
+ return { ...shipped, receipt: { ...shipped.receipt, adapterIds, responseFingerprint: responseFingerprint(raw) } };
171
+ }
172
+ if (response.version === '2')
173
+ return applyRangeResponse(task, response, raw, adapterIds);
174
+ if (!('replacements' in response))
175
+ return rejected(task, raw, [failure('invalid_response_shape', 'Response replacements must be an array.', 'replacements')], adapterIds);
122
176
  const seen = new Set();
123
177
  const sentenceMap = new Map(task.sentences.map((sentence) => [sentence.id, sentence]));
124
178
  for (const [index, replacement] of response.replacements.entries()) {
@@ -139,16 +193,82 @@ export function applyRewriteResponse(task, raw) {
139
193
  if (replacement !== undefined)
140
194
  candidate = `${candidate.slice(0, sentence.start)}${replacement}${candidate.slice(sentence.end)}`;
141
195
  }
142
- return { status: 'accepted', candidate, failures: [], receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds } };
196
+ return { status: 'accepted', candidate, failures: [], receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds, replacementSentenceIds: [...seen].sort((left, right) => left - right), mode: 'EDIT' } };
197
+ }
198
+ function applyRangeResponse(task, response, raw, adapterIds) {
199
+ const sentenceMap = new Map(task.sentences.map((sentence) => [sentence.id, sentence]));
200
+ let previousEnd = 0;
201
+ for (const [index, operation] of response.operations.entries()) {
202
+ if (index > 0 && operation.startSentenceId <= previousEnd)
203
+ return rejected(task, raw, [failure('overlapping_range', 'Operations must be ordered and non-overlapping.', `operations[${index}]`)], adapterIds);
204
+ if (index > 0 && operation.startSentenceId < response.operations[index - 1].startSentenceId) {
205
+ return rejected(task, raw, [failure('out_of_order_range', 'Operations must be in source order.', `operations[${index}]`)], adapterIds);
206
+ }
207
+ previousEnd = operation.endSentenceId;
208
+ for (let id = operation.startSentenceId; id <= operation.endSentenceId; id += 1) {
209
+ const sentence = sentenceMap.get(id);
210
+ if (!sentence)
211
+ return rejected(task, raw, [failure('unknown_sentence_id', 'Range sentenceId is not in this task.', `operations[${index}]`)], adapterIds);
212
+ if (!sentence.eligible)
213
+ return rejected(task, raw, [failure('partly_locked_range', 'A range may cover only eligible sentences.', `operations[${index}]`)], adapterIds);
214
+ if (id > operation.startSentenceId && !sentenceMap.has(id - 1))
215
+ return rejected(task, raw, [failure('noncontiguous_range', 'Ranges must be contiguous.', `operations[${index}]`)], adapterIds);
216
+ }
217
+ }
218
+ const eligibleHygiene = new Map(hygieneSourceFindings(task.draft).filter((finding) => finding.eligible).map((finding) => [`${finding.start}:${finding.end}`, finding]));
219
+ for (const [index, operation] of (response.hygieneOperations ?? []).entries()) {
220
+ if (!eligibleHygiene.has(`${operation.start}:${operation.end}`)) {
221
+ return rejected(task, raw, [failure('ineligible_hygiene_offset', 'Hygiene changes require an eligible source-offset finding.', `hygieneOperations[${index}]`)], adapterIds);
222
+ }
223
+ }
224
+ let candidate = task.draft;
225
+ for (const operation of [...(response.hygieneOperations ?? [])].sort((left, right) => right.start - left.start)) {
226
+ candidate = `${candidate.slice(0, operation.start)}${operation.text}${candidate.slice(operation.end)}`;
227
+ }
228
+ const mapped = sentences(candidate);
229
+ for (const operation of [...response.operations].reverse()) {
230
+ const start = mapped.find((sentence) => sentence.index === operation.startSentenceId);
231
+ const end = mapped.find((sentence) => sentence.index === operation.endSentenceId);
232
+ if (!start || !end)
233
+ return rejected(task, raw, [failure('unknown_sentence_id', 'Range sentenceId is not in this task.', 'operations')], adapterIds);
234
+ candidate = `${candidate.slice(0, start.start)}${operation.text}${candidate.slice(end.end)}`;
235
+ }
236
+ return {
237
+ status: 'accepted',
238
+ candidate,
239
+ failures: [],
240
+ receipt: {
241
+ version: '1',
242
+ taskFingerprint: task.fingerprint,
243
+ responseFingerprint: responseFingerprint(raw),
244
+ adapterIds,
245
+ operationRanges: response.operations.map((operation) => ({ startSentenceId: operation.startSentenceId, endSentenceId: operation.endSentenceId })),
246
+ mode: 'EDIT',
247
+ },
248
+ };
143
249
  }
144
250
  export function evaluateRewriteResponse(task, raw, profile) {
145
251
  const applied = applyRewriteResponse(task, raw);
146
252
  if (applied.status !== 'accepted' || !applied.candidate)
147
253
  return applied;
148
- const verification = task.copySpec
149
- ? verifyWithCopySpec(task.draft, applied.candidate, profile, task.copySpec, task.writingBrief)
150
- : verify(task.draft, applied.candidate, profile, task.writingBrief);
254
+ const { verification, artifact: deterministicArtifact } = verifyDeterministically(task.draft, applied.candidate, profile, task.copySpec, task.writingBrief);
151
255
  if (!verification.passed)
152
256
  return { ...applied, status: 'needs_escalation', verification };
153
- return { ...applied, status: 'needs_semantic_review', verification };
257
+ const lifecycleBinding = createRewriteLifecycleBinding(task, applied.receipt, deterministicArtifact);
258
+ return { ...applied, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
259
+ }
260
+ export function createRewriteLifecycleBinding(task, receipt, deterministic) {
261
+ if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint)
262
+ throw new Error('Lifecycle binding requires a passed deterministic artifact for this rewrite task.');
263
+ return {
264
+ rewriteTaskFingerprint: task.fingerprint,
265
+ rewriteResponseFingerprint: receipt.responseFingerprint,
266
+ deterministicArtifactFingerprint: deterministic.artifactFingerprint,
267
+ sourceHash: deterministic.sourceHash,
268
+ candidateHash: deterministic.candidateHash,
269
+ profileId: deterministic.profileId,
270
+ profileRevisionDigest: deterministic.profileRevisionDigest,
271
+ rulesetVersion: deterministic.rulesetVersion,
272
+ schemaVersion: '1',
273
+ };
154
274
  }