@holdyourvoice/hyv 3.4.2 → 3.4.4

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