@holdyourvoice/hyv 3.4.2 → 3.4.3
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 +69 -1
- package/dist/cli.js +46 -1
- package/dist/delivery-integrity.js +42 -0
- package/dist/disposition.js +14 -0
- package/dist/mcp-tools.js +13 -0
- package/dist/mcp.js +17 -1
- package/dist/mcp.test.js +4 -2
- package/dist/mirror-refs.test.js +63 -0
- package/dist/production-gates.test.js +34 -0
- package/dist/profile-quality.js +17 -0
- package/dist/rebuild-task.test.js +1 -1
- package/dist/release-audit.test.js +93 -0
- package/dist/team-profile.js +50 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/Readme.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@holdyourvoice/hyv)
|
|
4
4
|
|
|
5
|
-
Hold Your Voice (`hyv`) is a local writing checker. It helps you edit AI-assisted writing without losing your own writing patterns.
|
|
5
|
+
Hold Your Voice (`hyv`) is a local writing checker. It helps you edit AI-assisted writing without losing your own writing patterns. It is not an AI-authorship detector: it provides local, inspectable writing evidence while a human still decides what to publish.
|
|
6
6
|
|
|
7
7
|
It runs two independent checks:
|
|
8
8
|
|
|
@@ -11,6 +11,66 @@ It runs two independent checks:
|
|
|
11
11
|
|
|
12
12
|
The package also checks hidden Unicode, source-backed facts, document logic, and protected claims. All checks run locally, without model calls, automatic draft changes, or runtime network requests.
|
|
13
13
|
|
|
14
|
+
## how it works
|
|
15
|
+
|
|
16
|
+
```mermaid
|
|
17
|
+
flowchart TD
|
|
18
|
+
samples["Writing samples"] --> profile["Local VoiceDNA profile"]
|
|
19
|
+
draft["Draft"] --> analyze["hyv analyze"]
|
|
20
|
+
profile --> analyze
|
|
21
|
+
brief["Optional WritingBrief"] -.-> analyze
|
|
22
|
+
|
|
23
|
+
subgraph inspect["1 · inspect the draft"]
|
|
24
|
+
analyze --> voice["VoiceDNA check"]
|
|
25
|
+
analyze --> patterns["AI pattern lint"]
|
|
26
|
+
analyze --> hidden["Hidden-text / Unicode check"]
|
|
27
|
+
voice --> local{"Local result"}
|
|
28
|
+
patterns --> local
|
|
29
|
+
hidden -.-> local
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
local -->|No blocking change| candidate["Candidate text"]
|
|
33
|
+
local -->|Blocking edit scope| editTask["Prepare fingerprint-bound edit task"]
|
|
34
|
+
local -->|Judgment required| judgment["Prepare and reduce judgments"]
|
|
35
|
+
judgment --> route{"SHIP, EDIT, or REBUILD?"}
|
|
36
|
+
route -->|SHIP| candidate
|
|
37
|
+
route -->|EDIT| editTask
|
|
38
|
+
route -->|REBUILD| authorization["REBUILD recommendation + CopySpec + signed authorization"]
|
|
39
|
+
authorization --> rebuildTask["Prepare fingerprint-bound rebuild task"]
|
|
40
|
+
editTask --> editor["Human editor or model you choose"]
|
|
41
|
+
rebuildTask --> editor
|
|
42
|
+
editor --> response["Bound response"]
|
|
43
|
+
|
|
44
|
+
brief -.-> logic
|
|
45
|
+
sources["Optional fact sources in WritingBrief"] -.-> facts
|
|
46
|
+
spec["Optional for verify-spec; required for rebuild"] -.-> authorization
|
|
47
|
+
spec -.-> standard
|
|
48
|
+
|
|
49
|
+
subgraph verification["2 · verification gate"]
|
|
50
|
+
candidate --> standard["hyv verify / verify-spec"]
|
|
51
|
+
response --> mode{"Bound task mode"}
|
|
52
|
+
mode -->|EDIT| editApply["apply-rewrite + standard verification"]
|
|
53
|
+
mode -->|REBUILD| rebuildApply["apply-rebuild + rebuild verification"]
|
|
54
|
+
standard --> standardRules["Preservation gate + CopySpec claims when supplied"]
|
|
55
|
+
editApply --> standardRules
|
|
56
|
+
rebuildApply --> rebuildRules["CopySpec claims; preservation reported"]
|
|
57
|
+
standardRules --> engines["VoiceDNA + AI Editor checks and blocking regressions"]
|
|
58
|
+
rebuildRules --> engines
|
|
59
|
+
engines --> logic["Logic lint"]
|
|
60
|
+
logic --> facts["Fact lint when sources are supplied"]
|
|
61
|
+
facts --> outputGate["Hidden-text + final-output gate"]
|
|
62
|
+
outputGate --> passed{"All required checks pass?"}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
passed -->|No| repair["Repair externally or prepare a new task"]
|
|
66
|
+
repair --> analyze
|
|
67
|
+
passed -->|Yes| review["Semantic review and human approval, when required"]
|
|
68
|
+
review --> final["Run final-check after the last change"]
|
|
69
|
+
final --> output["Exact accepted text"]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
HYV keeps draft inspection, candidate verification, and final delivery separate. Standard verification reruns VoiceDNA and AI Editor, rejects blocking regressions, enforces preservation, runs logic lint, applies fact lint when a WritingBrief supplies sources, and withholds hidden-text failures. `verify-spec` adds CopySpec claim checks. Authorized rebuilds require an upstream REBUILD recommendation, a CopySpec, and signed authorization; their verification reports preservation without using the standard preservation threshold. Run `final-check` again after the last human, model, formatter, or template change. HYV never calls a model; a human editor or model you choose supplies edits and judgments.
|
|
73
|
+
|
|
14
74
|
## install
|
|
15
75
|
|
|
16
76
|
You need Node.js 20 or newer and at least two writing samples you have the right to use.
|
|
@@ -57,6 +117,8 @@ producer | hyv final-check -
|
|
|
57
117
|
|
|
58
118
|
`final-check` writes accepted text to stdout. It withholds output and exits with code `2` when unresolved hidden Unicode remains.
|
|
59
119
|
|
|
120
|
+
`delivery-check` is a separate opt-in offline check for placeholders, likely credential patterns, local Markdown links, and citation IDs in a local policy. It never fetches a URL or proves a fact.
|
|
121
|
+
|
|
60
122
|
## commands
|
|
61
123
|
|
|
62
124
|
| Command | Purpose |
|
|
@@ -67,6 +129,10 @@ producer | hyv final-check -
|
|
|
67
129
|
| `hyv inspect-hidden-text <draft> [policy.json]` | Inspect hidden text with an optional policy. |
|
|
68
130
|
| `hyv apply-hidden-text-policy <draft> <policy.json> <output>` | Apply approved hidden-text removals. |
|
|
69
131
|
| `hyv final-check <path\|->` | Gate the exact text before delivery. |
|
|
132
|
+
| `hyv delivery-check <path\|-> [policy.json]` | Run optional local delivery-integrity checks. |
|
|
133
|
+
| `hyv profile assess <sample...>` | Inspect sample readiness before building a profile. |
|
|
134
|
+
| `hyv team-profile validate\|compose ...` | Validate or locally compose consent-bound team profile metadata. |
|
|
135
|
+
| `hyv dispositions <draft> <profile>` | Return normalized `block`, `review`, and `signal` findings. |
|
|
70
136
|
| `hyv fact-lint <draft\|-> --source=id:path` | Check claims against local source files. |
|
|
71
137
|
| `hyv logic-lint <draft\|-> [brief.json]` | Check deterministic document logic. |
|
|
72
138
|
| `hyv batch-analyze <draft...>` | Find repeated openings and endings across drafts. |
|
|
@@ -118,6 +184,8 @@ npm run check:release
|
|
|
118
184
|
|
|
119
185
|
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. The main design boundaries are in [Architecture](docs/ARCHITECTURE.md) and the full user guides are in the [wiki](https://github.com/shashank-sn/holdyourvoice/wiki).
|
|
120
186
|
|
|
187
|
+
See the [roadmap](docs/ROADMAP.md), [rule authoring guide](docs/RULE-AUTHORING.md), and synthetic [benchmark scorecard command](scripts/public-scorecard.mjs). The public fixture scorecard does not measure human preference or model quality.
|
|
188
|
+
|
|
121
189
|
## license
|
|
122
190
|
|
|
123
191
|
[MIT](LICENSE). Third-party writing and data keep their own rights.
|
package/dist/cli.js
CHANGED
|
@@ -20,7 +20,11 @@ import { loadApprovalContext } from './approval-context.js';
|
|
|
20
20
|
import { formatFactLintReport, lintFacts } from './fact-linter.js';
|
|
21
21
|
import { lintLogic } from './logic-linter.js';
|
|
22
22
|
import { loadAll, validateAll, validateId, sortedIds, describe, emitJson, emitPrompt } from './agents/index.js';
|
|
23
|
-
|
|
23
|
+
import { inspectDeliveryIntegrity, parseDeliveryIntegrityPolicy } from './delivery-integrity.js';
|
|
24
|
+
import { assessProfileReadiness } from './profile-quality.js';
|
|
25
|
+
import { normalizeFinding, parseSurfacePolicy } from './disposition.js';
|
|
26
|
+
import { composeTeamProfile, parseTeamProfileBundle } from './team-profile.js';
|
|
27
|
+
const usage = 'Commands: agent, profile, team-profile, analyze, hygiene, inspect-hidden-text, apply-hidden-text-policy, final-check, delivery-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, dispositions, mcp';
|
|
24
28
|
function input(path) {
|
|
25
29
|
return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
|
|
26
30
|
}
|
|
@@ -379,10 +383,39 @@ function runAgent(args) {
|
|
|
379
383
|
throw new Error('Usage: hyv agent <list|validate|describe|emit> ...');
|
|
380
384
|
}
|
|
381
385
|
function runProfile(args) {
|
|
386
|
+
if (args[0] === 'assess') {
|
|
387
|
+
if (args.length < 3)
|
|
388
|
+
throw new Error('Usage: hyv profile assess sample-a.md sample-b.md [sample-c.md]');
|
|
389
|
+
json(assessProfileReadiness(args.slice(1).map(input)));
|
|
390
|
+
return 0;
|
|
391
|
+
}
|
|
382
392
|
const { output, samples, avoid } = profileArguments(args);
|
|
383
393
|
writeJson(output, buildProfile(samples.map(input), avoid));
|
|
384
394
|
return 0;
|
|
385
395
|
}
|
|
396
|
+
function runTeamProfile(args) {
|
|
397
|
+
const [action, bundlePath, authorPath, ...brandPaths] = args;
|
|
398
|
+
if (action === 'validate' && bundlePath && !authorPath) {
|
|
399
|
+
json(parseTeamProfileBundle(readJson(bundlePath)));
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
402
|
+
if (action === 'compose' && bundlePath && authorPath) {
|
|
403
|
+
const brands = brandPaths.map(readProfile).filter((profile) => profile.version === '3');
|
|
404
|
+
if (brands.length !== brandPaths.length)
|
|
405
|
+
throw new Error('Team brand profiles must use Profile v3.');
|
|
406
|
+
json(composeTeamProfile(readProfile(authorPath), brands, parseTeamProfileBundle(readJson(bundlePath))));
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
throw new Error('Usage: hyv team-profile <validate bundle.json|compose bundle.json author-profile.json [brand-profile.json...]>');
|
|
410
|
+
}
|
|
411
|
+
function runDeliveryCheck(args) {
|
|
412
|
+
const [path, policyPath, ...extra] = args;
|
|
413
|
+
if (!path || extra.length)
|
|
414
|
+
throw new Error('Usage: hyv delivery-check <path|-> [policy.json]');
|
|
415
|
+
const report = inspectDeliveryIntegrity(input(path), policyPath ? parseDeliveryIntegrityPolicy(readJson(policyPath)) : undefined, process.cwd());
|
|
416
|
+
json(report);
|
|
417
|
+
return report.passed ? 0 : 2;
|
|
418
|
+
}
|
|
386
419
|
function runAnalyze(args) {
|
|
387
420
|
const [draft, profilePath, briefPath] = args;
|
|
388
421
|
if (!draft || !profilePath)
|
|
@@ -720,6 +753,15 @@ function runPatterns() {
|
|
|
720
753
|
json({ version: RULESET_VERSION, rules: serializedRules() });
|
|
721
754
|
return 0;
|
|
722
755
|
}
|
|
756
|
+
function runDispositions(args) {
|
|
757
|
+
const [draft, profilePath, briefPath, surfacePolicyPath] = args;
|
|
758
|
+
if (!draft || !profilePath)
|
|
759
|
+
throw new Error('Usage: hyv dispositions draft.md profile.json [writing-brief.json]');
|
|
760
|
+
const report = analyze(input(draft), readProfile(profilePath), readBrief(briefPath));
|
|
761
|
+
const policy = surfacePolicyPath ? parseSurfacePolicy(readJson(surfacePolicyPath)) : undefined;
|
|
762
|
+
json({ version: '1', findings: [report.voiceDna, report.aiEditor, report.editorial].flatMap((engine) => engine?.findings.map((finding) => normalizeFinding(finding, policy)) ?? []) });
|
|
763
|
+
return 0;
|
|
764
|
+
}
|
|
723
765
|
async function runMcp(args) {
|
|
724
766
|
if (args.length > 0)
|
|
725
767
|
throw new Error('Usage: hyv mcp');
|
|
@@ -729,11 +771,13 @@ async function runMcp(args) {
|
|
|
729
771
|
const commandHandlers = {
|
|
730
772
|
agent: runAgent,
|
|
731
773
|
profile: runProfile,
|
|
774
|
+
'team-profile': runTeamProfile,
|
|
732
775
|
analyze: runAnalyze,
|
|
733
776
|
hygiene: runHygiene,
|
|
734
777
|
'inspect-hidden-text': runInspectHiddenText,
|
|
735
778
|
'apply-hidden-text-policy': runApplyHiddenTextPolicy,
|
|
736
779
|
'final-check': runFinalCheck,
|
|
780
|
+
'delivery-check': runDeliveryCheck,
|
|
737
781
|
'fact-lint': runFactLint,
|
|
738
782
|
'logic-lint': runLogicLint,
|
|
739
783
|
'batch-analyze': runBatchAnalyze,
|
|
@@ -750,6 +794,7 @@ const commandHandlers = {
|
|
|
750
794
|
lifecycle: runLifecycle,
|
|
751
795
|
learning: runLearning,
|
|
752
796
|
patterns: runPatterns,
|
|
797
|
+
dispositions: runDispositions,
|
|
753
798
|
mcp: runMcp,
|
|
754
799
|
};
|
|
755
800
|
export async function runCli(args) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
function isInside(root, target) {
|
|
4
|
+
const path = relative(root, target);
|
|
5
|
+
return path === '' || (!path.startsWith('..') && !isAbsolute(path));
|
|
6
|
+
}
|
|
7
|
+
export function parseDeliveryIntegrityPolicy(value) {
|
|
8
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
9
|
+
throw new Error('Delivery policy must be an object.');
|
|
10
|
+
const policy = value;
|
|
11
|
+
if (policy.version !== '1' || Object.keys(policy).some((key) => !['version', 'block', 'sourceIds'].includes(key)))
|
|
12
|
+
throw new Error('Delivery policy is not valid.');
|
|
13
|
+
if (policy.block !== undefined && (!Array.isArray(policy.block) || !policy.block.every((item) => item === 'placeholder' || item === 'local_link')))
|
|
14
|
+
throw new Error('Delivery policy block list is not valid.');
|
|
15
|
+
if (policy.sourceIds !== undefined && (!Array.isArray(policy.sourceIds) || !policy.sourceIds.every((item) => typeof item === 'string' && item.length > 0)))
|
|
16
|
+
throw new Error('Delivery policy source IDs are not valid.');
|
|
17
|
+
return policy;
|
|
18
|
+
}
|
|
19
|
+
export function inspectDeliveryIntegrity(text, policy = { version: '1' }, root = process.cwd()) {
|
|
20
|
+
const findings = [];
|
|
21
|
+
const disposition = (kind) => policy.block?.includes(kind) ? 'block' : 'review';
|
|
22
|
+
for (const match of text.matchAll(/{{\s*[^}]+\s*}}|\[\[\s*[^\]]+\s*\]\]|\b(?:TODO|TBD)\b/g)) {
|
|
23
|
+
findings.push({ kind: 'placeholder', disposition: disposition('placeholder'), excerpt: match[0], reason: 'Unresolved template or editorial placeholder.', suggestion: 'Replace or explicitly remove it before delivery.' });
|
|
24
|
+
}
|
|
25
|
+
for (const match of text.matchAll(/(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16})/g)) {
|
|
26
|
+
findings.push({ kind: 'secret', disposition: 'review', excerpt: match[0].slice(0, 8) + '…', reason: 'Looks like a credential pattern; this is not proof that it is a secret.', suggestion: 'Remove it or verify it is safe to disclose.' });
|
|
27
|
+
}
|
|
28
|
+
for (const match of text.matchAll(/\[[^\]]+\]\(([^)#][^)]*)\)/g)) {
|
|
29
|
+
const target = match[1];
|
|
30
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('#'))
|
|
31
|
+
continue;
|
|
32
|
+
const resolved = resolve(root, target);
|
|
33
|
+
const missing = !isInside(root, resolved) || !existsSync(resolved) || !isInside(root, realpathSync(resolved));
|
|
34
|
+
if (missing)
|
|
35
|
+
findings.push({ kind: 'local_link', disposition: disposition('local_link'), excerpt: target, reason: 'Local Markdown target is missing or resolves outside the approved root.', suggestion: 'Fix the target or use an approved external URL.' });
|
|
36
|
+
}
|
|
37
|
+
for (const match of text.matchAll(/\[@([A-Za-z0-9._:-]+)\]/g)) {
|
|
38
|
+
if (!policy.sourceIds?.includes(match[1]))
|
|
39
|
+
findings.push({ kind: 'citation', disposition: 'signal', excerpt: match[0], reason: 'Citation identifier is not present in the supplied local source manifest.', suggestion: 'Add the ID to sourceIds or review the reference.' });
|
|
40
|
+
}
|
|
41
|
+
return { version: '1', passed: !findings.some((finding) => finding.disposition === 'block'), findings };
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function parseSurfacePolicy(value) {
|
|
2
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
3
|
+
throw new Error('Surface policy is invalid.');
|
|
4
|
+
const policy = value;
|
|
5
|
+
if (policy.version !== '1' || !policy.overrides || typeof policy.overrides !== 'object' || Array.isArray(policy.overrides) || Object.keys(policy).some((key) => !['version', 'overrides'].includes(key)) || !Object.entries(policy.overrides).every(([id, disposition]) => /^[a-z][a-z0-9.-]+$/.test(id) && ['block', 'review', 'signal'].includes(disposition)))
|
|
6
|
+
throw new Error('Surface policy is invalid.');
|
|
7
|
+
return policy;
|
|
8
|
+
}
|
|
9
|
+
export function normalizeFinding(finding, policy) {
|
|
10
|
+
const base = finding.appliedPolicy === 'blocking' || (finding.engine === 'voice_dna' && finding.severity === 'red') ? 'block'
|
|
11
|
+
: finding.appliedPolicy === 'judgment-required' ? 'review' : 'signal';
|
|
12
|
+
const disposition = policy?.overrides[finding.id] ?? base;
|
|
13
|
+
return { version: '1', id: finding.id, disposition, evidence: { engine: finding.engine, sentence: finding.sentence, excerpt: finding.excerpt }, reason: finding.reason, suggestion: finding.suggestion, confidence: 'deterministic' };
|
|
14
|
+
}
|
package/dist/mcp-tools.js
CHANGED
|
@@ -13,6 +13,9 @@ import { buildProfile } from './voice-dna.js';
|
|
|
13
13
|
import { finalOutputCheck, inspectHygiene } from './hygiene.js';
|
|
14
14
|
import { finalizeLifecycle, inspectLifecycle, prepareLifecycle, recordApprovedLearning, submitSemanticVerdict, validateFinalApproval } from './lifecycle-adapter.js';
|
|
15
15
|
import { MAX_JSON_BYTES } from './internal.js';
|
|
16
|
+
import { lintFacts } from './fact-linter.js';
|
|
17
|
+
import { inspectDeliveryIntegrity, parseDeliveryIntegrityPolicy } from './delivery-integrity.js';
|
|
18
|
+
import { assessProfileReadiness } from './profile-quality.js';
|
|
16
19
|
function profileFromJson(profileJson) {
|
|
17
20
|
try {
|
|
18
21
|
return parseProfile(JSON.parse(profileJson));
|
|
@@ -103,6 +106,16 @@ export function verifyCopySpecForMcp(original, candidate, profileJson, copySpecJ
|
|
|
103
106
|
export function logicLintForMcp(draft, writingBriefJson) {
|
|
104
107
|
return lintLogic(draft, writingBriefFromJson(writingBriefJson));
|
|
105
108
|
}
|
|
109
|
+
export function factLintForMcp(draft, sourcesJson, metadataJson) {
|
|
110
|
+
const sources = parsed(sourcesJson, 'Fact sources');
|
|
111
|
+
if (!Array.isArray(sources) || !sources.every((source) => source && typeof source === 'object' && typeof source.id === 'string' && typeof source.text === 'string'))
|
|
112
|
+
throw new Error('Fact sources are not valid.');
|
|
113
|
+
return lintFacts({ draft, sources: sources, metadata: metadataJson ? parsed(metadataJson, 'Fact metadata') : undefined });
|
|
114
|
+
}
|
|
115
|
+
export function deliveryCheckForMcp(text, policyJson) {
|
|
116
|
+
return inspectDeliveryIntegrity(text, policyJson ? parseDeliveryIntegrityPolicy(parsed(policyJson, 'Delivery policy')) : undefined);
|
|
117
|
+
}
|
|
118
|
+
export function assessProfileForMcp(samples) { return assessProfileReadiness(samples); }
|
|
106
119
|
function parsed(json, label) {
|
|
107
120
|
if (Buffer.byteLength(json, 'utf8') > MAX_JSON_BYTES)
|
|
108
121
|
throw new Error(`${label} exceeds the byte limit.`);
|
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, logicLintForMcp, 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, assessProfileForMcp, buildProfileForMcp, clearLearningForMcp, deliveryCheckForMcp, factLintForMcp, 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);
|
|
@@ -15,6 +15,7 @@ const lifecycleJson = z.string().min(1).max(1_048_576);
|
|
|
15
15
|
const approvedLearningText = z.string().min(1).max(1_048_576);
|
|
16
16
|
const evaluatorId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/);
|
|
17
17
|
const semanticViolation = z.enum(['action_change', 'dropped_object', 'unsupported_claim', 'constraint_weakened', 'clarity_regression']);
|
|
18
|
+
const factSourcesJson = z.string().min(2).max(250_000);
|
|
18
19
|
const redactsSensitiveInputs = process.env.HYV_MCP_SENSITIVE_INPUT_REDACTION === '1';
|
|
19
20
|
const learningOptions = {
|
|
20
21
|
mutation_id: z.string().min(1).max(200).optional(),
|
|
@@ -57,6 +58,10 @@ server.registerTool('hyv_build_profile', {
|
|
|
57
58
|
inputSchema: { samples, avoid },
|
|
58
59
|
annotations: { readOnlyHint: true },
|
|
59
60
|
}, async ({ samples: writingSamples, avoid: phrases }) => guardedJson(() => buildProfileForMcp(writingSamples, phrases)));
|
|
61
|
+
server.registerTool('hyv_profile_assess', {
|
|
62
|
+
description: 'Assess local sample readiness before building a VoiceDNA profile. It returns aggregate counts and one-way sample digests, never stored sample text or an authorship verdict.',
|
|
63
|
+
inputSchema: { samples }, annotations: { readOnlyHint: true },
|
|
64
|
+
}, async ({ samples: writingSamples }) => json(assessProfileForMcp(writingSamples)));
|
|
60
65
|
server.registerTool('hyv_analyze', {
|
|
61
66
|
description: 'Run separate VoiceDNA and AI Editor checks plus a non-scoring Unicode hygiene inspection against a draft using a portable profile JSON string.',
|
|
62
67
|
inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
|
|
@@ -80,6 +85,17 @@ server.registerTool('hyv_final_check', {
|
|
|
80
85
|
inputSchema: { text: hygieneText },
|
|
81
86
|
annotations: { readOnlyHint: true },
|
|
82
87
|
}, async ({ text }) => json(finalOutputCheckForMcp(text)));
|
|
88
|
+
server.registerTool('hyv_delivery_check', {
|
|
89
|
+
description: 'Run an opt-in local delivery-integrity check for placeholders, likely secrets, local links, and supplied citation IDs. It never fetches URLs and is separate from final-check.',
|
|
90
|
+
inputSchema: { text: hygieneText, policy_json: lifecycleJson.optional() }, annotations: { readOnlyHint: true },
|
|
91
|
+
}, async ({ text, policy_json }) => guardedJson(() => deliveryCheckForMcp(text, policy_json)));
|
|
92
|
+
server.registerTool('hyv_fact_lint', {
|
|
93
|
+
description: 'Compare a draft against explicitly supplied local evidence text. It is a source-consistency check, not a truth service, and does not make network requests.',
|
|
94
|
+
inputSchema: { draft: writing, sources_json: factSourcesJson, metadata_json: lifecycleJson.optional() }, annotations: { readOnlyHint: true },
|
|
95
|
+
}, async ({ draft, sources_json, metadata_json }) => guardedJson(() => factLintForMcp(draft, sources_json, metadata_json)));
|
|
96
|
+
server.registerTool('hyv_mcp_capabilities', {
|
|
97
|
+
description: 'Return the stable core MCP tool names and the compatibility-preserved advanced surface.', inputSchema: {}, annotations: { readOnlyHint: true },
|
|
98
|
+
}, async () => json({ version: '1', serverVersion: HYV_VERSION, core: ['hyv_analyze', 'hyv_verify', 'hyv_fact_lint', 'hyv_final_check'], advanced: ['hyv_logic_lint', 'hyv_delivery_check', 'hyv_profile_assess'], sensitiveInputRedaction: redactsSensitiveInputs }));
|
|
83
99
|
server.registerTool('hyv_logic_lint', {
|
|
84
100
|
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.',
|
|
85
101
|
inputSchema: { draft: writing, 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_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']);
|
|
67
|
+
assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_profile_assess', 'hyv_analyze', 'hyv_hygiene', 'hyv_inspect_hidden_text', 'hyv_apply_hidden_text_policy', 'hyv_final_check', 'hyv_delivery_check', 'hyv_fact_lint', 'hyv_mcp_capabilities', '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);
|
|
@@ -76,6 +76,8 @@ test('serves local Claude tools over stdio', async () => {
|
|
|
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
78
|
assert.equal(tools?.find((tool) => tool.name === 'hyv_logic_lint')?.annotations?.readOnlyHint, true);
|
|
79
|
+
assert.equal(tools?.find((tool) => tool.name === 'hyv_fact_lint')?.annotations?.readOnlyHint, true);
|
|
80
|
+
assert.equal(tools?.find((tool) => tool.name === 'hyv_mcp_capabilities')?.annotations?.readOnlyHint, true);
|
|
79
81
|
assert.equal(tools?.find((tool) => tool.name === 'hyv_apply_hidden_text_policy')?.annotations?.readOnlyHint, false);
|
|
80
82
|
assert.equal(tools?.find((tool) => tool.name === 'hyv_learning_clear')?.annotations?.readOnlyHint, false);
|
|
81
83
|
assert.deepEqual(tools?.filter((tool) => tool.name.startsWith('hyv_learning_')).map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.annotations?.destructiveHint]), [
|
|
@@ -98,7 +100,7 @@ test('registers capability tools only with host redaction attestation', async ()
|
|
|
98
100
|
assert.equal(stderr, '');
|
|
99
101
|
const response = stdout.trim().split('\n').map((line) => JSON.parse(line)).find((item) => item.id === 2);
|
|
100
102
|
const names = response.result.tools.map((tool) => tool.name);
|
|
101
|
-
assert.equal(names.length,
|
|
103
|
+
assert.equal(names.length, 35);
|
|
102
104
|
assert.ok(names.includes('hyv_lifecycle_validate_final_approval'));
|
|
103
105
|
assert.ok(names.includes('hyv_learning_record_approved'));
|
|
104
106
|
assert.ok(names.includes('hyv_prepare_rebuild'));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
const mirrorScript = new URL('../scripts/mirror-refs.mjs', import.meta.url).pathname;
|
|
8
|
+
function git(cwd, ...args) {
|
|
9
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
10
|
+
}
|
|
11
|
+
function refs(repository) {
|
|
12
|
+
const text = git(repository, 'for-each-ref', '--format=%(objectname)\t%(refname)', 'refs/heads', 'refs/tags');
|
|
13
|
+
return text ? text.split('\n').sort() : [];
|
|
14
|
+
}
|
|
15
|
+
test('reconciles branch and tag creates, rewrites, and deletions', () => {
|
|
16
|
+
const root = mkdtempSync(join(tmpdir(), 'hyv-mirror-refs-'));
|
|
17
|
+
const source = join(root, 'source.git');
|
|
18
|
+
const mirror = join(root, 'mirror.git');
|
|
19
|
+
const checkout = join(root, 'checkout');
|
|
20
|
+
try {
|
|
21
|
+
git(root, 'init', '--bare', '--quiet', source);
|
|
22
|
+
git(root, 'init', '--bare', '--quiet', mirror);
|
|
23
|
+
git(root, 'clone', '--quiet', source, checkout);
|
|
24
|
+
git(checkout, 'config', 'user.name', 'Mirror Test');
|
|
25
|
+
git(checkout, 'config', 'user.email', 'mirror-test@example.invalid');
|
|
26
|
+
git(checkout, 'switch', '--quiet', '-c', 'main');
|
|
27
|
+
writeFileSync(join(checkout, 'main.txt'), 'first\n');
|
|
28
|
+
git(checkout, 'add', 'main.txt');
|
|
29
|
+
git(checkout, 'commit', '--quiet', '-m', 'first');
|
|
30
|
+
git(checkout, 'switch', '--quiet', '-c', 'feature/test');
|
|
31
|
+
writeFileSync(join(checkout, 'feature.txt'), 'feature\n');
|
|
32
|
+
git(checkout, 'add', 'feature.txt');
|
|
33
|
+
git(checkout, 'commit', '--quiet', '-m', 'feature');
|
|
34
|
+
git(checkout, 'tag', 'v1.0.0');
|
|
35
|
+
git(checkout, 'push', '--quiet', '--all', 'origin');
|
|
36
|
+
git(checkout, 'push', '--quiet', '--tags', 'origin');
|
|
37
|
+
git(source, 'symbolic-ref', 'HEAD', 'refs/heads/main');
|
|
38
|
+
git(checkout, 'remote', 'set-head', 'origin', '-a');
|
|
39
|
+
git(checkout, 'remote', 'add', 'mirror', mirror);
|
|
40
|
+
execFileSync(process.execPath, [mirrorScript], { cwd: checkout, stdio: 'pipe' });
|
|
41
|
+
assert.deepEqual(refs(mirror), refs(source));
|
|
42
|
+
assert.equal(refs(mirror).some((ref) => ref.endsWith('refs/heads/HEAD')), false);
|
|
43
|
+
git(checkout, 'switch', '--quiet', 'main');
|
|
44
|
+
writeFileSync(join(checkout, 'main.txt'), 'rewritten\n');
|
|
45
|
+
git(checkout, 'add', 'main.txt');
|
|
46
|
+
git(checkout, 'commit', '--quiet', '--amend', '-m', 'rewritten main');
|
|
47
|
+
git(checkout, 'branch', '-D', 'feature/test');
|
|
48
|
+
git(checkout, 'tag', '-d', 'v1.0.0');
|
|
49
|
+
git(checkout, 'tag', 'v2.0.0');
|
|
50
|
+
git(checkout, 'push', '--quiet', '--force', 'origin', 'main');
|
|
51
|
+
git(checkout, 'push', '--quiet', 'origin', '--delete', 'feature/test');
|
|
52
|
+
git(checkout, 'push', '--quiet', 'origin', ':refs/tags/v1.0.0');
|
|
53
|
+
git(checkout, 'push', '--quiet', 'origin', 'v2.0.0');
|
|
54
|
+
execFileSync(process.execPath, [mirrorScript], { cwd: checkout, stdio: 'pipe' });
|
|
55
|
+
assert.deepEqual(refs(mirror), refs(source));
|
|
56
|
+
assert.equal(refs(mirror).some((ref) => ref.endsWith('refs/heads/feature/test')), false);
|
|
57
|
+
assert.equal(refs(mirror).some((ref) => ref.endsWith('refs/tags/v1.0.0')), false);
|
|
58
|
+
assert.equal(refs(mirror).some((ref) => ref.endsWith('refs/tags/v2.0.0')), true);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
rmSync(root, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { generateKeyPairSync, sign } from 'node:crypto';
|
|
4
|
+
import { inspectDeliveryIntegrity, parseDeliveryIntegrityPolicy } from './delivery-integrity.js';
|
|
5
|
+
import { normalizeFinding, parseSurfacePolicy } from './disposition.js';
|
|
6
|
+
import { assessProfileReadiness } from './profile-quality.js';
|
|
7
|
+
import { createTeamProfileBundle, parseTeamProfileBundle } from './team-profile.js';
|
|
8
|
+
test('keeps delivery integrity opt-in and local', () => {
|
|
9
|
+
const report = inspectDeliveryIntegrity('Hello {{name}} [missing](missing.md) [@brief]', parseDeliveryIntegrityPolicy({ version: '1', block: ['placeholder', 'local_link'], sourceIds: ['brief'] }), process.cwd());
|
|
10
|
+
assert.equal(report.passed, false);
|
|
11
|
+
assert.deepEqual(report.findings.map((finding) => [finding.kind, finding.disposition]), [['placeholder', 'block'], ['local_link', 'block']]);
|
|
12
|
+
assert.throws(() => parseDeliveryIntegrityPolicy({ version: '2' }), /not valid/);
|
|
13
|
+
});
|
|
14
|
+
test('reports profile readiness without retaining raw samples', () => {
|
|
15
|
+
const report = assessProfileReadiness(['A short sample.', 'A short sample.']);
|
|
16
|
+
assert.equal(report.sampleDigests.length, 2);
|
|
17
|
+
assert.ok(report.findings.some((finding) => finding.id === 'duplicate_sample'));
|
|
18
|
+
assert.doesNotMatch(JSON.stringify(report), /A short sample/);
|
|
19
|
+
});
|
|
20
|
+
test('normalizes legacy findings without changing legacy fields', () => {
|
|
21
|
+
assert.equal(normalizeFinding({ engine: 'ai_editor', id: 'x', severity: 'red', appliedPolicy: 'blocking', sentence: 1, excerpt: 'x', reason: 'r', suggestion: 's' }).disposition, 'block');
|
|
22
|
+
assert.equal(normalizeFinding({ engine: 'ai_editor', id: 'x', severity: 'yellow', appliedPolicy: 'judgment-required', sentence: 1, excerpt: 'x', reason: 'r', suggestion: 's' }).disposition, 'review');
|
|
23
|
+
assert.equal(normalizeFinding({ engine: 'voice_dna', id: 'x', severity: 'yellow', sentence: 1, excerpt: 'x', reason: 'r', suggestion: 's' }).disposition, 'signal');
|
|
24
|
+
assert.equal(normalizeFinding({ engine: 'ai_editor', id: 'ai.question-hook', severity: 'yellow', appliedPolicy: 'advisory', sentence: 1, excerpt: 'x', reason: 'r', suggestion: 's' }, parseSurfacePolicy({ version: '1', overrides: { 'ai.question-hook': 'review' } })).disposition, 'review');
|
|
25
|
+
});
|
|
26
|
+
test('requires current, consent-bound team profile membership', () => {
|
|
27
|
+
const fingerprint = 'a'.repeat(64);
|
|
28
|
+
const keys = generateKeyPairSync('ed25519');
|
|
29
|
+
const unsigned = { version: '1', id: 'team.example', createdAt: '2026-08-24T00:00:00.000Z', retention: 'delete on request', members: [{ role: 'author', sourceType: 'individual-writing', profileFingerprint: fingerprint, consent: { approved: true, basis: 'written consent', expiresAt: '2026-09-01T00:00:00.000Z' } }] };
|
|
30
|
+
const canonical = (value) => Array.isArray(value) ? `[${value.map(canonical).join(',')}]` : value && typeof value === 'object' ? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}` : JSON.stringify(value);
|
|
31
|
+
const bundle = createTeamProfileBundle({ ...unsigned, authorization: { issuer: 'test', publicKeyPem: keys.publicKey.export({ format: 'pem', type: 'spki' }).toString(), signatureBase64: sign(null, Buffer.from(canonical(unsigned)), keys.privateKey).toString('base64') } });
|
|
32
|
+
assert.equal(parseTeamProfileBundle(bundle, new Date('2026-08-25T00:00:00.000Z')).id, 'team.example');
|
|
33
|
+
assert.throws(() => parseTeamProfileBundle({ ...bundle, id: 'tampered' }, new Date('2026-08-25T00:00:00.000Z')), /digest/);
|
|
34
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { sentences, words } from './text.js';
|
|
3
|
+
export function assessProfileReadiness(samples) {
|
|
4
|
+
const totalWords = samples.reduce((sum, sample) => sum + words(sample).length, 0);
|
|
5
|
+
const totalSentences = samples.reduce((sum, sample) => sum + sentences(sample).length, 0);
|
|
6
|
+
const sampleDigests = samples.map((sample) => createHash('sha256').update(sample.trim().replace(/\s+/g, ' ')).digest('hex'));
|
|
7
|
+
const findings = [];
|
|
8
|
+
if (samples.length < 3)
|
|
9
|
+
findings.push({ id: 'sample_count', disposition: 'review', reason: 'Two samples can build a profile but give a narrow baseline.', suggestion: 'Use three or more rights-cleared samples from the same writer and context.' });
|
|
10
|
+
if (totalWords < 300 || totalSentences < 12)
|
|
11
|
+
findings.push({ id: 'sample_length', disposition: 'review', reason: 'The sample set has limited text coverage.', suggestion: 'Add longer samples before treating voice drift as high-confidence evidence.' });
|
|
12
|
+
if (new Set(sampleDigests).size !== sampleDigests.length)
|
|
13
|
+
findings.push({ id: 'duplicate_sample', disposition: 'review', reason: 'At least two normalized samples are identical.', suggestion: 'Replace duplicate samples with distinct writing.' });
|
|
14
|
+
if (samples.some((sample) => /^\s*[-*#]|^\s*subject:/im.test(sample)) && samples.some((sample) => !/^\s*[-*#]|^\s*subject:/im.test(sample)))
|
|
15
|
+
findings.push({ id: 'format_spread', disposition: 'signal', reason: 'Samples mix visibly different formats.', suggestion: 'Use a profile per audience and format when the distinction matters.' });
|
|
16
|
+
return { version: '1', sampleCount: samples.length, totalWords, totalSentences, sampleDigests, findings };
|
|
17
|
+
}
|
|
@@ -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.4.
|
|
177
|
+
assert.equal(HYV_VERSION, '3.4.3');
|
|
178
178
|
});
|
|
179
179
|
test('apply rejects forged tasks, missing capability, and substituted profiles', () => {
|
|
180
180
|
const reduction = rebuildRecommendation();
|
|
@@ -5,6 +5,37 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
6
6
|
import test from 'node:test';
|
|
7
7
|
const audit = new URL('../scripts/release-audit.mjs', import.meta.url).pathname;
|
|
8
|
+
const mirrorWorkflow = `on:
|
|
9
|
+
push:
|
|
10
|
+
delete:
|
|
11
|
+
workflow_dispatch:
|
|
12
|
+
schedule:
|
|
13
|
+
- cron: '17 3 * * *'
|
|
14
|
+
|
|
15
|
+
concurrency:
|
|
16
|
+
group: mirror-to-stitchflow
|
|
17
|
+
cancel-in-progress: false
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
mirror:
|
|
21
|
+
if: github.repository == 'shashank-sn/holdyourvoice'
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
timeout-minutes: 10
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v7
|
|
26
|
+
- run: |
|
|
27
|
+
if [ -z "\${MIRROR_DEPLOY_KEY:-}" ]; then
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
node scripts/mirror-refs.mjs
|
|
31
|
+
`;
|
|
32
|
+
const mirrorReconciler = `
|
|
33
|
+
const headsRefspec = '+refs/remotes/origin/*:refs/heads/*';
|
|
34
|
+
const tagsRefspec = '+refs/tags/*:refs/tags/*';
|
|
35
|
+
execFileSync('git', ['update-ref', '--no-deref', '-d', 'refs/remotes/origin/HEAD']);
|
|
36
|
+
spawnSync('git', ['push', '--prune', 'mirror', headsRefspec, tagsRefspec]);
|
|
37
|
+
execFileSync('git', ['ls-remote', '--refs', 'mirror', 'refs/heads/*', 'refs/tags/*']);
|
|
38
|
+
`;
|
|
8
39
|
const stage1Files = [
|
|
9
40
|
'scripts/evaluate-rewrite-benchmark.mjs',
|
|
10
41
|
'scripts/run-stage1-dry-run.mjs',
|
|
@@ -36,6 +67,8 @@ function fixture(files) {
|
|
|
36
67
|
].join('\n'),
|
|
37
68
|
'src/version.ts': "export const HYV_VERSION = '1.0.0';",
|
|
38
69
|
'src/stage1-evaluation.ts': "const baseline = '4e6269121d551c008a34db73077e1e4fea41b3f9'; const stage1 = '550ea24f652291dca13757fdbd2f0fa0b5e3f621';",
|
|
70
|
+
'.github/workflows/mirror-to-stitchflow.yml': mirrorWorkflow,
|
|
71
|
+
'scripts/mirror-refs.mjs': mirrorReconciler,
|
|
39
72
|
'skills/hyv-test/agent.json': '{}',
|
|
40
73
|
'skills/hyv-test/SKILL.md': '# test',
|
|
41
74
|
'skills/hyv-test/agents/openai.yaml': 'name: test',
|
|
@@ -60,6 +93,66 @@ test('accepts the complete public package contract', () => {
|
|
|
60
93
|
rmSync(directory, { recursive: true, force: true });
|
|
61
94
|
}
|
|
62
95
|
});
|
|
96
|
+
test('requires the mirror workflow to run only in the public source repository', () => {
|
|
97
|
+
const directory = fixture({
|
|
98
|
+
'README.md': '# public',
|
|
99
|
+
'.github/workflows/mirror-to-stitchflow.yml': mirrorWorkflow.replace(" if: github.repository == 'shashank-sn/holdyourvoice'\n", ''),
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
103
|
+
assert.notEqual(result.status, 0);
|
|
104
|
+
assert.match(result.stderr, /mirror workflow must run only in the public source repository with a bounded timeout/);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
rmSync(directory, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
for (const requirement of [
|
|
111
|
+
{ name: 'deleted refs', fragment: ' delete:\n', error: /mirror workflow must reconcile deleted refs/ },
|
|
112
|
+
{ name: 'manual recovery', fragment: ' workflow_dispatch:\n', error: /mirror workflow must support manual recovery/ },
|
|
113
|
+
{ name: 'scheduled reconciliation', fragment: " schedule:\n - cron: '17 3 \* \* \*'\n", error: /mirror workflow must reconcile refs on a schedule/ },
|
|
114
|
+
{ name: 'serialized updates', fragment: ' group: mirror-to-stitchflow\n', error: /mirror workflow must serialize full-ref updates and finish the active update/ },
|
|
115
|
+
{ name: 'non-cancelled active updates', fragment: ' cancel-in-progress: false\n', error: /mirror workflow must serialize full-ref updates and finish the active update/ },
|
|
116
|
+
{ name: 'bounded execution', fragment: ' timeout-minutes: 10\n', error: /mirror workflow must run only in the public source repository with a bounded timeout/ },
|
|
117
|
+
{ name: 'current checkout action', fragment: ' - uses: actions/checkout@v7\n', error: /mirror workflow must use the current checkout action/ },
|
|
118
|
+
{ name: 'deploy-key validation', fragment: ' if [ -z "${MIRROR_DEPLOY_KEY:-}" ]; then\n', error: /mirror workflow must validate the source deploy key/ },
|
|
119
|
+
{ name: 'tested reconciliation', fragment: ' node scripts/mirror-refs.mjs\n', error: /mirror workflow must run the tested ref reconciler/ },
|
|
120
|
+
]) {
|
|
121
|
+
test(`requires mirror ${requirement.name}`, () => {
|
|
122
|
+
const directory = fixture({
|
|
123
|
+
'README.md': '# public',
|
|
124
|
+
'.github/workflows/mirror-to-stitchflow.yml': mirrorWorkflow.replace(requirement.fragment, ''),
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
128
|
+
assert.notEqual(result.status, 0);
|
|
129
|
+
assert.match(result.stderr, requirement.error);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
rmSync(directory, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
for (const requirement of [
|
|
137
|
+
{ name: 'safe remote HEAD exclusion', fragment: "execFileSync('git', ['update-ref', '--no-deref', '-d', 'refs/remotes/origin/HEAD']);\n", error: /mirror reconciler must exclude the remote HEAD pseudo-ref without deleting the default branch/ },
|
|
138
|
+
{ name: 'destination pruning', fragment: "spawnSync('git', ['push', '--prune', 'mirror', headsRefspec, tagsRefspec]);\n", error: /mirror reconciler must prune destination refs/ },
|
|
139
|
+
{ name: 'post-push ref verification', fragment: "execFileSync('git', ['ls-remote', '--refs', 'mirror', 'refs/heads/*', 'refs/tags/*']);\n", error: /mirror reconciler must verify source and mirror ref parity/ },
|
|
140
|
+
]) {
|
|
141
|
+
test(`requires mirror reconciler ${requirement.name}`, () => {
|
|
142
|
+
const directory = fixture({
|
|
143
|
+
'README.md': '# public',
|
|
144
|
+
'scripts/mirror-refs.mjs': mirrorReconciler.replace(requirement.fragment, ''),
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
148
|
+
assert.notEqual(result.status, 0);
|
|
149
|
+
assert.match(result.stderr, requirement.error);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
rmSync(directory, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
63
156
|
test('requires exact Stage 1 scripts even when the checkpoint files remain', () => {
|
|
64
157
|
const directory = fixture({
|
|
65
158
|
'README.md': '# public',
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
import { profileFingerprint } from './learning.js';
|
|
3
|
+
function canonical(value) { if (Array.isArray(value))
|
|
4
|
+
return `[${value.map(canonical).join(',')}]`; if (value && typeof value === 'object') {
|
|
5
|
+
const record = value;
|
|
6
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(',')}}`;
|
|
7
|
+
} return JSON.stringify(value); }
|
|
8
|
+
function digest(value) { return createHash('sha256').update(canonical(value)).digest('hex'); }
|
|
9
|
+
function validDate(value) { return Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value; }
|
|
10
|
+
export function createTeamProfileBundle(input) {
|
|
11
|
+
const value = { version: '1', ...input };
|
|
12
|
+
return { ...value, digest: digest(value) };
|
|
13
|
+
}
|
|
14
|
+
export function parseTeamProfileBundle(value, now = new Date()) {
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
16
|
+
throw new Error('Team profile bundle is invalid.');
|
|
17
|
+
const bundle = value;
|
|
18
|
+
if (bundle.version !== '1' || typeof bundle.id !== 'string' || !validDate(bundle.createdAt) || typeof bundle.retention !== 'string' || !Array.isArray(bundle.members) || !bundle.members.length || !bundle.authorization || typeof bundle.authorization.issuer !== 'string' || typeof bundle.authorization.publicKeyPem !== 'string' || typeof bundle.authorization.signatureBase64 !== 'string' || !/^[a-f0-9]{64}$/.test(bundle.digest ?? ''))
|
|
19
|
+
throw new Error('Team profile bundle is invalid.');
|
|
20
|
+
const { digest: provided, ...unsigned } = bundle;
|
|
21
|
+
if (digest(unsigned) !== provided)
|
|
22
|
+
throw new Error('Team profile bundle digest does not match.');
|
|
23
|
+
const signed = canonical({ version: bundle.version, id: bundle.id, createdAt: bundle.createdAt, retention: bundle.retention, members: bundle.members });
|
|
24
|
+
try {
|
|
25
|
+
if (!verify(null, Buffer.from(signed), createPublicKey(bundle.authorization.publicKeyPem), Buffer.from(bundle.authorization.signatureBase64, 'base64')))
|
|
26
|
+
throw new Error('invalid');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error('Team profile bundle authorization is invalid.');
|
|
30
|
+
}
|
|
31
|
+
if (!bundle.members.some((member) => member.role === 'author') || !bundle.members.every((member) => (member.role === 'author' || member.role === 'brand') && ['individual-writing', 'approved-brand-guide'].includes(member.sourceType) && /^[a-f0-9]{64}$/.test(member.profileFingerprint) && member.consent?.approved === true && typeof member.consent.basis === 'string' && validDate(member.consent.expiresAt) && Date.parse(member.consent.expiresAt) > now.getTime()))
|
|
32
|
+
throw new Error('Team profile bundle consent is invalid or expired.');
|
|
33
|
+
return bundle;
|
|
34
|
+
}
|
|
35
|
+
export function composeTeamProfile(author, brands, bundle, now = new Date()) {
|
|
36
|
+
const parsed = parseTeamProfileBundle(bundle, now);
|
|
37
|
+
if (!parsed.members.some((member) => member.role === 'author' && member.profileFingerprint === profileFingerprint(author)))
|
|
38
|
+
throw new Error('Team profile bundle does not authorize this author profile.');
|
|
39
|
+
if (author.version !== '3')
|
|
40
|
+
return author;
|
|
41
|
+
const policy = { ...author.rulePolicy };
|
|
42
|
+
for (const brand of brands) {
|
|
43
|
+
if (!parsed.members.some((member) => member.role === 'brand' && member.profileFingerprint === profileFingerprint(brand)))
|
|
44
|
+
throw new Error('Team profile bundle does not authorize this brand profile.');
|
|
45
|
+
for (const [id, state] of Object.entries(brand.rulePolicy))
|
|
46
|
+
if (!policy[id] || state === 'blocking')
|
|
47
|
+
policy[id] = state;
|
|
48
|
+
}
|
|
49
|
+
return { ...author, avoid: [...new Set([...author.avoid, ...brands.flatMap((brand) => brand.avoid)])], rulePolicy: policy };
|
|
50
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const HYV_VERSION = '3.4.
|
|
1
|
+
export const HYV_VERSION = '3.4.3';
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holdyourvoice/hyv",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.3",
|
|
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" },
|
|
7
7
|
"files": ["dist", "skills", "Readme.md", "LICENSE"],
|
|
8
|
-
"scripts": { "build": "tsc -p tsconfig.json", "bundle:claude": "esbuild src/mcp.ts --bundle --platform=node --format=esm --target=node20 --outfile=mcpb/server/index.js", "build:claude": "npm run build && npm run bundle:claude", "pack:claude": "npm run build:claude && node scripts/pack-claude.mjs", "stage1:evaluate": "node scripts/evaluate-rewrite-benchmark.mjs", "stage1:dry-run": "npm run build && node scripts/run-stage1-dry-run.mjs", "stage1:human-packet": "npm run build && node scripts/run-stage1-human-packet.mjs", "stage2:human-packet": "npm run build && node scripts/run-stage2-human-packet.mjs", "test": "npm run build && node --test \"dist/**/*.test.js\"", "check:release": "node scripts/release-audit.mjs", "prepack": "npm run check:release && npm test" },
|
|
8
|
+
"scripts": { "build": "tsc -p tsconfig.json", "bundle:claude": "esbuild src/mcp.ts --bundle --platform=node --format=esm --target=node20 --outfile=mcpb/server/index.js", "build:claude": "npm run build && npm run bundle:claude", "pack:claude": "npm run build:claude && node scripts/pack-claude.mjs", "scorecard": "node scripts/public-scorecard.mjs benchmarks", "validate:rule-contributions": "node scripts/validate-rule-contributions.mjs", "stage1:evaluate": "node scripts/evaluate-rewrite-benchmark.mjs", "stage1:dry-run": "npm run build && node scripts/run-stage1-dry-run.mjs", "stage1:human-packet": "npm run build && node scripts/run-stage1-human-packet.mjs", "stage2:human-packet": "npm run build && node scripts/run-stage2-human-packet.mjs", "test": "npm run build && node --test \"dist/**/*.test.js\"", "check:release": "node scripts/release-audit.mjs", "prepack": "npm run check:release && npm test" },
|
|
9
9
|
"engines": { "node": ">=20" },
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"keywords": ["ai-writing", "cli", "editing", "voice", "writing"],
|