@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.
Files changed (67) hide show
  1. package/Readme.md +7 -0
  2. package/dist/agents/load.test.js +1 -1
  3. package/dist/ai-editor-rules.js +34 -0
  4. package/dist/ai-editor.js +120 -9
  5. package/dist/ai-editor.test.js +99 -8
  6. package/dist/ai-shadow-fixtures.js +7 -0
  7. package/dist/ai-shadow-generator.js +17 -0
  8. package/dist/backtest.js +16 -0
  9. package/dist/backtest.test.js +20 -0
  10. package/dist/cli.js +225 -8
  11. package/dist/cli.test.js +102 -5
  12. package/dist/editorial-packs.js +1 -0
  13. package/dist/hold-your-voice.mcpb +0 -0
  14. package/dist/local-eval.js +98 -0
  15. package/dist/local-eval.test.js +20 -0
  16. package/dist/mcp-tools.js +22 -2
  17. package/dist/mcp-tools.test.js +33 -1
  18. package/dist/mcp.js +35 -4
  19. package/dist/mcp.test.js +2 -2
  20. package/dist/pipeline.js +5 -4
  21. package/dist/pipeline.test.js +12 -0
  22. package/dist/profile-compose.js +97 -0
  23. package/dist/profile-compose.test.js +32 -0
  24. package/dist/profile-score.js +79 -0
  25. package/dist/profile-score.test.js +22 -0
  26. package/dist/profile-watch.js +34 -0
  27. package/dist/profile-watch.test.js +23 -0
  28. package/dist/profile.js +33 -2
  29. package/dist/profile.test.js +27 -0
  30. package/dist/rebuild-task.test.js +1 -1
  31. package/dist/rule-allowances.js +27 -0
  32. package/dist/rule-allowances.test.js +17 -0
  33. package/dist/sample-ingest.js +94 -0
  34. package/dist/sample-ingest.test.js +52 -0
  35. package/dist/strict-quality.js +64 -0
  36. package/dist/strict-quality.test.js +62 -0
  37. package/dist/version.js +1 -1
  38. package/dist/voice-dna.js +33 -1
  39. package/dist/voice-dna.test.js +12 -1
  40. package/dist/writing-examples.js +83 -0
  41. package/dist/writing-examples.test.js +35 -0
  42. package/package.json +56 -11
  43. package/skills/hyv-analyze/SKILL.md +3 -1
  44. package/skills/hyv-analyze/agent.json +2 -1
  45. package/skills/hyv-backtest/SKILL.md +14 -0
  46. package/skills/hyv-backtest/agent.json +16 -0
  47. package/skills/hyv-backtest/agents/openai.yaml +4 -0
  48. package/skills/hyv-evaluate-local/SKILL.md +14 -0
  49. package/skills/hyv-evaluate-local/agent.json +16 -0
  50. package/skills/hyv-evaluate-local/agents/openai.yaml +4 -0
  51. package/skills/hyv-final-check/SKILL.md +2 -0
  52. package/skills/hyv-find-writing-examples/SKILL.md +10 -0
  53. package/skills/hyv-find-writing-examples/agent.json +16 -0
  54. package/skills/hyv-find-writing-examples/agents/openai.yaml +4 -0
  55. package/skills/hyv-ingest/SKILL.md +31 -0
  56. package/skills/hyv-ingest/agent.json +28 -0
  57. package/skills/hyv-ingest/agents/openai.yaml +4 -0
  58. package/skills/hyv-patterns/SKILL.md +2 -0
  59. package/skills/hyv-profile/SKILL.md +2 -0
  60. package/skills/hyv-score/SKILL.md +26 -0
  61. package/skills/hyv-score/agent.json +28 -0
  62. package/skills/hyv-score/agents/openai.yaml +4 -0
  63. package/skills/hyv-strict-check/SKILL.md +26 -0
  64. package/skills/hyv-strict-check/agent.json +33 -0
  65. package/skills/hyv-strict-check/agents/openai.yaml +4 -0
  66. package/skills/hyv-verify/SKILL.md +1 -1
  67. package/skills/hyv-verify/agent.json +1 -0
@@ -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 ({ draft, profile_json, writing_brief_json }) => guardedJson(() => rewritePromptForMcp(draft, profile_json, {}, writing_brief_json)));
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, 35);
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);
@@ -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 PROFILE_V3_KEYS = ['version', 'id', 'revision', 'revisionDigest', 'sampleCount', 'metrics', 'avoid', 'provenance', 'rulePolicy', 'fingerprint', 'tolerances', 'metricFixtures'];
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) && hasKnownKeys(value, PROFILE_V3_KEYS)
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;
@@ -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.4.4');
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();