@holdyourvoice/hyv 3.2.0 → 3.3.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 (50) hide show
  1. package/Readme.md +51 -11
  2. package/dist/ai-editor-rules.js +5 -2
  3. package/dist/ai-editor.js +52 -9
  4. package/dist/ai-editor.test.js +62 -10
  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 +272 -19
  14. package/dist/cli.test.js +205 -8
  15. package/dist/hygiene.js +6 -0
  16. package/dist/hygiene.test.js +7 -1
  17. package/dist/judgment-task.js +171 -0
  18. package/dist/judgment-task.test.js +162 -0
  19. package/dist/learning.js +240 -100
  20. package/dist/learning.test.js +203 -3
  21. package/dist/lifecycle-adapter.js +75 -0
  22. package/dist/lifecycle-adapter.test.js +56 -0
  23. package/dist/mcp-tools.js +101 -7
  24. package/dist/mcp-tools.test.js +156 -6
  25. package/dist/mcp.js +213 -6
  26. package/dist/mcp.test.js +210 -11
  27. package/dist/pipeline.js +78 -14
  28. package/dist/pipeline.test.js +36 -2
  29. package/dist/preservation.js +89 -0
  30. package/dist/preservation.test.js +22 -0
  31. package/dist/profile.js +87 -0
  32. package/dist/profile.test.js +114 -0
  33. package/dist/rebuild-task.js +226 -0
  34. package/dist/rebuild-task.test.js +179 -0
  35. package/dist/release-audit.test.js +111 -2
  36. package/dist/rewrite-task.js +136 -16
  37. package/dist/rewrite-task.test.js +62 -7
  38. package/dist/rule-reconciliation.test.js +50 -0
  39. package/dist/semantic-review.js +176 -7
  40. package/dist/semantic-review.test.js +98 -14
  41. package/dist/stage1-dry-run.test.js +39 -0
  42. package/dist/stage1-evaluation.js +579 -0
  43. package/dist/stage1-evaluation.test.js +184 -0
  44. package/dist/stage1-human-packet.test.js +102 -0
  45. package/dist/stage1-schema-contract.test.js +95 -0
  46. package/dist/stage2-human-packet.test.js +81 -0
  47. package/dist/version.js +1 -1
  48. package/dist/voice-dna.js +53 -1
  49. package/dist/voice-dna.test.js +79 -1
  50. package/package.json +2 -2
@@ -0,0 +1,123 @@
1
+ const MAX_DEPTH = 64;
2
+ const MAX_NODES = 10_000;
3
+ function isPlainObject(value) {
4
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
5
+ }
6
+ function validUnicode(value) {
7
+ for (let index = 0; index < value.length; index += 1) {
8
+ const code = value.charCodeAt(index);
9
+ if (code >= 0xd800 && code <= 0xdbff) {
10
+ const next = value.charCodeAt(index + 1);
11
+ if (!(next >= 0xdc00 && next <= 0xdfff))
12
+ return false;
13
+ index += 1;
14
+ }
15
+ else if (code >= 0xdc00 && code <= 0xdfff)
16
+ return false;
17
+ }
18
+ return true;
19
+ }
20
+ function serialize(value, depth, budget) {
21
+ budget.nodes += 1;
22
+ if (depth > MAX_DEPTH || budget.nodes > MAX_NODES)
23
+ throw new Error('Value is not valid canonical JSON: complexity limit exceeded.');
24
+ if (value === null || typeof value === 'boolean')
25
+ return JSON.stringify(value);
26
+ if (typeof value === 'string') {
27
+ if (!validUnicode(value))
28
+ throw new Error('Value is not valid canonical JSON: lone surrogate.');
29
+ return JSON.stringify(value);
30
+ }
31
+ if (typeof value === 'number') {
32
+ if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value)) || Object.is(value, -0))
33
+ throw new Error('Value is not valid canonical JSON: unsafe number.');
34
+ return JSON.stringify(value);
35
+ }
36
+ if (Array.isArray(value))
37
+ return `[${value.map((item) => serialize(item, depth + 1, budget)).join(',')}]`;
38
+ if (isPlainObject(value)) {
39
+ const entries = Object.keys(value).sort().map((key) => {
40
+ if (!validUnicode(key))
41
+ throw new Error('Value is not valid canonical JSON: lone surrogate key.');
42
+ return `${JSON.stringify(key)}:${serialize(value[key], depth + 1, budget)}`;
43
+ });
44
+ return `{${entries.join(',')}}`;
45
+ }
46
+ throw new Error('Value is not valid canonical JSON: unsupported value.');
47
+ }
48
+ export function canonicalJson(value) { return serialize(value, 0, { nodes: 0 }); }
49
+ export function canonicalJsonBytes(value) {
50
+ return Buffer.from(canonicalJson(value), 'utf8');
51
+ }
52
+ function hasDuplicateKeys(source) {
53
+ const stack = [];
54
+ let index = 0;
55
+ let expectingKey = false;
56
+ while (index < source.length) {
57
+ const char = source[index];
58
+ if (char === '{') {
59
+ stack.push(new Set());
60
+ expectingKey = true;
61
+ index += 1;
62
+ continue;
63
+ }
64
+ if (char === '[') {
65
+ stack.push(null);
66
+ expectingKey = false;
67
+ index += 1;
68
+ continue;
69
+ }
70
+ if (char === '}' || char === ']') {
71
+ stack.pop();
72
+ expectingKey = false;
73
+ index += 1;
74
+ continue;
75
+ }
76
+ if (char === ',') {
77
+ expectingKey = stack.at(-1) instanceof Set;
78
+ index += 1;
79
+ continue;
80
+ }
81
+ if (char === '"') {
82
+ let end = index + 1;
83
+ while (end < source.length) {
84
+ if (source[end] === '\\') {
85
+ end += 2;
86
+ continue;
87
+ }
88
+ if (source[end] === '"')
89
+ break;
90
+ end += 1;
91
+ }
92
+ if (expectingKey && source[end + 1] === ':') {
93
+ const key = JSON.parse(source.slice(index, end + 1));
94
+ const keys = stack.at(-1);
95
+ if (keys.has(key))
96
+ return true;
97
+ keys.add(key);
98
+ expectingKey = false;
99
+ }
100
+ index = end + 1;
101
+ continue;
102
+ }
103
+ index += 1;
104
+ }
105
+ return false;
106
+ }
107
+ export function parseCanonicalJson(bytes) {
108
+ const source = Buffer.from(bytes).toString('utf8');
109
+ if (!Buffer.from(source, 'utf8').equals(Buffer.from(bytes)) || source.includes('\ufffd'))
110
+ throw new Error('Payload is not canonical JSON.');
111
+ if (hasDuplicateKeys(source))
112
+ throw new Error('Payload contains a duplicate JSON key.');
113
+ let value;
114
+ try {
115
+ value = JSON.parse(source);
116
+ }
117
+ catch {
118
+ throw new Error('Payload is not canonical JSON.');
119
+ }
120
+ if (canonicalJson(value) !== source)
121
+ throw new Error('Payload is not canonical JSON.');
122
+ return value;
123
+ }
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { canonicalJson, canonicalJsonBytes, parseCanonicalJson } from './canonical-json.js';
4
+ test('canonicalizes nested objects, arrays, escapes, unicode, and numbers', () => {
5
+ const value = { z: [3, { b: '\u20ac', a: '\n' }], a: 1e-7 };
6
+ assert.equal(canonicalJson(value), '{"a":1e-7,"z":[3,{"a":"\\n","b":"€"}]}');
7
+ assert.deepEqual(canonicalJsonBytes(value), Buffer.from(canonicalJson(value), 'utf8'));
8
+ });
9
+ test('rejects duplicate keys, lone surrogates, unsafe integers, negative zero, and unsupported values', () => {
10
+ assert.throws(() => parseCanonicalJson(Buffer.from('{"a":1,"a":1}')), /duplicate/i);
11
+ for (const value of ['\ud800', Number.MAX_SAFE_INTEGER + 1, -0, undefined]) {
12
+ assert.throws(() => canonicalJson(value), /canonical/i);
13
+ }
14
+ });
15
+ test('requires exact canonical payload bytes', () => {
16
+ assert.deepEqual(parseCanonicalJson(Buffer.from('{"a":1,"b":2}')), { a: 1, b: 2 });
17
+ assert.throws(() => parseCanonicalJson(Buffer.from('{ "a": 1, "b": 2 }')), /canonical/i);
18
+ });
19
+ test('rejects inputs beyond the canonical complexity budget', () => {
20
+ let value = 1;
21
+ for (let index = 0; index < 70; index += 1)
22
+ value = [value];
23
+ assert.throws(() => canonicalJson(value), /complexity limit/);
24
+ });
package/dist/cli.js CHANGED
@@ -1,22 +1,146 @@
1
1
  #!/usr/bin/env node
2
- import { linkSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { closeSync, constants, fstatSync, linkSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, extname, join, resolve } from 'node:path';
4
4
  import { RULESET_VERSION, serializedRules } from './ai-editor.js';
5
5
  import { parseCopySpec } from './copy-spec.js';
6
6
  import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
7
- import { addLearningInstruction, clearLearning, composeLearning, profileFingerprint, recordVerifiedCandidate } from './learning.js';
7
+ import { clearLearning, composeLearning, inspectLearning, migrateLearningV2ToV3, profileFingerprint, ratifyLearningEvent, recordLearningInstruction, supersedeLearningEvent } from './learning.js';
8
8
  import { cleanHygiene, finalOutputCheck, inspectHygiene } from './hygiene.js';
9
9
  import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
10
10
  import { parseProfile } from './profile.js';
11
11
  import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
12
+ import { parseJudgmentEnvelope, preparePostCandidateJudgment, preparePreEditJudgment, reducePostCandidate, reducePreEdit } from './judgment-task.js';
13
+ import { evaluateRebuildResponse, parseRebuildTask, prepareRebuildTask } from './rebuild-task.js';
14
+ import { canonicalJson, parseCanonicalJson } from './canonical-json.js';
15
+ import { finalizeLifecycle, inspectLifecycle, prepareLifecycle, recordApprovedLearning, submitSemanticVerdict, validateFinalApproval } from './lifecycle-adapter.js';
12
16
  import { buildProfile } from './voice-dna.js';
13
- const usage = 'Commands: profile, analyze, hygiene, final-check, batch-analyze, rewrite-prompt, prepare-rewrite, apply-rewrite, verify, verify-spec, learning, patterns, mcp';
17
+ import { loadApprovalContext } from './approval-context.js';
18
+ const usage = 'Commands: profile, analyze, hygiene, final-check, batch-analyze, rewrite-prompt, prepare-rewrite, apply-rewrite, prepare-judgment, reduce-judgment, prepare-rebuild, apply-rebuild, verify, verify-spec, lifecycle, learning, patterns, mcp';
19
+ const MAX_JSON_BYTES = 1024 * 1024;
14
20
  function input(path) {
15
21
  return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
16
22
  }
23
+ function parseBoundedJson(text) {
24
+ if (Buffer.byteLength(text, 'utf8') > MAX_JSON_BYTES)
25
+ throw new Error('JSON input exceeds the byte limit.');
26
+ const value = JSON.parse(text);
27
+ canonicalJson(value);
28
+ return value;
29
+ }
30
+ function readBoundedDescriptor(descriptor) {
31
+ const chunks = [];
32
+ let size = 0;
33
+ while (size <= MAX_JSON_BYTES) {
34
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_JSON_BYTES + 1 - size));
35
+ const count = readSync(descriptor, chunk, 0, chunk.length, null);
36
+ if (!count)
37
+ break;
38
+ chunks.push(chunk.subarray(0, count));
39
+ size += count;
40
+ }
41
+ if (size > MAX_JSON_BYTES)
42
+ throw new Error('JSON input exceeds the byte limit.');
43
+ return Buffer.concat(chunks, size).toString('utf8');
44
+ }
45
+ function readJson(path) {
46
+ if (path === '-')
47
+ return parseBoundedJson(readBoundedDescriptor(0));
48
+ let descriptor;
49
+ try {
50
+ descriptor = openSync(path, constants.O_RDONLY);
51
+ return parseBoundedJson(readBoundedDescriptor(descriptor));
52
+ }
53
+ finally {
54
+ if (descriptor !== undefined)
55
+ closeSync(descriptor);
56
+ }
57
+ }
58
+ function capabilityArguments(args) {
59
+ const values = [];
60
+ let source;
61
+ for (let index = 0; index < args.length; index += 1) {
62
+ if (args[index] === '--capability-stdin') {
63
+ if (source)
64
+ throw new Error('Choose one capability source.');
65
+ source = { kind: 'stdin' };
66
+ continue;
67
+ }
68
+ if (args[index] === '--capability-file') {
69
+ const path = args[index + 1];
70
+ if (source || !path || path.startsWith('--capability-'))
71
+ throw new Error('Choose one capability source.');
72
+ source = { kind: 'file', path };
73
+ index += 1;
74
+ continue;
75
+ }
76
+ values.push(args[index]);
77
+ }
78
+ if (!source)
79
+ return { values };
80
+ if (source.kind === 'stdin' && values.includes('-'))
81
+ throw new Error('Capability stdin cannot be combined with another stdin input.');
82
+ let raw;
83
+ if (source.kind === 'stdin')
84
+ raw = readBoundedDescriptor(0);
85
+ else {
86
+ let descriptor;
87
+ try {
88
+ descriptor = openSync(source.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
89
+ const before = fstatSync(descriptor);
90
+ if (!before.isFile() || before.uid !== process.geteuid?.() || (before.mode & 0o077) !== 0 || before.nlink !== 1 || before.size > MAX_JSON_BYTES)
91
+ throw new Error('Capability file is unavailable or unsafe.');
92
+ raw = readBoundedDescriptor(descriptor);
93
+ const after = fstatSync(descriptor);
94
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
95
+ throw new Error('Capability file is unavailable or unsafe.');
96
+ }
97
+ catch {
98
+ throw new Error('Capability file is unavailable or unsafe.');
99
+ }
100
+ finally {
101
+ if (descriptor !== undefined)
102
+ closeSync(descriptor);
103
+ }
104
+ }
105
+ if (Buffer.byteLength(raw, 'utf8') > MAX_JSON_BYTES)
106
+ throw new Error('JSON input exceeds the byte limit.');
107
+ return { values, capability: parseCanonicalJson(Buffer.from(raw, 'utf8')) };
108
+ }
17
109
  function readProfile(path) {
18
110
  return parseProfile(JSON.parse(input(path)));
19
111
  }
112
+ function requireProfileV3(profile) {
113
+ if (profile.version !== '3')
114
+ throw new Error('This learning operation requires a Profile v3.');
115
+ return profile;
116
+ }
117
+ function learningArguments(args) {
118
+ const values = [];
119
+ const options = {};
120
+ for (const argument of args) {
121
+ if (!argument.startsWith('--')) {
122
+ values.push(argument);
123
+ continue;
124
+ }
125
+ const [name, ...parts] = argument.slice(2).split('=');
126
+ const value = parts.join('=').trim();
127
+ if (!value)
128
+ throw new Error(`Learning option --${name} requires a value.`);
129
+ if (name === 'mutation-id' && value.length <= 200)
130
+ options.mutationId = value;
131
+ else if (name === 'authority' && ['founder', 'team', 'system'].includes(value))
132
+ options.authority = value;
133
+ else if (name === 'provenance' && value.length <= 500)
134
+ options.provenance = value;
135
+ else if (name === 'weight' && Number.isFinite(Number(value)) && Number(value) > 0)
136
+ options.weight = Number(value);
137
+ else if (name === 'compatibility' && ['same-or-newer', 'exact'].includes(value))
138
+ options.compatibility = value;
139
+ else
140
+ throw new Error(`Invalid learning option: --${name}=${value}`);
141
+ }
142
+ return { values, options };
143
+ }
20
144
  function readBrief(path) {
21
145
  return path ? parseWritingBrief(JSON.parse(input(path))) : undefined;
22
146
  }
@@ -53,6 +177,7 @@ function prepareContext(paths) {
53
177
  function json(value) {
54
178
  console.log(JSON.stringify(value, null, 2));
55
179
  }
180
+ function canonical(value) { process.stdout.write(`${canonicalJson(value)}\n`); }
56
181
  function profileArguments(args) {
57
182
  const [output, ...rest] = args;
58
183
  const samples = [];
@@ -201,6 +326,50 @@ export async function runCli(args) {
201
326
  json(result);
202
327
  return result.status === 'accepted' ? 0 : 2;
203
328
  }
329
+ if (command === 'prepare-judgment') {
330
+ const [stage, kind, draft, profilePath, output, candidatePath] = rest;
331
+ if (!stage || !kind || !draft || !profilePath || !output)
332
+ throw new Error('Usage: hyv prepare-judgment pre-edit|post-candidate kind draft.md profile.json task.json [candidate.md]');
333
+ if (stage === 'post-candidate' && !candidatePath)
334
+ throw new Error('Usage: hyv prepare-judgment post-candidate kind draft.md profile.json task.json candidate.md');
335
+ const profile = readProfile(profilePath);
336
+ const task = stage === 'pre-edit'
337
+ ? preparePreEditJudgment(input(draft), profile, kind)
338
+ : preparePostCandidateJudgment(input(draft), input(candidatePath ?? ''), profile, kind);
339
+ writeFileSync(output, `${JSON.stringify(task, null, 2)}\n`);
340
+ json({ version: task.version, stage: task.stage, judgmentType: task.judgmentType, taskFingerprint: task.taskFingerprint });
341
+ return 0;
342
+ }
343
+ if (command === 'reduce-judgment') {
344
+ if (rest.length < 3)
345
+ throw new Error('Usage: hyv reduce-judgment envelope.json envelope.json [envelope.json...]');
346
+ const envelopes = rest.map((path) => parseJudgmentEnvelope(JSON.parse(input(path))));
347
+ const stage = envelopes[0]?.stage;
348
+ json(stage === 'pre-edit' ? reducePreEdit(envelopes) : reducePostCandidate(envelopes));
349
+ return 0;
350
+ }
351
+ if (command === 'prepare-rebuild') {
352
+ const { values, capability } = capabilityArguments(rest);
353
+ const [draft, profilePath, reductionPath, specPath, output, briefPath] = values;
354
+ if (!draft || !profilePath || !reductionPath || !specPath || !output || !capability) {
355
+ throw new Error('Usage: hyv prepare-rebuild draft.md profile.json reduction.json copy-spec.json task.json [writing-brief.json] (--capability-stdin|--capability-file path)');
356
+ }
357
+ const context = loadApprovalContext();
358
+ const task = prepareRebuildTask(input(draft), readProfile(profilePath), readJson(reductionPath), parseCopySpec(JSON.parse(input(specPath))), capability, context.trustStore, context.now, briefPath ? parseWritingBrief(JSON.parse(input(briefPath))) : undefined);
359
+ writeFileSync(output, `${JSON.stringify(task, null, 2)}\n`);
360
+ json({ version: task.version, fingerprint: task.fingerprint, recommendationFingerprint: task.recommendationFingerprint, authorizationFingerprint: task.authorizationFingerprint });
361
+ return 0;
362
+ }
363
+ if (command === 'apply-rebuild') {
364
+ const { values, capability } = capabilityArguments(rest);
365
+ const [taskPath, responsePath, profilePath, ...extra] = values;
366
+ if (!taskPath || !responsePath || !profilePath || extra.length || !capability)
367
+ throw new Error('Usage: hyv apply-rebuild task.json response.json profile.json (--capability-stdin|--capability-file path)');
368
+ const context = loadApprovalContext();
369
+ const result = evaluateRebuildResponse(parseRebuildTask(JSON.parse(input(taskPath))), input(responsePath), readProfile(profilePath), capability, context.trustStore, context.now);
370
+ json(result);
371
+ return result.status === 'accepted' ? 0 : 2;
372
+ }
204
373
  if (command === 'verify') {
205
374
  const [original, candidate, profilePath, briefPath] = rest;
206
375
  if (!original || !candidate || !profilePath)
@@ -209,9 +378,6 @@ export async function runCli(args) {
209
378
  const originalText = input(original);
210
379
  const candidateText = input(candidate);
211
380
  const result = verify(originalText, candidateText, profile, readBrief(briefPath));
212
- const learning = recordVerifiedCandidate(profile, result, candidateText);
213
- if (learning === 'write_failed')
214
- console.error('Warning: verification passed, but local learning could not be saved.');
215
381
  json(result);
216
382
  return result.passed ? 0 : 2;
217
383
  }
@@ -222,35 +388,122 @@ export async function runCli(args) {
222
388
  const profile = readProfile(profilePath);
223
389
  const candidateText = input(candidate);
224
390
  const result = verifyWithCopySpec(input(original), candidateText, profile, parseCopySpec(JSON.parse(input(specPath))), readBrief(briefPath));
225
- if (result.passed) {
226
- const learning = recordVerifiedCandidate(profile, result, candidateText);
227
- if (learning === 'write_failed')
228
- console.error('Warning: verification passed, but local learning could not be saved.');
229
- }
230
391
  json(result);
231
392
  return result.passed ? 0 : 2;
232
393
  }
394
+ if (command === 'lifecycle') {
395
+ const [action, ...raw] = rest;
396
+ if (action === 'prepare-semantic') {
397
+ const [deterministicPath, bindingPath, receiptPath, policy, violationsPath, output, ...extra] = raw;
398
+ if (!deterministicPath || !bindingPath || !receiptPath || !policy || !violationsPath || !output || extra.length || !['normal', 'high_assurance'].includes(policy))
399
+ throw new Error('Usage: hyv lifecycle prepare-semantic deterministic.json binding.json receipt.json <normal|high_assurance> violations.json output.json');
400
+ if (policy === 'high_assurance')
401
+ throw new Error('High-assurance semantic review requires a trusted embedding.');
402
+ const result = prepareLifecycle(readJson(deterministicPath), readJson(bindingPath), readJson(receiptPath), policy, readJson(violationsPath));
403
+ const serialized = canonicalJson(result);
404
+ writeFileSync(output, `${serialized}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
405
+ process.stdout.write(`${serialized}\n`);
406
+ return 0;
407
+ }
408
+ if (action === 'submit-verdict') {
409
+ const [artifactPath, taskPath, evaluatorId, verdictPath, ...extra] = raw;
410
+ if (!artifactPath || !taskPath || !evaluatorId || !verdictPath || extra.length)
411
+ throw new Error('Usage: hyv lifecycle submit-verdict artifact.json task.json evaluator-id verdict.json');
412
+ const artifact = readJson(artifactPath);
413
+ const task = readJson(taskPath);
414
+ if (task.policy !== 'normal')
415
+ throw new Error('High-assurance semantic review requires a trusted embedding.');
416
+ const result = submitSemanticVerdict(artifact, task, evaluatorId, readJson(verdictPath), loadApprovalContext());
417
+ canonical(result.ok ? result.artifact : { error: result.error });
418
+ return result.ok && result.artifact.status === 'ready_for_human_review' ? 0 : 2;
419
+ }
420
+ if (action === 'inspect') {
421
+ const [artifactPath, ...extra] = raw;
422
+ if (!artifactPath || extra.length)
423
+ throw new Error('Usage: hyv lifecycle inspect artifact.json');
424
+ canonical(inspectLifecycle(readJson(artifactPath)));
425
+ return 0;
426
+ }
427
+ if (action === 'validate-final-approval' || action === 'finalize') {
428
+ const { values, capability } = capabilityArguments(raw);
429
+ if (action === 'validate-final-approval') {
430
+ const [artifactPath, ...extra] = values;
431
+ if (!artifactPath || extra.length || !capability)
432
+ throw new Error('Usage: hyv lifecycle validate-final-approval artifact.json (--capability-stdin|--capability-file path)');
433
+ const result = validateFinalApproval(readJson(artifactPath), capability, loadApprovalContext());
434
+ canonical(result);
435
+ return result.ok ? 0 : 2;
436
+ }
437
+ const [artifactPath, decisionPath, ...extra] = values;
438
+ if (!artifactPath || !decisionPath || extra.length)
439
+ throw new Error('Usage: hyv lifecycle finalize artifact.json decision.json [--capability-stdin|--capability-file path]');
440
+ const decision = readJson(decisionPath);
441
+ if (decision.decision === 'approve' && !capability)
442
+ throw new Error('Approval requires a capability.');
443
+ if (decision.decision === 'reject' && capability)
444
+ throw new Error('Rejection does not accept a capability.');
445
+ const result = finalizeLifecycle(readJson(artifactPath), decision, loadApprovalContext(), capability);
446
+ canonical(result.ok ? result.artifact : { error: result.error });
447
+ return result.ok && result.artifact.status === 'approved' ? 0 : 2;
448
+ }
449
+ throw new Error('Usage: hyv lifecycle <prepare-semantic|submit-verdict|inspect|validate-final-approval|finalize> ...');
450
+ }
233
451
  if (command === 'learning') {
234
- const [action, profilePath, ...instruction] = rest;
452
+ const [action, ...raw] = rest;
453
+ if (action === 'record-approved') {
454
+ const { values, capability } = capabilityArguments(raw);
455
+ const [readyPath, approvedPath, originalPath, candidatePath, profilePath, decisionPath, ...contextPaths] = values;
456
+ if (!readyPath || !approvedPath || !originalPath || !candidatePath || !profilePath || !decisionPath || !capability)
457
+ throw new Error('Usage: hyv learning record-approved ready.json approved.json original.md candidate.md profile.json decision.json [copy-spec.json] [writing-brief.json] (--capability-stdin|--capability-file path)');
458
+ const context = prepareContext(contextPaths);
459
+ const status = recordApprovedLearning({ ready: readJson(readyPath), approved: readJson(approvedPath), decision: readJson(decisionPath), capability, source: input(originalPath), candidate: input(candidatePath), profile: readProfile(profilePath), context: loadApprovalContext(), copySpec: context.copySpec, writingBrief: context.writingBrief });
460
+ canonical({ status });
461
+ return status === 'write_failed' ? 2 : 0;
462
+ }
463
+ const { values, options } = learningArguments(raw);
464
+ const [profilePath, ...operands] = values;
235
465
  if (!action || !profilePath)
236
- throw new Error('Usage: hyv learning <show|add|clear> profile.json [instruction]');
466
+ throw new Error('Usage: hyv learning <show|inspect|add|record|ratify|supersede|migrate|clear> profile.json [value] [options]');
237
467
  const profile = readProfile(profilePath);
238
468
  if (action === 'show') {
239
- json({ profile: profileFingerprint(profile), preferences: composeLearning(profile) });
469
+ json({ profile: profileFingerprint(profile), preferences: composeLearning(profile, options) });
240
470
  return 0;
241
471
  }
242
- if (action === 'add') {
243
- const text = instruction.join(' ').trim();
472
+ if (action === 'inspect') {
473
+ if (Object.keys(options).length)
474
+ throw new Error('Usage: hyv learning inspect profile.json');
475
+ json(inspectLearning(profile));
476
+ return 0;
477
+ }
478
+ if (action === 'add' || action === 'record') {
479
+ const text = operands.join(' ').trim();
244
480
  if (!text)
245
- throw new Error('Usage: hyv learning add profile.json "instruction"');
246
- json({ added: addLearningInstruction(profile, text) });
481
+ throw new Error('Usage: hyv learning record profile.json "instruction" [options]');
482
+ const result = recordLearningInstruction(profile, text, options);
483
+ json(action === 'add' ? { added: result.status === 'recorded' } : result);
484
+ return 0;
485
+ }
486
+ if (action === 'ratify' || action === 'supersede') {
487
+ const [eventId, ...extra] = operands;
488
+ if (!eventId || extra.length)
489
+ throw new Error(`Usage: hyv learning ${action} profile.json event-id [options]`);
490
+ json(action === 'ratify' ? ratifyLearningEvent(requireProfileV3(profile), eventId, options) : supersedeLearningEvent(requireProfileV3(profile), eventId, options));
491
+ return 0;
492
+ }
493
+ if (action === 'migrate') {
494
+ const [targetPath, ...extra] = operands;
495
+ if (!targetPath || extra.length || profile.version !== '2')
496
+ throw new Error('Usage: hyv learning migrate source-v2.json target-v3.json [options]');
497
+ json(migrateLearningV2ToV3(profile, requireProfileV3(readProfile(targetPath)), options));
247
498
  return 0;
248
499
  }
249
500
  if (action === 'clear') {
501
+ if (operands.length || Object.keys(options).length)
502
+ throw new Error('Usage: hyv learning clear profile.json');
250
503
  json({ cleared: clearLearning(profile) });
251
504
  return 0;
252
505
  }
253
- throw new Error('Usage: hyv learning <show|add|clear> profile.json [instruction]');
506
+ throw new Error('Usage: hyv learning <show|inspect|add|record|ratify|supersede|migrate|clear> profile.json [value] [options]');
254
507
  }
255
508
  if (command === 'patterns') {
256
509
  json({ version: RULESET_VERSION, rules: serializedRules() });