@holdyourvoice/hyv 3.5.1 → 3.6.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.
Files changed (48) hide show
  1. package/Readme.md +5 -59
  2. package/dist/ai-editor.js +4 -20
  3. package/dist/cli/agents.js +108 -0
  4. package/dist/cli/checks.js +226 -0
  5. package/dist/cli/context.test.js +55 -0
  6. package/dist/cli/io.js +137 -0
  7. package/dist/cli/lifecycle.js +162 -0
  8. package/dist/cli/profiles.js +246 -0
  9. package/dist/cli/rewriting.js +111 -0
  10. package/dist/cli.js +5 -974
  11. package/dist/cli.test.js +1 -1
  12. package/dist/hold-your-voice.mcpb +0 -0
  13. package/dist/learning.js +16 -9
  14. package/dist/mcp-tools.js +8 -17
  15. package/dist/mcp.js +12 -18
  16. package/dist/mirror-refs.test.js +4 -4
  17. package/dist/pipeline.js +30 -31
  18. package/dist/pipeline.test.js +5 -3
  19. package/dist/rebuild-task.js +4 -27
  20. package/dist/rebuild-task.test.js +1 -1
  21. package/dist/rewrite-response.js +27 -0
  22. package/dist/rewrite-task.js +9 -35
  23. package/dist/rewrite-task.test.js +10 -9
  24. package/dist/strict-quality.js +1 -2
  25. package/dist/strict-quality.test.js +3 -3
  26. package/dist/version.js +1 -1
  27. package/package.json +1 -1
  28. package/skills/hyv-analyze/SKILL.md +1 -1
  29. package/skills/hyv-analyze/agents/openai.yaml +1 -1
  30. package/skills/hyv-apply-rebuild/SKILL.md +1 -0
  31. package/skills/hyv-apply-rebuild/agents/openai.yaml +1 -1
  32. package/skills/hyv-apply-rewrite/SKILL.md +1 -0
  33. package/skills/hyv-apply-rewrite/agents/openai.yaml +1 -1
  34. package/skills/hyv-prepare-rebuild/SKILL.md +1 -0
  35. package/skills/hyv-prepare-rebuild/agents/openai.yaml +1 -1
  36. package/skills/hyv-prepare-rewrite/SKILL.md +1 -0
  37. package/skills/hyv-prepare-rewrite/agents/openai.yaml +1 -1
  38. package/skills/hyv-rebuild-writer-request/SKILL.md +1 -0
  39. package/skills/hyv-rebuild-writer-request/agents/openai.yaml +1 -1
  40. package/skills/hyv-rewrite-prompt/SKILL.md +4 -3
  41. package/skills/hyv-rewrite-prompt/agent.json +1 -1
  42. package/skills/hyv-rewrite-prompt/agents/openai.yaml +2 -2
  43. package/skills/hyv-strict-check/SKILL.md +3 -3
  44. package/skills/hyv-verify/SKILL.md +2 -1
  45. package/skills/hyv-verify/agents/openai.yaml +1 -1
  46. package/skills/hyv-verify-spec/SKILL.md +3 -2
  47. package/skills/hyv-verify-spec/agent.json +1 -1
  48. package/skills/hyv-verify-spec/agents/openai.yaml +2 -2
package/dist/cli.test.js CHANGED
@@ -272,7 +272,7 @@ test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
272
272
  assert.equal(run(['profile', profile, first, second, '--avoid=unlock']).status, 0);
273
273
  const verification = run(['verify', original, candidate, profile]);
274
274
  assert.equal(verification.status, 2);
275
- assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'finalOutput', 'logicLint', 'original', 'passed', 'preservationScore', 'regressions', 'version']);
275
+ assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'finalOutput', 'logicLint', 'original', 'passed', 'preservationScore', 'regressions', 'strictFindings', 'version']);
276
276
  assert.equal(run(['unknown-command']).status, 1);
277
277
  assert.equal(run(['mcp', 'unexpected']).status, 1);
278
278
  }
Binary file
package/dist/learning.js CHANGED
@@ -170,6 +170,9 @@ function receipt(profile, event, status) {
170
170
  const publicMetadata = { mutationId: event.mutationId, eventId: event.eventId, profileId: identity(profile), profileRevision: revision(profile), status, timestamp: event.timestamp };
171
171
  return { version: '1', ...publicMetadata, digest: digest(publicMetadata) };
172
172
  }
173
+ function requestDigest(event) {
174
+ return digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
175
+ }
173
176
  function eventBase(profile, kind, options) {
174
177
  const mutationId = options.mutationId ?? randomUUID();
175
178
  const base = {
@@ -177,7 +180,7 @@ function eventBase(profile, kind, options) {
177
180
  profileRevision: revision(profile), revisionDigest: revisionDigest(profile), authority: options.authority ?? 'team',
178
181
  provenance: options.provenance ?? 'local', weight: options.weight ?? 1, compatibility: options.compatibility ?? 'same-or-newer', kind,
179
182
  };
180
- return { ...base, requestDigest: digest({ ...base, eventId: undefined, timestamp: undefined, requestDigest: undefined }) };
183
+ return { ...base, requestDigest: requestDigest(base) };
181
184
  }
182
185
  function mutate(profile, event, options, validate) {
183
186
  try {
@@ -225,7 +228,7 @@ export function recordVerifiedCandidate(profile, verification, candidate, option
225
228
  if (readEvents(profile, options).some((event) => event.kind === 'verified_candidate' && event.outcome === outcome))
226
229
  return 'nothing_to_learn';
227
230
  const event = { ...eventBase(profile, 'verified_candidate', { ...options, mutationId: options.mutationId ?? outcome }), resolved: resolved.slice(0, MAX_RESOLVED_FINDINGS), outcome };
228
- event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
231
+ event.requestDigest = requestDigest(event);
229
232
  const result = mutate(profile, event, options);
230
233
  return result.status === 'recorded' ? 'recorded' : result.status === 'already_recorded' ? 'nothing_to_learn' : 'write_failed';
231
234
  }
@@ -236,19 +239,23 @@ export function recordLearningInstruction(profile, instruction, options = {}) {
236
239
  if (normalized.length > MAX_INSTRUCTION_CHARACTERS)
237
240
  throw new Error(`Learning instructions must be ${MAX_INSTRUCTION_CHARACTERS} characters or fewer.`);
238
241
  const event = { ...eventBase(profile, 'instruction', options), instruction: normalized };
239
- event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
242
+ event.requestDigest = requestDigest(event);
240
243
  return mutate(profile, event, options);
241
244
  }
242
245
  export function addLearningInstruction(profile, instruction, options = {}) { return recordLearningInstruction(profile, instruction, options).status === 'recorded'; }
246
+ function recordControlEvent(profile, kind, targetEventId, options) {
247
+ const event = { ...eventBase(profile, kind, options), targetEventId };
248
+ event.requestDigest = requestDigest(event);
249
+ return mutate(profile, event, options, (events) => {
250
+ const target = events.find((item) => item.eventId === targetEventId);
251
+ return !target ? 'not_found' : authorityRank[event.authority] < authorityRank[target.authority] ? 'unauthorized' : undefined;
252
+ });
253
+ }
243
254
  export function ratifyLearningEvent(profile, targetEventId, options = {}) {
244
- const event = { ...eventBase(profile, 'ratification', options), targetEventId };
245
- event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
246
- return mutate(profile, event, options, (events) => { const target = events.find((item) => item.eventId === targetEventId); return !target ? 'not_found' : authorityRank[event.authority] < authorityRank[target.authority] ? 'unauthorized' : undefined; });
255
+ return recordControlEvent(profile, 'ratification', targetEventId, options);
247
256
  }
248
257
  export function supersedeLearningEvent(profile, targetEventId, options = {}) {
249
- const event = { ...eventBase(profile, 'supersession', options), targetEventId };
250
- event.requestDigest = digest({ ...event, eventId: undefined, timestamp: undefined, requestDigest: undefined });
251
- return mutate(profile, event, options, (events) => { const target = events.find((item) => item.eventId === targetEventId); return !target ? 'not_found' : authorityRank[event.authority] < authorityRank[target.authority] ? 'unauthorized' : undefined; });
258
+ return recordControlEvent(profile, 'supersession', targetEventId, options);
252
259
  }
253
260
  export function migrateLearningV2ToV3(source, target, options = {}) {
254
261
  const sourceFingerprint = profileFingerprint(source);
package/dist/mcp-tools.js CHANGED
@@ -21,14 +21,17 @@ import { scoreHeldoutProfile } from './profile-score.js';
21
21
  import { findWritingExamples } from './writing-examples.js';
22
22
  import { evaluateIsolatedBacktest } from './backtest.js';
23
23
  import { evaluateLocalComposite } from './local-eval.js';
24
- function profileFromJson(profileJson) {
24
+ function parseDocument(text, parse, label) {
25
25
  try {
26
- return parseProfile(JSON.parse(profileJson));
26
+ return parse(JSON.parse(text));
27
27
  }
28
28
  catch (error) {
29
- throw new Error(error instanceof Error ? error.message : 'Profile is not valid JSON.');
29
+ throw new Error(error instanceof Error ? error.message : `${label} is not valid JSON.`);
30
30
  }
31
31
  }
32
+ function profileFromJson(profileJson) {
33
+ return parseDocument(profileJson, parseProfile, 'Profile');
34
+ }
32
35
  function profileV3FromJson(profileJson) {
33
36
  const profile = profileFromJson(profileJson);
34
37
  if (profile.version !== '3')
@@ -36,22 +39,10 @@ function profileV3FromJson(profileJson) {
36
39
  return profile;
37
40
  }
38
41
  function copySpecFromJson(copySpecJson) {
39
- try {
40
- return parseCopySpec(JSON.parse(copySpecJson));
41
- }
42
- catch (error) {
43
- throw new Error(error instanceof Error ? error.message : 'CopySpec is not valid JSON.');
44
- }
42
+ return parseDocument(copySpecJson, parseCopySpec, 'CopySpec');
45
43
  }
46
44
  function writingBriefFromJson(writingBriefJson) {
47
- if (!writingBriefJson)
48
- return undefined;
49
- try {
50
- return parseWritingBrief(JSON.parse(writingBriefJson));
51
- }
52
- catch (error) {
53
- throw new Error(error instanceof Error ? error.message : 'WritingBrief is not valid JSON.');
54
- }
45
+ return writingBriefJson ? parseDocument(writingBriefJson, parseWritingBrief, 'WritingBrief') : undefined;
55
46
  }
56
47
  export function buildProfileForMcp(samples, avoid = []) {
57
48
  return buildProfile(samples, avoid);
package/dist/mcp.js CHANGED
@@ -12,7 +12,6 @@ const profileJson = z.string().min(1).max(50_000);
12
12
  const copySpecJson = z.string().min(1).max(250_000);
13
13
  const writingBriefJson = z.string().min(1).max(50_000);
14
14
  const samples = z.array(writing).min(2).max(20);
15
- const strictSamples = z.array(writing).min(2).max(20);
16
15
  const heldoutSamples = z.array(writing).min(3).max(20);
17
16
  const evalParagraphs = z.array(z.object({ paragraph_id: z.string().min(1).max(160), text: writing })).min(2).max(50);
18
17
  const writingExamples = z.array(z.object({ basename: z.string().min(1).max(160), text: writing })).min(1).max(64);
@@ -39,9 +38,6 @@ function json(value) {
39
38
  function failure(error) {
40
39
  return { content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }], isError: true };
41
40
  }
42
- function lifecycleResult(result) {
43
- return json(result.ok ? result.artifact : { error: result.error });
44
- }
45
41
  function guardedJson(run, publicError) {
46
42
  try {
47
43
  return json(run());
@@ -51,12 +47,10 @@ function guardedJson(run, publicError) {
51
47
  }
52
48
  }
53
49
  function guardedLifecycle(run, publicError) {
54
- try {
55
- return lifecycleResult(run());
56
- }
57
- catch (error) {
58
- return failure(publicError ? new Error(publicError) : error);
59
- }
50
+ return guardedJson(() => {
51
+ const result = run();
52
+ return result.ok ? result.artifact : { error: result.error };
53
+ }, publicError);
60
54
  }
61
55
  const server = new McpServer({ name: 'hold-your-voice', version: HYV_VERSION });
62
56
  server.registerTool('hyv_build_profile', {
@@ -74,8 +68,8 @@ server.registerTool('hyv_analyze', {
74
68
  annotations: { readOnlyHint: true },
75
69
  }, async ({ draft, profile_json, writing_brief_json }) => guardedJson(() => analyzeForMcp(draft, profile_json, writing_brief_json)));
76
70
  server.registerTool('hyv_strict_check', {
77
- description: 'Run the opt-in local strict quality gate. It requires a calibrated Profile v3 and local writing samples; it returns strict-ready, needs-human-review, or blocked without changing text or learning state.',
78
- inputSchema: { draft: writing, profile_json: profileJson, samples: strictSamples, writing_brief_json: writingBriefJson.optional() },
71
+ description: 'Run the calibrated local strict-quality gate. It requires a Profile v3 and local writing samples; it returns strict-ready, needs-human-review, or blocked without changing text or learning state.',
72
+ inputSchema: { draft: writing, profile_json: profileJson, samples, writing_brief_json: writingBriefJson.optional() },
79
73
  annotations: { readOnlyHint: true },
80
74
  }, async ({ draft, profile_json, samples: localSamples, writing_brief_json }) => guardedJson(() => strictCheckForMcp(draft, profile_json, localSamples, writing_brief_json)));
81
75
  server.registerTool('hyv_score', {
@@ -128,7 +122,7 @@ server.registerTool('hyv_logic_lint', {
128
122
  annotations: { readOnlyHint: true },
129
123
  }, async ({ draft, writing_brief_json }) => guardedJson(() => logicLintForMcp(draft, writing_brief_json)));
130
124
  server.registerTool('hyv_rewrite_prompt', {
131
- description: 'Create a constrained editing brief. Explicit local examples are redacted in memory and injected only as advisory cadence evidence. It does not rewrite the draft or call a model.',
125
+ description: 'Create a strict constrained editing brief. Every active AI Editor finding is a required repair; explicit local examples remain advisory cadence evidence. It does not rewrite the draft or call a model.',
132
126
  inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional(), examples: writingExamples.optional() },
133
127
  annotations: { readOnlyHint: true },
134
128
  }, async ({ draft, profile_json, writing_brief_json, examples }) => guardedJson(() => rewritePromptForMcp(draft, profile_json, {}, writing_brief_json, examples)));
@@ -138,12 +132,12 @@ server.registerTool('hyv_find_writing_examples', {
138
132
  annotations: { readOnlyHint: true },
139
133
  }, async ({ query, examples }) => guardedJson(() => findWritingExamplesForMcp(query, examples)));
140
134
  server.registerTool('hyv_prepare_rewrite', {
141
- description: 'Prepare a local, versioned rewrite task. The caller may forward it to a provider; doing so shares the draft and must be an explicit choice.',
135
+ description: 'Prepare a strict local, versioned rewrite task. Every active AI Editor finding is eligible for source-faithful repair; the caller may forward the task to a provider only by explicit choice.',
142
136
  inputSchema: { draft: writing, profile_json: profileJson, copy_spec_json: copySpecJson.optional(), writing_brief_json: writingBriefJson.optional() },
143
137
  annotations: { readOnlyHint: true },
144
138
  }, async ({ draft, profile_json, copy_spec_json, writing_brief_json }) => guardedJson(() => prepareRewriteForMcp(draft, profile_json, copy_spec_json, writing_brief_json)));
145
139
  server.registerTool('hyv_apply_rewrite', {
146
- description: 'Validate and apply a model response to a prepared task, then run the local gates. It never calls a provider or stores source or candidate text.',
140
+ description: 'Validate a model response to a prepared task, then reject any candidate with an unresolved active AI Editor finding. It never calls a provider or stores source or candidate text.',
147
141
  inputSchema: { task_json: z.string().min(1).max(250_000), response_json: z.string().min(1).max(100_000), profile_json: profileJson },
148
142
  annotations: { readOnlyHint: true },
149
143
  }, async ({ task_json, response_json, profile_json }) => guardedJson(() => applyRewriteForMcp(task_json, response_json, profile_json)));
@@ -164,12 +158,12 @@ server.registerTool('hyv_reduce_judgment', {
164
158
  annotations: { readOnlyHint: true },
165
159
  }, async ({ envelopes_json }) => guardedJson(() => reduceJudgmentForMcp(envelopes_json)));
166
160
  server.registerTool('hyv_verify', {
167
- description: 'Verify a revised candidate against an original draft and portable profile without changing learning state.',
161
+ description: 'Verify a revised candidate against an original draft and portable profile. Every active AI Editor finding fails default verification; learning state remains unchanged.',
168
162
  inputSchema: { original: writing, candidate: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
169
163
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
170
164
  }, async ({ original, candidate, profile_json, writing_brief_json }) => guardedJson(() => verifyForMcp(original, candidate, profile_json, writing_brief_json)));
171
165
  server.registerTool('hyv_verify_copy_spec', {
172
- description: 'Verify a candidate against the existing voice gates and a local CopySpec. Immutable claims remain verbatim unless atoms are supplied; then each declared atom must remain. Prohibited claims fail closed.',
166
+ description: 'Verify a candidate against strict default voice gates and a local CopySpec. Every active AI Editor finding fails verification; immutable claims remain verbatim unless atoms are supplied, and prohibited claims fail closed.',
173
167
  inputSchema: { original: writing, candidate: writing, profile_json: profileJson, copy_spec_json: copySpecJson, writing_brief_json: writingBriefJson.optional() },
174
168
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
175
169
  }, async ({ original, candidate, profile_json, copy_spec_json, writing_brief_json }) => guardedJson(() => verifyCopySpecForMcp(original, candidate, profile_json, copy_spec_json, writing_brief_json)));
@@ -250,7 +244,7 @@ if (redactsSensitiveInputs) {
250
244
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
251
245
  }, async (args) => guardedJson(() => prepareRebuildForMcp(args.draft, args.profile_json, args.reduction_json, args.copy_spec_json, args.capability_json, loadApprovalContext(), args.writing_brief_json, args.recomposition_policy_json), 'Rebuild preparation failed.'));
252
246
  server.registerTool('hyv_apply_rebuild', {
253
- description: 'Validate and evaluate a whole-document rebuild response against a prepared authorized rebuild task. Capability input requires host-guaranteed sensitive-input redaction. It never calls a provider.',
247
+ description: 'Validate a whole-document rebuild response against a prepared authorized rebuild task and reject unresolved active AI Editor findings. Capability input requires host-guaranteed sensitive-input redaction. It never calls a provider.',
254
248
  inputSchema: { task_json: lifecycleJson, response_json: z.string().min(1).max(100_000), profile_json: profileJson, capability_json: lifecycleJson },
255
249
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
256
250
  }, async (args) => guardedJson(() => applyRebuildForMcp(args.task_json, args.response_json, args.profile_json, args.capability_json, loadApprovalContext()), 'Rebuild application failed.'));
@@ -23,7 +23,7 @@ test('reconciles branch and tag creates, rewrites, and deletions', () => {
23
23
  git(root, 'clone', '--quiet', source, checkout);
24
24
  git(checkout, 'config', 'user.name', 'Mirror Test');
25
25
  git(checkout, 'config', 'user.email', 'mirror-test@example.invalid');
26
- git(checkout, 'switch', '--quiet', '-c', 'main');
26
+ git(checkout, 'switch', '--quiet', '-c', 'fixture/main');
27
27
  writeFileSync(join(checkout, 'main.txt'), 'first\n');
28
28
  git(checkout, 'add', 'main.txt');
29
29
  git(checkout, 'commit', '--quiet', '-m', 'first');
@@ -34,20 +34,20 @@ test('reconciles branch and tag creates, rewrites, and deletions', () => {
34
34
  git(checkout, 'tag', 'v1.0.0');
35
35
  git(checkout, 'push', '--quiet', '--all', 'origin');
36
36
  git(checkout, 'push', '--quiet', '--tags', 'origin');
37
- git(source, 'symbolic-ref', 'HEAD', 'refs/heads/main');
37
+ git(source, 'symbolic-ref', 'HEAD', 'refs/heads/fixture/main');
38
38
  git(checkout, 'remote', 'set-head', 'origin', '-a');
39
39
  git(checkout, 'remote', 'add', 'mirror', mirror);
40
40
  execFileSync(process.execPath, [mirrorScript], { cwd: checkout, stdio: 'pipe' });
41
41
  assert.deepEqual(refs(mirror), refs(source));
42
42
  assert.equal(refs(mirror).some((ref) => ref.endsWith('refs/heads/HEAD')), false);
43
- git(checkout, 'switch', '--quiet', 'main');
43
+ git(checkout, 'switch', '--quiet', 'fixture/main');
44
44
  writeFileSync(join(checkout, 'main.txt'), 'rewritten\n');
45
45
  git(checkout, 'add', 'main.txt');
46
46
  git(checkout, 'commit', '--quiet', '--amend', '-m', 'rewritten main');
47
47
  git(checkout, 'branch', '-D', 'feature/test');
48
48
  git(checkout, 'tag', '-d', 'v1.0.0');
49
49
  git(checkout, 'tag', 'v2.0.0');
50
- git(checkout, 'push', '--quiet', '--force', 'origin', 'main');
50
+ git(checkout, 'push', '--quiet', '--force', 'origin', 'fixture/main');
51
51
  git(checkout, 'push', '--quiet', 'origin', '--delete', 'feature/test');
52
52
  git(checkout, 'push', '--quiet', 'origin', ':refs/tags/v1.0.0');
53
53
  git(checkout, 'push', '--quiet', 'origin', 'v2.0.0');
package/dist/pipeline.js CHANGED
@@ -29,10 +29,19 @@ function formatFindings(findings) {
29
29
  export function isBlockingFinding(finding) {
30
30
  return finding.engine === 'ai_editor' ? finding.appliedPolicy === 'blocking' : finding.severity === 'red';
31
31
  }
32
- export function deriveEditScope(result) {
33
- const findings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
34
- const blocking = findings.filter(isBlockingFinding);
35
- const pendingJudgment = findings.filter((finding) => finding.appliedPolicy === 'judgment-required');
32
+ export function isStrictFinding(finding) {
33
+ return finding.engine === 'ai_editor' || isBlockingFinding(finding);
34
+ }
35
+ function analysisFindings(result) {
36
+ return [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
37
+ }
38
+ export function strictFindings(result) {
39
+ return analysisFindings(result).filter(isStrictFinding);
40
+ }
41
+ export function deriveEditScope(result, strict = false) {
42
+ const findings = analysisFindings(result);
43
+ const blocking = findings.filter(strict ? isStrictFinding : isBlockingFinding);
44
+ const pendingJudgment = strict ? [] : findings.filter((finding) => finding.appliedPolicy === 'judgment-required');
36
45
  return {
37
46
  eligibleSentenceIds: [...new Set(blocking.map((finding) => finding.sentence))].sort((left, right) => left - right),
38
47
  blocking,
@@ -40,17 +49,17 @@ export function deriveEditScope(result) {
40
49
  };
41
50
  }
42
51
  export function renderRewritePrompt(draft, profile, result, learning = [], brief, examples = []) {
43
- const allFindings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
44
- const scope = deriveEditScope(result);
52
+ const allFindings = analysisFindings(result);
53
+ const scope = deriveEditScope(result, true);
45
54
  const redFindings = scope.blocking;
46
- const yellowFindings = allFindings.filter((finding) => !isBlockingFinding(finding) && finding.appliedPolicy !== 'judgment-required');
55
+ const yellowFindings = allFindings.filter((finding) => !isStrictFinding(finding) && finding.appliedPolicy !== 'judgment-required');
47
56
  const metrics = profile.metrics;
48
57
  return [
49
58
  '# Tier 0 — non-negotiable preservation',
50
59
  'Preserve facts, names, numbers, claims, and every unflagged sentence exactly. Do not add claims, examples, sections, hooks, or CTAs.',
51
60
  '',
52
- '# Tier 1 — release blockers',
53
- 'Treat each blocker as a required repair. Replace the flagged sentence with a stronger, source-faithful sentence; do not merely swap one stock phrase for another.',
61
+ '# Tier 1 — strict repair requirements',
62
+ 'Every active AI Editor finding is a required repair. Replace each flagged sentence with a stronger, source-faithful sentence; do not merely swap one stock phrase for another.',
54
63
  'Use only facts already present in the draft, CopySpec, WritingBrief, or supplied source context. Do not invent a source, metric, date, quotation, mechanism, example, CTA, or opinion.',
55
64
  `VoiceDNA: ${result.voiceDna.score}/100 (${result.voiceDna.passed ? 'pass' : 'fail'}).`,
56
65
  `AI Editor: ${result.aiEditor.score}/100 (${result.aiEditor.passed ? 'pass' : 'fail'}).`,
@@ -87,8 +96,8 @@ export function rewritePrompt(draft, profile, learning = [], brief, examples = [
87
96
  function compareCandidates(original, candidate, profile, brief) {
88
97
  const baseline = analyze(original, profile, brief);
89
98
  const checked = analyze(candidate, profile, brief);
90
- const baselineFindings = [...baseline.voiceDna.findings, ...baseline.aiEditor.findings, ...(baseline.editorial?.findings ?? [])];
91
- const checkedFindings = [...checked.voiceDna.findings, ...checked.aiEditor.findings, ...(checked.editorial?.findings ?? [])];
99
+ const baselineFindings = analysisFindings(baseline);
100
+ const checkedFindings = analysisFindings(checked);
92
101
  const known = new Set(baselineFindings.map((finding) => `${finding.engine}:${finding.id}:${finding.sentence}`));
93
102
  const regressions = checkedFindings.filter((finding) => !known.has(`${finding.engine}:${finding.id}:${finding.sentence}`));
94
103
  const preservation = legacySetPreservation(original, candidate).score;
@@ -114,48 +123,38 @@ function verifyRequiredFacts(candidate, brief) {
114
123
  return result;
115
124
  return { ...result, passed: false, failures: [...result.failures, ...reversed.map((fact) => ({ id: fact.id, code: 'missing_immutable_claim', message: `Required fact ${fact.id} is negated or denied.`, evidence: 'WritingBrief required fact.' }))] };
116
125
  }
117
- export function verify(original, candidate, profile, brief) {
126
+ function verifyCandidate(original, candidate, profile, brief, rebuild) {
118
127
  const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
128
+ const claims = rebuild ? verifyClaims(candidate, rebuild.copySpec) : undefined;
119
129
  const finalOutput = finalOutputCheck(candidate);
120
130
  const logicLint = lintLogic(candidate, brief);
121
131
  const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
122
132
  const requiredFacts = verifyRequiredFacts(candidate, brief);
133
+ const unresolvedStrictFindings = strictFindings(checked);
123
134
  return {
124
135
  version: '2',
125
136
  original: baseline,
126
137
  candidate: checked,
127
138
  preservationScore: preservation,
128
139
  regressions,
140
+ strictFindings: unresolvedStrictFindings,
141
+ ...(claims ? { claims } : {}),
129
142
  finalOutput,
130
143
  logicLint,
131
144
  ...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
132
- passed: checked.passed && !regressions.some(isBlockingFinding) && preservation >= 70 && finalOutput.accepted && logicLint.passed && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
145
+ passed: checked.passed && unresolvedStrictFindings.length === 0 && !regressions.some(isBlockingFinding) && (claims ? claims.passed : preservation >= 70) && finalOutput.accepted && logicLint.passed && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
133
146
  };
134
147
  }
148
+ export function verify(original, candidate, profile, brief) {
149
+ return verifyCandidate(original, candidate, profile, brief);
150
+ }
135
151
  export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
136
152
  const verification = verify(original, candidate, profile, brief);
137
153
  const claims = verifyClaims(candidate, spec);
138
154
  return { ...verification, claims, passed: verification.passed && claims.passed };
139
155
  }
140
156
  export function verifyRebuildWithCopySpec(original, candidate, profile, spec, brief) {
141
- const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
142
- const claims = verifyClaims(candidate, spec);
143
- const finalCheck = finalOutputCheck(candidate);
144
- const logicLint = lintLogic(candidate, brief);
145
- const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
146
- const requiredFacts = verifyRequiredFacts(candidate, brief);
147
- return {
148
- version: '2',
149
- original: baseline,
150
- candidate: checked,
151
- preservationScore: preservation,
152
- regressions,
153
- claims,
154
- finalOutput: finalCheck,
155
- logicLint,
156
- ...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
157
- passed: checked.passed && !regressions.some(isBlockingFinding) && claims.passed && finalCheck.accepted && logicLint.passed && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
158
- };
157
+ return verifyCandidate(original, candidate, profile, brief, { copySpec: spec });
159
158
  }
160
159
  function projectDeterministicVerificationArtifact(source, candidate, profile, verification, copySpec, writingBrief, verificationKind = 'claims' in verification ? 'copy_spec' : 'standard') {
161
160
  const identity = profileIdentity(profile);
@@ -83,7 +83,7 @@ test('orders rewrite instructions by importance tier', () => {
83
83
  });
84
84
  test('gives strong, source-faithful repair feedback without authorizing a broader rewrite', () => {
85
85
  const prompt = rewritePrompt('The scheduler failed — twice. Experts say this changes everything. The launch is on 14 August.', profile);
86
- assert.match(prompt, /Treat each blocker as a required repair/);
86
+ assert.match(prompt, /Every active AI Editor finding is a required repair/);
87
87
  assert.match(prompt, /Do not invent a source, metric, date, quotation, mechanism, example, CTA, or opinion/);
88
88
  assert.match(prompt, /otherwise remove the unsupported framing without widening the claim/);
89
89
  assert.match(prompt, /Before responding, check every Tier 1 finding against its replacement/);
@@ -119,9 +119,11 @@ test('advisory and pending-judgment findings pass while blocking findings fail',
119
119
  const blocking = analyze('The scheduler failed — twice.', profile);
120
120
  assert.equal(blocking.passed, false);
121
121
  });
122
- test('verify rejects only new policy-blocking regressions', () => {
122
+ test('verify rejects unresolved active AI Editor findings under the default strict policy', () => {
123
123
  const neutralProfile = buildProfile(['I write plainly.', 'I name the mechanism.']);
124
- assert.equal(verify('The scheduler failed twice.', 'The scheduler failed twice. We leverage logs.', neutralProfile).passed, true);
124
+ const unresolved = verify('The scheduler failed twice.', 'The scheduler failed twice. We leverage logs.', neutralProfile);
125
+ assert.equal(unresolved.passed, false);
126
+ assert.ok(unresolved.strictFindings.some((finding) => finding.id === 'ai.leverage'));
125
127
  assert.equal(verify('The scheduler failed twice.', 'The scheduler failed — twice.', profile).passed, false);
126
128
  });
127
129
  test('keeps verify pass/fail on the legacy metric while calibration reports both metrics', () => {
@@ -10,26 +10,13 @@ import { HYV_VERSION } from './version.js';
10
10
  import { buildRecompositionBrief, measureLexicalResidual, parseRecompositionPolicy } from './recomposition.js';
11
11
  import { provenanceStatusForRebuild, writerRequestForRebuild } from './provenance-status.js';
12
12
  import { fingerprint, profileIdentity, sha256 as digest } from './internal.js';
13
- const MAX_RESPONSE_BYTES = 100_000;
13
+ import { failure, parseResponseJson, projectLifecycleBinding } from './rewrite-response.js';
14
14
  const MAX_CANDIDATE_CHARACTERS = 100_000;
15
- function failure(code, message, path) {
16
- return { code, message, ...(path ? { path } : {}) };
17
- }
18
15
  function isFailure(value) {
19
16
  return typeof value === 'object' && value !== null && 'code' in value && 'message' in value;
20
17
  }
21
- function parseJson(value) {
22
- if (Buffer.byteLength(value) > MAX_RESPONSE_BYTES)
23
- return failure('response_too_large', `Response exceeds ${MAX_RESPONSE_BYTES} bytes.`);
24
- try {
25
- return JSON.parse(value);
26
- }
27
- catch {
28
- return failure('invalid_json', 'Response must be valid JSON.');
29
- }
30
- }
31
18
  function parseRebuildResponse(value) {
32
- const raw = typeof value === 'string' ? parseJson(value) : value;
19
+ const raw = typeof value === 'string' ? parseResponseJson(value) : value;
33
20
  if (isFailure(raw))
34
21
  return raw;
35
22
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
@@ -159,7 +146,7 @@ function rejected(task, raw, failures) {
159
146
  };
160
147
  }
161
148
  export function applyRebuildResponse(task, raw) {
162
- const response = parseRebuildResponse(typeof raw === 'string' ? parseJson(raw) : raw);
149
+ const response = parseRebuildResponse(typeof raw === 'string' ? parseResponseJson(raw) : raw);
163
150
  if (isFailure(response))
164
151
  return rejected(task, raw, [response]);
165
152
  if (response.taskFingerprint !== task.fingerprint) {
@@ -227,15 +214,5 @@ export function createRebuildLifecycleBinding(task, receipt, deterministic) {
227
214
  if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint || receipt.mode !== 'REBUILD' || deterministic.verificationKind !== 'rebuild') {
228
215
  throw new Error('Lifecycle binding requires a passed rebuild artifact for this rebuild task.');
229
216
  }
230
- return {
231
- rewriteTaskFingerprint: task.fingerprint,
232
- rewriteResponseFingerprint: receipt.responseFingerprint,
233
- deterministicArtifactFingerprint: deterministic.artifactFingerprint,
234
- sourceHash: deterministic.sourceHash,
235
- candidateHash: deterministic.candidateHash,
236
- profileId: deterministic.profileId,
237
- profileRevisionDigest: deterministic.profileRevisionDigest,
238
- rulesetVersion: deterministic.rulesetVersion,
239
- schemaVersion: '1',
240
- };
217
+ return projectLifecycleBinding(task.fingerprint, receipt, deterministic);
241
218
  }
@@ -174,7 +174,7 @@ test('CLI and MCP rebuild helpers share fingerprints', () => {
174
174
  version: '1', audience: 'operators', intent: 'explain', format: 'outreach',
175
175
  });
176
176
  assert.match(briefTask.prompt, /# WritingBrief/);
177
- assert.equal(HYV_VERSION, '3.5.1');
177
+ assert.equal(HYV_VERSION, '3.6.1');
178
178
  });
179
179
  test('apply rejects forged tasks, missing capability, and substituted profiles', () => {
180
180
  const reduction = rebuildRecommendation();
@@ -0,0 +1,27 @@
1
+ const MAX_RESPONSE_BYTES = 100_000;
2
+ export function failure(code, message, path) {
3
+ return { code, message, ...(path ? { path } : {}) };
4
+ }
5
+ export function parseResponseJson(value) {
6
+ if (Buffer.byteLength(value) > MAX_RESPONSE_BYTES)
7
+ return failure('response_too_large', `Response exceeds ${MAX_RESPONSE_BYTES} bytes.`);
8
+ try {
9
+ return JSON.parse(value);
10
+ }
11
+ catch {
12
+ return failure('invalid_json', 'Response must be valid JSON.');
13
+ }
14
+ }
15
+ export function projectLifecycleBinding(taskFingerprint, receipt, deterministic) {
16
+ return {
17
+ rewriteTaskFingerprint: taskFingerprint,
18
+ rewriteResponseFingerprint: receipt.responseFingerprint,
19
+ deterministicArtifactFingerprint: deterministic.artifactFingerprint,
20
+ sourceHash: deterministic.sourceHash,
21
+ candidateHash: deterministic.candidateHash,
22
+ profileId: deterministic.profileId,
23
+ profileRevisionDigest: deterministic.profileRevisionDigest,
24
+ rulesetVersion: deterministic.rulesetVersion,
25
+ schemaVersion: '1',
26
+ };
27
+ }
@@ -3,30 +3,14 @@ import { finalOutputCheck, hygieneSourceFindings } from './hygiene.js';
3
3
  import { analyze, deriveEditScope, renderRewritePrompt, verifyDeterministically } from './pipeline.js';
4
4
  import { sentences } from './text.js';
5
5
  import { fingerprint } from './internal.js';
6
- const MAX_RESPONSE_BYTES = 100_000;
6
+ import { failure, parseResponseJson, projectLifecycleBinding } from './rewrite-response.js';
7
7
  const MAX_REPLACEMENTS = 100;
8
8
  const MAX_REPLACEMENT_CHARACTERS = 10_000;
9
- function failure(code, message, path) {
10
- return { code, message, ...(path ? { path } : {}) };
11
- }
12
- function responseFingerprint(response) {
13
- return fingerprint(response);
14
- }
15
- function parseJson(value) {
16
- if (Buffer.byteLength(value) > MAX_RESPONSE_BYTES)
17
- return failure('response_too_large', `Response exceeds ${MAX_RESPONSE_BYTES} bytes.`);
18
- try {
19
- return JSON.parse(value);
20
- }
21
- catch {
22
- return failure('invalid_json', 'Response must be valid JSON.');
23
- }
24
- }
25
9
  function isFailure(value) {
26
10
  return typeof value === 'object' && value !== null && 'code' in value;
27
11
  }
28
12
  function parseResponse(value) {
29
- const raw = typeof value === 'string' ? parseJson(value) : value;
13
+ const raw = typeof value === 'string' ? parseResponseJson(value) : value;
30
14
  if (isFailure(raw))
31
15
  return raw;
32
16
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
@@ -106,7 +90,7 @@ export function prepareRewriteTask(draft, profile, copySpec, writingBrief, autho
106
90
  const result = analyze(draft, profile, writingBrief);
107
91
  const prompt = renderRewritePrompt(draft, profile, result, [], writingBrief);
108
92
  const mapped = sentences(draft);
109
- const eligibleSentenceIds = new Set([...deriveEditScope(result).eligibleSentenceIds, ...authorizedSentenceIds]);
93
+ const eligibleSentenceIds = new Set([...deriveEditScope(result, true).eligibleSentenceIds, ...authorizedSentenceIds]);
110
94
  const taskBase = {
111
95
  version: '1',
112
96
  draft,
@@ -133,7 +117,7 @@ export function parseRewriteTask(value) {
133
117
  return task;
134
118
  }
135
119
  function rejected(task, raw, failures, adapterIds = []) {
136
- return { status: 'repairable', failures, receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds, replacementSentenceIds: [] } };
120
+ return { status: 'repairable', failures, receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: fingerprint(raw), adapterIds, replacementSentenceIds: [] } };
137
121
  }
138
122
  export function applyShip(task) {
139
123
  return {
@@ -151,7 +135,7 @@ export function applyShip(task) {
151
135
  };
152
136
  }
153
137
  export function applyRewriteResponse(task, raw) {
154
- const source = typeof raw === 'string' ? parseJson(raw) : raw;
138
+ const source = typeof raw === 'string' ? parseResponseJson(raw) : raw;
155
139
  const parsed = isFailure(source) ? source : parseResponse(source);
156
140
  const fenced = isFailure(parsed) && parsed.code === 'invalid_json' ? repairFencedJson(raw) : { value: source };
157
141
  const repaired = isFailure(parsed) && parsed.code === 'invalid_response_shape' ? repairStringifiedReplacements(source) : fenced;
@@ -163,7 +147,7 @@ export function applyRewriteResponse(task, raw) {
163
147
  return rejected(task, raw, [failure('task_fingerprint_mismatch', 'Response task fingerprint does not match this task.', 'taskFingerprint')], adapterIds);
164
148
  if ('mode' in response && response.mode === 'SHIP') {
165
149
  const shipped = applyShip(task);
166
- return { ...shipped, receipt: { ...shipped.receipt, adapterIds, responseFingerprint: responseFingerprint(raw) } };
150
+ return { ...shipped, receipt: { ...shipped.receipt, adapterIds, responseFingerprint: fingerprint(raw) } };
167
151
  }
168
152
  if (response.version === '2')
169
153
  return applyRangeResponse(task, response, raw, adapterIds);
@@ -189,7 +173,7 @@ export function applyRewriteResponse(task, raw) {
189
173
  if (replacement !== undefined)
190
174
  candidate = `${candidate.slice(0, sentence.start)}${replacement}${candidate.slice(sentence.end)}`;
191
175
  }
192
- return { status: 'accepted', candidate, failures: [], receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds, replacementSentenceIds: [...seen].sort((left, right) => left - right), mode: 'EDIT' } };
176
+ return { status: 'accepted', candidate, failures: [], receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: fingerprint(raw), adapterIds, replacementSentenceIds: [...seen].sort((left, right) => left - right), mode: 'EDIT' } };
193
177
  }
194
178
  function applyRangeResponse(task, response, raw, adapterIds) {
195
179
  const sentenceMap = new Map(task.sentences.map((sentence) => [sentence.id, sentence]));
@@ -236,7 +220,7 @@ function applyRangeResponse(task, response, raw, adapterIds) {
236
220
  receipt: {
237
221
  version: '1',
238
222
  taskFingerprint: task.fingerprint,
239
- responseFingerprint: responseFingerprint(raw),
223
+ responseFingerprint: fingerprint(raw),
240
224
  adapterIds,
241
225
  operationRanges: response.operations.map((operation) => ({ startSentenceId: operation.startSentenceId, endSentenceId: operation.endSentenceId })),
242
226
  mode: 'EDIT',
@@ -262,15 +246,5 @@ export function evaluateRewriteResponse(task, raw, profile) {
262
246
  export function createRewriteLifecycleBinding(task, receipt, deterministic) {
263
247
  if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint)
264
248
  throw new Error('Lifecycle binding requires a passed deterministic artifact for this rewrite task.');
265
- return {
266
- rewriteTaskFingerprint: task.fingerprint,
267
- rewriteResponseFingerprint: receipt.responseFingerprint,
268
- deterministicArtifactFingerprint: deterministic.artifactFingerprint,
269
- sourceHash: deterministic.sourceHash,
270
- candidateHash: deterministic.candidateHash,
271
- profileId: deterministic.profileId,
272
- profileRevisionDigest: deterministic.profileRevisionDigest,
273
- rulesetVersion: deterministic.rulesetVersion,
274
- schemaVersion: '1',
275
- };
249
+ return projectLifecycleBinding(task.fingerprint, receipt, deterministic);
276
250
  }
@@ -21,18 +21,19 @@ test('applies only eligible numbered replacements and preserves all clean bytes'
21
21
  assert.equal(result.candidate, 'I use the answer. The launch is on 14 August.');
22
22
  assert.deepEqual(result.receipt.adapterIds, []);
23
23
  });
24
- test('does not make an advisory broad catalog match eligible', () => {
24
+ test('makes every active AI Editor finding eligible under the default strict rewrite policy', () => {
25
25
  const task = prepareRewriteTask('I write clear notes. This work is meaningful. I keep the mechanism visible.', profile);
26
- assert.deepEqual(task.eligibleSentenceIds, []);
27
- assert.deepEqual(task.sentences.map((sentence) => sentence.eligible), [false, false, false]);
26
+ assert.deepEqual(task.eligibleSentenceIds, [2]);
27
+ assert.deepEqual(task.sentences.map((sentence) => sentence.eligible), [false, true, false]);
28
+ assert.match(task.prompt, /Every active AI Editor finding is a required repair/);
28
29
  });
29
- test('grants edit scope only to blocking findings and keeps pending judgment separate', () => {
30
+ test('makes advisory and judgment-required findings required repairs', () => {
30
31
  const advisory = prepareRewriteTask('Firstly, check the invoice.', profile);
31
- assert.deepEqual(advisory.eligibleSentenceIds, []);
32
+ assert.deepEqual(advisory.eligibleSentenceIds, [1]);
32
33
  const neutralProfile = buildProfile(['I write plainly.', 'I name the mechanism.']);
33
34
  const pending = prepareRewriteTask('We leverage the scheduler.', neutralProfile);
34
- assert.deepEqual(pending.eligibleSentenceIds, []);
35
- assert.match(pending.prompt, /pending judgment/i);
35
+ assert.deepEqual(pending.eligibleSentenceIds, [1]);
36
+ assert.match(pending.prompt, /Every active AI Editor finding is a required repair/);
36
37
  const blocking = prepareRewriteTask('The scheduler failed — twice. The owner checked it.', profile);
37
38
  assert.deepEqual(blocking.eligibleSentenceIds, [1]);
38
39
  });
@@ -40,9 +41,9 @@ test('keeps blocking VoiceDNA avoid findings eligible', () => {
40
41
  const task = prepareRewriteTask('The plan uses leverage.', profile);
41
42
  assert.deepEqual(task.eligibleSentenceIds, [1]);
42
43
  });
43
- test('preserves founder reframes that reconciled policy makes advisory', () => {
44
+ test('makes reconciled active founder reframes eligible under the default strict policy', () => {
44
45
  const task = prepareRewriteTask("This isn't positioning. This is proof.", profile);
45
- assert.deepEqual(task.eligibleSentenceIds, []);
46
+ assert.deepEqual(task.eligibleSentenceIds, [1]);
46
47
  });
47
48
  test('preserves a clean draft byte-for-byte when the rewrite response is empty', () => {
48
49
  const draft = 'I write clear notes.\n\nI keep the mechanism visible.\n';