@holdyourvoice/hyv 3.6.1 → 4.0.1

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 (87) hide show
  1. package/Readme.md +91 -96
  2. package/dist/ai-editor-rules.js +1 -0
  3. package/dist/ai-editor.js +19 -13
  4. package/dist/analysis.js +33 -0
  5. package/dist/cli/agents.js +15 -35
  6. package/dist/cli/io.js +41 -37
  7. package/dist/copy-spec.js +6 -6
  8. package/dist/editorial-packs.js +4 -3
  9. package/dist/fact-linter.js +87 -37
  10. package/dist/hidden-text.js +6 -6
  11. package/dist/hold-your-voice.mcpb +0 -0
  12. package/dist/internal.js +8 -0
  13. package/dist/judgment-task.js +10 -14
  14. package/dist/learning.js +66 -67
  15. package/dist/lifecycle-adapter.js +2 -5
  16. package/dist/local-eval.js +16 -31
  17. package/dist/mcp-server.js +272 -0
  18. package/dist/mcp-tools.js +2 -2
  19. package/dist/mcp.js +2 -261
  20. package/dist/pipeline.js +9 -85
  21. package/dist/profile-compose.js +18 -11
  22. package/dist/profile-score.js +6 -6
  23. package/dist/profile.js +17 -19
  24. package/dist/provenance-status.js +1 -3
  25. package/dist/rebuild-task.js +14 -25
  26. package/dist/recomposition.js +18 -13
  27. package/dist/rewrite-prompt.js +125 -0
  28. package/dist/rewrite-task.js +117 -70
  29. package/dist/rule-allowances.js +6 -10
  30. package/dist/semantic-review.js +71 -47
  31. package/dist/stage1-evaluation.js +29 -14
  32. package/dist/strict-quality.js +10 -18
  33. package/dist/version.js +1 -1
  34. package/dist/voice-dna.js +34 -21
  35. package/dist/writing-examples.js +2 -7
  36. package/package.json +6 -3
  37. package/skills/hyv-prepare-judgment/SKILL.md +4 -0
  38. package/dist/agents/catalog.test.js +0 -60
  39. package/dist/agents/emit.test.js +0 -64
  40. package/dist/agents/load.test.js +0 -149
  41. package/dist/ai-editor.test.js +0 -265
  42. package/dist/approval-capability.test.js +0 -52
  43. package/dist/approval-context.test.js +0 -38
  44. package/dist/backtest.test.js +0 -20
  45. package/dist/benchmark.test.js +0 -328
  46. package/dist/canonical-json.test.js +0 -24
  47. package/dist/cli/context.test.js +0 -55
  48. package/dist/cli.test.js +0 -731
  49. package/dist/editorial-packs.test.js +0 -94
  50. package/dist/fact-linter.test.js +0 -85
  51. package/dist/hidden-text.test.js +0 -26
  52. package/dist/hygiene.test.js +0 -83
  53. package/dist/judgment-task.test.js +0 -162
  54. package/dist/learning.test.js +0 -325
  55. package/dist/lifecycle-adapter.test.js +0 -56
  56. package/dist/local-eval.test.js +0 -20
  57. package/dist/logic-linter-corpus.test.js +0 -22
  58. package/dist/logic-linter.test.js +0 -39
  59. package/dist/mcp-tools.test.js +0 -286
  60. package/dist/mcp.test.js +0 -312
  61. package/dist/mirror-refs.test.js +0 -63
  62. package/dist/pipeline.test.js +0 -247
  63. package/dist/preservation.test.js +0 -22
  64. package/dist/production-gates.test.js +0 -34
  65. package/dist/profile-compose.test.js +0 -32
  66. package/dist/profile-score.test.js +0 -22
  67. package/dist/profile-watch.test.js +0 -23
  68. package/dist/profile.test.js +0 -141
  69. package/dist/provenance-status.test.js +0 -22
  70. package/dist/rebuild-task.test.js +0 -206
  71. package/dist/recomposition.test.js +0 -34
  72. package/dist/release-audit.test.js +0 -292
  73. package/dist/rewrite-task.test.js +0 -166
  74. package/dist/rule-allowances.test.js +0 -17
  75. package/dist/rule-reconciliation.test.js +0 -50
  76. package/dist/sample-ingest.test.js +0 -52
  77. package/dist/semantic-review.test.js +0 -101
  78. package/dist/stage1-dry-run.test.js +0 -39
  79. package/dist/stage1-evaluation.test.js +0 -184
  80. package/dist/stage1-human-packet.test.js +0 -102
  81. package/dist/stage1-schema-contract.test.js +0 -95
  82. package/dist/stage2-human-packet.test.js +0 -81
  83. package/dist/strict-quality.test.js +0 -62
  84. package/dist/text-provenance.feature.test.js +0 -45
  85. package/dist/text.test.js +0 -16
  86. package/dist/voice-dna.test.js +0 -121
  87. package/dist/writing-examples.test.js +0 -35
package/dist/voice-dna.js CHANGED
@@ -16,12 +16,18 @@ function top(items, limit) {
16
16
  counts.set(item, (counts.get(item) ?? 0) + 1);
17
17
  return [...counts].sort((left, right) => right[1] - left[1]).slice(0, limit).map(([item]) => item);
18
18
  }
19
+ function measureWriting(text, tokens) {
20
+ const mapped = sentences(text);
21
+ return { text, mapped, tokens: tokens ?? words(text), sentenceWords: mapped.map((sentence) => words(sentence.text)) };
22
+ }
19
23
  export function profileMetrics(text) {
20
- const draftSentences = sentences(text);
21
- const draftWords = words(text);
22
- const lengths = draftSentences.map((sentence) => words(sentence.text).length);
23
- const starters = draftSentences.map((sentence) => words(sentence.text)[0]?.toLowerCase() ?? '');
24
- const structures = draftSentences.map((sentence) => words(sentence.text).slice(0, 3).map((word) => word.toLowerCase()).join(' '));
24
+ return metricsFor(measureWriting(text));
25
+ }
26
+ function metricsFor(writing) {
27
+ const { text, mapped: draftSentences, tokens: draftWords, sentenceWords } = writing;
28
+ const lengths = sentenceWords.map((tokens) => tokens.length);
29
+ const starters = sentenceWords.map((tokens) => tokens[0]?.toLowerCase() ?? '');
30
+ const structures = sentenceWords.map((tokens) => tokens.slice(0, 3).map((word) => word.toLowerCase()).join(' '));
25
31
  const lowerWords = draftWords.map((word) => word.toLowerCase());
26
32
  const nonStop = lowerWords.filter((word) => word.length > 3 && !STOP_WORDS.has(word));
27
33
  const first = lowerWords.filter((word) => ['i', 'we', 'my', 'our', 'us'].includes(word)).length;
@@ -52,17 +58,18 @@ export function profileMetrics(text) {
52
58
  };
53
59
  }
54
60
  export function measureFounderFingerprint(text) {
55
- const draftWords = words(text);
61
+ return fingerprintFor(measureWriting(text, words(text)));
62
+ }
63
+ function fingerprintFor(writing) {
64
+ const { text, mapped: draftSentences, tokens: draftWords, sentenceWords } = writing;
56
65
  const wordDenominator = Math.max(1, draftWords.length);
57
66
  const contractionCount = draftWords.filter((word) => CONTRACTIONS.has(word.toLowerCase().replaceAll('’', "'"))).length;
58
- const draftSentences = sentences(text);
59
67
  const sentenceDenominator = Math.max(1, draftSentences.length);
60
68
  const buckets = { short: 0, medium: 0, long: 0 };
61
- for (const sentence of draftSentences) {
62
- const length = words(sentence.text).length;
63
- if (length <= 8)
69
+ for (const tokens of sentenceWords) {
70
+ if (tokens.length <= 8)
64
71
  buckets.short += 1;
65
- else if (length <= 20)
72
+ else if (tokens.length <= 20)
66
73
  buckets.medium += 1;
67
74
  else
68
75
  buckets.long += 1;
@@ -81,16 +88,21 @@ export function measureFounderFingerprint(text) {
81
88
  enDashRate: (text.match(/–/g) ?? []).length / wordDenominator,
82
89
  };
83
90
  }
84
- export function buildProfile(samples, avoid = []) {
91
+ function measureSamples(samples, avoid) {
85
92
  if (samples.length < 2)
86
93
  throw new Error('Provide at least two local writing samples.');
87
94
  if (samples.some((sample) => !words(sample).length))
88
95
  throw new Error('Every local writing sample must contain writing.');
89
- return { version: '2', sampleCount: samples.length, metrics: profileMetrics(samples.join('\n\n')), avoid };
96
+ const writing = measureWriting(samples.join('\n\n'));
97
+ const profile = { version: '2', sampleCount: samples.length, metrics: metricsFor(writing), avoid };
98
+ return { writing, profile };
99
+ }
100
+ export function buildProfile(samples, avoid = []) {
101
+ return measureSamples(samples, avoid).profile;
90
102
  }
91
103
  export function buildProfileV3(samples, id, channel, avoid = [], tone) {
92
- const base = buildProfile(samples, avoid);
93
- const sampleText = samples.join('\n\n');
104
+ const { profile: base, writing } = measureSamples(samples, avoid);
105
+ const ruleAllowances = deriveRuleAllowances(samples);
94
106
  const fixtureIds = samples.map((_, index) => 'sample.' + String(index + 1).padStart(3, '0'));
95
107
  const unsigned = {
96
108
  version: '3',
@@ -101,10 +113,10 @@ export function buildProfileV3(samples, id, channel, avoid = [], tone) {
101
113
  avoid: base.avoid,
102
114
  provenance: { source: 'local-author-owned-samples', rights: 'author-owned', createdAt: new Date().toISOString() },
103
115
  rulePolicy: {},
104
- ...(Object.keys(deriveRuleAllowances(samples)).length ? { ruleAllowances: deriveRuleAllowances(samples) } : {}),
116
+ ...(Object.keys(ruleAllowances).length ? { ruleAllowances } : {}),
105
117
  channel,
106
118
  ...(tone ? { tone } : {}),
107
- fingerprint: measureFounderFingerprint(sampleText),
119
+ fingerprint: fingerprintFor(writing),
108
120
  tolerances: {
109
121
  contractionRate: { absolute: 0.1, calibrated: false },
110
122
  sentenceLengthDistribution: { absolute: 0.15, calibrated: false },
@@ -121,11 +133,12 @@ function finding(id, severity, sentence, excerpt, reason, suggestion) {
121
133
  return { engine: 'voice_dna', id, severity, sentence, excerpt, reason, suggestion };
122
134
  }
123
135
  export function analyzeVoiceDna(text, profile) {
124
- const metrics = profileMetrics(text);
136
+ const writing = measureWriting(text);
137
+ const metrics = metricsFor(writing);
125
138
  const findings = [];
126
139
  const tolerance = Math.max(8, profile.metrics.sentenceVariation * 2.2);
127
- for (const sentence of sentences(text)) {
128
- const count = words(sentence.text).length;
140
+ for (const [index, sentence] of writing.mapped.entries()) {
141
+ const count = writing.sentenceWords[index].length;
129
142
  if (Math.abs(count - profile.metrics.sentenceLength) > tolerance)
130
143
  findings.push(finding('dna.sentence-length', 'yellow', sentence.index, sentence.text, `Sentence length (${count}) is outside the profile band around ${profile.metrics.sentenceLength}.`, 'Restore the writer’s usual sentence length where it improves clarity.'));
131
144
  for (const banned of profile.avoid)
@@ -139,7 +152,7 @@ export function analyzeVoiceDna(text, profile) {
139
152
  if (metrics.pointOfView !== profile.metrics.pointOfView && profile.metrics.pointOfView !== 'mixed')
140
153
  findings.push(finding('dna.point-of-view', 'yellow', 1, text.slice(0, 160), `Draft point of view is ${metrics.pointOfView}; profile is ${profile.metrics.pointOfView}.`, 'Restore the writer’s normal narrative distance.'));
141
154
  if (profile.version === '3') {
142
- const measured = measureFounderFingerprint(text);
155
+ const measured = fingerprintFor(writing);
143
156
  const fingerprintChecks = [
144
157
  ['contraction-rate', measured.contractionRate, profile.fingerprint.contractionRate, profile.tolerances.contractionRate],
145
158
  ['sentence-length-distribution', Math.max(Math.abs(measured.sentenceLengthDistribution.short - profile.fingerprint.sentenceLengthDistribution.short), Math.abs(measured.sentenceLengthDistribution.medium - profile.fingerprint.sentenceLengthDistribution.medium), Math.abs(measured.sentenceLengthDistribution.long - profile.fingerprint.sentenceLengthDistribution.long)), 0, profile.tolerances.sentenceLengthDistribution],
@@ -31,19 +31,14 @@ function validate(inputs) {
31
31
  function indexExamples(inputs) {
32
32
  const entries = inputs.flatMap((input) => sentences(input.text).map((sentence, position) => ({ source: input.basename, position, text: sentence.text, terms: tokens(sentence.text) })));
33
33
  const postings = new Map();
34
- const documentFrequency = new Map();
35
34
  for (const [entryIndex, entry] of entries.entries()) {
36
- const seen = new Set();
37
35
  for (const term of entry.terms) {
38
36
  const posting = postings.get(term) ?? new Map();
39
37
  posting.set(entryIndex, (posting.get(entryIndex) ?? 0) + 1);
40
38
  postings.set(term, posting);
41
- seen.add(term);
42
39
  }
43
- for (const term of seen)
44
- documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
45
40
  }
46
- return { entries, postings, documentFrequency };
41
+ return { entries, postings };
47
42
  }
48
43
  /** Finds redacted local examples in supplied memory only; it never creates a corpus or index on disk. */
49
44
  export function findWritingExamples(query, inputs, limit = 3) {
@@ -58,7 +53,7 @@ export function findWritingExamples(query, inputs, limit = 3) {
58
53
  const posting = index.postings.get(term);
59
54
  if (!posting)
60
55
  continue;
61
- const idf = Math.log(1 + (index.entries.length - (index.documentFrequency.get(term) ?? 0) + 0.5) / ((index.documentFrequency.get(term) ?? 0) + 0.5));
56
+ const idf = Math.log(1 + (index.entries.length - posting.size + 0.5) / (posting.size + 0.5));
62
57
  for (const [entryIndex, frequency] of posting) {
63
58
  const entry = index.entries[entryIndex];
64
59
  const denominator = frequency + 1.2 * (1 - 0.75 + 0.75 * entry.terms.length / Math.max(1, averageLength));
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@holdyourvoice/hyv",
3
- "version": "3.6.1",
4
- "description": "A local-first dual-engine writing gate that protects voice and catches generic AI patterns.",
3
+ "version": "4.0.1",
4
+ "description": "Local writing checks, voice profiles, and verified editing workflows for humans and agents.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "hyv": "dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist",
11
+ "!dist/**/*.test.js",
11
12
  "skills",
12
13
  "Readme.md",
13
14
  "LICENSE"
@@ -17,6 +18,7 @@
17
18
  "bundle:claude": "esbuild src/mcp.ts --bundle --platform=node --format=esm --target=node20 --outfile=mcpb/server/index.js",
18
19
  "build:claude": "npm run build && npm run bundle:claude",
19
20
  "pack:claude": "npm run build:claude && node scripts/pack-claude.mjs",
21
+ "pack:chatgpt-plugin": "node scripts/pack-chatgpt-plugin.mjs",
20
22
  "scorecard": "node scripts/public-scorecard.mjs benchmarks",
21
23
  "validate:rule-contributions": "node scripts/validate-rule-contributions.mjs",
22
24
  "stage1:evaluate": "node scripts/evaluate-rewrite-benchmark.mjs",
@@ -25,7 +27,8 @@
25
27
  "stage2:human-packet": "npm run build && node scripts/run-stage2-human-packet.mjs",
26
28
  "test": "npm run build && node --test \"dist/**/*.test.js\"",
27
29
  "check:release": "node scripts/release-audit.mjs",
28
- "prepack": "npm run check:release && npm test && npm run pack:claude"
30
+ "prepack": "npm run check:release && npm test && npm run pack:claude",
31
+ "check:compatibility": "node scripts/verify-compatibility.mjs"
29
32
  },
30
33
  "engines": {
31
34
  "node": ">=20"
@@ -19,6 +19,10 @@ hyv prepare-judgment pre-edit|post-candidate kind draft.md profile.json task.jso
19
19
  - Commands that write use an explicit output path. Confirm that path before running the command.
20
20
  - This agent describes how to invoke the command and what it returns. It does not change command behavior or exit codes.
21
21
 
22
+ ## Word economy review
23
+
24
+ For a post-candidate form judgment, review whether every word earns its place before final verification and delivery. Name the phrase and sentence range, propose a cut, and explain why meaning, evidence, clarity, and voice survive it. Preserve necessary uncertainty, attribution, emphasis, rhythm, and transitions. Do not use a word-count target. Record unresolved cuts as findings and use ESCALATE; do not return CLEAR merely because the candidate is shorter. Report concerns outside the authorized edit scope without changing text. The local command prepares the task; it does not perform this editorial judgment.
25
+
22
26
  ## Handoff
23
27
 
24
28
  Run `hyv prepare-judgment` directly to execute the operation. Follow-on agents: hyv-reduce-judgment.
@@ -1,60 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { resolveHost, listHosts, runtime } from './catalog.js';
4
- import { AGENT_SCHEMA_VERSION } from './types.js';
5
- function minimalDescriptor(id, permissions) {
6
- return {
7
- schema_version: AGENT_SCHEMA_VERSION,
8
- id,
9
- title: id,
10
- description: 'test',
11
- instruction_file: 'SKILL.md',
12
- role: 'Tester',
13
- workflow_phase: 'test',
14
- input: { required: ['draft'], optional: [] },
15
- output: { required: ['report'], optional: [] },
16
- evidence_requirements: ['evidence'],
17
- permissions,
18
- stop_conditions: ['stop'],
19
- tool_free_mode: { available: true, behavior: 'report', unavailable_statuses: ['NOT_AVAILABLE'] },
20
- handoff_to: [],
21
- };
22
- }
23
- test('resolves known hosts and falls back to generic for unknown ids', () => {
24
- assert.equal(resolveHost('codex').id, 'codex');
25
- assert.equal(resolveHost('future-ide').id, 'generic');
26
- assert.equal(resolveHost('future-ide').native_skills, false);
27
- assert.equal(resolveHost('future-ide').cli, true);
28
- });
29
- test('catalog has unique host ids and includes generic', () => {
30
- const hosts = listHosts();
31
- const ids = hosts.map((host) => host.id);
32
- assert.ok(ids.includes('generic'));
33
- assert.equal(new Set(ids).size, ids.length);
34
- });
35
- test('runtime marks unsupported permissions unavailable and switches to prompt-only', () => {
36
- const descriptor = minimalDescriptor('hyv-verify', ['read_repository', 'network', 'subagents']);
37
- const resolved = runtime(descriptor, 'generic');
38
- assert.deepEqual(resolved.available_capabilities, []);
39
- assert.deepEqual(resolved.unavailable_capabilities, [
40
- { capability: 'read_repository', status: 'NOT_AVAILABLE' },
41
- { capability: 'network', status: 'NOT_AVAILABLE' },
42
- { capability: 'subagents', status: 'NOT_AVAILABLE' },
43
- ]);
44
- assert.equal(resolved.execution_mode, 'prompt-only');
45
- });
46
- test('runtime uses native mode when the host supports every permission', () => {
47
- const descriptor = minimalDescriptor('hyv-verify', ['read_repository', 'execute_commands']);
48
- const resolved = runtime(descriptor, 'codex');
49
- assert.deepEqual(resolved.available_capabilities, ['read_repository', 'execute_commands']);
50
- assert.deepEqual(resolved.unavailable_capabilities, []);
51
- assert.equal(resolved.execution_mode, 'native');
52
- });
53
- test('network is never available on any host', () => {
54
- for (const host of listHosts()) {
55
- const resolved = runtime(minimalDescriptor('hyv-verify', ['network']), host.id);
56
- assert.deepEqual(resolved.available_capabilities, []);
57
- assert.deepEqual(resolved.unavailable_capabilities, [{ capability: 'network', status: 'NOT_AVAILABLE' }]);
58
- assert.equal(resolved.execution_mode, 'prompt-only');
59
- }
60
- });
@@ -1,64 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { describe, emitJson, emitPrompt } from './emit.js';
4
- import { AGENT_SCHEMA_VERSION } from './types.js';
5
- function makePackage(id) {
6
- return {
7
- directory: '/pkg',
8
- instructions: 'Usage: hyv verify original.md candidate.md profile.json',
9
- descriptor: {
10
- schema_version: AGENT_SCHEMA_VERSION,
11
- id,
12
- title: 'hyv-verify',
13
- description: 'verify a candidate',
14
- instruction_file: 'SKILL.md',
15
- role: 'Verifier',
16
- workflow_phase: 'verify',
17
- input: { required: ['original', 'candidate'], optional: ['writing brief'] },
18
- output: { required: ['verification report'], optional: [] },
19
- evidence_requirements: ['preserve revision evidence'],
20
- permissions: ['read_repository', 'network'],
21
- stop_conditions: ['stop when evidence is stale'],
22
- tool_free_mode: {
23
- available: true,
24
- behavior: 'report the decision and exact unavailable capability',
25
- unavailable_statuses: ['NOT_AVAILABLE', 'NOT_CONFIGURED', 'NOT_RUN', 'STALE', 'ERROR'],
26
- },
27
- handoff_to: ['hyv-lifecycle'],
28
- },
29
- };
30
- }
31
- test('describe resolves a host and returns a runtime descriptor', () => {
32
- const resolved = describe(makePackage('hyv-verify'), 'codex');
33
- assert.equal(resolved.agent.id, 'hyv-verify');
34
- assert.equal(resolved.host.id, 'codex');
35
- assert.ok(resolved.available_capabilities.includes('read_repository'));
36
- assert.deepEqual(resolved.unavailable_capabilities, [{ capability: 'network', status: 'NOT_AVAILABLE' }]);
37
- assert.equal(resolved.execution_mode, 'prompt-only');
38
- });
39
- test('emitJson renders a valid JSON runtime descriptor', () => {
40
- const body = emitJson(makePackage('hyv-verify'), 'generic');
41
- const parsed = JSON.parse(body);
42
- assert.equal(parsed.agent.id, 'hyv-verify');
43
- assert.equal(parsed.host.id, 'generic');
44
- assert.ok(Array.isArray(parsed.unavailable_capabilities));
45
- assert.deepEqual(parsed.unavailable_capabilities, [
46
- { capability: 'read_repository', status: 'NOT_AVAILABLE' },
47
- { capability: 'network', status: 'NOT_AVAILABLE' },
48
- ]);
49
- });
50
- test('emitPrompt includes instructions, capability boundary, and required statuses', () => {
51
- const prompt = emitPrompt(makePackage('hyv-verify'), 'codex');
52
- assert.match(prompt, /# hyv-verify/);
53
- assert.match(prompt, /Role: Verifier/);
54
- assert.match(prompt, /Usage: hyv verify original\.md candidate\.md profile\.json/);
55
- assert.match(prompt, /Available: read_repository/);
56
- assert.match(prompt, /Unavailable: network \(NOT_AVAILABLE\)/);
57
- assert.match(prompt, /NOT_AVAILABLE, NOT_CONFIGURED, NOT_RUN, STALE, ERROR/);
58
- assert.match(prompt, /Next agents: hyv-lifecycle/);
59
- });
60
- test('emitPrompt reports no available capabilities for a fully unavailable host', () => {
61
- const prompt = emitPrompt(makePackage('hyv-verify'), 'generic');
62
- assert.match(prompt, /Available: none/);
63
- assert.match(prompt, /Execution mode: prompt-only/);
64
- });
@@ -1,149 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
- import { tmpdir } from 'node:os';
5
- import { join } from 'node:path';
6
- import { loadAll, loadAllFrom, validateAll, validateId, sortedIds } from './load.js';
7
- import { AGENT_SCHEMA_VERSION } from './types.js';
8
- function makePackage(dir, id, overrides = {}) {
9
- const packageDir = join(dir, 'skills', id);
10
- mkdirSync(join(packageDir, 'agents'), { recursive: true });
11
- const descriptor = {
12
- schema_version: AGENT_SCHEMA_VERSION,
13
- id,
14
- title: id,
15
- description: 'test agent',
16
- instruction_file: 'SKILL.md',
17
- role: 'Tester',
18
- workflow_phase: 'test',
19
- input: { required: ['draft'], optional: [] },
20
- output: { required: ['report'], optional: [] },
21
- evidence_requirements: ['preserve evidence'],
22
- permissions: ['read_repository'],
23
- stop_conditions: ['stop when evidence missing'],
24
- tool_free_mode: {
25
- available: true,
26
- behavior: 'report NOT_AVAILABLE',
27
- unavailable_statuses: ['NOT_AVAILABLE', 'NOT_RUN'],
28
- },
29
- handoff_to: [],
30
- ...overrides,
31
- };
32
- writeFileSync(join(packageDir, 'agent.json'), JSON.stringify(descriptor));
33
- writeFileSync(join(packageDir, 'SKILL.md'), `# ${id}\n\nUsage: hyv ${id} draft.md\n`);
34
- writeFileSync(join(packageDir, 'agents', 'openai.yaml'), `interface:\n display_name: "${id}"\n short_description: "test agent"\n default_prompt: "Use ${id}"\n`);
35
- return packageDir;
36
- }
37
- function makeTempRoot() {
38
- return mkdtempSync(join(tmpdir(), 'hyv-agents-'));
39
- }
40
- test('loads all valid packages and rejects unknown schema version', () => {
41
- const root = makeTempRoot();
42
- try {
43
- const first = makePackage(root, 'hyv-alpha');
44
- makePackage(root, 'hyv-beta', { handoff_to: ['hyv-alpha'] });
45
- const packages = loadAllFrom(root);
46
- assert.deepEqual(sortedIds(packages), ['hyv-alpha', 'hyv-beta']);
47
- assert.equal(packages.get('hyv-alpha').directory, first);
48
- assert.match(packages.get('hyv-alpha').instructions, /^# hyv-alpha/);
49
- }
50
- finally {
51
- rmSync(root, { recursive: true, force: true });
52
- }
53
- });
54
- test('rejects a package whose id does not match its directory name', () => {
55
- const root = makeTempRoot();
56
- try {
57
- makePackage(root, 'hyv-alpha', { id: 'hyv-mismatch' });
58
- assert.throws(() => loadAllFrom(root), /id must match skill directory/);
59
- }
60
- finally {
61
- rmSync(root, { recursive: true, force: true });
62
- }
63
- });
64
- test('rejects a handoff target that does not exist', () => {
65
- const root = makeTempRoot();
66
- try {
67
- makePackage(root, 'hyv-alpha', { handoff_to: ['hyv-ghost'] });
68
- assert.throws(() => loadAllFrom(root), /hands off to unknown agent/);
69
- }
70
- finally {
71
- rmSync(root, { recursive: true, force: true });
72
- }
73
- });
74
- test('rejects an unsupported permission and an unsupported status', () => {
75
- const root = makeTempRoot();
76
- try {
77
- makePackage(root, 'hyv-alpha', { permissions: ['definitely-not-real'] });
78
- assert.throws(() => loadAllFrom(root), /unsupported permission/);
79
- rmSync(root, { recursive: true, force: true });
80
- const root2 = makeTempRoot();
81
- makePackage(root2, 'hyv-beta', {
82
- tool_free_mode: { available: true, behavior: 'x', unavailable_statuses: ['NOPE'] },
83
- });
84
- assert.throws(() => loadAllFrom(root2), /tool_free_mode requires/);
85
- }
86
- finally {
87
- rmSync(root, { recursive: true, force: true });
88
- }
89
- });
90
- test('validateAll and validateId report PASS or unknown id', () => {
91
- const root = makeTempRoot();
92
- try {
93
- makePackage(root, 'hyv-alpha');
94
- const packages = loadAllFrom(root);
95
- validateAll(packages);
96
- validateId(packages, 'hyv-alpha');
97
- assert.throws(() => validateId(packages, 'hyv-missing'), /unknown agent/);
98
- }
99
- finally {
100
- rmSync(root, { recursive: true, force: true });
101
- }
102
- });
103
- test('rejects a malformed agent.json', () => {
104
- const root = makeTempRoot();
105
- try {
106
- const packageDir = join(root, 'skills', 'hyv-alpha');
107
- mkdirSync(join(packageDir, 'agents'), { recursive: true });
108
- writeFileSync(join(packageDir, 'agent.json'), '{ not json');
109
- writeFileSync(join(packageDir, 'SKILL.md'), '# x');
110
- assert.throws(() => loadAllFrom(root), /not valid JSON/);
111
- }
112
- finally {
113
- rmSync(root, { recursive: true, force: true });
114
- }
115
- });
116
- test('rejects unknown contract fields and invalid OpenAI interface metadata', () => {
117
- const root = makeTempRoot();
118
- try {
119
- makePackage(root, 'hyv-alpha', { unsupported: true });
120
- assert.throws(() => loadAllFrom(root), /supported contract fields/);
121
- rmSync(root, { recursive: true, force: true });
122
- const root2 = makeTempRoot();
123
- const packageDir = makePackage(root2, 'hyv-beta');
124
- rmSync(join(packageDir, 'agents', 'openai.yaml'));
125
- assert.throws(() => loadAllFrom(root2), /openai\.yaml/);
126
- rmSync(root2, { recursive: true, force: true });
127
- const root3 = makeTempRoot();
128
- const invalidPackageDir = makePackage(root3, 'hyv-gamma');
129
- writeFileSync(join(invalidPackageDir, 'agents', 'openai.yaml'), 'interface:\n display_name: "unterminated\n short_description: "test"\n default_prompt: "Use hyv-gamma"\n');
130
- assert.throws(() => loadAllFrom(root3), /supported interface metadata/);
131
- rmSync(root3, { recursive: true, force: true });
132
- }
133
- finally {
134
- rmSync(root, { recursive: true, force: true });
135
- }
136
- });
137
- test('published packages declare command execution and required writes', () => {
138
- const packages = loadAll();
139
- const writers = new Set(['hyv-profile', 'hyv-ingest', 'hyv-hygiene', 'hyv-apply-hidden-text-policy', 'hyv-prepare-rewrite', 'hyv-prepare-judgment', 'hyv-prepare-rebuild', 'hyv-rebuild-writer-request', 'hyv-lifecycle', 'hyv-learning']);
140
- for (const [id, pkg] of packages) {
141
- assert.ok(pkg.descriptor.permissions.includes('execute_commands'), `${id} must require command execution`);
142
- assert.equal(pkg.descriptor.permissions.includes('write_repository'), writers.has(id), `${id} write permission mismatch`);
143
- }
144
- });
145
- test('published instructions document command-specific argument requirements', () => {
146
- const packages = loadAll();
147
- assert.match(packages.get('hyv-reduce-judgment').instructions, /hyv reduce-judgment envelope\.json envelope\.json envelope\.json \[envelope\.json\.\.\.\]/);
148
- assert.match(packages.get('hyv-learning').instructions, /record-approved/);
149
- });