@holdyourvoice/hyv 3.5.1 → 3.6.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 (48) hide show
  1. package/Readme.md +5 -59
  2. package/dist/ai-editor.js +4 -20
  3. package/dist/cli/agents.js +108 -0
  4. package/dist/cli/checks.js +226 -0
  5. package/dist/cli/context.test.js +55 -0
  6. package/dist/cli/io.js +137 -0
  7. package/dist/cli/lifecycle.js +162 -0
  8. package/dist/cli/profiles.js +246 -0
  9. package/dist/cli/rewriting.js +111 -0
  10. package/dist/cli.js +5 -974
  11. package/dist/cli.test.js +1 -1
  12. package/dist/hold-your-voice.mcpb +0 -0
  13. package/dist/learning.js +16 -9
  14. package/dist/mcp-tools.js +8 -17
  15. package/dist/mcp.js +12 -18
  16. package/dist/mirror-refs.test.js +4 -4
  17. package/dist/pipeline.js +30 -31
  18. package/dist/pipeline.test.js +5 -3
  19. package/dist/rebuild-task.js +4 -27
  20. package/dist/rebuild-task.test.js +1 -1
  21. package/dist/rewrite-response.js +27 -0
  22. package/dist/rewrite-task.js +9 -35
  23. package/dist/rewrite-task.test.js +10 -9
  24. package/dist/strict-quality.js +1 -2
  25. package/dist/strict-quality.test.js +3 -3
  26. package/dist/version.js +1 -1
  27. package/package.json +1 -1
  28. package/skills/hyv-analyze/SKILL.md +1 -1
  29. package/skills/hyv-analyze/agents/openai.yaml +1 -1
  30. package/skills/hyv-apply-rebuild/SKILL.md +1 -0
  31. package/skills/hyv-apply-rebuild/agents/openai.yaml +1 -1
  32. package/skills/hyv-apply-rewrite/SKILL.md +1 -0
  33. package/skills/hyv-apply-rewrite/agents/openai.yaml +1 -1
  34. package/skills/hyv-prepare-rebuild/SKILL.md +1 -0
  35. package/skills/hyv-prepare-rebuild/agents/openai.yaml +1 -1
  36. package/skills/hyv-prepare-rewrite/SKILL.md +1 -0
  37. package/skills/hyv-prepare-rewrite/agents/openai.yaml +1 -1
  38. package/skills/hyv-rebuild-writer-request/SKILL.md +1 -0
  39. package/skills/hyv-rebuild-writer-request/agents/openai.yaml +1 -1
  40. package/skills/hyv-rewrite-prompt/SKILL.md +4 -3
  41. package/skills/hyv-rewrite-prompt/agent.json +1 -1
  42. package/skills/hyv-rewrite-prompt/agents/openai.yaml +2 -2
  43. package/skills/hyv-strict-check/SKILL.md +3 -3
  44. package/skills/hyv-verify/SKILL.md +2 -1
  45. package/skills/hyv-verify/agents/openai.yaml +1 -1
  46. package/skills/hyv-verify-spec/SKILL.md +3 -2
  47. package/skills/hyv-verify-spec/agent.json +1 -1
  48. package/skills/hyv-verify-spec/agents/openai.yaml +2 -2
package/Readme.md CHANGED
@@ -13,63 +13,7 @@ The package also checks hidden Unicode, source-backed facts, document logic, and
13
13
 
14
14
  ## how it works
15
15
 
16
- ```mermaid
17
- flowchart TD
18
- samples["Writing samples"] --> profile["Local VoiceDNA profile"]
19
- draft["Draft"] --> analyze["hyv analyze"]
20
- profile --> analyze
21
- brief["Optional WritingBrief"] -.-> analyze
22
-
23
- subgraph inspect["1 · inspect the draft"]
24
- analyze --> voice["VoiceDNA check"]
25
- analyze --> patterns["AI pattern lint"]
26
- analyze --> hidden["Hidden-text / Unicode check"]
27
- voice --> local{"Local result"}
28
- patterns --> local
29
- hidden -.-> local
30
- end
31
-
32
- local -->|No blocking change| candidate["Candidate text"]
33
- local -->|Blocking edit scope| editTask["Prepare fingerprint-bound edit task"]
34
- local -->|Judgment required| judgment["Prepare and reduce judgments"]
35
- judgment --> route{"SHIP, EDIT, or REBUILD?"}
36
- route -->|SHIP| candidate
37
- route -->|EDIT| editTask
38
- route -->|REBUILD| authorization["REBUILD recommendation + CopySpec + signed authorization"]
39
- authorization --> rebuildTask["Prepare fingerprint-bound rebuild task"]
40
- editTask --> editor["Human editor or model you choose"]
41
- rebuildTask --> editor
42
- editor --> response["Bound response"]
43
-
44
- brief -.-> logic
45
- sources["Optional fact sources in WritingBrief"] -.-> facts
46
- spec["Optional for verify-spec; required for rebuild"] -.-> authorization
47
- spec -.-> standard
48
-
49
- subgraph verification["2 · verification gate"]
50
- candidate --> standard["hyv verify / verify-spec"]
51
- response --> mode{"Bound task mode"}
52
- mode -->|EDIT| editApply["apply-rewrite + standard verification"]
53
- mode -->|REBUILD| rebuildApply["apply-rebuild + rebuild verification"]
54
- standard --> standardRules["Preservation gate + CopySpec claims when supplied"]
55
- editApply --> standardRules
56
- rebuildApply --> rebuildRules["CopySpec claims; preservation reported"]
57
- standardRules --> engines["VoiceDNA + AI Editor checks and blocking regressions"]
58
- rebuildRules --> engines
59
- engines --> logic["Logic lint"]
60
- logic --> facts["Fact lint when sources are supplied"]
61
- facts --> outputGate["Hidden-text + final-output gate"]
62
- outputGate --> passed{"All required checks pass?"}
63
- end
64
-
65
- passed -->|No| repair["Repair externally or prepare a new task"]
66
- repair --> analyze
67
- passed -->|Yes| review["Semantic review and human approval, when required"]
68
- review --> final["Run final-check after the last change"]
69
- final --> output["Exact accepted text"]
70
- ```
71
-
72
- HYV keeps draft inspection, candidate verification, and final delivery separate. Standard verification reruns VoiceDNA and AI Editor, rejects blocking regressions, enforces preservation, runs logic lint, applies fact lint when a WritingBrief supplies sources, and withholds hidden-text failures. `verify-spec` adds CopySpec claim checks. Authorized rebuilds require an upstream REBUILD recommendation, a CopySpec, and signed authorization; their verification reports preservation without using the standard preservation threshold. Run `final-check` again after the last human, model, formatter, or template change. HYV never calls a model; a human editor or model you choose supplies edits and judgments.
16
+ inspect the draft, let your editor make the changes, then verify the candidate and check the exact output. the [architecture guide](docs/ARCHITECTURE.md#writing-workflow) shows the full flow, including judgments, authorized rebuilds, and approval gates.
73
17
 
74
18
  ## install
75
19
 
@@ -131,7 +75,7 @@ producer | hyv final-check -
131
75
  | `hyv score <draft> <profile-v3.json> <heldout...>` | Measure the draft against a separate held-out local writing range; may abstain. |
132
76
  | `hyv ingest <source> <export> --owner=owner --output=/absolute/dir` | Create redacted, owner-authorized Gmail or Telegram samples and a text-free receipt. |
133
77
  | `hyv analyze <draft> <profile.json>` | Run VoiceDNA, AI Editor, and hygiene checks. |
134
- | `hyv strict-check <draft> <profile-v3.json> <sample...>` | Run the opt-in strict local quality gate. It requires a calibrated V3 profile built from five or more samples, five non-duplicate samples with a consistent visible format and 1,500 words total, and returns `strict-ready`, `needs-human-review`, or `blocked`. |
78
+ | `hyv strict-check <draft> <profile-v3.json> <sample...>` | Run the calibrated local strict-quality gate. It requires a V3 profile built from five or more samples, five non-duplicate validation samples with a consistent visible format and 1,500 words total, and returns `strict-ready`, `needs-human-review`, or `blocked`. |
135
79
  | `hyv hygiene <draft> [--fix]` | Inspect hidden Unicode or write a conservative cleaned copy. |
136
80
  | `hyv inspect-hidden-text <draft> [policy.json]` | Inspect hidden text with an optional policy. |
137
81
  | `hyv apply-hidden-text-policy <draft> <policy.json> <output>` | Apply approved hidden-text removals. |
@@ -143,7 +87,7 @@ producer | hyv final-check -
143
87
  | `hyv fact-lint <draft\|-> --source=id:path` | Check claims against local source files. |
144
88
  | `hyv logic-lint <draft\|-> [brief.json]` | Check deterministic document logic. |
145
89
  | `hyv batch-analyze <draft...>` | Find repeated openings and endings across drafts. |
146
- | `hyv rewrite-prompt <draft> <profile.json>` | Create a constrained editing brief. |
90
+ | `hyv rewrite-prompt <draft> <profile.json>` | Create a strict constrained editing brief. |
147
91
  | `hyv prepare-rewrite ...` | Create a fingerprint-bound edit task. |
148
92
  | `hyv apply-rewrite ...` | Apply and verify a response to an edit task. |
149
93
  | `hyv prepare-judgment ...` | Create a pre-edit or post-candidate judgment task. |
@@ -159,6 +103,8 @@ producer | hyv final-check -
159
103
  | `hyv agent list\|validate\|describe\|emit` | Inspect or emit portable agent contracts. |
160
104
  | `hyv mcp` | Start the local MCP server on standard input/output. |
161
105
 
106
+ `rewrite-prompt`, `prepare-rewrite`, `apply-rewrite`, and `verify` are strict by default: every active AI Editor finding must be repaired, and verification rejects a candidate that leaves one unresolved. A Profile v3 `disabled` policy is the only deliberate exception. `strict-check` adds the separate calibrated voice-evidence decision.
107
+
162
108
  Most commands return JSON. Exit code `0` means the command completed, `2` means a content or policy gate failed, and `1` means the command or input was invalid.
163
109
 
164
110
  Run `hyv <command>` without enough arguments to see its exact usage. Read the [CLI reference](docs/wiki/CLI-Reference.md) for every option.
package/dist/ai-editor.js CHANGED
@@ -104,11 +104,11 @@ export function serializedRules() {
104
104
  scope: rule.scope ?? 'sentence',
105
105
  }));
106
106
  }
107
- function documentFinding(rule, sentence) {
107
+ function ruleFinding(rule, sentence) {
108
108
  return { engine: 'ai_editor', id: rule.id, severity: rule.severity, sentence: sentence.index, excerpt: sentence.text, reason: rule.reason, suggestion: rule.suggestion };
109
109
  }
110
110
  function documentMatches(rule, prose, mapped) {
111
- const at = (index) => mapped[index] ? [documentFinding(rule, mapped[index])] : [];
111
+ const at = (index) => mapped[index] ? [ruleFinding(rule, mapped[index])] : [];
112
112
  if (rule.id === 'ai.repeated-sentence-opening') {
113
113
  for (let index = 0; index + 2 < mapped.length; index += 1) {
114
114
  const opening = mapped[index].text.match(/^\s*(\p{L}+)/u)?.[1]?.toLocaleLowerCase();
@@ -161,15 +161,7 @@ export function analyzeAiEditor(text, profile) {
161
161
  for (const sentence of mapped) {
162
162
  for (const rule of sentenceRules) {
163
163
  if (rule.expression.test(prose.slice(sentence.start, sentence.end))) {
164
- matched.push({
165
- engine: 'ai_editor',
166
- id: rule.id,
167
- severity: rule.severity,
168
- sentence: sentence.index,
169
- excerpt: sentence.text,
170
- reason: rule.reason,
171
- suggestion: rule.suggestion,
172
- });
164
+ matched.push(ruleFinding(rule, sentence));
173
165
  }
174
166
  }
175
167
  }
@@ -184,15 +176,7 @@ export function analyzeAiEditor(text, profile) {
184
176
  ?? mapped.find((candidate) => candidate.start >= lineStart && candidate.start < lineStart + line.length);
185
177
  if (!sentence)
186
178
  continue;
187
- matched.push({
188
- engine: 'ai_editor',
189
- id: rule.id,
190
- severity: rule.severity,
191
- sentence: sentence.index,
192
- excerpt: sentence.text,
193
- reason: rule.reason,
194
- suggestion: rule.suggestion,
195
- });
179
+ matched.push(ruleFinding(rule, sentence));
196
180
  }
197
181
  lineStart += line.length + 1;
198
182
  }
@@ -0,0 +1,108 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { loadAll, validateAll, validateId, sortedIds, describe, emitJson, emitPrompt } from '../agents/index.js';
3
+ import { json } from './io.js';
4
+ function agentFlags(args) {
5
+ const values = [];
6
+ let host = 'generic';
7
+ let mode;
8
+ let output;
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const argument = args[index];
11
+ if (argument === '--host') {
12
+ const value = args[index + 1];
13
+ if (!value || value.startsWith('--'))
14
+ throw new Error('Usage: hyv agent --host HOST requires a value.');
15
+ host = value;
16
+ index += 1;
17
+ }
18
+ else if (argument.startsWith('--host=')) {
19
+ const value = argument.slice('--host='.length);
20
+ if (!value)
21
+ throw new Error('Usage: hyv agent --host HOST requires a value.');
22
+ host = value;
23
+ }
24
+ else if (argument === '--mode') {
25
+ const value = args[index + 1];
26
+ if (value !== 'prompt' && value !== 'json')
27
+ throw new Error('Usage: hyv agent --mode prompt|json requires a mode.');
28
+ mode = value;
29
+ index += 1;
30
+ }
31
+ else if (argument.startsWith('--mode=')) {
32
+ const value = argument.slice('--mode='.length);
33
+ if (value !== 'prompt' && value !== 'json')
34
+ throw new Error('Usage: hyv agent --mode prompt|json requires a mode.');
35
+ mode = value;
36
+ }
37
+ else if (argument === '--output') {
38
+ const value = args[index + 1];
39
+ if (!value || value.startsWith('--'))
40
+ throw new Error('Usage: hyv agent --output FILE requires a value.');
41
+ output = value;
42
+ index += 1;
43
+ }
44
+ else if (argument.startsWith('--output=')) {
45
+ const value = argument.slice('--output='.length);
46
+ if (!value)
47
+ throw new Error('Usage: hyv agent --output FILE requires a value.');
48
+ output = value;
49
+ }
50
+ else {
51
+ values.push(argument);
52
+ }
53
+ }
54
+ return { values, host, mode, output };
55
+ }
56
+ export function runAgent(args) {
57
+ const [subcommand, ...subargs] = args;
58
+ if (subcommand === 'list') {
59
+ if (subargs.length)
60
+ throw new Error('Usage: hyv agent list');
61
+ const packages = loadAll();
62
+ const ids = sortedIds(packages);
63
+ json(ids.map((id) => {
64
+ const descriptor = packages.get(id).descriptor;
65
+ return { id, role: descriptor.role, workflow_phase: descriptor.workflow_phase, description: descriptor.description };
66
+ }));
67
+ return 0;
68
+ }
69
+ if (subcommand === 'validate') {
70
+ if (subargs.length > 1)
71
+ throw new Error('Usage: hyv agent validate [id]');
72
+ const packages = loadAll();
73
+ const id = subargs[0];
74
+ if (id !== undefined)
75
+ validateId(packages, id);
76
+ validateAll(packages);
77
+ json({ schema_version: '1.0.0', status: 'PASS', agent: id ?? 'all' });
78
+ return 0;
79
+ }
80
+ if (subcommand === 'describe') {
81
+ const { values, host, mode, output } = agentFlags(subargs);
82
+ if (values.length !== 1 || mode !== undefined || output !== undefined)
83
+ throw new Error('Usage: hyv agent describe <id> [--host HOST]');
84
+ const packages = loadAll();
85
+ validateId(packages, values[0]);
86
+ json(describe(packages.get(values[0]), host));
87
+ return 0;
88
+ }
89
+ if (subcommand === 'emit') {
90
+ const { values, host, mode, output } = agentFlags(subargs);
91
+ if (values.length !== 1)
92
+ throw new Error('Usage: hyv agent emit <id> --mode prompt|json [--host HOST] [--output FILE]');
93
+ if (!mode)
94
+ throw new Error('Usage: hyv agent emit <id> --mode prompt|json [--host HOST] [--output FILE]');
95
+ const packages = loadAll();
96
+ validateId(packages, values[0]);
97
+ const pkg = packages.get(values[0]);
98
+ const body = mode === 'prompt' ? emitPrompt(pkg, host) : `${emitJson(pkg, host)}\n`;
99
+ if (output !== undefined) {
100
+ writeFileSync(output, body, { encoding: 'utf8', flag: 'wx' });
101
+ }
102
+ else {
103
+ process.stdout.write(body);
104
+ }
105
+ return 0;
106
+ }
107
+ throw new Error('Usage: hyv agent <list|validate|describe|emit> ...');
108
+ }
@@ -0,0 +1,226 @@
1
+ import { linkSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, extname, join, resolve } from 'node:path';
3
+ import { RULESET_VERSION, serializedRules } from '../ai-editor.js';
4
+ import { parseCopySpec } from '../copy-spec.js';
5
+ import { analyzeBatch } from '../editorial-packs.js';
6
+ import { cleanHygiene, finalOutputCheck, inspectHygiene } from '../hygiene.js';
7
+ import { applyHiddenTextPolicy, inspectHiddenText, parseHiddenTextPolicy } from '../hidden-text.js';
8
+ import { analyze, verify, verifyWithCopySpec } from '../pipeline.js';
9
+ import { formatFactLintReport, lintFacts } from '../fact-linter.js';
10
+ import { lintLogic } from '../logic-linter.js';
11
+ import { inspectDeliveryIntegrity, parseDeliveryIntegrityPolicy } from '../delivery-integrity.js';
12
+ import { evaluateStrictQuality } from '../strict-quality.js';
13
+ import { normalizeFinding, parseSurfacePolicy } from '../disposition.js';
14
+ import { input, readJson, readProfile, readBrief, json } from './io.js';
15
+ function cleanedPath(path) {
16
+ const extension = extname(path);
17
+ const stem = extension ? path.slice(0, -extension.length) : path;
18
+ return `${stem}.cleaned${extension}`;
19
+ }
20
+ function hygieneArguments(args) {
21
+ const [path, ...options] = args;
22
+ if (!path)
23
+ throw new Error('Usage: hyv hygiene draft.md [--fix] [--output=cleaned.md]');
24
+ let fix = false;
25
+ let output;
26
+ for (const option of options) {
27
+ if (option === '--fix')
28
+ fix = true;
29
+ else if (option.startsWith('--output='))
30
+ output = option.slice('--output='.length).trim();
31
+ else
32
+ throw new Error('Usage: hyv hygiene draft.md [--fix] [--output=cleaned.md]');
33
+ }
34
+ if (output !== undefined && (!output || !fix))
35
+ throw new Error('--output requires --fix and a non-empty path.');
36
+ return { path, fix, ...(output ? { output } : {}) };
37
+ }
38
+ function writeNewFileAtomically(path, text) {
39
+ const temporaryDirectory = mkdtempSync(join(dirname(resolve(path)), '.hyv-hygiene-'));
40
+ const temporaryPath = join(temporaryDirectory, 'cleaned');
41
+ let primaryError;
42
+ try {
43
+ writeFileSync(temporaryPath, text, 'utf8');
44
+ try {
45
+ linkSync(temporaryPath, path);
46
+ }
47
+ catch (error) {
48
+ const code = error.code;
49
+ if (!['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV'].includes(code ?? ''))
50
+ throw error;
51
+ throw new Error(`Atomic hygiene output is not supported by this filesystem: ${path}`);
52
+ }
53
+ }
54
+ catch (error) {
55
+ primaryError = error.code === 'EEXIST' ? new Error(`Hygiene output already exists: ${path}`) : error;
56
+ }
57
+ try {
58
+ rmSync(temporaryDirectory, { recursive: true, force: true });
59
+ }
60
+ catch (error) {
61
+ if (!primaryError)
62
+ console.error(`Warning: output was published, but temporary-file cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
63
+ }
64
+ if (primaryError)
65
+ throw primaryError;
66
+ }
67
+ export function runDeliveryCheck(args) {
68
+ const [path, policyPath, ...extra] = args;
69
+ if (!path || extra.length)
70
+ throw new Error('Usage: hyv delivery-check <path|-> [policy.json]');
71
+ const report = inspectDeliveryIntegrity(input(path), policyPath ? parseDeliveryIntegrityPolicy(readJson(policyPath)) : undefined, process.cwd());
72
+ json(report);
73
+ return report.passed ? 0 : 2;
74
+ }
75
+ export function runAnalyze(args) {
76
+ const [draft, profilePath, briefPath] = args;
77
+ if (!draft || !profilePath)
78
+ throw new Error('Usage: hyv analyze draft.md profile.json [writing-brief.json]');
79
+ json(analyze(input(draft), readProfile(profilePath), readBrief(briefPath)));
80
+ return 0;
81
+ }
82
+ export function runStrictCheck(args) {
83
+ const [draft, profilePath, ...samplePaths] = args;
84
+ if (!draft || !profilePath || samplePaths.length < 2)
85
+ throw new Error('Usage: hyv strict-check draft.md profile-v3.json sample-a.md sample-b.md [sample-c.md ...]');
86
+ const report = evaluateStrictQuality(input(draft), readProfile(profilePath), samplePaths.map(input));
87
+ json(report);
88
+ return report.disposition === 'strict-ready' ? 0 : 2;
89
+ }
90
+ export function runHygiene(args) {
91
+ const { path, fix, output } = hygieneArguments(args);
92
+ if (fix && path === '-')
93
+ throw new Error('hyv hygiene --fix requires a file path so the original can be preserved.');
94
+ const text = input(path);
95
+ if (!fix) {
96
+ json(inspectHygiene(text));
97
+ return 0;
98
+ }
99
+ const outputPath = output ?? cleanedPath(path);
100
+ if (resolve(outputPath) === resolve(path))
101
+ throw new Error('Hygiene output must differ from the input path.');
102
+ const result = cleanHygiene(text);
103
+ writeNewFileAtomically(outputPath, result.cleaned);
104
+ json({ ...result.report, changed: result.changed, changes: result.changes, outputPath });
105
+ return 0;
106
+ }
107
+ export function runInspectHiddenText(args) {
108
+ const [path, policyPath, ...extra] = args;
109
+ if (!path || extra.length)
110
+ throw new Error('Usage: hyv inspect-hidden-text draft.md [policy.json]');
111
+ json(inspectHiddenText(input(path), policyPath ? parseHiddenTextPolicy(readJson(policyPath)) : undefined));
112
+ return 0;
113
+ }
114
+ export function runApplyHiddenTextPolicy(args) {
115
+ const [path, policyPath, output, ...extra] = args;
116
+ if (!path || !policyPath || !output || extra.length)
117
+ throw new Error('Usage: hyv apply-hidden-text-policy draft.md policy.json output.md');
118
+ if (path === '-' || resolve(path) === resolve(output))
119
+ throw new Error('Hidden-text output must differ from the input path.');
120
+ const result = applyHiddenTextPolicy(input(path), parseHiddenTextPolicy(readJson(policyPath)));
121
+ writeNewFileAtomically(output, result.output);
122
+ json({ ...result, outputPath: output });
123
+ return 0;
124
+ }
125
+ export function runFinalCheck(args) {
126
+ const [path, ...options] = args;
127
+ if (!path || options.length)
128
+ throw new Error('Usage: hyv final-check <path|->');
129
+ const result = finalOutputCheck(input(path));
130
+ if (!result.accepted) {
131
+ console.error(JSON.stringify(result, null, 2));
132
+ return 2;
133
+ }
134
+ if (result.changed)
135
+ console.error(JSON.stringify({ changed: true, changes: result.changes }, null, 2));
136
+ process.stdout.write(result.output);
137
+ return 0;
138
+ }
139
+ export function runFactLint(args) {
140
+ const [draftPath, ...options] = args;
141
+ const sources = [];
142
+ let metadata;
143
+ let strict = false;
144
+ let human = false;
145
+ if (!draftPath)
146
+ throw new Error('Usage: hyv fact-lint <draft|-> --source=id:path [--source=id:path] [--metadata=metadata.json] [--strict] [--human]');
147
+ for (const option of options) {
148
+ if (option === '--strict') {
149
+ strict = true;
150
+ continue;
151
+ }
152
+ if (option === '--human') {
153
+ human = true;
154
+ continue;
155
+ }
156
+ if (option.startsWith('--source=')) {
157
+ const value = option.slice('--source='.length);
158
+ const separator = value.indexOf(':');
159
+ const id = value.slice(0, separator).trim();
160
+ const path = value.slice(separator + 1);
161
+ if (separator < 1 || !id || !path)
162
+ throw new Error('Sources must use --source=id:path.');
163
+ sources.push({ id, text: input(path) });
164
+ continue;
165
+ }
166
+ if (option.startsWith('--metadata=')) {
167
+ metadata = JSON.parse(input(option.slice('--metadata='.length)));
168
+ continue;
169
+ }
170
+ throw new Error('Usage: hyv fact-lint <draft|-> --source=id:path [--source=id:path] [--metadata=metadata.json] [--strict] [--human]');
171
+ }
172
+ const report = lintFacts({ sources, draft: input(draftPath), metadata });
173
+ if (human)
174
+ console.log(formatFactLintReport(report));
175
+ else
176
+ json(report);
177
+ return strict && report.findings.some((item) => item.severity === 'error') ? 2 : 0;
178
+ }
179
+ export function runLogicLint(args) {
180
+ const [draftPath, briefPath, ...extra] = args;
181
+ if (!draftPath || extra.length)
182
+ throw new Error('Usage: hyv logic-lint <draft|-> [writing-brief.json]');
183
+ const report = lintLogic(input(draftPath), readBrief(briefPath));
184
+ json(report);
185
+ return report.passed ? 0 : 2;
186
+ }
187
+ export function runBatchAnalyze(args) {
188
+ if (args.length < 2)
189
+ throw new Error('Usage: hyv batch-analyze draft-a.md draft-b.md [draft-c.md]');
190
+ json(analyzeBatch(args.map(input)));
191
+ return 0;
192
+ }
193
+ export function runVerify(args) {
194
+ const [original, candidate, profilePath, briefPath] = args;
195
+ if (!original || !candidate || !profilePath)
196
+ throw new Error('Usage: hyv verify original.md candidate.md profile.json [writing-brief.json]');
197
+ const profile = readProfile(profilePath);
198
+ const originalText = input(original);
199
+ const candidateText = input(candidate);
200
+ const result = verify(originalText, candidateText, profile, readBrief(briefPath));
201
+ json(result);
202
+ return result.passed ? 0 : 2;
203
+ }
204
+ export function runVerifySpec(args) {
205
+ const [original, candidate, profilePath, specPath, briefPath] = args;
206
+ if (!original || !candidate || !profilePath || !specPath)
207
+ throw new Error('Usage: hyv verify-spec original.md candidate.md profile.json copy-spec.json [writing-brief.json]');
208
+ const profile = readProfile(profilePath);
209
+ const candidateText = input(candidate);
210
+ const result = verifyWithCopySpec(input(original), candidateText, profile, parseCopySpec(JSON.parse(input(specPath))), readBrief(briefPath));
211
+ json(result);
212
+ return result.passed ? 0 : 2;
213
+ }
214
+ export function runPatterns() {
215
+ json({ version: RULESET_VERSION, rules: serializedRules() });
216
+ return 0;
217
+ }
218
+ export function runDispositions(args) {
219
+ const [draft, profilePath, briefPath, surfacePolicyPath] = args;
220
+ if (!draft || !profilePath)
221
+ throw new Error('Usage: hyv dispositions draft.md profile.json [writing-brief.json]');
222
+ const report = analyze(input(draft), readProfile(profilePath), readBrief(briefPath));
223
+ const policy = surfacePolicyPath ? parseSurfacePolicy(readJson(surfacePolicyPath)) : undefined;
224
+ json({ version: '1', findings: [report.voiceDna, report.aiEditor, report.editorial].flatMap((engine) => engine?.findings.map((finding) => normalizeFinding(finding, policy)) ?? []) });
225
+ return 0;
226
+ }
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { buildProfile } from '../voice-dna.js';
8
+ test('prepare-rewrite recognizes contexts in either order and rejects duplicates before writing', () => {
9
+ const directory = mkdtempSync(join(tmpdir(), 'hyv-cli-context-'));
10
+ try {
11
+ const draft = join(directory, 'draft.md');
12
+ const profile = join(directory, 'profile.json');
13
+ const task = join(directory, 'task.json');
14
+ const spec = join(directory, 'spec.json');
15
+ const brief = join(directory, 'brief.json');
16
+ const ambiguous = join(directory, 'ambiguous.json');
17
+ const invalid = join(directory, 'invalid.json');
18
+ const copySpec = { version: '1', audience: 'writers', intent: 'explain', channel: 'general', claims: [{ id: 'mechanism', text: 'I keep the mechanism clear.', evidence: 'The source draft.' }] };
19
+ const writingBrief = { version: '1', audience: 'writers', intent: 'explain', format: 'general' };
20
+ writeFileSync(draft, 'I keep the mechanism clear.');
21
+ writeFileSync(profile, JSON.stringify(buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'])));
22
+ writeFileSync(spec, JSON.stringify(copySpec));
23
+ writeFileSync(brief, JSON.stringify(writingBrief));
24
+ writeFileSync(ambiguous, JSON.stringify({ ...copySpec, ...writingBrief }));
25
+ writeFileSync(invalid, '{}');
26
+ const run = (contexts) => spawnSync(process.execPath, [new URL('../cli.js', import.meta.url).pathname, 'prepare-rewrite', draft, profile, task, ...contexts], { encoding: 'utf8' });
27
+ for (const contexts of [[spec, brief], [brief, spec]]) {
28
+ const result = run(contexts);
29
+ assert.equal(result.status, 0, result.stderr);
30
+ const prepared = JSON.parse(readFileSync(task, 'utf8'));
31
+ assert.deepEqual(prepared.copySpec, copySpec);
32
+ assert.deepEqual(prepared.writingBrief, writingBrief);
33
+ }
34
+ const result = run([ambiguous]);
35
+ assert.equal(result.status, 0, result.stderr);
36
+ const prepared = JSON.parse(readFileSync(task, 'utf8'));
37
+ assert.deepEqual(prepared.copySpec, { ...copySpec, ...writingBrief });
38
+ assert.equal(prepared.writingBrief, undefined);
39
+ for (const [contexts, error] of [
40
+ [[spec, spec], 'Prepare-rewrite accepts at most one CopySpec.'],
41
+ [[brief, brief], 'Prepare-rewrite accepts at most one WritingBrief.'],
42
+ [[invalid], `Expected a valid CopySpec or WritingBrief at ${invalid}.`],
43
+ ]) {
44
+ const before = readFileSync(task, 'utf8');
45
+ const rejected = run([...contexts]);
46
+ assert.equal(rejected.status, 1);
47
+ assert.equal(rejected.stdout, '');
48
+ assert.equal(rejected.stderr, `${error}\n`);
49
+ assert.equal(readFileSync(task, 'utf8'), before);
50
+ }
51
+ }
52
+ finally {
53
+ rmSync(directory, { recursive: true, force: true });
54
+ }
55
+ });
package/dist/cli/io.js ADDED
@@ -0,0 +1,137 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs';
2
+ import { parseCopySpec } from '../copy-spec.js';
3
+ import { parseWritingBrief } from '../editorial-packs.js';
4
+ import { parseProfile } from '../profile.js';
5
+ import { canonicalJson, parseCanonicalJson } from '../canonical-json.js';
6
+ import { MAX_JSON_BYTES } from '../internal.js';
7
+ export function input(path) {
8
+ return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
9
+ }
10
+ function parseBoundedJson(text) {
11
+ if (Buffer.byteLength(text, 'utf8') > MAX_JSON_BYTES)
12
+ throw new Error('JSON input exceeds the byte limit.');
13
+ const value = JSON.parse(text);
14
+ canonicalJson(value);
15
+ return value;
16
+ }
17
+ function readBoundedDescriptor(descriptor) {
18
+ const chunks = [];
19
+ let size = 0;
20
+ while (size <= MAX_JSON_BYTES) {
21
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_JSON_BYTES + 1 - size));
22
+ const count = readSync(descriptor, chunk, 0, chunk.length, null);
23
+ if (!count)
24
+ break;
25
+ chunks.push(chunk.subarray(0, count));
26
+ size += count;
27
+ }
28
+ if (size > MAX_JSON_BYTES)
29
+ throw new Error('JSON input exceeds the byte limit.');
30
+ return Buffer.concat(chunks, size).toString('utf8');
31
+ }
32
+ export function readJson(path) {
33
+ if (path === '-')
34
+ return parseBoundedJson(readBoundedDescriptor(0));
35
+ let descriptor;
36
+ try {
37
+ descriptor = openSync(path, constants.O_RDONLY);
38
+ return parseBoundedJson(readBoundedDescriptor(descriptor));
39
+ }
40
+ finally {
41
+ if (descriptor !== undefined)
42
+ closeSync(descriptor);
43
+ }
44
+ }
45
+ export function capabilityArguments(args) {
46
+ const values = [];
47
+ let source;
48
+ for (let index = 0; index < args.length; index += 1) {
49
+ if (args[index] === '--capability-stdin') {
50
+ if (source)
51
+ throw new Error('Choose one capability source.');
52
+ source = { kind: 'stdin' };
53
+ continue;
54
+ }
55
+ if (args[index] === '--capability-file') {
56
+ const path = args[index + 1];
57
+ if (source || !path || path.startsWith('--capability-'))
58
+ throw new Error('Choose one capability source.');
59
+ source = { kind: 'file', path };
60
+ index += 1;
61
+ continue;
62
+ }
63
+ values.push(args[index]);
64
+ }
65
+ if (!source)
66
+ return { values };
67
+ if (source.kind === 'stdin' && values.includes('-'))
68
+ throw new Error('Capability stdin cannot be combined with another stdin input.');
69
+ let raw;
70
+ if (source.kind === 'stdin')
71
+ raw = readBoundedDescriptor(0);
72
+ else {
73
+ let descriptor;
74
+ try {
75
+ descriptor = openSync(source.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
76
+ const before = fstatSync(descriptor);
77
+ if (!before.isFile() || before.uid !== process.geteuid?.() || (before.mode & 0o077) !== 0 || before.nlink !== 1 || before.size > MAX_JSON_BYTES)
78
+ throw new Error('Capability file is unavailable or unsafe.');
79
+ raw = readBoundedDescriptor(descriptor);
80
+ const after = fstatSync(descriptor);
81
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
82
+ throw new Error('Capability file is unavailable or unsafe.');
83
+ }
84
+ catch {
85
+ throw new Error('Capability file is unavailable or unsafe.');
86
+ }
87
+ finally {
88
+ if (descriptor !== undefined)
89
+ closeSync(descriptor);
90
+ }
91
+ }
92
+ if (Buffer.byteLength(raw, 'utf8') > MAX_JSON_BYTES)
93
+ throw new Error('JSON input exceeds the byte limit.');
94
+ return { values, capability: parseCanonicalJson(Buffer.from(raw, 'utf8')) };
95
+ }
96
+ export function readProfile(path) {
97
+ return parseProfile(JSON.parse(input(path)));
98
+ }
99
+ export function readBrief(path) {
100
+ return path ? parseWritingBrief(JSON.parse(input(path))) : undefined;
101
+ }
102
+ function tryParse(parse, value) {
103
+ try {
104
+ return parse(value);
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ export function prepareContext(paths) {
111
+ let copySpec;
112
+ let writingBrief;
113
+ for (const path of paths) {
114
+ const value = JSON.parse(input(path));
115
+ const parsedCopySpec = tryParse(parseCopySpec, value);
116
+ if (parsedCopySpec) {
117
+ if (copySpec)
118
+ throw new Error('Prepare-rewrite accepts at most one CopySpec.');
119
+ copySpec = parsedCopySpec;
120
+ continue;
121
+ }
122
+ const parsedBrief = tryParse(parseWritingBrief, value);
123
+ if (!parsedBrief)
124
+ throw new Error(`Expected a valid CopySpec or WritingBrief at ${path}.`);
125
+ if (writingBrief)
126
+ throw new Error('Prepare-rewrite accepts at most one WritingBrief.');
127
+ writingBrief = parsedBrief;
128
+ }
129
+ return { copySpec, writingBrief };
130
+ }
131
+ export function json(value) {
132
+ console.log(JSON.stringify(value, null, 2));
133
+ }
134
+ export function canonical(value) { process.stdout.write(`${canonicalJson(value)}\n`); }
135
+ export function writeJson(path, value) {
136
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
137
+ }