@holdyourvoice/hyv 3.2.0 → 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 (50) hide show
  1. package/Readme.md +23 -10
  2. package/dist/ai-editor-rules.js +5 -2
  3. package/dist/ai-editor.js +52 -9
  4. package/dist/ai-editor.test.js +62 -10
  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 +272 -19
  14. package/dist/cli.test.js +205 -8
  15. package/dist/hygiene.js +6 -0
  16. package/dist/hygiene.test.js +7 -1
  17. package/dist/judgment-task.js +171 -0
  18. package/dist/judgment-task.test.js +162 -0
  19. package/dist/learning.js +240 -100
  20. package/dist/learning.test.js +203 -3
  21. package/dist/lifecycle-adapter.js +75 -0
  22. package/dist/lifecycle-adapter.test.js +56 -0
  23. package/dist/mcp-tools.js +101 -7
  24. package/dist/mcp-tools.test.js +156 -6
  25. package/dist/mcp.js +213 -6
  26. package/dist/mcp.test.js +210 -11
  27. package/dist/pipeline.js +78 -14
  28. package/dist/pipeline.test.js +36 -2
  29. package/dist/preservation.js +89 -0
  30. package/dist/preservation.test.js +22 -0
  31. package/dist/profile.js +87 -0
  32. package/dist/profile.test.js +114 -0
  33. package/dist/rebuild-task.js +226 -0
  34. package/dist/rebuild-task.test.js +179 -0
  35. package/dist/release-audit.test.js +111 -2
  36. package/dist/rewrite-task.js +136 -16
  37. package/dist/rewrite-task.test.js +62 -7
  38. package/dist/rule-reconciliation.test.js +50 -0
  39. package/dist/semantic-review.js +176 -7
  40. package/dist/semantic-review.test.js +98 -14
  41. package/dist/stage1-dry-run.test.js +39 -0
  42. package/dist/stage1-evaluation.js +579 -0
  43. package/dist/stage1-evaluation.test.js +184 -0
  44. package/dist/stage1-human-packet.test.js +102 -0
  45. package/dist/stage1-schema-contract.test.js +95 -0
  46. package/dist/stage2-human-packet.test.js +81 -0
  47. package/dist/version.js +1 -1
  48. package/dist/voice-dna.js +53 -1
  49. package/dist/voice-dna.test.js +79 -1
  50. package/package.json +2 -2
@@ -5,21 +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'], version: '1.0.0' }),
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' }),
14
28
  'claude-plugin/.claude-plugin/plugin.json': JSON.stringify({ version: '1.0.0' }),
15
29
  '.claude-plugin/marketplace.json': JSON.stringify({ plugins: [{ name: 'hold-your-voice', version: '1.0.0' }] }),
16
30
  'claude-plugin/.mcp.json': JSON.stringify({ mcpServers: { 'hold-your-voice': { args: ['--package=@holdyourvoice/hyv@1.0.0'] } } }),
31
+ 'Readme.md': '# public',
17
32
  LICENSE: [
18
33
  'Permission is hereby granted, free of charge, to any person obtaining a copy',
19
34
  'The above copyright notice and this permission notice shall be included in all',
20
35
  'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND',
21
36
  ].join('\n'),
22
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, '{}'])),
23
40
  };
24
41
  for (const [file, text] of Object.entries({ ...defaults, ...files })) {
25
42
  const path = join(directory, file);
@@ -29,6 +46,34 @@ function fixture(files) {
29
46
  execFileSync('git', ['add', 'README.md'], { cwd: directory });
30
47
  return directory;
31
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
+ });
32
77
  test('rejects unquoted credentials in an untracked source file', () => {
33
78
  const directory = fixture({ 'README.md': '# public', 'src/unsafe.ts': ['const ', 'API_KEY', '=topsecret;'].join('') });
34
79
  try {
@@ -43,7 +88,7 @@ test('rejects unquoted credentials in an untracked source file', () => {
43
88
  test('requires the Claude extension version to match npm', () => {
44
89
  const directory = fixture({
45
90
  'README.md': '# public',
46
- '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' } }),
47
92
  'mcpb/manifest.json': JSON.stringify({ version: '1.0.1' }),
48
93
  });
49
94
  try {
@@ -84,3 +129,67 @@ test('requires the MCP runtime version to match npm', () => {
84
129
  rmSync(directory, { recursive: true, force: true });
85
130
  }
86
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
  }
@@ -1,8 +1,10 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { createHash } from 'node:crypto';
3
3
  import test from 'node:test';
4
- import { applyRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
4
+ import { applyRewriteResponse, createRewriteLifecycleBinding, evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
5
5
  import { parseWritingBrief } from './editorial-packs.js';
6
+ import { canonicalJson } from './canonical-json.js';
7
+ import { verifyDeterministically } from './pipeline.js';
6
8
  import { buildProfile } from './voice-dna.js';
7
9
  const profile = buildProfile([
8
10
  'I write clear notes. I keep the mechanism visible.',
@@ -19,10 +21,28 @@ test('applies only eligible numbered replacements and preserves all clean bytes'
19
21
  assert.equal(result.candidate, 'I use the answer. The launch is on 14 August.');
20
22
  assert.deepEqual(result.receipt.adapterIds, []);
21
23
  });
22
- test('makes only the sentence with a broad catalog match eligible', () => {
24
+ test('does not make an advisory broad catalog match eligible', () => {
23
25
  const task = prepareRewriteTask('I write clear notes. This work is meaningful. I keep the mechanism visible.', profile);
24
- assert.deepEqual(task.eligibleSentenceIds, [2]);
25
- assert.deepEqual(task.sentences.map((sentence) => sentence.eligible), [false, true, false]);
26
+ assert.deepEqual(task.eligibleSentenceIds, []);
27
+ assert.deepEqual(task.sentences.map((sentence) => sentence.eligible), [false, false, false]);
28
+ });
29
+ test('grants edit scope only to blocking findings and keeps pending judgment separate', () => {
30
+ const advisory = prepareRewriteTask('Firstly, check the invoice.', profile);
31
+ assert.deepEqual(advisory.eligibleSentenceIds, []);
32
+ const neutralProfile = buildProfile(['I write plainly.', 'I name the mechanism.']);
33
+ const pending = prepareRewriteTask('We leverage the scheduler.', neutralProfile);
34
+ assert.deepEqual(pending.eligibleSentenceIds, []);
35
+ assert.match(pending.prompt, /pending judgment/i);
36
+ const blocking = prepareRewriteTask('The scheduler failed — twice. The owner checked it.', profile);
37
+ assert.deepEqual(blocking.eligibleSentenceIds, [1]);
38
+ });
39
+ test('keeps blocking VoiceDNA avoid findings eligible', () => {
40
+ const task = prepareRewriteTask('The plan uses leverage.', profile);
41
+ assert.deepEqual(task.eligibleSentenceIds, [1]);
42
+ });
43
+ test('preserves founder reframes that reconciled policy makes advisory', () => {
44
+ const task = prepareRewriteTask("This isn't positioning. This is proof.", profile);
45
+ assert.deepEqual(task.eligibleSentenceIds, []);
26
46
  });
27
47
  test('preserves a clean draft byte-for-byte when the rewrite response is empty', () => {
28
48
  const draft = 'I write clear notes.\n\nI keep the mechanism visible.\n';
@@ -32,24 +52,33 @@ test('preserves a clean draft byte-for-byte when the rewrite response is empty',
32
52
  assert.equal(result.status, 'accepted');
33
53
  assert.equal(result.candidate, draft);
34
54
  });
55
+ test('rejects an attempted edit to a clean sentence without changing any source bytes', () => {
56
+ const draft = 'I leverage the answer.\n\nThe launch starts Tuesday.\n';
57
+ const task = prepareRewriteTask(draft, profile);
58
+ const result = applyRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 2, text: 'The launch starts Wednesday.' }] });
59
+ assert.equal(result.status, 'repairable');
60
+ assert.equal(result.candidate, undefined);
61
+ assert.equal(result.failures[0]?.code, 'ineligible_sentence_id');
62
+ assert.equal(task.draft, draft);
63
+ });
35
64
  test('carries WritingBrief context into a fingerprinted rewrite task', () => {
36
65
  const brief = parseWritingBrief({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' });
37
66
  const task = prepareRewriteTask('A pattern I keep seeing in founder posts is vague advice.', profile, undefined, brief);
38
67
  assert.equal(task.writingBrief?.format, 'social');
39
- assert.ok(task.eligibleSentenceIds.includes(1));
68
+ assert.deepEqual(task.eligibleSentenceIds, []);
40
69
  });
41
70
  test('rejects a fingerprint-valid rewrite task with malformed WritingBrief data', () => {
42
71
  const task = prepareRewriteTask('I leverage the answer.', profile);
43
72
  const { fingerprint: _fingerprint, ...taskBase } = task;
44
73
  const base = { ...taskBase, writingBrief: { version: '1', audience: 'operators', intent: 'write', format: 'general', prohibitedTerms: [42] } };
45
- const malformed = { ...base, fingerprint: createHash('sha256').update(JSON.stringify(base)).digest('hex') };
74
+ const malformed = { ...base, fingerprint: createHash('sha256').update(canonicalJson(base)).digest('hex') };
46
75
  assert.throws(() => parseRewriteTask(malformed), /WritingBrief/);
47
76
  });
48
77
  test('rejects a fingerprint-valid rewrite task with a null WritingBrief', () => {
49
78
  const task = prepareRewriteTask('I leverage the answer.', profile);
50
79
  const { fingerprint: _fingerprint, ...taskBase } = task;
51
80
  const base = { ...taskBase, writingBrief: null };
52
- const malformed = { ...base, fingerprint: createHash('sha256').update(JSON.stringify(base)).digest('hex') };
81
+ const malformed = { ...base, fingerprint: createHash('sha256').update(canonicalJson(base)).digest('hex') };
53
82
  assert.throws(() => parseRewriteTask(malformed), /WritingBrief/);
54
83
  });
55
84
  test('rejects a duplicate, unknown, or clean sentence replacement before it creates a candidate', () => {
@@ -85,6 +114,32 @@ test('keeps a valid response byte-for-byte unchanged by repair adapters', () =>
85
114
  assert.equal(result.status, 'accepted');
86
115
  assert.equal(result.receipt.responseFingerprint.length, 64);
87
116
  assert.deepEqual(result.receipt.adapterIds, []);
117
+ assert.deepEqual(result.receipt.replacementSentenceIds, [1]);
118
+ });
119
+ test('binds the task, response, deterministic artifact, text hashes, and profile revision', () => {
120
+ const draft = 'I write clear notes.';
121
+ const task = prepareRewriteTask(draft, profile);
122
+ const applied = applyRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [] });
123
+ assert.equal(applied.status, 'accepted');
124
+ const artifact = verifyDeterministically(draft, applied.candidate, profile).artifact;
125
+ const binding = createRewriteLifecycleBinding(task, applied.receipt, artifact);
126
+ assert.equal(binding.rewriteTaskFingerprint, task.fingerprint);
127
+ assert.equal(binding.rewriteResponseFingerprint, applied.receipt.responseFingerprint);
128
+ assert.equal(binding.deterministicArtifactFingerprint, artifact.artifactFingerprint);
129
+ assert.match(binding.sourceHash, /^[a-f0-9]{64}$/);
130
+ });
131
+ test('returns the deterministic artifact and lifecycle binding at the semantic gate', () => {
132
+ const task = prepareRewriteTask('I write clear notes.', profile);
133
+ const result = evaluateRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [] }, profile);
134
+ assert.equal(result.status, 'needs_semantic_review');
135
+ assert.equal(result.deterministicArtifact?.passed, true);
136
+ assert.equal(result.lifecycleBinding?.deterministicArtifactFingerprint, result.deterministicArtifact?.artifactFingerprint);
137
+ });
138
+ test('keeps object response fingerprints stable across property order', () => {
139
+ const task = prepareRewriteTask('I leverage the answer.', profile);
140
+ const first = applyRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer.' }] });
141
+ const second = applyRewriteResponse(task, { replacements: [{ text: 'I use the answer.', sentenceId: 1 }], taskFingerprint: task.fingerprint, version: '1' });
142
+ assert.equal(first.receipt.responseFingerprint, second.receipt.responseFingerprint);
88
143
  });
89
144
  test('repairs only an exact outer JSON code fence after JSON parsing fails', () => {
90
145
  const task = prepareRewriteTask('I leverage the answer with useful detail and clear mechanism.', profile);
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { rules } from './ai-editor-rules.js';
4
+ function rule(id) {
5
+ const match = rules.find((candidate) => candidate.id === id);
6
+ assert.ok(match, `missing rule ${id}`);
7
+ return match;
8
+ }
9
+ test('struct.not-x-y only detects an explicit two-beat contrast', () => {
10
+ const matcher = rule('struct.not-x-y').expression;
11
+ assert.equal(matcher.test('Not speed. Reliability.'), true);
12
+ assert.equal(matcher.test('This does not include the standard onboarding steps every team runs.'), false);
13
+ });
14
+ test('keeps the explicit this-isnt reframe matcher available to policy', () => {
15
+ const matcher = rule('struct.this-isnt-x-this-is-y').expression;
16
+ assert.equal(matcher.test("This isn't positioning. This is proof."), true);
17
+ assert.equal(matcher.test('This positioning claim has proof.'), false);
18
+ });
19
+ test('treats both long dash characters as red findings', () => {
20
+ const emDash = rule('punct.em-dash');
21
+ const enDash = rule('punct.en-dash');
22
+ assert.equal(emDash.severity, 'red');
23
+ assert.equal(enDash.severity, 'red');
24
+ assert.equal(emDash.expression.test('The job failed — retry it.'), true);
25
+ assert.equal(enDash.expression.test('Use pages 6–11.'), true);
26
+ assert.equal(emDash.expression.test('The job failed; retry it.'), false);
27
+ assert.equal(enDash.expression.test('Use pages 6-11.'), false);
28
+ });
29
+ test('detects clear performative-sincerity phrases as red', () => {
30
+ const matcher = rule('formula.performative-sincerity');
31
+ assert.equal(matcher.severity, 'red');
32
+ for (const text of ['To be honest, the launch failed.', 'In all honesty, the launch failed.']) {
33
+ assert.equal(matcher.expression.test(text), true, text);
34
+ }
35
+ assert.equal(matcher.expression.test('The honest report says the launch failed.'), false);
36
+ });
37
+ test('detects performative-sincerity adverbs as yellow', () => {
38
+ const matcher = rule('hedge.performative-sincerity-adverb');
39
+ assert.equal(matcher.severity, 'yellow');
40
+ for (const text of [
41
+ 'Honestly, the launch failed.',
42
+ 'Genuinely, the launch failed.',
43
+ 'Truly, the launch failed.',
44
+ 'Frankly, the launch failed.',
45
+ 'Actually, the launch failed.',
46
+ ]) {
47
+ assert.equal(matcher.expression.test(text), true, text);
48
+ }
49
+ assert.equal(matcher.expression.test('The genuine invoice arrived.'), false);
50
+ });