@holdyourvoice/hyv 3.3.2 → 3.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Readme.md CHANGED
@@ -14,7 +14,7 @@ Those programs keep separate findings, scores, and pass states. A strong result
14
14
 
15
15
  Everything in the CLI runs from local files: accounts, API calls, telemetry, payment collection, and runtime network requests stay out of the core path. The optional Claude extension adds a local stdio MCP adapter around that same engine; it is not a hosted service.
16
16
 
17
- > **Status:** [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv) **3.3.2** is the public founder-aware rewrite. It runs locally and makes no runtime network requests. The package includes Profile v3 policy, pre-edit SHIP/EDIT/REBUILD judgments, contiguous range edits, authorized rebuild, and a signed semantic lifecycle.
17
+ > **Status:** [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv) **3.3.4** is the public founder-aware rewrite. It runs locally and makes no runtime network requests. The package includes Profile v3 policy, pre-edit SHIP/EDIT/REBUILD judgments, contiguous range edits, authorized rebuild, and a signed semantic lifecycle.
18
18
 
19
19
  ## Why it exists
20
20
 
package/dist/cli.js CHANGED
@@ -17,7 +17,8 @@ import { finalizeLifecycle, inspectLifecycle, prepareLifecycle, recordApprovedLe
17
17
  import { buildProfile } from './voice-dna.js';
18
18
  import { loadApprovalContext } from './approval-context.js';
19
19
  import { formatFactLintReport, lintFacts } from './fact-linter.js';
20
- const usage = 'Commands: profile, analyze, hygiene, inspect-hidden-text, apply-hidden-text-policy, final-check, fact-lint, batch-analyze, rewrite-prompt, prepare-rewrite, apply-rewrite, prepare-judgment, reduce-judgment, prepare-rebuild, rebuild-writer-request, apply-rebuild, verify, verify-spec, lifecycle, learning, patterns, mcp';
20
+ import { lintLogic } from './logic-linter.js';
21
+ const usage = 'Commands: profile, analyze, hygiene, inspect-hidden-text, apply-hidden-text-policy, final-check, fact-lint, logic-lint, batch-analyze, rewrite-prompt, prepare-rewrite, apply-rewrite, prepare-judgment, reduce-judgment, prepare-rebuild, rebuild-writer-request, apply-rebuild, verify, verify-spec, lifecycle, learning, patterns, mcp';
21
22
  const MAX_JSON_BYTES = 1024 * 1024;
22
23
  function input(path) {
23
24
  return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
@@ -371,6 +372,14 @@ export async function runCli(args) {
371
372
  json(report);
372
373
  return strict && report.findings.some((item) => item.severity === 'error') ? 2 : 0;
373
374
  }
375
+ if (command === 'logic-lint') {
376
+ const [draftPath, briefPath, ...extra] = rest;
377
+ if (!draftPath || extra.length)
378
+ throw new Error('Usage: hyv logic-lint <draft|-> [writing-brief.json]');
379
+ const report = lintLogic(input(draftPath), readBrief(briefPath));
380
+ json(report);
381
+ return report.passed ? 0 : 2;
382
+ }
374
383
  if (command === 'batch-analyze') {
375
384
  if (rest.length < 2)
376
385
  throw new Error('Usage: hyv batch-analyze draft-a.md draft-b.md [draft-c.md]');
package/dist/cli.test.js CHANGED
@@ -181,7 +181,7 @@ test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
181
181
  assert.equal(run(['profile', profile, first, second, '--avoid=unlock']).status, 0);
182
182
  const verification = run(['verify', original, candidate, profile]);
183
183
  assert.equal(verification.status, 2);
184
- assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'finalOutput', 'original', 'passed', 'preservationScore', 'regressions', 'version']);
184
+ assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'finalOutput', 'logicLint', 'original', 'passed', 'preservationScore', 'regressions', 'version']);
185
185
  assert.equal(run(['unknown-command']).status, 1);
186
186
  assert.equal(run(['mcp', 'unexpected']).status, 1);
187
187
  }
@@ -189,6 +189,19 @@ test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
189
189
  rmSync(directory, { recursive: true, force: true });
190
190
  }
191
191
  });
192
+ test('runs the logic linter as a standalone final gate', () => {
193
+ const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-logic-lint-'));
194
+ try {
195
+ const draft = join(directory, 'draft.md');
196
+ writeFileSync(draft, 'The release checklist names rollback ownership. Each owner signs before deployment. Espresso machines use a dual boiler for stable temperature control. The checklist catches missing rollback steps.');
197
+ const result = run(['logic-lint', draft]);
198
+ assert.equal(result.status, 2, result.stderr);
199
+ assert.equal(JSON.parse(result.stdout).findings[0].kind, 'topic_drift');
200
+ }
201
+ finally {
202
+ rmSync(directory, { recursive: true, force: true });
203
+ }
204
+ });
192
205
  test('fails the CopySpec gate when a locked claim changes', () => {
193
206
  const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
194
207
  try {
@@ -0,0 +1,22 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import { lintLogic } from './logic-linter.js';
5
+ import { words } from './text.js';
6
+ const domains = ['marketing', 'engineering', 'deep-tech'];
7
+ function corpus(domain) {
8
+ return JSON.parse(readFileSync(new URL(`../test-fixtures/logic-linter/${domain}.json`, import.meta.url), 'utf8'));
9
+ }
10
+ test('keeps a 54-post long-form corpus cohesive across marketing, engineering, and deep tech', () => {
11
+ const posts = domains.flatMap(corpus);
12
+ assert.equal(posts.length, 54);
13
+ assert.equal(new Set(posts.map((post) => post.id)).size, 54);
14
+ for (const domain of domains)
15
+ assert.equal(corpus(domain).length, 18);
16
+ for (const post of posts) {
17
+ const wordCount = words(post.text).length;
18
+ assert.ok(wordCount >= 350 && wordCount <= 1_500, `${post.id} must contain 350–1,500 words; got ${wordCount}`);
19
+ const report = lintLogic(post.text);
20
+ assert.equal(report.passed, true, `${post.id}: ${JSON.stringify(report.findings)}`);
21
+ }
22
+ });
@@ -0,0 +1,101 @@
1
+ import { sentences, words } from './text.js';
2
+ const STOP_WORDS = new Set(['about', 'after', 'again', 'also', 'among', 'another', 'because', 'before', 'being', 'between', 'build', 'could', 'every', 'first', 'from', 'have', 'into', 'just', 'like', 'many', 'more', 'most', 'only', 'other', 'over', 'same', 'some', 'than', 'that', 'their', 'there', 'these', 'they', 'this', 'those', 'through', 'under', 'very', 'what', 'when', 'which', 'while', 'with', 'would', 'your']);
3
+ const ANAPHORA = /\b(?:this|that|these|those|it|they|them|its|their|the result|the change|the work|that choice|this choice|this means)\b/i;
4
+ const INFERENCE = /^(?:but|however|therefore|thus|so|instead|because|as a result|that means|this means|the result|in practice)\b/i;
5
+ const NEGATION = /\b(?:not|never|no|cannot|can't|won't|isn't|aren't|wasn't|weren't|doesn't|don't|didn't)\b/i;
6
+ function stem(word) {
7
+ if (word.length > 6 && word.endsWith('ies'))
8
+ return `${word.slice(0, -3)}y`;
9
+ if (word.length > 5 && word.endsWith('ing'))
10
+ return word.slice(0, -3);
11
+ if (word.length > 4 && word.endsWith('ed'))
12
+ return word.slice(0, -2);
13
+ if (word.length > 4 && word.endsWith('s'))
14
+ return word.slice(0, -1);
15
+ return word;
16
+ }
17
+ function anchors(text) {
18
+ return new Set(words(text.toLowerCase()).map(stem).filter((word) => word.length > 3 && !STOP_WORDS.has(word)));
19
+ }
20
+ function overlap(left, right) {
21
+ return [...left].some((item) => right.has(item));
22
+ }
23
+ function allArgumentAnchors(brief) {
24
+ if (!brief?.argumentMap)
25
+ return new Set();
26
+ const map = brief.argumentMap;
27
+ return anchors(`${map.observation} ${map.mechanism} ${map.consequence} ${map.readerValue}`);
28
+ }
29
+ function contradictionKey(text) {
30
+ const normalized = text.toLowerCase().replace(/[^\p{L}\p{N}\s']/gu, ' ').replace(/\s+/g, ' ').trim();
31
+ const match = normalized.match(/^((?:[\p{L}\p{N}'-]+\s+){0,4}[\p{L}\p{N}'-]+)\s+(?:is|are|was|were|will|can|should|does|do|did|has|have)\s+(.+)$/u);
32
+ if (!match)
33
+ return undefined;
34
+ const subject = match[1].split(' ').filter((word) => word.length > 2 && !STOP_WORDS.has(word)).map(stem).join(' ');
35
+ const predicate = match[2].replace(NEGATION, '').split(' ').filter((word) => word.length > 2 && !STOP_WORDS.has(word)).map(stem).join(' ');
36
+ return subject && predicate ? { key: `${subject}|${predicate}`, negated: NEGATION.test(normalized) } : undefined;
37
+ }
38
+ function finding(kind, severity, sentence, excerpt, reason, suggestion, relatedSentence) {
39
+ return { kind, severity, sentence, ...(relatedSentence ? { relatedSentence } : {}), excerpt, reason, suggestion };
40
+ }
41
+ export function lintLogic(draft, brief) {
42
+ const document = sentences(draft);
43
+ const findings = [];
44
+ const skippedChecks = [];
45
+ const sentenceAnchors = document.map((sentence) => anchors(sentence.text));
46
+ const frequencies = new Map();
47
+ for (const list of sentenceAnchors)
48
+ for (const anchor of list)
49
+ frequencies.set(anchor, (frequencies.get(anchor) ?? 0) + 1);
50
+ const dominant = new Set([...frequencies].filter(([, count]) => count >= 2).map(([anchor]) => anchor));
51
+ const briefAnchors = allArgumentAnchors(brief);
52
+ const priorClaims = new Map();
53
+ for (const sentence of document) {
54
+ const claim = contradictionKey(sentence.text);
55
+ if (!claim)
56
+ continue;
57
+ const previous = priorClaims.get(claim.key);
58
+ if (previous && previous.negated !== claim.negated) {
59
+ findings.push(finding('internal_contradiction', 'error', sentence.index, sentence.text, `This statement reverses the polarity of sentence ${previous.sentence}.`, 'Keep one position, or explain the changed condition explicitly.', previous.sentence));
60
+ }
61
+ else
62
+ priorClaims.set(claim.key, { sentence: sentence.index, negated: claim.negated });
63
+ }
64
+ if (document.length < 3)
65
+ skippedChecks.push('topic_drift_short_post');
66
+ else {
67
+ for (let index = 2; index < document.length - 1; index += 1) {
68
+ const current = document[index];
69
+ const currentAnchors = sentenceAnchors[index];
70
+ const previousAnchors = sentenceAnchors[index - 1];
71
+ const nextAnchors = sentenceAnchors[index + 1];
72
+ const connected = overlap(currentAnchors, previousAnchors) || overlap(currentAnchors, nextAnchors) || overlap(currentAnchors, dominant) || overlap(currentAnchors, briefAnchors) || ANAPHORA.test(current.text);
73
+ if (!connected && currentAnchors.size >= 5 && !current.text.trim().endsWith('?')) {
74
+ findings.push(finding('topic_drift', 'error', current.index, current.text, 'This sentence has no detectable topic anchor in the surrounding argument or brief.', 'Connect it to the current subject, or remove it from this post.'));
75
+ }
76
+ if (INFERENCE.test(current.text) && !overlap(currentAnchors, previousAnchors) && !ANAPHORA.test(current.text)) {
77
+ findings.push(finding('unanchored_inference', 'error', current.index, current.text, 'The transition claims an inference or contrast without a detectable subject bridge to the prior sentence.', 'Name the subject that carries across the transition.'));
78
+ }
79
+ }
80
+ }
81
+ if (brief?.argumentMap) {
82
+ const draftAnchors = new Set(sentenceAnchors.flatMap((set) => [...set]));
83
+ const missing = [...briefAnchors].filter((anchor) => !draftAnchors.has(anchor));
84
+ if (briefAnchors.size >= 3 && missing.length === briefAnchors.size) {
85
+ const first = document[0];
86
+ if (first)
87
+ findings.push(finding('argument_map_gap', 'needs_human_review', first.index, first.text, 'No terms from the brief argument map are visible in the draft.', 'Check that this draft answers the assigned argument.'));
88
+ }
89
+ }
90
+ const errors = findings.filter((item) => item.severity === 'error').length;
91
+ const needsHumanReview = findings.filter((item) => item.severity === 'needs_human_review').length;
92
+ return { version: '1', passed: errors === 0, summary: { checkedSentences: document.length, errors, needsHumanReview }, findings, skippedChecks, limitations: ['English-only lexical coherence check.', 'Does not establish factual truth, rhetorical quality, or publication approval.', 'A pass means no configured deterministic logic failure was found.'] };
93
+ }
94
+ export function formatLogicLintReport(report) {
95
+ const lines = [`logic lint: ${report.summary.checkedSentences} sentences, ${report.summary.errors} errors, ${report.summary.needsHumanReview} review findings`];
96
+ for (const item of report.findings)
97
+ lines.push(`${item.severity} ${item.kind} s${item.sentence}: ${item.reason}`);
98
+ if (report.skippedChecks.length)
99
+ lines.push(`skipped: ${report.skippedChecks.join(', ')}`);
100
+ return lines.join('\n');
101
+ }
@@ -0,0 +1,39 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { lintLogic } from './logic-linter.js';
4
+ test('accepts a post that develops one connected marketing argument', () => {
5
+ const report = lintLogic('Most marketing teams measure clicks before they measure qualified conversations. That choice rewards cheap attention. We changed the weekly review to start with qualified conversations, then used click data to explain movement. The review now tells the team which message created demand.');
6
+ assert.equal(report.passed, true);
7
+ assert.deepEqual(report.findings, []);
8
+ });
9
+ test('blocks an abrupt, unsupported topic jump', () => {
10
+ const report = lintLogic('The release checklist makes rollback ownership explicit. Each service owner signs the same checklist before deployment. The best espresso machines use a dual boiler for stable temperature control. The checklist now catches missing rollback steps before they become incidents.');
11
+ assert.equal(report.passed, false);
12
+ assert.equal(report.findings[0]?.kind, 'topic_drift');
13
+ assert.equal(report.findings[0]?.severity, 'error');
14
+ });
15
+ test('allows an explicit bridge into a consequence', () => {
16
+ const report = lintLogic('The compiler now records each cache key. This means the build dashboard can explain a cache miss without replaying the job. That explanation makes incident review faster.');
17
+ assert.equal(report.passed, true);
18
+ });
19
+ test('does not treat an isolated rhetorical question as a hard topic-drift finding', () => {
20
+ const report = lintLogic('The migration writes its recovery marker before it changes customer data. What happens if execution stops between two writes? The recovery marker lets the worker resume the migration safely.');
21
+ assert.equal(report.passed, true);
22
+ });
23
+ test('blocks directly contradictory claims', () => {
24
+ const report = lintLogic('The migration is ready for production. The migration is not ready for production.');
25
+ assert.equal(report.passed, false);
26
+ assert.equal(report.findings[0]?.kind, 'internal_contradiction');
27
+ });
28
+ test('uses the brief argument map as an additional topical anchor', () => {
29
+ const report = lintLogic('Operators still reconcile customer changes by hand. A small approval queue records the owner and reason for each change. The queue turns a vague audit request into a traceable review.', {
30
+ version: '1', audience: 'operators', intent: 'explain', format: 'social',
31
+ argumentMap: { observation: 'manual customer change reconciliation', mechanism: 'approval queue', consequence: 'traceable audit review', readerValue: 'faster operator review' },
32
+ });
33
+ assert.equal(report.passed, true);
34
+ });
35
+ test('does not make a drift verdict for a short post', () => {
36
+ const report = lintLogic('The release is ready. Espresso needs fresh beans.');
37
+ assert.equal(report.passed, true);
38
+ assert.deepEqual(report.skippedChecks, ['topic_drift_short_post']);
39
+ });
package/dist/mcp-tools.js CHANGED
@@ -3,6 +3,7 @@ import { parseCopySpec } from './copy-spec.js';
3
3
  import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
4
4
  import { clearLearning, composeLearning, inspectLearning, migrateLearningV2ToV3, ratifyLearningEvent, recordLearningInstruction, supersedeLearningEvent } from './learning.js';
5
5
  import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
6
+ import { lintLogic } from './logic-linter.js';
6
7
  import { parseProfile } from './profile.js';
7
8
  import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
8
9
  import { parseJudgmentEnvelope, preparePostCandidateJudgment, preparePreEditJudgment, reducePostCandidate, reducePreEdit } from './judgment-task.js';
@@ -98,6 +99,9 @@ export function verifyCopySpecForMcp(original, candidate, profileJson, copySpecJ
98
99
  const profile = profileFromJson(profileJson);
99
100
  return verifyWithCopySpec(original, candidate, profile, copySpecFromJson(copySpecJson), writingBriefFromJson(writingBriefJson));
100
101
  }
102
+ export function logicLintForMcp(draft, writingBriefJson) {
103
+ return lintLogic(draft, writingBriefFromJson(writingBriefJson));
104
+ }
101
105
  function parsed(json, label) {
102
106
  if (Buffer.byteLength(json, 'utf8') > 1024 * 1024)
103
107
  throw new Error(`${label} exceeds the byte limit.`);
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
- import { analyzeBatchForMcp, analyzeForMcp, applyHiddenTextPolicyForMcp, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, inspectHiddenTextForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, recordApprovedLearningForMcp, recordLearningForMcp, rebuildWriterRequestForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
7
+ import { analyzeBatchForMcp, analyzeForMcp, applyHiddenTextPolicyForMcp, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, inspectHiddenTextForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, logicLintForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, recordApprovedLearningForMcp, recordLearningForMcp, rebuildWriterRequestForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
8
8
  import { canonicalJson } from './canonical-json.js';
9
9
  const profile = buildProfileForMcp(['I write clearly. I keep the useful detail.', 'I make the call. Then I explain the trade-off.'], ['leverage']);
10
10
  const profileJson = JSON.stringify(profile);
@@ -23,6 +23,11 @@ test('inspects Unicode hygiene through MCP without a voice profile', () => {
23
23
  assert.equal(result.suspiciousCount, 2);
24
24
  assert.equal(result.fixableCount, 0);
25
25
  });
26
+ test('exposes the deterministic logic gate through MCP helpers', () => {
27
+ const report = logicLintForMcp('The service records cache keys. Each build writes its cache key. Espresso machines use dual boilers for stable temperature control. The service uses cache keys during incident review.');
28
+ assert.equal(report.passed, false);
29
+ assert.equal(report.findings[0]?.kind, 'topic_drift');
30
+ });
26
31
  test('applies only explicit hidden-text removals through MCP', () => {
27
32
  const policy = JSON.stringify({ version: '1', name: 'minimal-text-control-cleanup', approvedRemovals: ['ascii_control'], acknowledgement: 'Removes only listed non-semantic controls; all other findings remain review-only.' });
28
33
  const inspected = inspectHiddenTextForMcp('one\u0007two\uFEFFthree\u200D', policy);
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
- import { analyzeBatchForMcp, analyzeForMcp, applyHiddenTextPolicyForMcp, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, finalizeRejectionForMcp, inspectHiddenTextForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, migrateLearningForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, rebuildWriterRequestForMcp, recordApprovedLearningForMcp, recordLearningForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
4
+ import { analyzeBatchForMcp, analyzeForMcp, applyHiddenTextPolicyForMcp, applyRebuildForMcp, applyRewriteForMcp, buildProfileForMcp, clearLearningForMcp, finalOutputCheckForMcp, finalizeLifecycleForMcp, finalizeRejectionForMcp, inspectHiddenTextForMcp, inspectHygieneForMcp, inspectLearningForMcp, inspectLifecycleForMcp, logicLintForMcp, migrateLearningForMcp, patternsForMcp, prepareJudgmentForMcp, prepareLifecycleForMcp, prepareRebuildForMcp, prepareRewriteForMcp, ratifyLearningForMcp, rebuildWriterRequestForMcp, recordApprovedLearningForMcp, recordLearningForMcp, reduceJudgmentForMcp, rewritePromptForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
5
5
  import { HYV_VERSION } from './version.js';
6
6
  import { loadApprovalContext } from './approval-context.js';
7
7
  const writing = z.string().min(1).max(100_000);
@@ -78,6 +78,18 @@ server.registerTool('hyv_final_check', {
78
78
  inputSchema: { text: hygieneText },
79
79
  annotations: { readOnlyHint: true },
80
80
  }, async ({ text }) => json(finalOutputCheckForMcp(text)));
81
+ server.registerTool('hyv_logic_lint', {
82
+ description: 'Run the deterministic document-coherence gate. It detects configured topic drift, unanchored inference, and direct internal contradictions; it does not verify facts or approve publication.',
83
+ inputSchema: { draft: writing, writing_brief_json: writingBriefJson.optional() },
84
+ annotations: { readOnlyHint: true },
85
+ }, async ({ draft, writing_brief_json }) => {
86
+ try {
87
+ return json(logicLintForMcp(draft, writing_brief_json));
88
+ }
89
+ catch (error) {
90
+ return failure(error);
91
+ }
92
+ });
81
93
  server.registerTool('hyv_rewrite_prompt', {
82
94
  description: 'Create a constrained editing brief. It does not rewrite the draft or call a model.',
83
95
  inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
package/dist/mcp.test.js CHANGED
@@ -64,7 +64,7 @@ test('serves local Claude tools over stdio', async () => {
64
64
  assert.equal(code, 0);
65
65
  const responses = stdout.trim().split('\n').map((line) => JSON.parse(line));
66
66
  const tools = responses.find((response) => response.id === 2)?.result?.tools;
67
- assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_hygiene', 'hyv_inspect_hidden_text', 'hyv_apply_hidden_text_policy', 'hyv_final_check', 'hyv_rewrite_prompt', 'hyv_prepare_rewrite', 'hyv_apply_rewrite', 'hyv_prepare_judgment', 'hyv_reduce_judgment', 'hyv_verify', 'hyv_verify_copy_spec', 'hyv_batch_analyze', 'hyv_patterns', 'hyv_learning_inspect', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear', 'hyv_lifecycle_prepare_semantic', 'hyv_lifecycle_submit_verdict', 'hyv_lifecycle_inspect', 'hyv_lifecycle_finalize']);
67
+ assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_analyze', 'hyv_hygiene', 'hyv_inspect_hidden_text', 'hyv_apply_hidden_text_policy', 'hyv_final_check', 'hyv_logic_lint', 'hyv_rewrite_prompt', 'hyv_prepare_rewrite', 'hyv_apply_rewrite', 'hyv_prepare_judgment', 'hyv_reduce_judgment', 'hyv_verify', 'hyv_verify_copy_spec', 'hyv_batch_analyze', 'hyv_patterns', 'hyv_learning_inspect', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear', 'hyv_lifecycle_prepare_semantic', 'hyv_lifecycle_submit_verdict', 'hyv_lifecycle_inspect', 'hyv_lifecycle_finalize']);
68
68
  assert.ok(tools?.filter((tool) => !['hyv_verify', 'hyv_verify_copy_spec', 'hyv_apply_hidden_text_policy', 'hyv_learning_record', 'hyv_learning_ratify', 'hyv_learning_supersede', 'hyv_learning_migrate', 'hyv_learning_clear'].includes(tool.name)).every((tool) => tool.annotations?.readOnlyHint));
69
69
  assert.equal(tools?.find((tool) => tool.name === 'hyv_verify')?.annotations?.readOnlyHint, true);
70
70
  assert.equal(tools?.find((tool) => tool.name === 'hyv_verify_copy_spec')?.annotations?.readOnlyHint, true);
@@ -75,6 +75,7 @@ test('serves local Claude tools over stdio', async () => {
75
75
  assert.equal('capability_json' in (tools?.find((tool) => tool.name === 'hyv_lifecycle_finalize')?.inputSchema?.properties ?? {}), false);
76
76
  assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_inspect')?.annotations?.readOnlyHint, true);
77
77
  assert.equal(tools?.find((tool) => tool.name === 'hyv_inspect_hidden_text')?.annotations?.readOnlyHint, true);
78
+ assert.equal(tools?.find((tool) => tool.name === 'hyv_logic_lint')?.annotations?.readOnlyHint, true);
78
79
  assert.equal(tools?.find((tool) => tool.name === 'hyv_apply_hidden_text_policy')?.annotations?.readOnlyHint, false);
79
80
  assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_clear')?.annotations?.readOnlyHint, false);
80
81
  assert.deepEqual(tools?.filter((tool) => tool.name.startsWith('hyv_learning_')).map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.annotations?.destructiveHint]), [
@@ -97,7 +98,7 @@ test('registers capability tools only with host redaction attestation', async ()
97
98
  assert.equal(stderr, '');
98
99
  const response = stdout.trim().split('\n').map((line) => JSON.parse(line)).find((item) => item.id === 2);
99
100
  const names = response.result.tools.map((tool) => tool.name);
100
- assert.equal(names.length, 30);
101
+ assert.equal(names.length, 31);
101
102
  assert.ok(names.includes('hyv_lifecycle_validate_final_approval'));
102
103
  assert.ok(names.includes('hyv_learning_record_approved'));
103
104
  assert.ok(names.includes('hyv_prepare_rebuild'));
package/dist/pipeline.js CHANGED
@@ -8,6 +8,7 @@ import { finalOutputCheck, inspectHygiene } from './hygiene.js';
8
8
  import { analyzeVoiceDna } from './voice-dna.js';
9
9
  import { legacySetPreservation } from './preservation.js';
10
10
  import { lintFacts } from './fact-linter.js';
11
+ import { lintLogic } from './logic-linter.js';
11
12
  import { sentences } from './text.js';
12
13
  export function analyze(text, profile, brief) {
13
14
  const voiceDna = analyzeVoiceDna(text, profile);
@@ -112,6 +113,7 @@ function verifyRequiredFacts(candidate, brief) {
112
113
  export function verify(original, candidate, profile, brief) {
113
114
  const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
114
115
  const finalOutput = finalOutputCheck(candidate);
116
+ const logicLint = lintLogic(candidate, brief);
115
117
  const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
116
118
  const requiredFacts = verifyRequiredFacts(candidate, brief);
117
119
  return {
@@ -121,8 +123,9 @@ export function verify(original, candidate, profile, brief) {
121
123
  preservationScore: preservation,
122
124
  regressions,
123
125
  finalOutput,
126
+ logicLint,
124
127
  ...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
125
- passed: checked.passed && !regressions.some(isBlockingFinding) && preservation >= 70 && finalOutput.accepted && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
128
+ passed: checked.passed && !regressions.some(isBlockingFinding) && preservation >= 70 && finalOutput.accepted && logicLint.passed && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
126
129
  };
127
130
  }
128
131
  export function verifyWithCopySpec(original, candidate, profile, spec, brief) {
@@ -134,6 +137,7 @@ export function verifyRebuildWithCopySpec(original, candidate, profile, spec, br
134
137
  const { baseline, checked, regressions, preservation } = compareCandidates(original, candidate, profile, brief);
135
138
  const claims = verifyClaims(candidate, spec);
136
139
  const finalCheck = finalOutputCheck(candidate);
140
+ const logicLint = lintLogic(candidate, brief);
137
141
  const factLint = brief?.factSources?.length ? lintFacts({ sources: brief.factSources, draft: candidate, metadata: brief.factMetadata }) : undefined;
138
142
  const requiredFacts = verifyRequiredFacts(candidate, brief);
139
143
  return {
@@ -144,8 +148,9 @@ export function verifyRebuildWithCopySpec(original, candidate, profile, spec, br
144
148
  regressions,
145
149
  claims,
146
150
  finalOutput: finalCheck,
151
+ logicLint,
147
152
  ...(factLint ? { factLint } : {}), ...(requiredFacts ? { requiredFacts } : {}),
148
- passed: checked.passed && !regressions.some(isBlockingFinding) && claims.passed && finalCheck.accepted && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
153
+ passed: checked.passed && !regressions.some(isBlockingFinding) && claims.passed && finalCheck.accepted && logicLint.passed && !factLint?.findings.some((finding) => finding.severity === 'error') && (requiredFacts?.passed ?? true),
149
154
  };
150
155
  }
151
156
  function digest(value) { return createHash('sha256').update(value).digest('hex'); }
@@ -94,6 +94,14 @@ test('verification applies the final-output gate by default', () => {
94
94
  assert.equal(result.finalOutput.accepted, false);
95
95
  assert.equal('output' in result.finalOutput, false);
96
96
  });
97
+ test('verification blocks a candidate with document-level topic drift', () => {
98
+ const source = 'The checklist names rollback ownership. Each owner signs before deployment. The checklist catches missing rollback steps. The owner reviews the checklist after release.';
99
+ const candidate = 'The checklist names rollback ownership. Each owner signs before deployment. Espresso machines use a dual boiler for stable temperature control. The checklist catches missing rollback steps.';
100
+ const result = verify(source, candidate, profile);
101
+ assert.equal(result.logicLint.passed, false);
102
+ assert.equal(result.logicLint.findings[0]?.kind, 'topic_drift');
103
+ assert.equal(result.passed, false);
104
+ });
97
105
  test('advisory and pending-judgment findings pass while blocking findings fail', () => {
98
106
  const advisory = analyze('Firstly, check the invoice.', profile);
99
107
  assert.equal(advisory.passed, true);
@@ -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.3.2');
177
+ assert.equal(HYV_VERSION, '3.3.4');
178
178
  });
179
179
  test('apply rejects forged tasks, missing capability, and substituted profiles', () => {
180
180
  const reduction = rebuildRecommendation();
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const HYV_VERSION = '3.3.2';
1
+ export const HYV_VERSION = '3.3.4';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@holdyourvoice/hyv",
3
- "version": "3.3.2",
3
+ "version": "3.3.4",
4
4
  "description": "A local-first dual-engine writing gate that protects voice and catches generic AI patterns.",
5
5
  "type": "module",
6
6
  "bin": { "hyv": "dist/cli.js" },