@holdyourvoice/hyv 3.4.4 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +7 -0
- package/dist/agents/load.test.js +1 -1
- package/dist/ai-editor-rules.js +34 -0
- package/dist/ai-editor.js +120 -9
- package/dist/ai-editor.test.js +99 -8
- package/dist/ai-shadow-fixtures.js +7 -0
- package/dist/ai-shadow-generator.js +17 -0
- package/dist/backtest.js +16 -0
- package/dist/backtest.test.js +20 -0
- package/dist/cli.js +225 -8
- package/dist/cli.test.js +102 -5
- package/dist/editorial-packs.js +1 -0
- package/dist/hold-your-voice.mcpb +0 -0
- package/dist/local-eval.js +98 -0
- package/dist/local-eval.test.js +20 -0
- package/dist/mcp-tools.js +22 -2
- package/dist/mcp-tools.test.js +33 -1
- package/dist/mcp.js +35 -4
- package/dist/mcp.test.js +2 -2
- package/dist/pipeline.js +5 -4
- package/dist/pipeline.test.js +12 -0
- package/dist/profile-compose.js +97 -0
- package/dist/profile-compose.test.js +32 -0
- package/dist/profile-score.js +79 -0
- package/dist/profile-score.test.js +22 -0
- package/dist/profile-watch.js +34 -0
- package/dist/profile-watch.test.js +23 -0
- package/dist/profile.js +33 -2
- package/dist/profile.test.js +27 -0
- package/dist/rebuild-task.test.js +1 -1
- package/dist/rule-allowances.js +27 -0
- package/dist/rule-allowances.test.js +17 -0
- package/dist/sample-ingest.js +94 -0
- package/dist/sample-ingest.test.js +52 -0
- package/dist/strict-quality.js +64 -0
- package/dist/strict-quality.test.js +62 -0
- package/dist/version.js +1 -1
- package/dist/voice-dna.js +33 -1
- package/dist/voice-dna.test.js +12 -1
- package/dist/writing-examples.js +83 -0
- package/dist/writing-examples.test.js +35 -0
- package/package.json +56 -11
- package/skills/hyv-analyze/SKILL.md +3 -1
- package/skills/hyv-analyze/agent.json +2 -1
- package/skills/hyv-backtest/SKILL.md +14 -0
- package/skills/hyv-backtest/agent.json +16 -0
- package/skills/hyv-backtest/agents/openai.yaml +4 -0
- package/skills/hyv-evaluate-local/SKILL.md +14 -0
- package/skills/hyv-evaluate-local/agent.json +16 -0
- package/skills/hyv-evaluate-local/agents/openai.yaml +4 -0
- package/skills/hyv-final-check/SKILL.md +2 -0
- package/skills/hyv-find-writing-examples/SKILL.md +10 -0
- package/skills/hyv-find-writing-examples/agent.json +16 -0
- package/skills/hyv-find-writing-examples/agents/openai.yaml +4 -0
- package/skills/hyv-ingest/SKILL.md +31 -0
- package/skills/hyv-ingest/agent.json +28 -0
- package/skills/hyv-ingest/agents/openai.yaml +4 -0
- package/skills/hyv-patterns/SKILL.md +2 -0
- package/skills/hyv-profile/SKILL.md +2 -0
- package/skills/hyv-score/SKILL.md +26 -0
- package/skills/hyv-score/agent.json +28 -0
- package/skills/hyv-score/agents/openai.yaml +4 -0
- package/skills/hyv-strict-check/SKILL.md +26 -0
- package/skills/hyv-strict-check/agent.json +33 -0
- package/skills/hyv-strict-check/agents/openai.yaml +4 -0
- package/skills/hyv-verify/SKILL.md +1 -1
- package/skills/hyv-verify/agent.json +1 -0
package/dist/mcp-tools.test.js
CHANGED
|
@@ -4,8 +4,10 @@ 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, logicLintForMcp, 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, scoreHeldoutForMcp, strictCheckForMcp, submitSemanticVerdictForMcp, supersedeLearningForMcp, validateFinalApprovalForMcp, verifyCopySpecForMcp, verifyForMcp } from './mcp-tools.js';
|
|
8
8
|
import { canonicalJson } from './canonical-json.js';
|
|
9
|
+
import { findWritingExamplesForMcp } from './mcp-tools.js';
|
|
10
|
+
import { backtestForMcp } from './mcp-tools.js';
|
|
9
11
|
const profile = buildProfileForMcp(['I write clearly. I keep the useful detail.', 'I make the call. Then I explain the trade-off.'], ['leverage']);
|
|
10
12
|
const profileJson = JSON.stringify(profile);
|
|
11
13
|
test('builds a portable profile for MCP without files', () => {
|
|
@@ -18,6 +20,36 @@ test('keeps the dual-engine analysis shape through MCP tools', () => {
|
|
|
18
20
|
assert.equal(result.aiEditor.engine, 'ai_editor');
|
|
19
21
|
assert.equal(result.hygiene.suspiciousCount, 1);
|
|
20
22
|
});
|
|
23
|
+
test('exposes the strict local quality gate through MCP helpers', () => {
|
|
24
|
+
const report = strictCheckForMcp('I leverage a clear plan.', profileJson, ['one.', 'two.']);
|
|
25
|
+
assert.equal(report.disposition, 'blocked');
|
|
26
|
+
assert.ok(report.findings.some((finding) => finding.id === 'strict.profile.version'));
|
|
27
|
+
});
|
|
28
|
+
test('scores explicit held-out samples through MCP without storing them', () => {
|
|
29
|
+
const samples = [
|
|
30
|
+
'I write a direct note about the launch. The owner checks the evidence before we ship. The next step stays clear and small.',
|
|
31
|
+
'I name the trade-off before I make a decision. We keep the mechanism visible for the person doing the work. The release has one owner.',
|
|
32
|
+
'I start from evidence in the issue. Then I explain the constraint and choose a concrete next step. The team checks the result.',
|
|
33
|
+
];
|
|
34
|
+
const result = scoreHeldoutForMcp(samples[0], JSON.stringify(buildProfileForMcp(samples)), samples);
|
|
35
|
+
assert.equal(result.version, '1');
|
|
36
|
+
assert.equal(result.selfSimilarity?.ceiling, 100);
|
|
37
|
+
});
|
|
38
|
+
test('runs an isolated backtest without returning target or candidate prose', () => {
|
|
39
|
+
const samples = ['I write direct evidence for the operator doing the release work today.', 'The owner checks each rollback and names the next step for production.', 'The report keeps the mechanism visible and gives the team one concrete action.'];
|
|
40
|
+
const report = backtestForMcp('Explain the rollout owner.', 'The owner checks rollback.', 'The owner checks rollback.', JSON.stringify(buildProfileForMcp(samples)), samples);
|
|
41
|
+
assert.equal(report.preservation.score, 100);
|
|
42
|
+
assert.equal(JSON.stringify(report).includes('The owner checks rollback.'), false);
|
|
43
|
+
});
|
|
44
|
+
test('finds and injects redacted local writing examples without storing an index', () => {
|
|
45
|
+
const examples = [{ basename: 'email.md', text: 'The retry queue stays local; owner@example.com receives the review.' }];
|
|
46
|
+
const found = findWritingExamplesForMcp('The retry queue needs review.', examples);
|
|
47
|
+
assert.equal(found[0]?.source, 'email.md');
|
|
48
|
+
assert.match(found[0]?.text ?? '', /REDACTED:EMAIL/);
|
|
49
|
+
const prompt = rewritePromptForMcp('The retry queue needs review.', profileJson, {}, undefined, examples);
|
|
50
|
+
assert.match(prompt.prompt, /Approved local writing examples/);
|
|
51
|
+
assert.doesNotMatch(prompt.prompt, /owner@example\.com/);
|
|
52
|
+
});
|
|
21
53
|
test('inspects Unicode hygiene through MCP without a voice profile', () => {
|
|
22
54
|
const result = inspectHygieneForMcp('one\u200Btwo\u00A0three');
|
|
23
55
|
assert.equal(result.suspiciousCount, 2);
|
package/dist/mcp.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
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, 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';
|
|
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, scoreHeldoutForMcp, strictCheckForMcp, 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
|
+
import { findWritingExamplesForMcp } from './mcp-tools.js';
|
|
8
|
+
import { backtestForMcp, evaluateLocalForMcp } from './mcp-tools.js';
|
|
7
9
|
const writing = z.string().min(1).max(100_000);
|
|
8
10
|
const hygieneText = z.string().max(100_000);
|
|
9
11
|
const profileJson = z.string().min(1).max(50_000);
|
|
10
12
|
const copySpecJson = z.string().min(1).max(250_000);
|
|
11
13
|
const writingBriefJson = z.string().min(1).max(50_000);
|
|
12
14
|
const samples = z.array(writing).min(2).max(20);
|
|
15
|
+
const strictSamples = z.array(writing).min(2).max(20);
|
|
16
|
+
const heldoutSamples = z.array(writing).min(3).max(20);
|
|
17
|
+
const evalParagraphs = z.array(z.object({ paragraph_id: z.string().min(1).max(160), text: writing })).min(2).max(50);
|
|
18
|
+
const writingExamples = z.array(z.object({ basename: z.string().min(1).max(160), text: writing })).min(1).max(64);
|
|
13
19
|
const avoid = z.array(z.string().min(1).max(200)).max(50).optional();
|
|
14
20
|
const lifecycleJson = z.string().min(1).max(1_048_576);
|
|
15
21
|
const approvedLearningText = z.string().min(1).max(1_048_576);
|
|
@@ -67,6 +73,26 @@ server.registerTool('hyv_analyze', {
|
|
|
67
73
|
inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
|
|
68
74
|
annotations: { readOnlyHint: true },
|
|
69
75
|
}, async ({ draft, profile_json, writing_brief_json }) => guardedJson(() => analyzeForMcp(draft, profile_json, writing_brief_json)));
|
|
76
|
+
server.registerTool('hyv_strict_check', {
|
|
77
|
+
description: 'Run the opt-in local strict quality gate. It requires a calibrated Profile v3 and local writing samples; it returns strict-ready, needs-human-review, or blocked without changing text or learning state.',
|
|
78
|
+
inputSchema: { draft: writing, profile_json: profileJson, samples: strictSamples, writing_brief_json: writingBriefJson.optional() },
|
|
79
|
+
annotations: { readOnlyHint: true },
|
|
80
|
+
}, async ({ draft, profile_json, samples: localSamples, writing_brief_json }) => guardedJson(() => strictCheckForMcp(draft, profile_json, localSamples, writing_brief_json)));
|
|
81
|
+
server.registerTool('hyv_score', {
|
|
82
|
+
description: 'Score a draft against explicit held-out local samples. It reports a VoiceDNA component vector and the writer’s own similarity band, or abstains when channel or language evidence is inadequate. It is not an authorship score.',
|
|
83
|
+
inputSchema: { draft: writing, profile_json: profileJson, samples: heldoutSamples, channel: z.enum(['general', 'email', 'chat', 'long-form', 'social', 'docs']).optional() },
|
|
84
|
+
annotations: { readOnlyHint: true },
|
|
85
|
+
}, async ({ draft, profile_json, samples: localSamples, channel }) => guardedJson(() => scoreHeldoutForMcp(draft, profile_json, localSamples, channel)));
|
|
86
|
+
server.registerTool('hyv_backtest', {
|
|
87
|
+
description: 'Score a caller-supplied reconstruction against a held-out target without generating text. Returns separate preservation, AI Editor, and held-out-band reports without returning prose.',
|
|
88
|
+
inputSchema: { context: writing, target: writing, candidate: writing, profile_json: profileJson, samples: heldoutSamples },
|
|
89
|
+
annotations: { readOnlyHint: true },
|
|
90
|
+
}, async ({ context, target, candidate, profile_json, samples: localSamples }) => guardedJson(() => backtestForMcp(context, target, candidate, profile_json, localSamples)));
|
|
91
|
+
server.registerTool('hyv_evaluate_local', {
|
|
92
|
+
description: 'Run a deterministic optional local evaluation composite: train-only TF-IDF logistic proxy, content F1, AI-tell change, and stylometric cosine. It groups paragraph IDs before splitting and is not an authorship verdict.',
|
|
93
|
+
inputSchema: { input: writing, candidate: writing, user: evalParagraphs, ai_shadow: evalParagraphs },
|
|
94
|
+
annotations: { readOnlyHint: true },
|
|
95
|
+
}, async ({ input, candidate, user, ai_shadow }) => guardedJson(() => evaluateLocalForMcp(input, candidate, user.map((item) => ({ paragraphId: item.paragraph_id, text: item.text })), ai_shadow.map((item) => ({ paragraphId: item.paragraph_id, text: item.text })))));
|
|
70
96
|
server.registerTool('hyv_hygiene', {
|
|
71
97
|
description: 'Inspect text for zero-width characters, bidirectional controls, Unicode tag characters, and unusual spaces without changing it or requiring a voice profile.',
|
|
72
98
|
inputSchema: { draft: hygieneText },
|
|
@@ -102,10 +128,15 @@ server.registerTool('hyv_logic_lint', {
|
|
|
102
128
|
annotations: { readOnlyHint: true },
|
|
103
129
|
}, async ({ draft, writing_brief_json }) => guardedJson(() => logicLintForMcp(draft, writing_brief_json)));
|
|
104
130
|
server.registerTool('hyv_rewrite_prompt', {
|
|
105
|
-
description: 'Create a constrained editing brief. It does not rewrite the draft or call a model.',
|
|
106
|
-
inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional() },
|
|
131
|
+
description: 'Create a constrained editing brief. Explicit local examples are redacted in memory and injected only as advisory cadence evidence. It does not rewrite the draft or call a model.',
|
|
132
|
+
inputSchema: { draft: writing, profile_json: profileJson, writing_brief_json: writingBriefJson.optional(), examples: writingExamples.optional() },
|
|
133
|
+
annotations: { readOnlyHint: true },
|
|
134
|
+
}, async ({ draft, profile_json, writing_brief_json, examples }) => guardedJson(() => rewritePromptForMcp(draft, profile_json, {}, writing_brief_json, examples)));
|
|
135
|
+
server.registerTool('hyv_find_writing_examples', {
|
|
136
|
+
description: 'Find up to three redacted excerpts from explicit in-memory local samples. Returns basenames only and never writes an index.',
|
|
137
|
+
inputSchema: { query: writing, examples: writingExamples },
|
|
107
138
|
annotations: { readOnlyHint: true },
|
|
108
|
-
}, async ({
|
|
139
|
+
}, async ({ query, examples }) => guardedJson(() => findWritingExamplesForMcp(query, examples)));
|
|
109
140
|
server.registerTool('hyv_prepare_rewrite', {
|
|
110
141
|
description: 'Prepare a local, versioned rewrite task. The caller may forward it to a provider; doing so shares the draft and must be an explicit choice.',
|
|
111
142
|
inputSchema: { draft: writing, profile_json: profileJson, copy_spec_json: copySpecJson.optional(), 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_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']);
|
|
67
|
+
assert.deepEqual(tools?.map((tool) => tool.name), ['hyv_build_profile', 'hyv_profile_assess', 'hyv_analyze', 'hyv_strict_check', 'hyv_score', 'hyv_backtest', 'hyv_evaluate_local', '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_find_writing_examples', '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);
|
|
@@ -100,7 +100,7 @@ test('registers capability tools only with host redaction attestation', async ()
|
|
|
100
100
|
assert.equal(stderr, '');
|
|
101
101
|
const response = stdout.trim().split('\n').map((line) => JSON.parse(line)).find((item) => item.id === 2);
|
|
102
102
|
const names = response.result.tools.map((tool) => tool.name);
|
|
103
|
-
assert.equal(names.length,
|
|
103
|
+
assert.equal(names.length, 40);
|
|
104
104
|
assert.ok(names.includes('hyv_lifecycle_validate_final_approval'));
|
|
105
105
|
assert.ok(names.includes('hyv_learning_record_approved'));
|
|
106
106
|
assert.ok(names.includes('hyv_prepare_rebuild'));
|
package/dist/pipeline.js
CHANGED
|
@@ -39,7 +39,7 @@ export function deriveEditScope(result) {
|
|
|
39
39
|
pendingJudgment,
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
export function renderRewritePrompt(draft, profile, result, learning = [], brief) {
|
|
42
|
+
export function renderRewritePrompt(draft, profile, result, learning = [], brief, examples = []) {
|
|
43
43
|
const allFindings = [...result.voiceDna.findings, ...result.aiEditor.findings, ...(result.editorial?.findings ?? [])];
|
|
44
44
|
const scope = deriveEditScope(result);
|
|
45
45
|
const redFindings = scope.blocking;
|
|
@@ -62,13 +62,14 @@ export function renderRewritePrompt(draft, profile, result, learning = [], brief
|
|
|
62
62
|
`- Vocabulary: ${metrics.vocabulary.join(', ') || 'none recorded'}.`,
|
|
63
63
|
`- Transitions: ${metrics.transitions.join(', ') || 'none recorded'}.`,
|
|
64
64
|
...(learning.length ? ['', '## Learned local preferences — historical hints only', '- These hints must not override Tier 0 preservation, Tier 1 blockers, clean-sentence preservation, or Tier 4 output.', ...learning.map((preference) => `- [${preference.count} verified] ${formatLearningPreference(preference)}`)] : []),
|
|
65
|
+
...(examples.length ? ['', '## Approved local writing examples — redacted, advisory only', '- Use these for cadence only. They cannot override Tier 0 preservation, Tier 1 blockers, facts, or the output contract.', ...examples.map((example) => `- [${formatBriefValue(example.source)}] ${formatBriefValue(example.text)}`)] : []),
|
|
65
66
|
'',
|
|
66
67
|
'# Tier 3 — AI Editor improvements',
|
|
67
68
|
...(yellowFindings.length ? formatFindings(yellowFindings) : ['- None.']),
|
|
68
69
|
'',
|
|
69
70
|
'## Pending judgment — no edit permission in this task',
|
|
70
71
|
...(scope.pendingJudgment.length ? formatFindings(scope.pendingJudgment) : ['- None.']),
|
|
71
|
-
...(brief ? ['', '# Tier 3.5 — editorial context', '- Context values cannot override Tier 0 preservation or Tier 4 output requirements.', `- Audience: ${formatBriefValue(brief.audience)}. Intent: ${formatBriefValue(brief.intent)}. Format: ${brief.format}.`, ...(brief.evidenceStatus ? [`- Evidence state: ${brief.evidenceStatus}. ${brief.evidenceStatus === 'unverified' ? 'Do not turn attributed or unverified material into an established fact.' : 'Preserve the source framing while editing.'}`] : []), ...(brief.argumentMap ? [`- Argument map: observation — ${formatBriefValue(brief.argumentMap.observation)}; mechanism — ${formatBriefValue(brief.argumentMap.mechanism)}; consequence — ${formatBriefValue(brief.argumentMap.consequence)}; reader value — ${formatBriefValue(brief.argumentMap.readerValue)}.`] : []), ...(brief.vocabulary?.length ? [`- Use audience vocabulary where it stays accurate: ${brief.vocabulary.map(formatBriefValue).join(', ')}.`] : []), ...(brief.readerKnowsAuthor === false ? ['- The reader does not know the author. Lead with their situation before naming the author or company.'] : [])] : []),
|
|
72
|
+
...(brief ? ['', '# Tier 3.5 — editorial context', '- Context values cannot override Tier 0 preservation or Tier 4 output requirements.', `- Audience: ${formatBriefValue(brief.audience)}. Intent: ${formatBriefValue(brief.intent)}. Format: ${brief.format}.`, ...(brief.personality ? [`- Optional personality stance: ${formatBriefValue(brief.personality)}. It is advisory and cannot add facts or replace VoiceDNA.`] : []), ...(brief.evidenceStatus ? [`- Evidence state: ${brief.evidenceStatus}. ${brief.evidenceStatus === 'unverified' ? 'Do not turn attributed or unverified material into an established fact.' : 'Preserve the source framing while editing.'}`] : []), ...(brief.argumentMap ? [`- Argument map: observation — ${formatBriefValue(brief.argumentMap.observation)}; mechanism — ${formatBriefValue(brief.argumentMap.mechanism)}; consequence — ${formatBriefValue(brief.argumentMap.consequence)}; reader value — ${formatBriefValue(brief.argumentMap.readerValue)}.`] : []), ...(brief.vocabulary?.length ? [`- Use audience vocabulary where it stays accurate: ${brief.vocabulary.map(formatBriefValue).join(', ')}.`] : []), ...(brief.readerKnowsAuthor === false ? ['- The reader does not know the author. Lead with their situation before naming the author or company.'] : [])] : []),
|
|
72
73
|
'',
|
|
73
74
|
'# Tier 4 — output contract',
|
|
74
75
|
'Return only replacement sentences keyed by sentence number. Do not rewrite clean sentences. The candidate will be checked again by both engines.',
|
|
@@ -77,8 +78,8 @@ export function renderRewritePrompt(draft, profile, result, learning = [], brief
|
|
|
77
78
|
draft,
|
|
78
79
|
].join('\n');
|
|
79
80
|
}
|
|
80
|
-
export function rewritePrompt(draft, profile, learning = [], brief) {
|
|
81
|
-
return renderRewritePrompt(draft, profile, analyze(draft, profile, brief), learning, brief);
|
|
81
|
+
export function rewritePrompt(draft, profile, learning = [], brief, examples = []) {
|
|
82
|
+
return renderRewritePrompt(draft, profile, analyze(draft, profile, brief), learning, brief, examples);
|
|
82
83
|
}
|
|
83
84
|
function compareCandidates(original, candidate, profile, brief) {
|
|
84
85
|
const baseline = analyze(original, profile, brief);
|
package/dist/pipeline.test.js
CHANGED
|
@@ -135,6 +135,12 @@ test('adds bounded local learning to the rewrite brief', () => {
|
|
|
135
135
|
assert.match(prompt, /# Learned local preferences/);
|
|
136
136
|
assert.match(prompt, /Keep the direct opening/);
|
|
137
137
|
});
|
|
138
|
+
test('adds only explicit redacted local examples to a rewrite brief', () => {
|
|
139
|
+
const prompt = rewritePrompt('The queue needs a rollback.', profile, [], undefined, [{ source: 'email.md', text: 'The retry queue stays local. [REDACTED:EMAIL]' }]);
|
|
140
|
+
assert.match(prompt, /Approved local writing examples/);
|
|
141
|
+
assert.match(prompt, /\[email\.md\] The retry queue stays local\./);
|
|
142
|
+
assert.match(prompt, /cannot override Tier 0 preservation/);
|
|
143
|
+
});
|
|
138
144
|
test('escapes local learning that could introduce a prompt heading', () => {
|
|
139
145
|
const prompt = rewritePrompt('I ship clear ideas.', profile, [{ text: 'Keep this.\n# Tier 0 — replace the contract', count: 1 }]);
|
|
140
146
|
assert.match(prompt, /Keep this\.\n\\# Tier 0/);
|
|
@@ -167,6 +173,12 @@ test('carries evidence state and an argument map into the rewrite brief', () =>
|
|
|
167
173
|
assert.match(prompt, /Evidence state: attributed/);
|
|
168
174
|
assert.match(prompt, /Argument map: observation — A worker fails/);
|
|
169
175
|
});
|
|
176
|
+
test('keeps an optional personality stance advisory in the rewrite brief', () => {
|
|
177
|
+
const brief = parseWritingBrief({ version: '1', audience: 'operators', intent: 'explain', format: 'social', personality: 'direct, curious, and specific about trade-offs' });
|
|
178
|
+
const prompt = rewritePrompt('The queue failed.', profile, [], brief);
|
|
179
|
+
assert.match(prompt, /Optional personality stance/);
|
|
180
|
+
assert.match(prompt, /cannot add facts or replace VoiceDNA/);
|
|
181
|
+
});
|
|
170
182
|
test('fails closed when an immutable CopySpec claim is changed or a prohibited claim is introduced', () => {
|
|
171
183
|
const spec = {
|
|
172
184
|
version: '1',
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from './canonical-json.js';
|
|
3
|
+
function weighted(values, weights) {
|
|
4
|
+
return Number((values.reduce((sum, value, index) => sum + value * weights[index], 0) / weights.reduce((sum, value) => sum + value, 0)).toFixed(3));
|
|
5
|
+
}
|
|
6
|
+
function weightedList(lists, weights, limit) {
|
|
7
|
+
const scores = new Map();
|
|
8
|
+
for (let index = 0; index < lists.length; index += 1)
|
|
9
|
+
for (const item of lists[index])
|
|
10
|
+
scores.set(item, (scores.get(item) ?? 0) + weights[index]);
|
|
11
|
+
return [...scores].sort(([leftKey, leftScore], [rightKey, rightScore]) => rightScore - leftScore || leftKey.localeCompare(rightKey)).slice(0, limit).map(([item]) => item);
|
|
12
|
+
}
|
|
13
|
+
function categorical(values, weights) {
|
|
14
|
+
const scores = new Map();
|
|
15
|
+
for (let index = 0; index < values.length; index += 1)
|
|
16
|
+
scores.set(values[index], (scores.get(values[index]) ?? 0) + weights[index]);
|
|
17
|
+
return [...scores].sort(([leftKey, leftScore], [rightKey, rightScore]) => rightScore - leftScore || leftKey.localeCompare(rightKey))[0][0];
|
|
18
|
+
}
|
|
19
|
+
function strictest(states) {
|
|
20
|
+
const rank = { disabled: 0, advisory: 1, 'judgment-required': 2, blocking: 3 };
|
|
21
|
+
return [...states].sort((left, right) => rank[right] - rank[left])[0];
|
|
22
|
+
}
|
|
23
|
+
function metrics(profiles, weights) {
|
|
24
|
+
const source = profiles.map((profile) => profile.metrics);
|
|
25
|
+
const numeric = (key) => weighted(source.map((item) => item[key]), weights);
|
|
26
|
+
const punctuation = Object.fromEntries(['!', '?', ';', ':', '—'].map((key) => [key, weighted(source.map((item) => item.punctuation[key] ?? 0), weights)]));
|
|
27
|
+
return {
|
|
28
|
+
sentenceLength: numeric('sentenceLength'), sentenceVariation: numeric('sentenceVariation'), rhythm: numeric('rhythm'), paragraphLength: numeric('paragraphLength'),
|
|
29
|
+
lexicalDensity: numeric('lexicalDensity'), questionRate: numeric('questionRate'), punctuation,
|
|
30
|
+
sentenceStructure: weightedList(source.map((item) => item.sentenceStructure), weights, 8),
|
|
31
|
+
openingMoves: weightedList(source.map((item) => item.openingMoves), weights, 8),
|
|
32
|
+
vocabulary: weightedList(source.map((item) => item.vocabulary), weights, 20),
|
|
33
|
+
transitions: weightedList(source.map((item) => item.transitions), weights, 8),
|
|
34
|
+
pointOfView: categorical(source.map((item) => item.pointOfView), weights),
|
|
35
|
+
caseStyle: categorical(source.map((item) => item.caseStyle), weights),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function fingerprint(profiles, weights) {
|
|
39
|
+
return {
|
|
40
|
+
contractionRate: weighted(profiles.map((profile) => profile.fingerprint.contractionRate), weights),
|
|
41
|
+
sentenceLengthDistribution: {
|
|
42
|
+
short: weighted(profiles.map((profile) => profile.fingerprint.sentenceLengthDistribution.short), weights),
|
|
43
|
+
medium: weighted(profiles.map((profile) => profile.fingerprint.sentenceLengthDistribution.medium), weights),
|
|
44
|
+
long: weighted(profiles.map((profile) => profile.fingerprint.sentenceLengthDistribution.long), weights),
|
|
45
|
+
},
|
|
46
|
+
bulletRate: weighted(profiles.map((profile) => profile.fingerprint.bulletRate), weights),
|
|
47
|
+
enDashRate: weighted(profiles.map((profile) => profile.fingerprint.enDashRate), weights),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function tone(profiles, weights) {
|
|
51
|
+
if (profiles.some((profile) => !profile.tone))
|
|
52
|
+
return undefined;
|
|
53
|
+
return {
|
|
54
|
+
formality: weighted(profiles.map((profile) => profile.tone.formality), weights),
|
|
55
|
+
confidence: weighted(profiles.map((profile) => profile.tone.confidence), weights),
|
|
56
|
+
warmth: weighted(profiles.map((profile) => profile.tone.warmth), weights),
|
|
57
|
+
energy: weighted(profiles.map((profile) => profile.tone.energy), weights),
|
|
58
|
+
complexity: weighted(profiles.map((profile) => profile.tone.complexity), weights),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function parseProfileRatio(value, count) {
|
|
62
|
+
const values = value.split(':').map((part) => Number(part));
|
|
63
|
+
if (values.length !== count || values.some((item) => !Number.isFinite(item) || item <= 0))
|
|
64
|
+
throw new Error('Profile ratio must contain ' + count + ' positive colon-separated values.');
|
|
65
|
+
return values;
|
|
66
|
+
}
|
|
67
|
+
/** Composes local Profile v3 mechanics. Policies intersect conservatively. */
|
|
68
|
+
export function composeProfiles(profiles, ratio) {
|
|
69
|
+
if (profiles.length < 2 || ratio.length !== profiles.length)
|
|
70
|
+
throw new Error('Compose at least two Profile v3 inputs with one ratio value each.');
|
|
71
|
+
const channel = categorical(profiles.map((profile) => profile.channel ?? 'general'), ratio);
|
|
72
|
+
const policyIds = new Set(profiles.flatMap((profile) => Object.keys(profile.rulePolicy)));
|
|
73
|
+
const rulePolicy = Object.fromEntries([...policyIds].map((id) => [id, strictest(profiles.map((profile) => profile.rulePolicy[id] ?? 'disabled'))]));
|
|
74
|
+
const fixtures = (key) => [...new Set(profiles.flatMap((profile) => profile.metricFixtures[key]))].sort().slice(0, 64);
|
|
75
|
+
const composedTone = tone(profiles, ratio);
|
|
76
|
+
const unsigned = {
|
|
77
|
+
version: '3',
|
|
78
|
+
id: 'composed.' + createHash('sha256').update(canonicalJson({ profiles: profiles.map((profile) => [profile.id, profile.revision, profile.revisionDigest]), ratio })).digest('hex').slice(0, 24),
|
|
79
|
+
revision: 1,
|
|
80
|
+
sampleCount: profiles.reduce((sum, profile) => sum + profile.sampleCount, 0),
|
|
81
|
+
metrics: metrics(profiles, ratio),
|
|
82
|
+
avoid: [...new Set(profiles.flatMap((profile) => profile.avoid))],
|
|
83
|
+
provenance: { source: 'local-profile-composition', rights: 'derived-from-author-owned-profiles', createdAt: [...profiles].map((profile) => profile.provenance.createdAt).sort().at(-1) },
|
|
84
|
+
rulePolicy,
|
|
85
|
+
channel,
|
|
86
|
+
...(composedTone ? { tone: composedTone } : {}),
|
|
87
|
+
fingerprint: fingerprint(profiles, ratio),
|
|
88
|
+
tolerances: {
|
|
89
|
+
contractionRate: { absolute: Math.min(...profiles.map((profile) => profile.tolerances.contractionRate.absolute)), calibrated: profiles.every((profile) => profile.tolerances.contractionRate.calibrated) },
|
|
90
|
+
sentenceLengthDistribution: { absolute: Math.min(...profiles.map((profile) => profile.tolerances.sentenceLengthDistribution.absolute)), calibrated: profiles.every((profile) => profile.tolerances.sentenceLengthDistribution.calibrated) },
|
|
91
|
+
bulletRate: { absolute: Math.min(...profiles.map((profile) => profile.tolerances.bulletRate.absolute)), calibrated: profiles.every((profile) => profile.tolerances.bulletRate.calibrated) },
|
|
92
|
+
enDashRate: { absolute: Math.min(...profiles.map((profile) => profile.tolerances.enDashRate.absolute)), calibrated: profiles.every((profile) => profile.tolerances.enDashRate.calibrated) },
|
|
93
|
+
},
|
|
94
|
+
metricFixtures: { contractionRate: fixtures('contractionRate'), sentenceLengthDistribution: fixtures('sentenceLengthDistribution'), bulletRate: fixtures('bulletRate'), enDashRate: fixtures('enDashRate') },
|
|
95
|
+
};
|
|
96
|
+
return { ...unsigned, revisionDigest: createHash('sha256').update(canonicalJson(unsigned)).digest('hex') };
|
|
97
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import { canonicalJson } from './canonical-json.js';
|
|
5
|
+
import { composeProfiles, parseProfileRatio } from './profile-compose.js';
|
|
6
|
+
import { parseProfile } from './profile.js';
|
|
7
|
+
function profile(id, channel, sentenceLength, policy) {
|
|
8
|
+
const unsigned = {
|
|
9
|
+
version: '3', id, revision: 1, sampleCount: 3,
|
|
10
|
+
metrics: { sentenceLength, sentenceVariation: 2, sentenceStructure: ['i name the'], rhythm: 2, paragraphLength: 2, openingMoves: ['i'], vocabulary: ['mechanism'], lexicalDensity: 0.5, pointOfView: 'first_person', punctuation: { '!': 0, '?': 0, ';': 0, ':': 0, '—': 0 }, caseStyle: 'lowercase', questionRate: 0, transitions: ['but'] },
|
|
11
|
+
avoid: [id], provenance: { source: 'test', rights: 'test', createdAt: '2026-08-13T00:00:00.000Z' }, rulePolicy: policy, channel,
|
|
12
|
+
tone: { formality: 0.4, confidence: 0.6, warmth: 0.7, energy: 0.3, complexity: 0.5 },
|
|
13
|
+
fingerprint: { contractionRate: 0.2, sentenceLengthDistribution: { short: 0.3, medium: 0.5, long: 0.2 }, bulletRate: 0.1, enDashRate: 0 },
|
|
14
|
+
tolerances: { contractionRate: { absolute: 0.1, calibrated: true }, sentenceLengthDistribution: { absolute: 0.1, calibrated: true }, bulletRate: { absolute: 0.1, calibrated: true }, enDashRate: { absolute: 0.1, calibrated: true } },
|
|
15
|
+
metricFixtures: { contractionRate: ['fixture.a'], sentenceLengthDistribution: ['fixture.b'], bulletRate: ['fixture.c'], enDashRate: ['fixture.d'] },
|
|
16
|
+
};
|
|
17
|
+
return { ...unsigned, revisionDigest: createHash('sha256').update(canonicalJson(unsigned)).digest('hex') };
|
|
18
|
+
}
|
|
19
|
+
test('composes metrics by ratio and intersects policy conservatively', () => {
|
|
20
|
+
const email = profile('founder.email', 'email', 10, { 'ai.leverage': 'advisory' });
|
|
21
|
+
const docs = profile('founder.docs', 'docs', 20, { 'ai.leverage': 'blocking' });
|
|
22
|
+
const composed = composeProfiles([email, docs], parseProfileRatio('70:30', 2));
|
|
23
|
+
assert.equal(composed.metrics.sentenceLength, 13);
|
|
24
|
+
assert.equal(composed.rulePolicy['ai.leverage'], 'blocking');
|
|
25
|
+
assert.deepEqual([...composed.avoid].sort(), ['founder.docs', 'founder.email']);
|
|
26
|
+
assert.equal(composed.channel, 'email');
|
|
27
|
+
assert.strictEqual(parseProfile(composed), composed);
|
|
28
|
+
});
|
|
29
|
+
test('rejects malformed profile ratios', () => {
|
|
30
|
+
assert.throws(() => parseProfileRatio('70:0', 2), /positive/);
|
|
31
|
+
assert.throws(() => parseProfileRatio('70:30:10', 2), /contain 2/);
|
|
32
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { profileMetrics } from './voice-dna.js';
|
|
2
|
+
import { words } from './text.js';
|
|
3
|
+
const METRICS = ['sentenceLength', 'sentenceVariation', 'sentenceStructure', 'rhythm', 'paragraphLength', 'openingMoves', 'vocabulary', 'lexicalDensity', 'pointOfView', 'punctuation', 'caseStyle', 'questionRate', 'transitions'];
|
|
4
|
+
function clamp(value) { return Math.max(0, Math.min(1, value)); }
|
|
5
|
+
function rounded(value) { return Number(value.toFixed(3)); }
|
|
6
|
+
function setSimilarity(left, right) {
|
|
7
|
+
const a = new Set(left);
|
|
8
|
+
const b = new Set(right);
|
|
9
|
+
if (!a.size && !b.size)
|
|
10
|
+
return 1;
|
|
11
|
+
const intersection = [...a].filter((item) => b.has(item)).length;
|
|
12
|
+
return intersection / new Set([...a, ...b]).size;
|
|
13
|
+
}
|
|
14
|
+
function numericSimilarity(left, right, scale) {
|
|
15
|
+
return clamp(1 - Math.abs(left - right) / Math.max(scale, Number.EPSILON));
|
|
16
|
+
}
|
|
17
|
+
function punctuationSimilarity(left, right) {
|
|
18
|
+
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
19
|
+
const difference = [...keys].reduce((sum, key) => sum + Math.abs((left[key] ?? 0) - (right[key] ?? 0)), 0);
|
|
20
|
+
const magnitude = [...keys].reduce((sum, key) => sum + Math.max(left[key] ?? 0, right[key] ?? 0), 0);
|
|
21
|
+
return magnitude === 0 ? 1 : clamp(1 - difference / magnitude);
|
|
22
|
+
}
|
|
23
|
+
function componentScores(candidate, target) {
|
|
24
|
+
return {
|
|
25
|
+
sentenceLength: numericSimilarity(candidate.sentenceLength, target.sentenceLength, Math.max(8, target.sentenceLength * 0.75)),
|
|
26
|
+
sentenceVariation: numericSimilarity(candidate.sentenceVariation, target.sentenceVariation, Math.max(4, target.sentenceVariation * 1.5)),
|
|
27
|
+
sentenceStructure: setSimilarity(candidate.sentenceStructure, target.sentenceStructure),
|
|
28
|
+
rhythm: numericSimilarity(candidate.rhythm, target.rhythm, Math.max(4, target.rhythm * 1.5)),
|
|
29
|
+
paragraphLength: numericSimilarity(candidate.paragraphLength, target.paragraphLength, Math.max(2, target.paragraphLength)),
|
|
30
|
+
openingMoves: setSimilarity(candidate.openingMoves, target.openingMoves),
|
|
31
|
+
vocabulary: setSimilarity(candidate.vocabulary, target.vocabulary),
|
|
32
|
+
lexicalDensity: numericSimilarity(candidate.lexicalDensity, target.lexicalDensity, 0.25),
|
|
33
|
+
pointOfView: Number(candidate.pointOfView === target.pointOfView || target.pointOfView === 'mixed'),
|
|
34
|
+
punctuation: punctuationSimilarity(candidate.punctuation, target.punctuation),
|
|
35
|
+
caseStyle: Number(candidate.caseStyle === target.caseStyle || target.caseStyle === 'mixed'),
|
|
36
|
+
questionRate: numericSimilarity(candidate.questionRate, target.questionRate, 0.25),
|
|
37
|
+
transitions: setSimilarity(candidate.transitions, target.transitions),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function score(components) {
|
|
41
|
+
return rounded(100 * METRICS.reduce((sum, key) => sum + components[key], 0) / METRICS.length);
|
|
42
|
+
}
|
|
43
|
+
function percentile(values, fraction) {
|
|
44
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
45
|
+
if (!sorted.length)
|
|
46
|
+
return 0;
|
|
47
|
+
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * fraction))];
|
|
48
|
+
}
|
|
49
|
+
function languageConfidence(text) {
|
|
50
|
+
const letters = text.match(/\p{L}/gu) ?? [];
|
|
51
|
+
const latin = text.match(/\p{Script=Latin}/gu) ?? [];
|
|
52
|
+
return words(text).length >= 20 && letters.length > 0 && latin.length / letters.length >= 0.8;
|
|
53
|
+
}
|
|
54
|
+
export function scoreHeldoutProfile(candidate, profile, heldoutSamples, channel) {
|
|
55
|
+
const selectedChannel = profile.version === '3' ? profile.channel ?? 'general' : 'general';
|
|
56
|
+
if (channel && channel !== selectedChannel)
|
|
57
|
+
return { version: '1', disposition: 'abstain', channel: selectedChannel, reason: 'The requested channel does not match the selected profile.' };
|
|
58
|
+
if (heldoutSamples.length < 3)
|
|
59
|
+
return { version: '1', disposition: 'abstain', channel: selectedChannel, reason: 'Held-out scoring needs at least three distinct samples.' };
|
|
60
|
+
if (![candidate, ...heldoutSamples].every(languageConfidence))
|
|
61
|
+
return { version: '1', disposition: 'abstain', channel: selectedChannel, reason: 'Language confidence is too low for the current English-oriented metric set.' };
|
|
62
|
+
const heldout = heldoutSamples.map(profileMetrics);
|
|
63
|
+
const pairScores = [];
|
|
64
|
+
for (let left = 0; left < heldout.length; left += 1)
|
|
65
|
+
for (let right = left + 1; right < heldout.length; right += 1)
|
|
66
|
+
pairScores.push(score(componentScores(heldout[left], heldout[right])));
|
|
67
|
+
const components = componentScores(profileMetrics(candidate), profile.metrics);
|
|
68
|
+
const candidateScore = score(components);
|
|
69
|
+
const lowerBound = rounded(percentile(pairScores, 0.1));
|
|
70
|
+
const median = rounded(percentile(pairScores, 0.5));
|
|
71
|
+
return {
|
|
72
|
+
version: '1',
|
|
73
|
+
disposition: candidateScore >= lowerBound ? 'inside_band' : 'review',
|
|
74
|
+
channel: selectedChannel,
|
|
75
|
+
candidateScore,
|
|
76
|
+
selfSimilarity: { samplePairs: pairScores.length, ceiling: 100, lowerBound, median },
|
|
77
|
+
components: Object.fromEntries(METRICS.map((key) => [key, rounded(components[key] * 100)])),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { buildProfile } from './voice-dna.js';
|
|
4
|
+
import { scoreHeldoutProfile } from './profile-score.js';
|
|
5
|
+
const samples = [
|
|
6
|
+
'I write a direct note about the launch. The owner checks the evidence before we ship. The next step is clear and the work stays small.',
|
|
7
|
+
'I name the trade-off before I make the decision. We keep the mechanism visible for the person doing the work. The release has one owner.',
|
|
8
|
+
'I start from the evidence in the issue. Then I explain the constraint and choose a concrete next step. The team can check the result.',
|
|
9
|
+
];
|
|
10
|
+
test('scores held-out writing against the writer range without calling it authorship', () => {
|
|
11
|
+
const profile = buildProfile(samples);
|
|
12
|
+
const report = scoreHeldoutProfile('I name the evidence, explain the trade-off, and choose the next step. The owner can check the work before release.', profile, samples);
|
|
13
|
+
assert.equal(report.disposition, 'inside_band');
|
|
14
|
+
assert.equal(report.selfSimilarity?.ceiling, 100);
|
|
15
|
+
assert.equal(report.selfSimilarity?.samplePairs, 3);
|
|
16
|
+
assert.equal(Object.keys(report.components ?? {}).length, 13);
|
|
17
|
+
});
|
|
18
|
+
test('abstains for insufficient held-out writing and language confidence', () => {
|
|
19
|
+
const profile = buildProfile(samples);
|
|
20
|
+
assert.equal(scoreHeldoutProfile(samples[0], profile, samples.slice(0, 2)).disposition, 'abstain');
|
|
21
|
+
assert.equal(scoreHeldoutProfile('これは十分な日本語の文章ですが、現在の英語メトリクスでは評価しません。', profile, samples).disposition, 'abstain');
|
|
22
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
/** Watches only explicitly named local samples and collapses bursts into one rebuild. */
|
|
3
|
+
export function watchProfileSamples(options) {
|
|
4
|
+
if (options.samples.length < 2)
|
|
5
|
+
throw new Error('Profile watch requires at least two explicit local samples.');
|
|
6
|
+
const debounceMs = options.debounceMs ?? 500;
|
|
7
|
+
if (!Number.isInteger(debounceMs) || debounceMs < 100 || debounceMs > 60_000)
|
|
8
|
+
throw new Error('Profile watch debounce must be an integer from 100 to 60000 milliseconds.');
|
|
9
|
+
const createWatcher = options.watchFile ?? ((path, listener) => watch(path, { persistent: true }, listener));
|
|
10
|
+
const schedule = options.schedule ?? setTimeout;
|
|
11
|
+
const cancel = options.cancel ?? clearTimeout;
|
|
12
|
+
let timer;
|
|
13
|
+
let closed = false;
|
|
14
|
+
const trigger = () => {
|
|
15
|
+
if (closed)
|
|
16
|
+
return;
|
|
17
|
+
if (timer)
|
|
18
|
+
cancel(timer);
|
|
19
|
+
timer = schedule(() => { timer = undefined; if (!closed)
|
|
20
|
+
options.rebuild(); }, debounceMs);
|
|
21
|
+
};
|
|
22
|
+
const watchers = options.samples.map((path) => createWatcher(path, trigger));
|
|
23
|
+
return {
|
|
24
|
+
close() {
|
|
25
|
+
if (closed)
|
|
26
|
+
return;
|
|
27
|
+
closed = true;
|
|
28
|
+
if (timer)
|
|
29
|
+
cancel(timer);
|
|
30
|
+
for (const watcher of watchers)
|
|
31
|
+
watcher.close();
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { watchProfileSamples } from './profile-watch.js';
|
|
4
|
+
test('watches only explicit samples and debounces one local rebuild', () => {
|
|
5
|
+
const listeners = [];
|
|
6
|
+
let rebuilt = 0;
|
|
7
|
+
let pending;
|
|
8
|
+
let closed = 0;
|
|
9
|
+
const handle = watchProfileSamples({
|
|
10
|
+
samples: ['one.md', 'two.md'], debounceMs: 100, rebuild: () => { rebuilt += 1; },
|
|
11
|
+
watchFile: (_path, listener) => { listeners.push(listener); return { close: () => { closed += 1; } }; },
|
|
12
|
+
schedule: (callback) => { pending = callback; return 1; },
|
|
13
|
+
cancel: () => { pending = undefined; },
|
|
14
|
+
});
|
|
15
|
+
listeners[0]();
|
|
16
|
+
listeners[1]();
|
|
17
|
+
assert.equal(rebuilt, 0);
|
|
18
|
+
pending();
|
|
19
|
+
assert.equal(rebuilt, 1);
|
|
20
|
+
handle.close();
|
|
21
|
+
assert.equal(closed, 2);
|
|
22
|
+
assert.throws(() => watchProfileSamples({ samples: ['one.md'], rebuild: () => undefined }), /at least two/);
|
|
23
|
+
});
|
package/dist/profile.js
CHANGED
|
@@ -2,9 +2,11 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { canonicalJson } from './canonical-json.js';
|
|
3
3
|
import { isPlainObject } from './internal.js';
|
|
4
4
|
const METRICS_KEYS = ['sentenceLength', 'sentenceVariation', 'sentenceStructure', 'rhythm', 'paragraphLength', 'openingMoves', 'vocabulary', 'lexicalDensity', 'pointOfView', 'punctuation', 'caseStyle', 'questionRate', 'transitions'];
|
|
5
|
-
const
|
|
5
|
+
const PROFILE_V3_REQUIRED_KEYS = ['version', 'id', 'revision', 'revisionDigest', 'sampleCount', 'metrics', 'avoid', 'provenance', 'rulePolicy', 'fingerprint', 'tolerances', 'metricFixtures'];
|
|
6
|
+
const PROFILE_V3_ALLOWED_KEYS = [...PROFILE_V3_REQUIRED_KEYS, 'ruleAllowances', 'channel', 'tone'];
|
|
6
7
|
const FINGERPRINT_METRICS = ['contractionRate', 'sentenceLengthDistribution', 'bulletRate', 'enDashRate'];
|
|
7
8
|
const STABLE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,127})$/;
|
|
9
|
+
export const SAMPLE_ALLOWANCE_RULE_IDS = new Set(['punct.em-dash', 'punct.en-dash', 'format.curly-quotes']);
|
|
8
10
|
function hasKnownKeys(value, keys) {
|
|
9
11
|
return Object.keys(value).every((key) => keys.includes(key)) && keys.every((key) => key in value);
|
|
10
12
|
}
|
|
@@ -58,6 +60,24 @@ function isRulePolicy(value) {
|
|
|
58
60
|
const states = ['blocking', 'advisory', 'judgment-required', 'disabled'];
|
|
59
61
|
return Object.entries(value).every(([id, state]) => STABLE_ID.test(id) && states.includes(state));
|
|
60
62
|
}
|
|
63
|
+
function isRuleAllowances(value, profileSampleCount) {
|
|
64
|
+
if (!isPlainObject(value) || Object.keys(value).length > SAMPLE_ALLOWANCE_RULE_IDS.size || typeof profileSampleCount !== 'number')
|
|
65
|
+
return false;
|
|
66
|
+
return Object.entries(value).every(([id, allowance]) => {
|
|
67
|
+
if (!SAMPLE_ALLOWANCE_RULE_IDS.has(id) || !isPlainObject(allowance))
|
|
68
|
+
return false;
|
|
69
|
+
const candidate = allowance;
|
|
70
|
+
return typeof candidate.sampleCount === 'number' && Number.isInteger(candidate.sampleCount) && candidate.sampleCount >= 2 && candidate.sampleCount <= profileSampleCount
|
|
71
|
+
&& typeof candidate.evidenceDigest === 'string' && /^[a-f0-9]{64}$/.test(candidate.evidenceDigest);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function isProfileChannel(value) {
|
|
75
|
+
return ['general', 'email', 'chat', 'long-form', 'social', 'docs'].includes(value);
|
|
76
|
+
}
|
|
77
|
+
function isTone(value) {
|
|
78
|
+
return isPlainObject(value) && hasKnownKeys(value, ['formality', 'confidence', 'warmth', 'energy', 'complexity'])
|
|
79
|
+
&& Object.values(value).every(isRate);
|
|
80
|
+
}
|
|
61
81
|
function isFingerprint(value) {
|
|
62
82
|
if (!isPlainObject(value) || !hasKnownKeys(value, FINGERPRINT_METRICS))
|
|
63
83
|
return false;
|
|
@@ -86,7 +106,8 @@ function hasValidRevisionDigest(profile) {
|
|
|
86
106
|
return createHash('sha256').update(canonicalJson(unsigned)).digest('hex') === revisionDigest;
|
|
87
107
|
}
|
|
88
108
|
function parseProfileV3(value) {
|
|
89
|
-
const valid = isPlainObject(value) &&
|
|
109
|
+
const valid = isPlainObject(value) && Object.keys(value).every((key) => PROFILE_V3_ALLOWED_KEYS.includes(key))
|
|
110
|
+
&& PROFILE_V3_REQUIRED_KEYS.every((key) => key in value)
|
|
90
111
|
&& typeof value.id === 'string' && STABLE_ID.test(value.id)
|
|
91
112
|
&& typeof value.revision === 'number' && Number.isSafeInteger(value.revision) && value.revision > 0
|
|
92
113
|
&& typeof value.sampleCount === 'number' && Number.isInteger(value.sampleCount) && value.sampleCount >= 2
|
|
@@ -99,6 +120,16 @@ function parseProfileV3(value) {
|
|
|
99
120
|
&& isMetricFixtures(value.metricFixtures);
|
|
100
121
|
if (!valid)
|
|
101
122
|
throw new Error('Profile is not a valid Hold Your Voice version 3 profile. Rebuild it from fixture-backed metrics.');
|
|
123
|
+
if (value.ruleAllowances !== undefined && !isRuleAllowances(value.ruleAllowances, value.sampleCount)) {
|
|
124
|
+
throw new Error('Profile version 3 rule allowances must be derived from at least two samples and use eligible rule IDs.');
|
|
125
|
+
}
|
|
126
|
+
if (value.ruleAllowances && Object.keys(value.ruleAllowances).some((id) => value.rulePolicy[id] === 'blocking')) {
|
|
127
|
+
throw new Error('Profile version 3 rule allowances cannot weaken an explicit blocking policy.');
|
|
128
|
+
}
|
|
129
|
+
if (value.channel !== undefined && !isProfileChannel(value.channel))
|
|
130
|
+
throw new Error('Profile version 3 channel must be one of the supported local writing channels.');
|
|
131
|
+
if (value.tone !== undefined && !isTone(value.tone))
|
|
132
|
+
throw new Error('Profile version 3 tone must contain five 0–1 advisory dimensions.');
|
|
102
133
|
if (!hasValidRevisionDigest(value))
|
|
103
134
|
throw new Error('Profile version 3 revision digest does not match its canonical contents.');
|
|
104
135
|
return value;
|
package/dist/profile.test.js
CHANGED
|
@@ -98,6 +98,33 @@ test('rejects malformed Profile v3 identity, policy, provenance, and unknown key
|
|
|
98
98
|
assert.throws(() => parseProfile(profile), /version 3 profile/);
|
|
99
99
|
}
|
|
100
100
|
});
|
|
101
|
+
test('accepts a signed eligible allowance and rejects an unsafe allowance', () => {
|
|
102
|
+
const profile = profileV3();
|
|
103
|
+
const unsigned = { ...profile };
|
|
104
|
+
delete unsigned.revisionDigest;
|
|
105
|
+
const withAllowance = {
|
|
106
|
+
...unsigned,
|
|
107
|
+
ruleAllowances: { 'punct.em-dash': { sampleCount: 2, evidenceDigest: 'a'.repeat(64) } },
|
|
108
|
+
};
|
|
109
|
+
const accepted = {
|
|
110
|
+
...withAllowance,
|
|
111
|
+
revisionDigest: createHash('sha256').update(canonicalJson(withAllowance)).digest('hex'),
|
|
112
|
+
};
|
|
113
|
+
assert.strictEqual(parseProfile(accepted), accepted);
|
|
114
|
+
const blocked = profileV3();
|
|
115
|
+
const blockedUnsigned = { ...blocked };
|
|
116
|
+
delete blockedUnsigned.revisionDigest;
|
|
117
|
+
const unsafe = {
|
|
118
|
+
...blockedUnsigned,
|
|
119
|
+
rulePolicy: { 'punct.em-dash': 'blocking' },
|
|
120
|
+
ruleAllowances: { 'punct.em-dash': { sampleCount: 2, evidenceDigest: 'a'.repeat(64) } },
|
|
121
|
+
};
|
|
122
|
+
const signedUnsafe = {
|
|
123
|
+
...unsafe,
|
|
124
|
+
revisionDigest: createHash('sha256').update(canonicalJson(unsafe)).digest('hex'),
|
|
125
|
+
};
|
|
126
|
+
assert.throws(() => parseProfile(signedUnsafe), /cannot weaken an explicit blocking policy/);
|
|
127
|
+
});
|
|
101
128
|
test('rejects unbounded or invalid Profile v3 metrics and tolerances', () => {
|
|
102
129
|
for (const mutate of [
|
|
103
130
|
(profile) => { profile.fingerprint.contractionRate = Number.NaN; },
|
|
@@ -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.
|
|
177
|
+
assert.equal(HYV_VERSION, '3.5.0');
|
|
178
178
|
});
|
|
179
179
|
test('apply rejects forged tasks, missing capability, and substituted profiles', () => {
|
|
180
180
|
const reduction = rebuildRecommendation();
|