@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.
- package/Readme.md +76 -17
- package/dist/ai-editor-rules.js +151 -0
- package/dist/ai-editor.js +104 -8
- package/dist/ai-editor.test.js +135 -22
- package/dist/approval-capability.js +111 -0
- package/dist/approval-capability.test.js +52 -0
- package/dist/approval-context.js +54 -0
- package/dist/approval-context.test.js +38 -0
- package/dist/benchmark.js +232 -0
- package/dist/benchmark.test.js +328 -0
- package/dist/canonical-json.js +123 -0
- package/dist/canonical-json.test.js +24 -0
- package/dist/cli.js +359 -21
- package/dist/cli.test.js +275 -7
- package/dist/copy-spec.js +35 -8
- package/dist/editorial-packs.js +25 -1
- package/dist/editorial-packs.test.js +45 -0
- package/dist/hygiene.js +91 -0
- package/dist/hygiene.test.js +73 -0
- package/dist/judgment-task.js +171 -0
- package/dist/judgment-task.test.js +162 -0
- package/dist/learning.js +240 -100
- package/dist/learning.test.js +203 -3
- package/dist/lifecycle-adapter.js +75 -0
- package/dist/lifecycle-adapter.test.js +56 -0
- package/dist/mcp-tools.js +110 -9
- package/dist/mcp-tools.test.js +188 -10
- package/dist/mcp.js +228 -9
- package/dist/mcp.test.js +248 -12
- package/dist/pipeline.js +81 -15
- package/dist/pipeline.test.js +94 -2
- package/dist/preservation.js +89 -0
- package/dist/preservation.test.js +22 -0
- package/dist/profile.js +87 -0
- package/dist/profile.test.js +114 -0
- package/dist/rebuild-task.js +226 -0
- package/dist/rebuild-task.test.js +179 -0
- package/dist/release-audit.test.js +144 -2
- package/dist/rewrite-task.js +136 -16
- package/dist/rewrite-task.test.js +72 -4
- package/dist/rule-reconciliation.test.js +50 -0
- package/dist/semantic-review.js +176 -7
- package/dist/semantic-review.test.js +98 -14
- package/dist/stage1-dry-run.test.js +39 -0
- package/dist/stage1-evaluation.js +579 -0
- package/dist/stage1-evaluation.test.js +184 -0
- package/dist/stage1-human-packet.test.js +102 -0
- package/dist/stage1-schema-contract.test.js +95 -0
- package/dist/stage2-human-packet.test.js +81 -0
- package/dist/version.js +1 -0
- package/dist/voice-dna.js +53 -1
- package/dist/voice-dna.test.js +79 -1
- package/package.json +2 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHash, generateKeyPairSync, sign } from 'node:crypto';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import { canonicalJson, canonicalJsonBytes } from './canonical-json.js';
|
|
5
|
+
import { applyRewriteResponse, prepareRewriteTask } from './rewrite-task.js';
|
|
6
|
+
import { applyRebuildResponse, evaluateRebuildResponse, parseRebuildTask, prepareRebuildTask } from './rebuild-task.js';
|
|
7
|
+
import { bindJudgmentEnvelope, preparePreEditJudgment, reducePreEdit } from './judgment-task.js';
|
|
8
|
+
import { verifyDeterministically, verifyRebuildWithCopySpec } from './pipeline.js';
|
|
9
|
+
import { prepareLifecycle, recordApprovedLearning } from './lifecycle-adapter.js';
|
|
10
|
+
import { buildProfile } from './voice-dna.js';
|
|
11
|
+
import { HYV_VERSION } from './version.js';
|
|
12
|
+
const profile = buildProfile([
|
|
13
|
+
'I write clear notes. I keep the mechanism visible.',
|
|
14
|
+
'I name the trade-off. Then I make the next step plain.',
|
|
15
|
+
], ['leverage']);
|
|
16
|
+
const draft = 'I leverage the answer. The launch is on 14 August.';
|
|
17
|
+
const rebuilt = 'Ship planning now treats one calendar fact as fixed. The launch is on 14 August. Every other sentence in this note is new operational language for the release desk.';
|
|
18
|
+
const copySpec = {
|
|
19
|
+
version: '1',
|
|
20
|
+
audience: 'operators',
|
|
21
|
+
intent: 'explain',
|
|
22
|
+
channel: 'email',
|
|
23
|
+
claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar, 7 August.' }],
|
|
24
|
+
};
|
|
25
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
26
|
+
const trustStore = {
|
|
27
|
+
version: '1',
|
|
28
|
+
audience: '@holdyourvoice/hyv',
|
|
29
|
+
maxCapabilityLifetimeSeconds: 300,
|
|
30
|
+
keys: [{ issuer: 'host.example', keyId: 'key-1', publicKeySpki: publicKey.export({ format: 'der', type: 'spki' }).toString('base64url'), status: 'active' }],
|
|
31
|
+
};
|
|
32
|
+
function envelope(task, decision, extra = {}) {
|
|
33
|
+
return {
|
|
34
|
+
version: '1',
|
|
35
|
+
stage: task.stage,
|
|
36
|
+
judgmentType: task.judgmentType,
|
|
37
|
+
taskFingerprint: task.taskFingerprint,
|
|
38
|
+
bindings: { ...task.bindings, evaluatorId: 'writer.1' },
|
|
39
|
+
findings: [],
|
|
40
|
+
decision,
|
|
41
|
+
...extra,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function rebuildRecommendation(text = draft) {
|
|
45
|
+
const triage = preparePreEditJudgment(text, profile, 'triage');
|
|
46
|
+
const argument = preparePreEditJudgment(text, profile, 'argument');
|
|
47
|
+
const form = preparePreEditJudgment(text, profile, 'form');
|
|
48
|
+
return reducePreEdit([
|
|
49
|
+
bindJudgmentEnvelope(triage, envelope(triage, 'SHIP')),
|
|
50
|
+
bindJudgmentEnvelope(argument, envelope(argument, 'REBUILD')),
|
|
51
|
+
bindJudgmentEnvelope(form, envelope(form, 'SHIP')),
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
function capability(reduction, overrides = {}, source = draft) {
|
|
55
|
+
const sourceHash = createHash('sha256').update(source).digest('hex');
|
|
56
|
+
const identity = `legacy-v2:${createHash('sha256').update(canonicalJson(profile)).digest('hex')}`;
|
|
57
|
+
const claims = {
|
|
58
|
+
version: '1',
|
|
59
|
+
purpose: 'hyv.rebuild-authorization',
|
|
60
|
+
issuer: 'host.example',
|
|
61
|
+
audience: '@holdyourvoice/hyv',
|
|
62
|
+
subjectArtifactFingerprint: reduction.recommendationFingerprint,
|
|
63
|
+
sourceHash,
|
|
64
|
+
candidateHash: sourceHash,
|
|
65
|
+
profileId: identity,
|
|
66
|
+
profileRevisionDigest: identity,
|
|
67
|
+
keyId: 'key-1',
|
|
68
|
+
issuedAt: 100,
|
|
69
|
+
notBefore: 100,
|
|
70
|
+
expiresAt: 200,
|
|
71
|
+
nonce: 'nonce-rebuild',
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
const payload = canonicalJsonBytes(claims);
|
|
75
|
+
return { payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') };
|
|
76
|
+
}
|
|
77
|
+
function evaluate(task, raw, reduction, boundProfile = profile) {
|
|
78
|
+
return evaluateRebuildResponse(task, raw, boundProfile, capability(reduction), trustStore, 150);
|
|
79
|
+
}
|
|
80
|
+
test('pre-edit reductions carry a stable recommendation fingerprint', () => {
|
|
81
|
+
const left = rebuildRecommendation();
|
|
82
|
+
const right = rebuildRecommendation();
|
|
83
|
+
assert.equal(left.decision, 'REBUILD');
|
|
84
|
+
assert.match(left.recommendationFingerprint, /^[a-f0-9]{64}$/);
|
|
85
|
+
assert.equal(left.recommendationFingerprint, right.recommendationFingerprint);
|
|
86
|
+
});
|
|
87
|
+
test('a caller cannot self-select rebuild or submit a forged authorization', () => {
|
|
88
|
+
const reduction = rebuildRecommendation();
|
|
89
|
+
const edit = { ...reduction, decision: 'EDIT' };
|
|
90
|
+
assert.throws(() => prepareRebuildTask(draft, profile, edit, copySpec, capability(reduction), trustStore, 150), /upstream REBUILD recommendation/);
|
|
91
|
+
assert.throws(() => prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { purpose: 'hyv.final-approval' }), trustStore, 150), /Rebuild authorization is invalid/);
|
|
92
|
+
assert.throws(() => prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { nonce: 'stale' }), { ...trustStore, keys: [{ ...trustStore.keys[0], status: 'revoked' }] }, 150), /Rebuild authorization is invalid/);
|
|
93
|
+
assert.throws(() => prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { sourceHash: '8'.repeat(64) }), trustStore, 150), /Rebuild authorization is invalid/);
|
|
94
|
+
assert.throws(() => prepareRebuildTask(draft, profile, { ...reduction, recommendationFingerprint: 'a'.repeat(64) }, copySpec, capability(reduction), trustStore, 150), /upstream REBUILD recommendation/);
|
|
95
|
+
});
|
|
96
|
+
test('missing CopySpec blocks rebuild before a candidate is evaluated', () => {
|
|
97
|
+
const reduction = rebuildRecommendation();
|
|
98
|
+
assert.throws(() => prepareRebuildTask(draft, profile, reduction, { ...copySpec, claims: [] }, capability(reduction), trustStore, 150), /CopySpec/);
|
|
99
|
+
});
|
|
100
|
+
test('edit and rebuild responses are mutually incompatible', () => {
|
|
101
|
+
const reduction = rebuildRecommendation();
|
|
102
|
+
const rebuildTask = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
|
|
103
|
+
const editTask = prepareRewriteTask(draft, profile);
|
|
104
|
+
const rebuildOnEdit = applyRewriteResponse(editTask, { version: '1', mode: 'REBUILD', taskFingerprint: editTask.fingerprint, candidate: rebuilt });
|
|
105
|
+
assert.equal(rebuildOnEdit.status, 'repairable');
|
|
106
|
+
assert.equal(rebuildOnEdit.failures[0]?.code, 'rebuild_response_on_edit_task');
|
|
107
|
+
const replacementsOnRebuild = applyRebuildResponse(rebuildTask, { version: '1', taskFingerprint: rebuildTask.fingerprint, replacements: [{ sentenceId: 1, text: 'I use the answer.' }] });
|
|
108
|
+
assert.equal(replacementsOnRebuild.status, 'repairable');
|
|
109
|
+
assert.equal(replacementsOnRebuild.failures[0]?.code, 'edit_response_on_rebuild_task');
|
|
110
|
+
const unsigned = applyRebuildResponse(rebuildTask, { version: '1', mode: 'REBUILD', taskFingerprint: rebuildTask.fingerprint, candidate: rebuilt });
|
|
111
|
+
assert.equal(unsigned.status, 'accepted');
|
|
112
|
+
assert.equal(unsigned.receipt.authorizationFingerprint, undefined);
|
|
113
|
+
assert.equal(unsigned.receipt.preservationBypass, undefined);
|
|
114
|
+
});
|
|
115
|
+
test('authorized rebuild allows low lexical survival while claims and hygiene still block', () => {
|
|
116
|
+
const reduction = rebuildRecommendation();
|
|
117
|
+
const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
|
|
118
|
+
const passed = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: rebuilt }, reduction);
|
|
119
|
+
assert.equal(passed.status, 'needs_semantic_review');
|
|
120
|
+
assert.ok((passed.verification?.preservationScore ?? 100) < 70);
|
|
121
|
+
assert.equal(passed.receipt.mode, 'REBUILD');
|
|
122
|
+
assert.equal(passed.receipt.preservationBypass, true);
|
|
123
|
+
assert.ok(Array.isArray(passed.receipt.replacementSentenceIds));
|
|
124
|
+
assert.ok((passed.receipt.replacementSentenceIds?.length ?? 0) > 0);
|
|
125
|
+
assert.equal(passed.deterministicArtifact?.verificationKind, 'rebuild');
|
|
126
|
+
assert.equal(prepareLifecycle(passed.deterministicArtifact, passed.lifecycleBinding, passed.receipt, 'normal', ['action_change']).artifact.status, 'needs_semantic_review');
|
|
127
|
+
assert.equal(verifyRebuildWithCopySpec(draft, rebuilt, profile, copySpec).passed, true);
|
|
128
|
+
const missingClaim = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: 'Operators should delay the launch indefinitely.' }, reduction);
|
|
129
|
+
assert.equal(missingClaim.status, 'needs_escalation');
|
|
130
|
+
const hygiene = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: `${rebuilt}\u200B` }, reduction);
|
|
131
|
+
assert.equal(hygiene.status, 'needs_escalation');
|
|
132
|
+
});
|
|
133
|
+
test('rebuild disagreement cannot record accepted learning', () => {
|
|
134
|
+
const reduction = rebuildRecommendation();
|
|
135
|
+
const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
|
|
136
|
+
const failed = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: 'Operators should delay the launch indefinitely.' }, reduction);
|
|
137
|
+
assert.equal(failed.status, 'needs_escalation');
|
|
138
|
+
const evaluated = evaluate(task, { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: rebuilt }, reduction);
|
|
139
|
+
assert.equal(evaluated.status, 'needs_semantic_review');
|
|
140
|
+
const standard = verifyDeterministically(draft, rebuilt, profile, copySpec);
|
|
141
|
+
assert.notEqual(standard.artifact.artifactFingerprint, evaluated.deterministicArtifact?.artifactFingerprint);
|
|
142
|
+
assert.throws(() => recordApprovedLearning({
|
|
143
|
+
ready: { version: '1', status: 'ready_for_human_review', artifactFingerprint: '1'.repeat(64), transitionFingerprint: '2'.repeat(64), binding: evaluated.lifecycleBinding, semanticPolicy: 'normal', semanticTaskFingerprint: '3'.repeat(64), semanticEvidenceScopeFingerprint: '4'.repeat(64), verdictFingerprints: [] },
|
|
144
|
+
approved: { version: '1', status: 'approved', artifactFingerprint: '5'.repeat(64), transitionFingerprint: '6'.repeat(64), binding: evaluated.lifecycleBinding, semanticPolicy: 'normal', semanticTaskFingerprint: '3'.repeat(64), semanticEvidenceScopeFingerprint: '4'.repeat(64), verdictFingerprints: [] },
|
|
145
|
+
decision: { evaluatorId: 'human.1', decision: 'approve' },
|
|
146
|
+
capability: capability(reduction),
|
|
147
|
+
source: draft,
|
|
148
|
+
candidate: rebuilt,
|
|
149
|
+
profile,
|
|
150
|
+
context: { now: 150, trustStore, authorizedSemanticEvaluatorIds: { normal: [], highAssurance: [] }, authorizedHumanFinalizerIds: ['human.1'] },
|
|
151
|
+
copySpec,
|
|
152
|
+
}), /Invalid lifecycle artifact|Approved learning is not authorized|does not match deterministic verification/);
|
|
153
|
+
});
|
|
154
|
+
test('CLI and MCP rebuild helpers share fingerprints', () => {
|
|
155
|
+
const reduction = rebuildRecommendation();
|
|
156
|
+
const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
|
|
157
|
+
const parsed = parseRebuildTask(JSON.parse(JSON.stringify(task)));
|
|
158
|
+
assert.equal(parsed.fingerprint, task.fingerprint);
|
|
159
|
+
assert.equal(parsed.authorizationFingerprint, task.authorizationFingerprint);
|
|
160
|
+
assert.match(task.prompt, /whole-document candidate/);
|
|
161
|
+
const briefTask = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction, { nonce: 'nonce-brief' }), trustStore, 150, {
|
|
162
|
+
version: '1', audience: 'operators', intent: 'explain', format: 'outreach',
|
|
163
|
+
});
|
|
164
|
+
assert.match(briefTask.prompt, /# WritingBrief/);
|
|
165
|
+
assert.equal(HYV_VERSION, '3.3.0');
|
|
166
|
+
});
|
|
167
|
+
test('apply rejects forged tasks, missing capability, and substituted profiles', () => {
|
|
168
|
+
const reduction = rebuildRecommendation();
|
|
169
|
+
const task = prepareRebuildTask(draft, profile, reduction, copySpec, capability(reduction), trustStore, 150);
|
|
170
|
+
const response = { version: '1', mode: 'REBUILD', taskFingerprint: task.fingerprint, candidate: rebuilt };
|
|
171
|
+
const { fingerprint: _ignored, ...base } = task;
|
|
172
|
+
const forgedBase = { ...base, authorizationFingerprint: 'a'.repeat(64) };
|
|
173
|
+
const forged = { ...forgedBase, fingerprint: createHash('sha256').update(canonicalJson(forgedBase)).digest('hex') };
|
|
174
|
+
assert.equal(parseRebuildTask(forged).fingerprint, forged.fingerprint);
|
|
175
|
+
assert.throws(() => evaluateRebuildResponse(forged, { ...response, taskFingerprint: forged.fingerprint }, profile, capability(reduction), trustStore, 150), /Rebuild authorization is invalid/);
|
|
176
|
+
assert.throws(() => evaluateRebuildResponse(task, response, profile, {}, trustStore, 150), /Rebuild authorization is invalid/);
|
|
177
|
+
const other = buildProfile(['I speak in a different register altogether.', 'I keep every sentence longer than the first profile would.'], ['mechanism']);
|
|
178
|
+
assert.throws(() => evaluate(task, response, reduction, other), /Rebuild profile binding does not match this task/);
|
|
179
|
+
});
|
|
@@ -5,17 +5,38 @@ 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 stage1Files = [
|
|
9
|
+
'scripts/evaluate-rewrite-benchmark.mjs',
|
|
10
|
+
'scripts/run-stage1-dry-run.mjs',
|
|
11
|
+
'scripts/run-stage1-human-packet.mjs',
|
|
12
|
+
'scripts/run-stage2-human-packet.mjs',
|
|
13
|
+
'benchmarks/schema/protocol-manifest.v1.schema.json',
|
|
14
|
+
'benchmarks/schema/run-event.v1.schema.json',
|
|
15
|
+
'benchmarks/schema/blind-packet.v1.schema.json',
|
|
16
|
+
'benchmarks/schema/blind-mapping.v1.schema.json',
|
|
17
|
+
'benchmarks/schema/reviewer-record.v1.schema.json',
|
|
18
|
+
'benchmarks/schema/ratings-seal.v1.schema.json',
|
|
19
|
+
'benchmarks/schema/aggregate-report.v1.schema.json',
|
|
20
|
+
'benchmarks/schema/checkpoint-disposition.v1.schema.json',
|
|
21
|
+
];
|
|
8
22
|
function fixture(files) {
|
|
9
23
|
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-audit-'));
|
|
10
24
|
execFileSync('git', ['init', '--quiet'], { cwd: directory });
|
|
11
25
|
const defaults = {
|
|
12
|
-
'package.json': JSON.stringify({ license: 'MIT', files: ['LICENSE'] }),
|
|
26
|
+
'package.json': JSON.stringify({ name: 'audit-fixture', license: 'MIT', files: ['dist', 'Readme.md', 'LICENSE'], scripts: { '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' }, version: '1.0.0', type: 'module', bin: { hyv: 'dist/cli.js' }, engines: { node: '>=20' } }),
|
|
13
27
|
'mcpb/manifest.json': JSON.stringify({ version: '1.0.0' }),
|
|
28
|
+
'claude-plugin/.claude-plugin/plugin.json': JSON.stringify({ version: '1.0.0' }),
|
|
29
|
+
'.claude-plugin/marketplace.json': JSON.stringify({ plugins: [{ name: 'hold-your-voice', version: '1.0.0' }] }),
|
|
30
|
+
'claude-plugin/.mcp.json': JSON.stringify({ mcpServers: { 'hold-your-voice': { args: ['--package=@holdyourvoice/hyv@1.0.0'] } } }),
|
|
31
|
+
'Readme.md': '# public',
|
|
14
32
|
LICENSE: [
|
|
15
33
|
'Permission is hereby granted, free of charge, to any person obtaining a copy',
|
|
16
34
|
'The above copyright notice and this permission notice shall be included in all',
|
|
17
35
|
'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND',
|
|
18
36
|
].join('\n'),
|
|
37
|
+
'src/version.ts': "export const HYV_VERSION = '1.0.0';",
|
|
38
|
+
'src/stage1-evaluation.ts': "const baseline = '4e6269121d551c008a34db73077e1e4fea41b3f9'; const stage1 = '550ea24f652291dca13757fdbd2f0fa0b5e3f621';",
|
|
39
|
+
...Object.fromEntries(stage1Files.map((file) => [file, '{}'])),
|
|
19
40
|
};
|
|
20
41
|
for (const [file, text] of Object.entries({ ...defaults, ...files })) {
|
|
21
42
|
const path = join(directory, file);
|
|
@@ -25,6 +46,34 @@ function fixture(files) {
|
|
|
25
46
|
execFileSync('git', ['add', 'README.md'], { cwd: directory });
|
|
26
47
|
return directory;
|
|
27
48
|
}
|
|
49
|
+
test('accepts the complete public package contract', () => {
|
|
50
|
+
const directory = fixture({ 'README.md': '# public' });
|
|
51
|
+
try {
|
|
52
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
53
|
+
assert.equal(result.status, 0, result.stderr);
|
|
54
|
+
assert.match(result.stdout, /release audit passed/);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
rmSync(directory, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
test('requires exact Stage 1 scripts even when the checkpoint files remain', () => {
|
|
61
|
+
const directory = fixture({
|
|
62
|
+
'README.md': '# public',
|
|
63
|
+
'package.json': JSON.stringify({ name: 'audit-fixture', license: 'MIT', files: ['dist', 'Readme.md', 'LICENSE'], scripts: { 'stage1:evaluate': 'node changed.mjs' }, version: '1.0.0', type: 'module', bin: { hyv: 'dist/cli.js' }, engines: { node: '>=20' } }),
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
67
|
+
assert.notEqual(result.status, 0);
|
|
68
|
+
assert.match(result.stderr, /Stage 1 script contract has drifted: stage1:evaluate/);
|
|
69
|
+
assert.match(result.stderr, /Stage 1 script contract has drifted: stage1:dry-run/);
|
|
70
|
+
assert.match(result.stderr, /Stage 1 script contract has drifted: stage1:human-packet/);
|
|
71
|
+
assert.match(result.stderr, /Stage 1 script contract has drifted: stage2:human-packet/);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
rmSync(directory, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
28
77
|
test('rejects unquoted credentials in an untracked source file', () => {
|
|
29
78
|
const directory = fixture({ 'README.md': '# public', 'src/unsafe.ts': ['const ', 'API_KEY', '=topsecret;'].join('') });
|
|
30
79
|
try {
|
|
@@ -39,7 +88,7 @@ test('rejects unquoted credentials in an untracked source file', () => {
|
|
|
39
88
|
test('requires the Claude extension version to match npm', () => {
|
|
40
89
|
const directory = fixture({
|
|
41
90
|
'README.md': '# public',
|
|
42
|
-
'package.json': JSON.stringify({ license: 'MIT', files: ['LICENSE'], version: '1.0.0' }),
|
|
91
|
+
'package.json': JSON.stringify({ name: 'audit-fixture', license: 'MIT', files: ['dist', 'Readme.md', 'LICENSE'], version: '1.0.0', type: 'module', bin: { hyv: 'dist/cli.js' }, engines: { node: '>=20' } }),
|
|
43
92
|
'mcpb/manifest.json': JSON.stringify({ version: '1.0.1' }),
|
|
44
93
|
});
|
|
45
94
|
try {
|
|
@@ -51,3 +100,96 @@ test('requires the Claude extension version to match npm', () => {
|
|
|
51
100
|
rmSync(directory, { recursive: true, force: true });
|
|
52
101
|
}
|
|
53
102
|
});
|
|
103
|
+
test('requires every Claude package surface to match npm', () => {
|
|
104
|
+
const directory = fixture({
|
|
105
|
+
'README.md': '# public',
|
|
106
|
+
'claude-plugin/.claude-plugin/plugin.json': JSON.stringify({ version: '0.9.0' }),
|
|
107
|
+
'.claude-plugin/marketplace.json': JSON.stringify({ plugins: [{ name: 'other', version: '1.0.0' }, { name: 'hold-your-voice', version: '0.9.0' }] }),
|
|
108
|
+
'claude-plugin/.mcp.json': JSON.stringify({ mcpServers: { 'hold-your-voice': { args: ['--package=@holdyourvoice/hyv@0.9.0'] } } }),
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
112
|
+
assert.notEqual(result.status, 0);
|
|
113
|
+
assert.match(result.stderr, /Claude plugin version must match package\.json/);
|
|
114
|
+
assert.match(result.stderr, /Claude marketplace version must match package\.json/);
|
|
115
|
+
assert.match(result.stderr, /Claude plugin package pin must match package\.json/);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
rmSync(directory, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
test('requires the MCP runtime version to match npm', () => {
|
|
122
|
+
const directory = fixture({ 'README.md': '# public', 'src/version.ts': "export const HYV_VERSION = '0.9.0';" });
|
|
123
|
+
try {
|
|
124
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
125
|
+
assert.notEqual(result.status, 0);
|
|
126
|
+
assert.match(result.stderr, /MCP runtime version must match package\.json/);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
rmSync(directory, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
test('requires the npm package runtime contract', () => {
|
|
133
|
+
const directory = fixture({
|
|
134
|
+
'README.md': '# public',
|
|
135
|
+
'package.json': JSON.stringify({
|
|
136
|
+
name: 'audit-fixture',
|
|
137
|
+
license: 'MIT',
|
|
138
|
+
files: ['LICENSE'],
|
|
139
|
+
version: '1.0.0',
|
|
140
|
+
type: 'commonjs',
|
|
141
|
+
bin: { hyv: 'src/cli.ts' },
|
|
142
|
+
engines: { node: '>=18' },
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
147
|
+
assert.notEqual(result.status, 0);
|
|
148
|
+
assert.match(result.stderr, /npm package must include dist/);
|
|
149
|
+
assert.match(result.stderr, /npm package must include Readme\.md/);
|
|
150
|
+
assert.match(result.stderr, /npm package must use the ESM runtime contract/);
|
|
151
|
+
assert.match(result.stderr, /npm hyv binary must point to dist\/cli\.js/);
|
|
152
|
+
assert.match(result.stderr, /npm package must require Node 20 or newer/);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
rmSync(directory, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
test('rejects every package manifest path outside the public allowlist', () => {
|
|
159
|
+
const directory = fixture({
|
|
160
|
+
'README.md': '# public',
|
|
161
|
+
'package.json': JSON.stringify({
|
|
162
|
+
name: 'audit-fixture',
|
|
163
|
+
license: 'MIT',
|
|
164
|
+
files: ['dist', 'Readme.md', 'LICENSE', 'docs', 'benchmarks/private', 'profiles'],
|
|
165
|
+
version: '1.0.0',
|
|
166
|
+
type: 'module',
|
|
167
|
+
bin: { hyv: 'dist/cli.js' },
|
|
168
|
+
engines: { node: '>=20' },
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
173
|
+
assert.notEqual(result.status, 0);
|
|
174
|
+
assert.match(result.stderr, /npm package exposes an unexpected path: docs/);
|
|
175
|
+
assert.match(result.stderr, /npm package exposes an unexpected path: benchmarks\/private/);
|
|
176
|
+
assert.match(result.stderr, /npm package exposes an unexpected path: profiles/);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
rmSync(directory, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
test('rejects a forbidden nested file from the actual npm package list', () => {
|
|
183
|
+
const directory = fixture({
|
|
184
|
+
'README.md': '# public',
|
|
185
|
+
'dist/profiles/private.json': '{"secret":true}',
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
const result = spawnSync(process.execPath, [audit], { cwd: directory, encoding: 'utf8' });
|
|
189
|
+
assert.notEqual(result.status, 0);
|
|
190
|
+
assert.match(result.stderr, /npm package contains an unexpected file: dist\/profiles\/private\.json/);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
rmSync(directory, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
package/dist/rewrite-task.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalJson } from './canonical-json.js';
|
|
2
3
|
import { parseWritingBrief } from './editorial-packs.js';
|
|
3
|
-
import {
|
|
4
|
+
import { hygieneSourceFindings } from './hygiene.js';
|
|
5
|
+
import { analyze, deriveEditScope, renderRewritePrompt, verifyDeterministically } from './pipeline.js';
|
|
4
6
|
import { sentences } from './text.js';
|
|
5
7
|
const MAX_RESPONSE_BYTES = 100_000;
|
|
6
8
|
const MAX_REPLACEMENTS = 100;
|
|
7
9
|
const MAX_REPLACEMENT_CHARACTERS = 10_000;
|
|
8
10
|
function fingerprint(value) {
|
|
9
|
-
return createHash('sha256').update(
|
|
11
|
+
return createHash('sha256').update(typeof value === 'string' ? value : canonicalJson(value)).digest('hex');
|
|
10
12
|
}
|
|
11
13
|
function failure(code, message, path) {
|
|
12
14
|
return { code, message, ...(path ? { path } : {}) };
|
|
@@ -34,10 +36,40 @@ function parseResponse(value) {
|
|
|
34
36
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
35
37
|
return failure('invalid_response_shape', 'Response must be an object.');
|
|
36
38
|
const response = raw;
|
|
37
|
-
if (response.version !== '1')
|
|
38
|
-
return failure('invalid_response_version', 'Response version must be "1".', 'version');
|
|
39
39
|
if (typeof response.taskFingerprint !== 'string' || response.taskFingerprint.length !== 64)
|
|
40
40
|
return failure('invalid_response_shape', 'Response must include the task fingerprint.', 'taskFingerprint');
|
|
41
|
+
if (response.mode === 'REBUILD')
|
|
42
|
+
return failure('rebuild_response_on_edit_task', 'Rebuild responses cannot satisfy edit tasks.', 'mode');
|
|
43
|
+
if (response.mode === 'SHIP' && response.version === '1') {
|
|
44
|
+
return { version: '1', mode: 'SHIP', taskFingerprint: response.taskFingerprint };
|
|
45
|
+
}
|
|
46
|
+
if (response.version === '2') {
|
|
47
|
+
if (!Array.isArray(response.operations))
|
|
48
|
+
return failure('invalid_response_shape', 'Version 2 responses require an operations array.', 'operations');
|
|
49
|
+
if (response.operations.length > MAX_REPLACEMENTS)
|
|
50
|
+
return failure('invalid_response_shape', `Response may include at most ${MAX_REPLACEMENTS} replacements.`, 'operations');
|
|
51
|
+
for (const [index, operation] of response.operations.entries()) {
|
|
52
|
+
if (!operation || typeof operation !== 'object' || !Number.isInteger(operation.startSentenceId) || !Number.isInteger(operation.endSentenceId) || typeof operation.text !== 'string') {
|
|
53
|
+
return failure('invalid_response_shape', 'Every operation requires integer startSentenceId, endSentenceId, and string text.', `operations[${index}]`);
|
|
54
|
+
}
|
|
55
|
+
if (operation.endSentenceId < operation.startSentenceId)
|
|
56
|
+
return failure('noncontiguous_range', 'A range must be inclusive and contiguous.', `operations[${index}]`);
|
|
57
|
+
if (operation.text.length > MAX_REPLACEMENT_CHARACTERS)
|
|
58
|
+
return failure('invalid_replacement_text', `Replacement text must contain at most ${MAX_REPLACEMENT_CHARACTERS} characters.`, `operations[${index}].text`);
|
|
59
|
+
}
|
|
60
|
+
if (response.hygieneOperations !== undefined) {
|
|
61
|
+
if (!Array.isArray(response.hygieneOperations))
|
|
62
|
+
return failure('invalid_response_shape', 'Hygiene operations must be an array.', 'hygieneOperations');
|
|
63
|
+
for (const [index, operation] of response.hygieneOperations.entries()) {
|
|
64
|
+
if (!operation || !Number.isInteger(operation.start) || !Number.isInteger(operation.end) || typeof operation.text !== 'string' || operation.end < operation.start) {
|
|
65
|
+
return failure('invalid_response_shape', 'Every hygiene operation requires integer start, end, and string text.', `hygieneOperations[${index}]`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return response;
|
|
70
|
+
}
|
|
71
|
+
if (response.version !== '1')
|
|
72
|
+
return failure('invalid_response_version', 'Response version must be "1" or "2".', 'version');
|
|
41
73
|
if (!Array.isArray(response.replacements))
|
|
42
74
|
return failure('invalid_response_shape', 'Response replacements must be an array.', 'replacements');
|
|
43
75
|
if (response.replacements.length > MAX_REPLACEMENTS)
|
|
@@ -74,18 +106,17 @@ function repairFencedJson(value) {
|
|
|
74
106
|
const match = value.match(/^```json\s*\n([\s\S]*?)\n```\s*$/i);
|
|
75
107
|
return match ? { value: match[1], adapterId: 'fenced_json_v1' } : { value };
|
|
76
108
|
}
|
|
77
|
-
export function prepareRewriteTask(draft, profile, copySpec, writingBrief) {
|
|
78
|
-
const
|
|
109
|
+
export function prepareRewriteTask(draft, profile, copySpec, writingBrief, authorizedSentenceIds = []) {
|
|
110
|
+
const result = analyze(draft, profile, writingBrief);
|
|
111
|
+
const prompt = renderRewritePrompt(draft, profile, result, [], writingBrief);
|
|
79
112
|
const mapped = sentences(draft);
|
|
80
|
-
const eligibleSentenceIds = new Set([
|
|
81
|
-
...analysis.matchAll(/^- Sentence (\d+) \[/gm),
|
|
82
|
-
].map((match) => Number(match[1])));
|
|
113
|
+
const eligibleSentenceIds = new Set([...deriveEditScope(result).eligibleSentenceIds, ...authorizedSentenceIds]);
|
|
83
114
|
const taskBase = {
|
|
84
115
|
version: '1',
|
|
85
116
|
draft,
|
|
86
117
|
sentences: mapped.map((sentence) => ({ id: sentence.index, text: sentence.text, eligible: eligibleSentenceIds.has(sentence.index) })),
|
|
87
118
|
eligibleSentenceIds: [...eligibleSentenceIds].sort((left, right) => left - right),
|
|
88
|
-
prompt
|
|
119
|
+
prompt,
|
|
89
120
|
...(copySpec ? { copySpec } : {}),
|
|
90
121
|
...(writingBrief ? { writingBrief } : {}),
|
|
91
122
|
};
|
|
@@ -106,7 +137,22 @@ export function parseRewriteTask(value) {
|
|
|
106
137
|
return task;
|
|
107
138
|
}
|
|
108
139
|
function rejected(task, raw, failures, adapterIds = []) {
|
|
109
|
-
return { status: 'repairable', failures, receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds } };
|
|
140
|
+
return { status: 'repairable', failures, receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds, replacementSentenceIds: [] } };
|
|
141
|
+
}
|
|
142
|
+
export function applyShip(task) {
|
|
143
|
+
return {
|
|
144
|
+
status: 'accepted',
|
|
145
|
+
candidate: task.draft,
|
|
146
|
+
failures: [],
|
|
147
|
+
receipt: {
|
|
148
|
+
version: '1',
|
|
149
|
+
taskFingerprint: task.fingerprint,
|
|
150
|
+
responseFingerprint: fingerprint({ version: '1', mode: 'SHIP', taskFingerprint: task.fingerprint }),
|
|
151
|
+
adapterIds: [],
|
|
152
|
+
replacementSentenceIds: [],
|
|
153
|
+
mode: 'SHIP',
|
|
154
|
+
},
|
|
155
|
+
};
|
|
110
156
|
}
|
|
111
157
|
export function applyRewriteResponse(task, raw) {
|
|
112
158
|
const source = typeof raw === 'string' ? parseJson(raw) : raw;
|
|
@@ -119,6 +165,14 @@ export function applyRewriteResponse(task, raw) {
|
|
|
119
165
|
return rejected(task, raw, [response], adapterIds);
|
|
120
166
|
if (response.taskFingerprint !== task.fingerprint)
|
|
121
167
|
return rejected(task, raw, [failure('task_fingerprint_mismatch', 'Response task fingerprint does not match this task.', 'taskFingerprint')], adapterIds);
|
|
168
|
+
if ('mode' in response && response.mode === 'SHIP') {
|
|
169
|
+
const shipped = applyShip(task);
|
|
170
|
+
return { ...shipped, receipt: { ...shipped.receipt, adapterIds, responseFingerprint: responseFingerprint(raw) } };
|
|
171
|
+
}
|
|
172
|
+
if (response.version === '2')
|
|
173
|
+
return applyRangeResponse(task, response, raw, adapterIds);
|
|
174
|
+
if (!('replacements' in response))
|
|
175
|
+
return rejected(task, raw, [failure('invalid_response_shape', 'Response replacements must be an array.', 'replacements')], adapterIds);
|
|
122
176
|
const seen = new Set();
|
|
123
177
|
const sentenceMap = new Map(task.sentences.map((sentence) => [sentence.id, sentence]));
|
|
124
178
|
for (const [index, replacement] of response.replacements.entries()) {
|
|
@@ -139,16 +193,82 @@ export function applyRewriteResponse(task, raw) {
|
|
|
139
193
|
if (replacement !== undefined)
|
|
140
194
|
candidate = `${candidate.slice(0, sentence.start)}${replacement}${candidate.slice(sentence.end)}`;
|
|
141
195
|
}
|
|
142
|
-
return { status: 'accepted', candidate, failures: [], receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds } };
|
|
196
|
+
return { status: 'accepted', candidate, failures: [], receipt: { version: '1', taskFingerprint: task.fingerprint, responseFingerprint: responseFingerprint(raw), adapterIds, replacementSentenceIds: [...seen].sort((left, right) => left - right), mode: 'EDIT' } };
|
|
197
|
+
}
|
|
198
|
+
function applyRangeResponse(task, response, raw, adapterIds) {
|
|
199
|
+
const sentenceMap = new Map(task.sentences.map((sentence) => [sentence.id, sentence]));
|
|
200
|
+
let previousEnd = 0;
|
|
201
|
+
for (const [index, operation] of response.operations.entries()) {
|
|
202
|
+
if (index > 0 && operation.startSentenceId <= previousEnd)
|
|
203
|
+
return rejected(task, raw, [failure('overlapping_range', 'Operations must be ordered and non-overlapping.', `operations[${index}]`)], adapterIds);
|
|
204
|
+
if (index > 0 && operation.startSentenceId < response.operations[index - 1].startSentenceId) {
|
|
205
|
+
return rejected(task, raw, [failure('out_of_order_range', 'Operations must be in source order.', `operations[${index}]`)], adapterIds);
|
|
206
|
+
}
|
|
207
|
+
previousEnd = operation.endSentenceId;
|
|
208
|
+
for (let id = operation.startSentenceId; id <= operation.endSentenceId; id += 1) {
|
|
209
|
+
const sentence = sentenceMap.get(id);
|
|
210
|
+
if (!sentence)
|
|
211
|
+
return rejected(task, raw, [failure('unknown_sentence_id', 'Range sentenceId is not in this task.', `operations[${index}]`)], adapterIds);
|
|
212
|
+
if (!sentence.eligible)
|
|
213
|
+
return rejected(task, raw, [failure('partly_locked_range', 'A range may cover only eligible sentences.', `operations[${index}]`)], adapterIds);
|
|
214
|
+
if (id > operation.startSentenceId && !sentenceMap.has(id - 1))
|
|
215
|
+
return rejected(task, raw, [failure('noncontiguous_range', 'Ranges must be contiguous.', `operations[${index}]`)], adapterIds);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const eligibleHygiene = new Map(hygieneSourceFindings(task.draft).filter((finding) => finding.eligible).map((finding) => [`${finding.start}:${finding.end}`, finding]));
|
|
219
|
+
for (const [index, operation] of (response.hygieneOperations ?? []).entries()) {
|
|
220
|
+
if (!eligibleHygiene.has(`${operation.start}:${operation.end}`)) {
|
|
221
|
+
return rejected(task, raw, [failure('ineligible_hygiene_offset', 'Hygiene changes require an eligible source-offset finding.', `hygieneOperations[${index}]`)], adapterIds);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
let candidate = task.draft;
|
|
225
|
+
for (const operation of [...(response.hygieneOperations ?? [])].sort((left, right) => right.start - left.start)) {
|
|
226
|
+
candidate = `${candidate.slice(0, operation.start)}${operation.text}${candidate.slice(operation.end)}`;
|
|
227
|
+
}
|
|
228
|
+
const mapped = sentences(candidate);
|
|
229
|
+
for (const operation of [...response.operations].reverse()) {
|
|
230
|
+
const start = mapped.find((sentence) => sentence.index === operation.startSentenceId);
|
|
231
|
+
const end = mapped.find((sentence) => sentence.index === operation.endSentenceId);
|
|
232
|
+
if (!start || !end)
|
|
233
|
+
return rejected(task, raw, [failure('unknown_sentence_id', 'Range sentenceId is not in this task.', 'operations')], adapterIds);
|
|
234
|
+
candidate = `${candidate.slice(0, start.start)}${operation.text}${candidate.slice(end.end)}`;
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
status: 'accepted',
|
|
238
|
+
candidate,
|
|
239
|
+
failures: [],
|
|
240
|
+
receipt: {
|
|
241
|
+
version: '1',
|
|
242
|
+
taskFingerprint: task.fingerprint,
|
|
243
|
+
responseFingerprint: responseFingerprint(raw),
|
|
244
|
+
adapterIds,
|
|
245
|
+
operationRanges: response.operations.map((operation) => ({ startSentenceId: operation.startSentenceId, endSentenceId: operation.endSentenceId })),
|
|
246
|
+
mode: 'EDIT',
|
|
247
|
+
},
|
|
248
|
+
};
|
|
143
249
|
}
|
|
144
250
|
export function evaluateRewriteResponse(task, raw, profile) {
|
|
145
251
|
const applied = applyRewriteResponse(task, raw);
|
|
146
252
|
if (applied.status !== 'accepted' || !applied.candidate)
|
|
147
253
|
return applied;
|
|
148
|
-
const verification = task.copySpec
|
|
149
|
-
? verifyWithCopySpec(task.draft, applied.candidate, profile, task.copySpec, task.writingBrief)
|
|
150
|
-
: verify(task.draft, applied.candidate, profile, task.writingBrief);
|
|
254
|
+
const { verification, artifact: deterministicArtifact } = verifyDeterministically(task.draft, applied.candidate, profile, task.copySpec, task.writingBrief);
|
|
151
255
|
if (!verification.passed)
|
|
152
256
|
return { ...applied, status: 'needs_escalation', verification };
|
|
153
|
-
|
|
257
|
+
const lifecycleBinding = createRewriteLifecycleBinding(task, applied.receipt, deterministicArtifact);
|
|
258
|
+
return { ...applied, status: 'needs_semantic_review', verification, deterministicArtifact, lifecycleBinding };
|
|
259
|
+
}
|
|
260
|
+
export function createRewriteLifecycleBinding(task, receipt, deterministic) {
|
|
261
|
+
if (!deterministic.passed || receipt.taskFingerprint !== task.fingerprint)
|
|
262
|
+
throw new Error('Lifecycle binding requires a passed deterministic artifact for this rewrite task.');
|
|
263
|
+
return {
|
|
264
|
+
rewriteTaskFingerprint: task.fingerprint,
|
|
265
|
+
rewriteResponseFingerprint: receipt.responseFingerprint,
|
|
266
|
+
deterministicArtifactFingerprint: deterministic.artifactFingerprint,
|
|
267
|
+
sourceHash: deterministic.sourceHash,
|
|
268
|
+
candidateHash: deterministic.candidateHash,
|
|
269
|
+
profileId: deterministic.profileId,
|
|
270
|
+
profileRevisionDigest: deterministic.profileRevisionDigest,
|
|
271
|
+
rulesetVersion: deterministic.rulesetVersion,
|
|
272
|
+
schemaVersion: '1',
|
|
273
|
+
};
|
|
154
274
|
}
|