@holdyourvoice/hyv 3.1.1 → 3.3.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 (53) hide show
  1. package/Readme.md +76 -17
  2. package/dist/ai-editor-rules.js +151 -0
  3. package/dist/ai-editor.js +104 -8
  4. package/dist/ai-editor.test.js +135 -22
  5. package/dist/approval-capability.js +111 -0
  6. package/dist/approval-capability.test.js +52 -0
  7. package/dist/approval-context.js +54 -0
  8. package/dist/approval-context.test.js +38 -0
  9. package/dist/benchmark.js +232 -0
  10. package/dist/benchmark.test.js +328 -0
  11. package/dist/canonical-json.js +123 -0
  12. package/dist/canonical-json.test.js +24 -0
  13. package/dist/cli.js +359 -21
  14. package/dist/cli.test.js +275 -7
  15. package/dist/copy-spec.js +35 -8
  16. package/dist/editorial-packs.js +25 -1
  17. package/dist/editorial-packs.test.js +45 -0
  18. package/dist/hygiene.js +91 -0
  19. package/dist/hygiene.test.js +73 -0
  20. package/dist/judgment-task.js +171 -0
  21. package/dist/judgment-task.test.js +162 -0
  22. package/dist/learning.js +240 -100
  23. package/dist/learning.test.js +203 -3
  24. package/dist/lifecycle-adapter.js +75 -0
  25. package/dist/lifecycle-adapter.test.js +56 -0
  26. package/dist/mcp-tools.js +110 -9
  27. package/dist/mcp-tools.test.js +188 -10
  28. package/dist/mcp.js +228 -9
  29. package/dist/mcp.test.js +248 -12
  30. package/dist/pipeline.js +81 -15
  31. package/dist/pipeline.test.js +94 -2
  32. package/dist/preservation.js +89 -0
  33. package/dist/preservation.test.js +22 -0
  34. package/dist/profile.js +87 -0
  35. package/dist/profile.test.js +114 -0
  36. package/dist/rebuild-task.js +226 -0
  37. package/dist/rebuild-task.test.js +179 -0
  38. package/dist/release-audit.test.js +144 -2
  39. package/dist/rewrite-task.js +136 -16
  40. package/dist/rewrite-task.test.js +72 -4
  41. package/dist/rule-reconciliation.test.js +50 -0
  42. package/dist/semantic-review.js +176 -7
  43. package/dist/semantic-review.test.js +98 -14
  44. package/dist/stage1-dry-run.test.js +39 -0
  45. package/dist/stage1-evaluation.js +579 -0
  46. package/dist/stage1-evaluation.test.js +184 -0
  47. package/dist/stage1-human-packet.test.js +102 -0
  48. package/dist/stage1-schema-contract.test.js +95 -0
  49. package/dist/stage2-human-packet.test.js +81 -0
  50. package/dist/version.js +1 -0
  51. package/dist/voice-dna.js +53 -1
  52. package/dist/voice-dna.test.js +79 -1
  53. package/package.json +2 -2
@@ -1,8 +1,10 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { createHash } from 'node:crypto';
3
3
  import test from 'node:test';
4
- import { applyRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
4
+ import { applyRewriteResponse, createRewriteLifecycleBinding, evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
5
5
  import { parseWritingBrief } from './editorial-packs.js';
6
+ import { canonicalJson } from './canonical-json.js';
7
+ import { verifyDeterministically } from './pipeline.js';
6
8
  import { buildProfile } from './voice-dna.js';
7
9
  const profile = buildProfile([
8
10
  'I write clear notes. I keep the mechanism visible.',
@@ -19,24 +21,64 @@ test('applies only eligible numbered replacements and preserves all clean bytes'
19
21
  assert.equal(result.candidate, 'I use the answer. The launch is on 14 August.');
20
22
  assert.deepEqual(result.receipt.adapterIds, []);
21
23
  });
24
+ test('does not make an advisory broad catalog match eligible', () => {
25
+ const task = prepareRewriteTask('I write clear notes. This work is meaningful. I keep the mechanism visible.', profile);
26
+ assert.deepEqual(task.eligibleSentenceIds, []);
27
+ assert.deepEqual(task.sentences.map((sentence) => sentence.eligible), [false, false, false]);
28
+ });
29
+ test('grants edit scope only to blocking findings and keeps pending judgment separate', () => {
30
+ const advisory = prepareRewriteTask('Firstly, check the invoice.', profile);
31
+ assert.deepEqual(advisory.eligibleSentenceIds, []);
32
+ const neutralProfile = buildProfile(['I write plainly.', 'I name the mechanism.']);
33
+ const pending = prepareRewriteTask('We leverage the scheduler.', neutralProfile);
34
+ assert.deepEqual(pending.eligibleSentenceIds, []);
35
+ assert.match(pending.prompt, /pending judgment/i);
36
+ const blocking = prepareRewriteTask('The scheduler failed — twice. The owner checked it.', profile);
37
+ assert.deepEqual(blocking.eligibleSentenceIds, [1]);
38
+ });
39
+ test('keeps blocking VoiceDNA avoid findings eligible', () => {
40
+ const task = prepareRewriteTask('The plan uses leverage.', profile);
41
+ assert.deepEqual(task.eligibleSentenceIds, [1]);
42
+ });
43
+ test('preserves founder reframes that reconciled policy makes advisory', () => {
44
+ const task = prepareRewriteTask("This isn't positioning. This is proof.", profile);
45
+ assert.deepEqual(task.eligibleSentenceIds, []);
46
+ });
47
+ test('preserves a clean draft byte-for-byte when the rewrite response is empty', () => {
48
+ const draft = 'I write clear notes.\n\nI keep the mechanism visible.\n';
49
+ const task = prepareRewriteTask(draft, profile);
50
+ assert.deepEqual(task.eligibleSentenceIds, []);
51
+ const result = applyRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [] });
52
+ assert.equal(result.status, 'accepted');
53
+ assert.equal(result.candidate, draft);
54
+ });
55
+ test('rejects an attempted edit to a clean sentence without changing any source bytes', () => {
56
+ const draft = 'I leverage the answer.\n\nThe launch starts Tuesday.\n';
57
+ const task = prepareRewriteTask(draft, profile);
58
+ const result = applyRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 2, text: 'The launch starts Wednesday.' }] });
59
+ assert.equal(result.status, 'repairable');
60
+ assert.equal(result.candidate, undefined);
61
+ assert.equal(result.failures[0]?.code, 'ineligible_sentence_id');
62
+ assert.equal(task.draft, draft);
63
+ });
22
64
  test('carries WritingBrief context into a fingerprinted rewrite task', () => {
23
65
  const brief = parseWritingBrief({ version: '1', audience: 'founders', intent: 'start a discussion', format: 'social' });
24
66
  const task = prepareRewriteTask('A pattern I keep seeing in founder posts is vague advice.', profile, undefined, brief);
25
67
  assert.equal(task.writingBrief?.format, 'social');
26
- assert.ok(task.eligibleSentenceIds.includes(1));
68
+ assert.deepEqual(task.eligibleSentenceIds, []);
27
69
  });
28
70
  test('rejects a fingerprint-valid rewrite task with malformed WritingBrief data', () => {
29
71
  const task = prepareRewriteTask('I leverage the answer.', profile);
30
72
  const { fingerprint: _fingerprint, ...taskBase } = task;
31
73
  const base = { ...taskBase, writingBrief: { version: '1', audience: 'operators', intent: 'write', format: 'general', prohibitedTerms: [42] } };
32
- const malformed = { ...base, fingerprint: createHash('sha256').update(JSON.stringify(base)).digest('hex') };
74
+ const malformed = { ...base, fingerprint: createHash('sha256').update(canonicalJson(base)).digest('hex') };
33
75
  assert.throws(() => parseRewriteTask(malformed), /WritingBrief/);
34
76
  });
35
77
  test('rejects a fingerprint-valid rewrite task with a null WritingBrief', () => {
36
78
  const task = prepareRewriteTask('I leverage the answer.', profile);
37
79
  const { fingerprint: _fingerprint, ...taskBase } = task;
38
80
  const base = { ...taskBase, writingBrief: null };
39
- const malformed = { ...base, fingerprint: createHash('sha256').update(JSON.stringify(base)).digest('hex') };
81
+ const malformed = { ...base, fingerprint: createHash('sha256').update(canonicalJson(base)).digest('hex') };
40
82
  assert.throws(() => parseRewriteTask(malformed), /WritingBrief/);
41
83
  });
42
84
  test('rejects a duplicate, unknown, or clean sentence replacement before it creates a candidate', () => {
@@ -72,6 +114,32 @@ test('keeps a valid response byte-for-byte unchanged by repair adapters', () =>
72
114
  assert.equal(result.status, 'accepted');
73
115
  assert.equal(result.receipt.responseFingerprint.length, 64);
74
116
  assert.deepEqual(result.receipt.adapterIds, []);
117
+ assert.deepEqual(result.receipt.replacementSentenceIds, [1]);
118
+ });
119
+ test('binds the task, response, deterministic artifact, text hashes, and profile revision', () => {
120
+ const draft = 'I write clear notes.';
121
+ const task = prepareRewriteTask(draft, profile);
122
+ const applied = applyRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [] });
123
+ assert.equal(applied.status, 'accepted');
124
+ const artifact = verifyDeterministically(draft, applied.candidate, profile).artifact;
125
+ const binding = createRewriteLifecycleBinding(task, applied.receipt, artifact);
126
+ assert.equal(binding.rewriteTaskFingerprint, task.fingerprint);
127
+ assert.equal(binding.rewriteResponseFingerprint, applied.receipt.responseFingerprint);
128
+ assert.equal(binding.deterministicArtifactFingerprint, artifact.artifactFingerprint);
129
+ assert.match(binding.sourceHash, /^[a-f0-9]{64}$/);
130
+ });
131
+ test('returns the deterministic artifact and lifecycle binding at the semantic gate', () => {
132
+ const task = prepareRewriteTask('I write clear notes.', profile);
133
+ const result = evaluateRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [] }, profile);
134
+ assert.equal(result.status, 'needs_semantic_review');
135
+ assert.equal(result.deterministicArtifact?.passed, true);
136
+ assert.equal(result.lifecycleBinding?.deterministicArtifactFingerprint, result.deterministicArtifact?.artifactFingerprint);
137
+ });
138
+ test('keeps object response fingerprints stable across property order', () => {
139
+ const task = prepareRewriteTask('I leverage the answer.', profile);
140
+ const first = applyRewriteResponse(task, { version: '1', taskFingerprint: task.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer.' }] });
141
+ const second = applyRewriteResponse(task, { replacements: [{ text: 'I use the answer.', sentenceId: 1 }], taskFingerprint: task.fingerprint, version: '1' });
142
+ assert.equal(first.receipt.responseFingerprint, second.receipt.responseFingerprint);
75
143
  });
76
144
  test('repairs only an exact outer JSON code fence after JSON parsing fails', () => {
77
145
  const task = prepareRewriteTask('I leverage the answer with useful detail and clear mechanism.', profile);
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { rules } from './ai-editor-rules.js';
4
+ function rule(id) {
5
+ const match = rules.find((candidate) => candidate.id === id);
6
+ assert.ok(match, `missing rule ${id}`);
7
+ return match;
8
+ }
9
+ test('struct.not-x-y only detects an explicit two-beat contrast', () => {
10
+ const matcher = rule('struct.not-x-y').expression;
11
+ assert.equal(matcher.test('Not speed. Reliability.'), true);
12
+ assert.equal(matcher.test('This does not include the standard onboarding steps every team runs.'), false);
13
+ });
14
+ test('keeps the explicit this-isnt reframe matcher available to policy', () => {
15
+ const matcher = rule('struct.this-isnt-x-this-is-y').expression;
16
+ assert.equal(matcher.test("This isn't positioning. This is proof."), true);
17
+ assert.equal(matcher.test('This positioning claim has proof.'), false);
18
+ });
19
+ test('treats both long dash characters as red findings', () => {
20
+ const emDash = rule('punct.em-dash');
21
+ const enDash = rule('punct.en-dash');
22
+ assert.equal(emDash.severity, 'red');
23
+ assert.equal(enDash.severity, 'red');
24
+ assert.equal(emDash.expression.test('The job failed — retry it.'), true);
25
+ assert.equal(enDash.expression.test('Use pages 6–11.'), true);
26
+ assert.equal(emDash.expression.test('The job failed; retry it.'), false);
27
+ assert.equal(enDash.expression.test('Use pages 6-11.'), false);
28
+ });
29
+ test('detects clear performative-sincerity phrases as red', () => {
30
+ const matcher = rule('formula.performative-sincerity');
31
+ assert.equal(matcher.severity, 'red');
32
+ for (const text of ['To be honest, the launch failed.', 'In all honesty, the launch failed.']) {
33
+ assert.equal(matcher.expression.test(text), true, text);
34
+ }
35
+ assert.equal(matcher.expression.test('The honest report says the launch failed.'), false);
36
+ });
37
+ test('detects performative-sincerity adverbs as yellow', () => {
38
+ const matcher = rule('hedge.performative-sincerity-adverb');
39
+ assert.equal(matcher.severity, 'yellow');
40
+ for (const text of [
41
+ 'Honestly, the launch failed.',
42
+ 'Genuinely, the launch failed.',
43
+ 'Truly, the launch failed.',
44
+ 'Frankly, the launch failed.',
45
+ 'Actually, the launch failed.',
46
+ ]) {
47
+ assert.equal(matcher.expression.test(text), true, text);
48
+ }
49
+ assert.equal(matcher.expression.test('The genuine invoice arrived.'), false);
50
+ });
@@ -1,12 +1,61 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonicalJson } from './canonical-json.js';
3
+ import { verifyApprovalCapability } from './approval-capability.js';
1
4
  const violations = new Set(['action_change', 'dropped_object', 'unsupported_claim', 'constraint_weakened', 'clarity_regression']);
2
- export function parseSemanticVerdict(evaluatorId, value) {
3
- if (!value || typeof value !== 'object' || Array.isArray(value))
4
- throw new Error('Semantic evaluator response must be an object.');
5
- const verdict = value;
6
- if (typeof verdict.approved !== 'boolean' || !Array.isArray(verdict.violations) || !verdict.violations.every((item) => typeof item === 'string' && violations.has(item))) {
7
- throw new Error('Semantic evaluator response must include approved and known violations.');
5
+ const HEX = /^[a-f0-9]{64}$/;
6
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
7
+ function hash(domain, value) { return createHash('sha256').update(`${domain}\0`).update(canonicalJson(value)).digest('hex'); }
8
+ function same(left, right) { return canonicalJson(left) === canonicalJson(right); }
9
+ function exact(value, required, optional = []) { return required.every((key) => key in value) && Object.keys(value).every((key) => required.includes(key) || optional.includes(key)); }
10
+ function plain(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; }
11
+ function bindingValid(binding) {
12
+ return binding.schemaVersion === '1' && [binding.rewriteTaskFingerprint, binding.rewriteResponseFingerprint, binding.deterministicArtifactFingerprint, binding.sourceHash, binding.candidateHash].every((item) => HEX.test(item))
13
+ && ID.test(binding.profileId) && ID.test(binding.profileRevisionDigest) && ID.test(binding.rulesetVersion);
14
+ }
15
+ export function prepareSemanticReviewTask(binding, policy, receipt, allowedViolations) {
16
+ if (!bindingValid(binding) || !['normal', 'high_assurance'].includes(policy))
17
+ throw new Error('Semantic review task has an invalid binding or policy.');
18
+ if (receipt.taskFingerprint !== binding.rewriteTaskFingerprint || receipt.responseFingerprint !== binding.rewriteResponseFingerprint || !Array.isArray(receipt.replacementSentenceIds))
19
+ throw new Error('Semantic review task requires its bound rewrite receipt.');
20
+ const evidenceScope = { sentenceIds: [...new Set(receipt.replacementSentenceIds)].sort((left, right) => left - right) };
21
+ if (evidenceScope.sentenceIds.some((id) => !Number.isSafeInteger(id) || id < 1) || new Set(allowedViolations).size !== allowedViolations.length || allowedViolations.some((item) => !violations.has(item)))
22
+ throw new Error('Semantic review task has invalid evidence scope or violations.');
23
+ const base = { version: '1', judgmentType: 'semantic', binding, policy, evidenceScope, allowedViolations: [...allowedViolations].sort() };
24
+ return { ...base, taskFingerprint: hash('hyv:semantic-task:v1', base) };
25
+ }
26
+ export function isValidSemanticReviewTask(task) {
27
+ try {
28
+ if (!plain(task) || !exact(task, ['version', 'judgmentType', 'binding', 'policy', 'evidenceScope', 'allowedViolations', 'taskFingerprint'])
29
+ || task.version !== '1' || task.judgmentType !== 'semantic' || !bindingValid(task.binding) || !['normal', 'high_assurance'].includes(task.policy)
30
+ || !plain(task.evidenceScope) || !exact(task.evidenceScope, ['sentenceIds']) || !Array.isArray(task.evidenceScope.sentenceIds)
31
+ || task.evidenceScope.sentenceIds.some((id) => !Number.isSafeInteger(id) || id < 1) || new Set(task.evidenceScope.sentenceIds).size !== task.evidenceScope.sentenceIds.length
32
+ || !Array.isArray(task.allowedViolations) || task.allowedViolations.some((item) => !violations.has(item)) || new Set(task.allowedViolations).size !== task.allowedViolations.length)
33
+ return false;
34
+ const { taskFingerprint, ...base } = task;
35
+ return taskFingerprint === hash('hyv:semantic-task:v1', base);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ export function parseSemanticVerdict(taskOrEvaluatorId, evaluatorIdOrValue, maybeValue) {
42
+ if (typeof taskOrEvaluatorId === 'string') {
43
+ const raw = evaluatorIdOrValue;
44
+ if (!plain(raw) || typeof raw.approved !== 'boolean' || !Array.isArray(raw.violations) || raw.violations.some((item) => typeof item !== 'string' || !violations.has(item)))
45
+ throw new Error('Semantic evaluator response must include approved and known violations.');
46
+ return { evaluatorId: taskOrEvaluatorId, approved: raw.approved, violations: raw.violations };
8
47
  }
9
- return { evaluatorId, approved: verdict.approved, violations: verdict.violations };
48
+ const task = taskOrEvaluatorId;
49
+ const evaluatorId = evaluatorIdOrValue;
50
+ const value = maybeValue;
51
+ if (!isValidSemanticReviewTask(task) || !ID.test(evaluatorId) || !value || typeof value !== 'object' || Array.isArray(value))
52
+ throw new Error('Semantic evaluator response must be a bounded object.');
53
+ const raw = value;
54
+ if (Object.keys(raw).some((key) => !['approved', 'violations'].includes(key)) || typeof raw.approved !== 'boolean' || !Array.isArray(raw.violations)
55
+ || raw.violations.some((item) => typeof item !== 'string' || !violations.has(item) || !task.allowedViolations.includes(item)) || new Set(raw.violations).size !== raw.violations.length
56
+ || (raw.approved && raw.violations.length !== 0) || (!raw.approved && raw.violations.length === 0))
57
+ throw new Error('Semantic evaluator response contradicts its known violations.');
58
+ return { version: '1', judgmentType: 'semantic', taskFingerprint: task.taskFingerprint, binding: task.binding, evidenceScope: task.evidenceScope, evaluatorId, approved: raw.approved, violations: raw.violations };
10
59
  }
11
60
  export function reviewSemanticVerdicts(verdicts) {
12
61
  const ids = new Set(verdicts.map((verdict) => verdict.evaluatorId));
@@ -18,3 +67,123 @@ export function reviewSemanticVerdicts(verdicts) {
18
67
  return { status: 'needs_escalation', verdicts, reason: 'evaluator_disagreement' };
19
68
  return { status: 'needs_escalation', verdicts, reason: 'semantic_violation' };
20
69
  }
70
+ function artifact(base) {
71
+ return { ...base, artifactFingerprint: hash('hyv:lifecycle-artifact:v1', base) };
72
+ }
73
+ export function isValidLifecycleArtifact(value) {
74
+ if (!plain(value) || value.version !== '1' || !['needs_semantic_review', 'ready_for_human_review', 'approved', 'needs_escalation'].includes(value.status) || !bindingValid(value.binding) || !['normal', 'high_assurance'].includes(value.semanticPolicy)
75
+ || !HEX.test(value.transitionFingerprint) || !HEX.test(value.semanticTaskFingerprint) || !HEX.test(value.semanticEvidenceScopeFingerprint)
76
+ || !Array.isArray(value.verdictFingerprints) || value.verdictFingerprints.some((item) => !HEX.test(item)) || new Set(value.verdictFingerprints).size !== value.verdictFingerprints.length)
77
+ return false;
78
+ const requiredKeys = ['version', 'status', 'artifactFingerprint', 'transitionFingerprint', 'binding', 'semanticPolicy', 'semanticTaskFingerprint', 'semanticEvidenceScopeFingerprint', 'verdictFingerprints'];
79
+ const optionalKeys = ['parentArtifactFingerprint', 'capabilityFingerprint', 'reason'];
80
+ if (!exact(value, requiredKeys, optionalKeys))
81
+ return false;
82
+ const requiredVerdicts = value.semanticPolicy === 'normal' ? 1 : 3;
83
+ if (value.status === 'needs_semantic_review' && (value.parentArtifactFingerprint !== undefined || value.verdictFingerprints.length !== 0 || value.capabilityFingerprint !== undefined || value.reason !== undefined))
84
+ return false;
85
+ if (value.status === 'ready_for_human_review' && (!value.parentArtifactFingerprint || !HEX.test(value.parentArtifactFingerprint) || value.verdictFingerprints.length !== requiredVerdicts || value.capabilityFingerprint !== undefined || value.reason !== undefined))
86
+ return false;
87
+ if (value.status === 'approved' && (!value.parentArtifactFingerprint || !HEX.test(value.parentArtifactFingerprint) || value.verdictFingerprints.length !== requiredVerdicts || !value.capabilityFingerprint || !HEX.test(value.capabilityFingerprint) || value.reason !== undefined))
88
+ return false;
89
+ if (value.status === 'needs_escalation' && (!value.parentArtifactFingerprint || !HEX.test(value.parentArtifactFingerprint) || !value.reason || !['semantic_rejection', 'semantic_disagreement', 'human_rejection'].includes(value.reason) || value.verdictFingerprints.length !== requiredVerdicts || value.capabilityFingerprint !== undefined))
90
+ return false;
91
+ const { artifactFingerprint, ...base } = value;
92
+ return HEX.test(artifactFingerprint) && artifactFingerprint === hash('hyv:lifecycle-artifact:v1', base);
93
+ }
94
+ export function createInitialLifecycleArtifact(task, deterministic) {
95
+ const { taskFingerprint, ...taskBase } = task;
96
+ const { artifactFingerprint, ...deterministicBase } = deterministic;
97
+ const expectedDeterministicFingerprint = createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(deterministicBase)}`).digest('hex');
98
+ if (taskFingerprint !== hash('hyv:semantic-task:v1', taskBase) || !deterministic.passed || artifactFingerprint !== expectedDeterministicFingerprint || deterministic.artifactFingerprint !== task.binding.deterministicArtifactFingerprint || deterministic.sourceHash !== task.binding.sourceHash || deterministic.candidateHash !== task.binding.candidateHash || deterministic.profileId !== task.binding.profileId || deterministic.profileRevisionDigest !== task.binding.profileRevisionDigest || deterministic.rulesetVersion !== task.binding.rulesetVersion)
99
+ throw new Error('Lifecycle creation requires its bound passed deterministic verification artifact.');
100
+ return artifact({ version: '1', status: 'needs_semantic_review', transitionFingerprint: hash('hyv:lifecycle-initial:v1', { binding: task.binding, semanticPolicy: task.policy }), binding: task.binding, semanticPolicy: task.policy, semanticTaskFingerprint: task.taskFingerprint, semanticEvidenceScopeFingerprint: hash('hyv:semantic-evidence-scope:v1', task.evidenceScope), verdictFingerprints: [] });
101
+ }
102
+ function fail(error) { return { ok: false, error }; }
103
+ function normalizeAction(value) {
104
+ if (!plain(value) || value.version !== '1' || typeof value.parentArtifactFingerprint !== 'string' || !HEX.test(value.parentArtifactFingerprint))
105
+ return undefined;
106
+ const action = value;
107
+ if (action.type === 'semantic_submission') {
108
+ if (!exact(value, ['version', 'type', 'parentArtifactFingerprint', 'taskFingerprint', 'verdicts']) || !HEX.test(action.taskFingerprint) || !Array.isArray(action.verdicts) || action.verdicts.length < 1 || action.verdicts.length > 3)
109
+ return undefined;
110
+ for (const verdict of action.verdicts) {
111
+ if (!verdict || typeof verdict !== 'object' || !exact(verdict, ['version', 'judgmentType', 'taskFingerprint', 'binding', 'evidenceScope', 'evaluatorId', 'approved', 'violations']) || verdict.version !== '1' || verdict.judgmentType !== 'semantic' || !ID.test(verdict.evaluatorId) || typeof verdict.approved !== 'boolean' || !Array.isArray(verdict.violations) || verdict.violations.some((item) => !violations.has(item)) || new Set(verdict.violations).size !== verdict.violations.length)
112
+ return undefined;
113
+ }
114
+ return { ...action, verdicts: [...action.verdicts].map((verdict) => ({ ...verdict, violations: [...verdict.violations].sort() })).sort((left, right) => left.evaluatorId < right.evaluatorId ? -1 : left.evaluatorId > right.evaluatorId ? 1 : 0) };
115
+ }
116
+ if (action.type === 'human_finalization' && exact(action, ['version', 'type', 'parentArtifactFingerprint', 'finalization']) && action.finalization?.version === '1') {
117
+ const finalization = action.finalization;
118
+ if (!plain(finalization) || !exact(finalization, ['version', 'judgmentType', 'parentArtifactFingerprint', 'binding', 'evaluatorId', 'evidenceScope', 'decision'], ['capability']) || action.finalization.judgmentType !== 'human_finalization' || !ID.test(action.finalization.evaluatorId) || !HEX.test(action.finalization.parentArtifactFingerprint) || !bindingValid(action.finalization.binding) || !plain(action.finalization.evidenceScope) || !exact(action.finalization.evidenceScope, ['kind']) || action.finalization.evidenceScope.kind !== 'candidate' || !['approve', 'reject'].includes(action.finalization.decision))
119
+ return undefined;
120
+ return action;
121
+ }
122
+ return undefined;
123
+ }
124
+ export function reduceRewriteLifecycle(current, rawAction, context) {
125
+ let action;
126
+ let transitionFingerprint;
127
+ try {
128
+ action = normalizeAction(rawAction);
129
+ if (!action)
130
+ return fail('invalid_action');
131
+ transitionFingerprint = hash('hyv:lifecycle-transition:v1', action);
132
+ }
133
+ catch {
134
+ return fail('invalid_action');
135
+ }
136
+ if (current.transitionFingerprint === transitionFingerprint)
137
+ return { ok: true, artifact: current };
138
+ if (action.parentArtifactFingerprint === current.parentArtifactFingerprint)
139
+ return fail('conflicting_replay');
140
+ if (action.parentArtifactFingerprint !== current.artifactFingerprint)
141
+ return fail('stale_parent');
142
+ if (current.status === 'approved' || current.status === 'needs_escalation')
143
+ return fail('terminal_state');
144
+ if (action.type === 'semantic_submission') {
145
+ if (current.status !== 'needs_semantic_review')
146
+ return fail('out_of_order_transition');
147
+ const required = current.semanticPolicy === 'normal' ? 1 : 3;
148
+ if (action.verdicts.length !== required)
149
+ return fail('invalid_verdict_count');
150
+ const ids = action.verdicts.map((verdict) => verdict.evaluatorId);
151
+ if (new Set(ids).size !== ids.length)
152
+ return fail('duplicate_evaluator');
153
+ const authorized = current.semanticPolicy === 'normal' ? context.authorizedSemanticEvaluatorIds.normal : context.authorizedSemanticEvaluatorIds.highAssurance;
154
+ if (current.semanticPolicy === 'high_assurance' && new Set(authorized).size < 3)
155
+ return fail('invalid_verdict_count');
156
+ if (ids.some((id) => !authorized.includes(id)))
157
+ return fail('evaluator_not_authorized');
158
+ for (const verdict of action.verdicts) {
159
+ if (action.taskFingerprint !== current.semanticTaskFingerprint || verdict.taskFingerprint !== action.taskFingerprint)
160
+ return fail('task_fingerprint_mismatch');
161
+ if (!same(verdict.binding, current.binding))
162
+ return fail('invalid_binding');
163
+ if (verdict.judgmentType !== 'semantic' || hash('hyv:semantic-evidence-scope:v1', verdict.evidenceScope) !== current.semanticEvidenceScopeFingerprint)
164
+ return fail('evidence_scope_mismatch');
165
+ if ((verdict.approved && verdict.violations.length) || (!verdict.approved && !verdict.violations.length))
166
+ return fail('contradictory_verdict');
167
+ }
168
+ const verdictFingerprints = action.verdicts.map((verdict) => hash('hyv:semantic-verdict:v1', verdict));
169
+ const approvals = action.verdicts.filter((verdict) => verdict.approved).length;
170
+ const status = approvals === required ? 'ready_for_human_review' : 'needs_escalation';
171
+ const reason = status === 'needs_escalation' ? (approvals === 0 ? 'semantic_rejection' : 'semantic_disagreement') : undefined;
172
+ return { ok: true, artifact: artifact({ version: '1', status, parentArtifactFingerprint: current.artifactFingerprint, transitionFingerprint, binding: current.binding, semanticPolicy: current.semanticPolicy, semanticTaskFingerprint: current.semanticTaskFingerprint, semanticEvidenceScopeFingerprint: current.semanticEvidenceScopeFingerprint, verdictFingerprints, ...(reason ? { reason } : {}) }) };
173
+ }
174
+ if (current.status !== 'ready_for_human_review')
175
+ return fail('out_of_order_transition');
176
+ const finalization = action.finalization;
177
+ if (finalization.parentArtifactFingerprint !== current.artifactFingerprint || !same(finalization.binding, current.binding) || finalization.judgmentType !== 'human_finalization' || finalization.evidenceScope.kind !== 'candidate')
178
+ return fail('invalid_binding');
179
+ if (!context.authorizedHumanFinalizerIds.includes(finalization.evaluatorId))
180
+ return fail('human_finalizer_not_authorized');
181
+ if (finalization.decision === 'reject')
182
+ return { ok: true, artifact: artifact({ version: '1', status: 'needs_escalation', parentArtifactFingerprint: current.artifactFingerprint, transitionFingerprint, binding: current.binding, semanticPolicy: current.semanticPolicy, semanticTaskFingerprint: current.semanticTaskFingerprint, semanticEvidenceScopeFingerprint: current.semanticEvidenceScopeFingerprint, verdictFingerprints: current.verdictFingerprints, reason: 'human_rejection' }) };
183
+ if (!finalization.capability)
184
+ return fail('capability_required');
185
+ const capability = verifyApprovalCapability(finalization.capability, context.trustStore, { now: context.now, expectedSubjectArtifactFingerprint: current.artifactFingerprint, binding: current.binding, expectedPurpose: 'hyv.final-approval' });
186
+ if (!capability.ok)
187
+ return fail('capability_invalid');
188
+ return { ok: true, artifact: artifact({ version: '1', status: 'approved', parentArtifactFingerprint: current.artifactFingerprint, transitionFingerprint, binding: current.binding, semanticPolicy: current.semanticPolicy, semanticTaskFingerprint: current.semanticTaskFingerprint, semanticEvidenceScopeFingerprint: current.semanticEvidenceScopeFingerprint, verdictFingerprints: current.verdictFingerprints, capabilityFingerprint: capability.capabilityFingerprint }) };
189
+ }
@@ -1,17 +1,101 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
2
3
  import test from 'node:test';
3
- import { parseSemanticVerdict, reviewSemanticVerdicts } from './semantic-review.js';
4
- test('accepts only three independent clean semantic verdicts', () => {
5
- const result = reviewSemanticVerdicts(['deepseek', 'kimi', 'sonnet'].map((evaluatorId) => parseSemanticVerdict(evaluatorId, { approved: true, violations: [] })));
6
- assert.equal(result.status, 'accepted');
7
- });
8
- test('escalates disagreement and action drift', () => {
9
- const disagreement = reviewSemanticVerdicts([
10
- parseSemanticVerdict('deepseek', { approved: true, violations: [] }),
11
- parseSemanticVerdict('kimi', { approved: false, violations: ['action_change'] }),
12
- parseSemanticVerdict('sonnet', { approved: false, violations: ['action_change'] }),
13
- ]);
14
- assert.equal(disagreement.reason, 'evaluator_disagreement');
15
- const rejected = reviewSemanticVerdicts(['deepseek', 'kimi', 'sonnet'].map((evaluatorId) => parseSemanticVerdict(evaluatorId, { approved: false, violations: ['action_change'] })));
16
- assert.equal(rejected.reason, 'semantic_violation');
4
+ import { canonicalJson, canonicalJsonBytes } from './canonical-json.js';
5
+ import { createInitialLifecycleArtifact, parseSemanticVerdict, prepareSemanticReviewTask, reduceRewriteLifecycle, reviewSemanticVerdicts } from './semantic-review.js';
6
+ const deterministicBase = { version: '1', verificationKind: 'standard', passed: true, analysisVersion: '2', rulesetVersion: '3.2.0', preservationMetricVersion: 'legacy-set-v1', preservationScore: 100, sourceHash: '4'.repeat(64), candidateHash: '5'.repeat(64), profileId: 'founder.primary', profileRevisionDigest: '6'.repeat(64), regressionKeys: [] };
7
+ const deterministicFingerprint = createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(deterministicBase)}`).digest('hex');
8
+ const binding = { rewriteTaskFingerprint: '1'.repeat(64), rewriteResponseFingerprint: '2'.repeat(64), deterministicArtifactFingerprint: deterministicFingerprint, sourceHash: deterministicBase.sourceHash, candidateHash: deterministicBase.candidateHash, profileId: deterministicBase.profileId, profileRevisionDigest: deterministicBase.profileRevisionDigest, rulesetVersion: deterministicBase.rulesetVersion, schemaVersion: '1' };
9
+ const receipt = { version: '1', taskFingerprint: binding.rewriteTaskFingerprint, responseFingerprint: binding.rewriteResponseFingerprint, adapterIds: [], replacementSentenceIds: [3, 1] };
10
+ function task(policy = 'normal') { return prepareSemanticReviewTask(binding, policy, receipt, ['action_change']); }
11
+ function deterministic(passed = true) { const base = { ...deterministicBase, passed }; return { ...base, artifactFingerprint: createHash('sha256').update(`hyv:deterministic-verification:v1\0${canonicalJson(base)}`).digest('hex') }; }
12
+ const emptyTrust = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [] };
13
+ const context = { now: 150, trustStore: emptyTrust, authorizedSemanticEvaluatorIds: { normal: ['one'], highAssurance: ['one', 'two', 'three'] }, authorizedHumanFinalizerIds: ['human-1'] };
14
+ test('preserves the version 3.2 semantic aggregation API', () => {
15
+ const verdicts = ['one', 'two', 'three'].map((id) => parseSemanticVerdict(id, { approved: true, violations: [] }));
16
+ assert.equal(reviewSemanticVerdicts(verdicts).status, 'accepted');
17
+ });
18
+ test('normal review clears with one clean authorized verdict and rejection escalates', () => {
19
+ const reviewTask = task();
20
+ const initial = createInitialLifecycleArtifact(reviewTask, deterministic());
21
+ const clean = parseSemanticVerdict(reviewTask, 'one', { approved: true, violations: [] });
22
+ const cleared = reduceRewriteLifecycle(initial, { version: '1', type: 'semantic_submission', parentArtifactFingerprint: initial.artifactFingerprint, taskFingerprint: reviewTask.taskFingerprint, verdicts: [clean] }, context);
23
+ assert.equal(cleared.ok && cleared.artifact.status, 'ready_for_human_review');
24
+ const rejected = parseSemanticVerdict(reviewTask, 'one', { approved: false, violations: ['action_change'] });
25
+ const escalated = reduceRewriteLifecycle(initial, { version: '1', type: 'semantic_submission', parentArtifactFingerprint: initial.artifactFingerprint, taskFingerprint: reviewTask.taskFingerprint, verdicts: [rejected] }, context);
26
+ assert.equal(escalated.ok && escalated.artifact.status, 'needs_escalation');
27
+ });
28
+ test('high assurance is permutation-stable and requires three configured evaluators', () => {
29
+ const reviewTask = task('high_assurance');
30
+ const initial = createInitialLifecycleArtifact(reviewTask, deterministic());
31
+ const verdicts = ['three', 'one', 'two'].map((id) => parseSemanticVerdict(reviewTask, id, { approved: true, violations: [] }));
32
+ const action = { version: '1', type: 'semantic_submission', parentArtifactFingerprint: initial.artifactFingerprint, taskFingerprint: reviewTask.taskFingerprint, verdicts };
33
+ const left = reduceRewriteLifecycle(initial, action, context);
34
+ const right = reduceRewriteLifecycle(initial, { ...action, verdicts: [...verdicts].reverse() }, context);
35
+ assert.deepEqual(left, right);
36
+ assert.deepEqual(reduceRewriteLifecycle(initial, { ...action, verdicts: verdicts.slice(0, 2) }, context), { ok: false, error: 'invalid_verdict_count' });
37
+ });
38
+ test('enforces replay, parent order, terminal states, bindings, and evaluator authority', () => {
39
+ const reviewTask = task();
40
+ const initial = createInitialLifecycleArtifact(reviewTask, deterministic());
41
+ const action = { version: '1', type: 'semantic_submission', parentArtifactFingerprint: initial.artifactFingerprint, taskFingerprint: reviewTask.taskFingerprint, verdicts: [parseSemanticVerdict(reviewTask, 'one', { approved: true, violations: [] })] };
42
+ const next = reduceRewriteLifecycle(initial, action, context);
43
+ assert.equal(next.ok, true);
44
+ if (!next.ok)
45
+ return;
46
+ assert.strictEqual(reduceRewriteLifecycle(next.artifact, action, context).ok, true);
47
+ assert.deepEqual(reduceRewriteLifecycle(next.artifact, { ...action, verdicts: [parseSemanticVerdict(reviewTask, 'one', { approved: false, violations: ['action_change'] })] }, context), { ok: false, error: 'conflicting_replay' });
48
+ assert.deepEqual(reduceRewriteLifecycle(initial, { ...action, parentArtifactFingerprint: '9'.repeat(64) }, context), { ok: false, error: 'stale_parent' });
49
+ assert.deepEqual(reduceRewriteLifecycle(initial, { ...action, verdicts: [parseSemanticVerdict(reviewTask, 'unknown', { approved: true, violations: [] })] }, context), { ok: false, error: 'evaluator_not_authorized' });
50
+ const terminal = reduceRewriteLifecycle(createInitialLifecycleArtifact(reviewTask, deterministic()), { ...action, verdicts: [parseSemanticVerdict(reviewTask, 'one', { approved: false, violations: ['action_change'] })] }, context);
51
+ if (terminal.ok)
52
+ assert.deepEqual(reduceRewriteLifecycle(terminal.artifact, { ...action, parentArtifactFingerprint: terminal.artifact.artifactFingerprint }, context), { ok: false, error: 'terminal_state' });
53
+ });
54
+ test('authorized human rejection escalates; approval requires a valid external capability', () => {
55
+ const reviewTask = task();
56
+ const initial = createInitialLifecycleArtifact(reviewTask, deterministic());
57
+ const semantic = reduceRewriteLifecycle(initial, { version: '1', type: 'semantic_submission', parentArtifactFingerprint: initial.artifactFingerprint, taskFingerprint: reviewTask.taskFingerprint, verdicts: [parseSemanticVerdict(reviewTask, 'one', { approved: true, violations: [] })] }, context);
58
+ assert.equal(semantic.ok, true);
59
+ if (!semantic.ok)
60
+ return;
61
+ const approvalBase = { version: '1', judgmentType: 'human_finalization', parentArtifactFingerprint: semantic.artifact.artifactFingerprint, binding, evaluatorId: 'human-1', evidenceScope: { kind: 'candidate' }, decision: 'approve' };
62
+ assert.deepEqual(reduceRewriteLifecycle(semantic.artifact, { version: '1', type: 'human_finalization', parentArtifactFingerprint: semantic.artifact.artifactFingerprint, finalization: approvalBase }, context), { ok: false, error: 'capability_required' });
63
+ assert.deepEqual(reduceRewriteLifecycle(semantic.artifact, { version: '1', type: 'human_finalization', parentArtifactFingerprint: semantic.artifact.artifactFingerprint, finalization: { ...approvalBase, capability: { payload: 'bad', signature: 'bad' } } }, context), { ok: false, error: 'capability_invalid' });
64
+ const rejection = reduceRewriteLifecycle(semantic.artifact, { version: '1', type: 'human_finalization', parentArtifactFingerprint: semantic.artifact.artifactFingerprint, finalization: { version: '1', judgmentType: 'human_finalization', parentArtifactFingerprint: semantic.artifact.artifactFingerprint, binding, evaluatorId: 'human-1', evidenceScope: { kind: 'candidate' }, decision: 'reject' } }, context);
65
+ assert.equal(rejection.ok && rejection.artifact.status, 'needs_escalation');
66
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
67
+ const trustStore = { version: '1', audience: '@holdyourvoice/hyv', maxCapabilityLifetimeSeconds: 300, keys: [{ issuer: 'host', keyId: 'k1', publicKeySpki: publicKey.export({ type: 'spki', format: 'der' }).toString('base64url'), status: 'active' }] };
68
+ const claims = { version: '1', purpose: 'hyv.final-approval', issuer: 'host', audience: '@holdyourvoice/hyv', subjectArtifactFingerprint: semantic.artifact.artifactFingerprint, sourceHash: binding.sourceHash, candidateHash: binding.candidateHash, profileId: binding.profileId, profileRevisionDigest: binding.profileRevisionDigest, keyId: 'k1', issuedAt: 100, notBefore: 100, expiresAt: 200, nonce: 'nonce' };
69
+ const payload = canonicalJsonBytes(claims);
70
+ const capability = { payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') };
71
+ const approved = reduceRewriteLifecycle(semantic.artifact, { version: '1', type: 'human_finalization', parentArtifactFingerprint: semantic.artifact.artifactFingerprint, finalization: { version: '1', judgmentType: 'human_finalization', parentArtifactFingerprint: semantic.artifact.artifactFingerprint, binding, evaluatorId: 'human-1', evidenceScope: { kind: 'candidate' }, decision: 'approve', capability } }, { ...context, trustStore });
72
+ assert.equal(approved.ok && approved.artifact.status, 'approved');
73
+ assert.doesNotMatch(JSON.stringify(approved), /nonce|payload|signature/);
74
+ });
75
+ test('failed deterministic verification cannot create an initial lifecycle artifact', () => {
76
+ assert.throws(() => createInitialLifecycleArtifact(task(), deterministic(false)), /passed deterministic verification/);
77
+ const reviewTask = task();
78
+ assert.throws(() => createInitialLifecycleArtifact({ ...reviewTask, evidenceScope: { sentenceIds: [99] } }, deterministic()), /passed deterministic verification/);
79
+ });
80
+ test('rejects oversized or deeply nested actions as invalid without throwing', () => {
81
+ const reviewTask = task();
82
+ const initial = createInitialLifecycleArtifact(reviewTask, deterministic());
83
+ const verdict = parseSemanticVerdict(reviewTask, 'one', { approved: true, violations: [] });
84
+ const oversized = { version: '1', type: 'semantic_submission', parentArtifactFingerprint: initial.artifactFingerprint, taskFingerprint: reviewTask.taskFingerprint, verdicts: Array.from({ length: 1000 }, () => verdict) };
85
+ assert.deepEqual(reduceRewriteLifecycle(initial, oversized, context), { ok: false, error: 'invalid_action' });
86
+ let nested = 'x';
87
+ for (let index = 0; index < 70; index += 1)
88
+ nested = [nested];
89
+ assert.deepEqual(reduceRewriteLifecycle(initial, { ...oversized, verdicts: [{ ...verdict, binding: nested }] }, context), { ok: false, error: 'invalid_action' });
90
+ const malformedFinalization = { version: '1', type: 'human_finalization', parentArtifactFingerprint: initial.artifactFingerprint, finalization: { version: '1', judgmentType: 'human_finalization', parentArtifactFingerprint: initial.artifactFingerprint, binding, evaluatorId: 'human-1', evidenceScope: null, decision: 'reject' } };
91
+ assert.deepEqual(reduceRewriteLifecycle(initial, malformedFinalization, context), { ok: false, error: 'invalid_action' });
92
+ });
93
+ test('semantic submissions reject changed task, binding, and evidence scope', () => {
94
+ const reviewTask = task();
95
+ const initial = createInitialLifecycleArtifact(reviewTask, deterministic());
96
+ const verdict = parseSemanticVerdict(reviewTask, 'one', { approved: true, violations: [] });
97
+ const base = { version: '1', type: 'semantic_submission', parentArtifactFingerprint: initial.artifactFingerprint, taskFingerprint: reviewTask.taskFingerprint, verdicts: [verdict] };
98
+ assert.deepEqual(reduceRewriteLifecycle(initial, { ...base, taskFingerprint: '9'.repeat(64) }, context), { ok: false, error: 'task_fingerprint_mismatch' });
99
+ assert.deepEqual(reduceRewriteLifecycle(initial, { ...base, verdicts: [{ ...verdict, binding: { ...binding, candidateHash: '9'.repeat(64) } }] }, context), { ok: false, error: 'invalid_binding' });
100
+ assert.deepEqual(reduceRewriteLifecycle(initial, { ...base, verdicts: [{ ...verdict, evidenceScope: { sentenceIds: [99] } }] }, context), { ok: false, error: 'evidence_scope_mismatch' });
17
101
  });
@@ -0,0 +1,39 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ test('synthetic Stage 1 packet executes outside the repository and remains blocked', () => {
8
+ const parent = mkdtempSync(join(tmpdir(), 'hyv-stage1-dry-run-test-'));
9
+ const outputRoot = join(parent, 'packet');
10
+ try {
11
+ const output = JSON.parse(execFileSync(process.execPath, ['scripts/run-stage1-dry-run.mjs', '--out', outputRoot], { encoding: 'utf8' }));
12
+ assert.equal(output.decision, 'BLOCKED');
13
+ assert.equal(output.promotable, false);
14
+ assert.deepEqual(output.blockers, ['human_writer_evidence_deferred', 'synthetic_fixture_evidence', 'locked_human_evidence_required']);
15
+ const report = JSON.parse(readFileSync(join(outputRoot, 'report.json'), 'utf8'));
16
+ assert.equal(report.decision, 'BLOCKED');
17
+ assert.equal(report.promotable, false);
18
+ assert.match(report.reportDigest, /^[a-f0-9]{64}$/);
19
+ }
20
+ finally {
21
+ rmSync(parent, { recursive: true, force: true });
22
+ }
23
+ });
24
+ test('synthetic Stage 1 packet refuses repository output', () => {
25
+ assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage1-dry-run.mjs', '--out', join(process.cwd(), 'stage1-output')], { stdio: 'pipe' }), /Dry-run output must stay outside the repository/);
26
+ });
27
+ test('synthetic Stage 1 packet refuses a symlinked ancestor into the repository', () => {
28
+ const parent = mkdtempSync(join(tmpdir(), 'hyv-stage1-symlink-test-'));
29
+ const repositoryTarget = join(process.cwd(), 'stage1-symlink-target');
30
+ mkdirSync(repositoryTarget);
31
+ symlinkSync(repositoryTarget, join(parent, 'redirect'));
32
+ try {
33
+ assert.throws(() => execFileSync(process.execPath, ['scripts/run-stage1-dry-run.mjs', '--out', join(parent, 'redirect', 'packet')], { stdio: 'pipe' }), /Dry-run output must stay outside the repository/);
34
+ }
35
+ finally {
36
+ rmSync(parent, { recursive: true, force: true });
37
+ rmSync(repositoryTarget, { recursive: true, force: true });
38
+ }
39
+ });