@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 +1 -1
- package/dist/cli.js +10 -1
- package/dist/cli.test.js +14 -1
- package/dist/logic-linter-corpus.test.js +22 -0
- package/dist/logic-linter.js +101 -0
- package/dist/logic-linter.test.js +39 -0
- package/dist/mcp-tools.js +4 -0
- package/dist/mcp-tools.test.js +6 -1
- package/dist/mcp.js +13 -1
- package/dist/mcp.test.js +3 -2
- package/dist/pipeline.js +7 -2
- package/dist/pipeline.test.js +8 -0
- package/dist/rebuild-task.test.js +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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.`);
|
package/dist/mcp-tools.test.js
CHANGED
|
@@ -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,
|
|
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'); }
|
package/dist/pipeline.test.js
CHANGED
|
@@ -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.
|
|
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.
|
|
1
|
+
export const HYV_VERSION = '3.3.4';
|
package/package.json
CHANGED