@holdyourvoice/hyv 3.3.0 → 3.3.2
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 +84 -13
- package/dist/cli.js +92 -6
- package/dist/cli.test.js +54 -2
- package/dist/editorial-packs.js +35 -1
- package/dist/fact-linter.js +191 -0
- package/dist/fact-linter.test.js +85 -0
- package/dist/hidden-text.js +77 -0
- package/dist/hidden-text.test.js +26 -0
- package/dist/hygiene.js +8 -1
- package/dist/hygiene.test.js +15 -5
- package/dist/mcp-tools.js +13 -3
- package/dist/mcp-tools.test.js +15 -2
- package/dist/mcp.js +22 -2
- package/dist/mcp.test.js +6 -3
- package/dist/pipeline.js +33 -3
- package/dist/pipeline.test.js +46 -1
- package/dist/provenance-status.js +15 -0
- package/dist/provenance-status.test.js +22 -0
- package/dist/rebuild-task.js +37 -7
- package/dist/rebuild-task.test.js +28 -1
- package/dist/recomposition.js +97 -0
- package/dist/recomposition.test.js +34 -0
- package/dist/rewrite-task.js +11 -5
- package/dist/rewrite-task.test.js +15 -0
- package/dist/text-provenance.feature.test.js +45 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -14,7 +14,7 @@ Those programs keep separate findings, scores, and pass states. A strong result
|
|
|
14
14
|
|
|
15
15
|
Everything in the CLI runs from local files: accounts, API calls, telemetry, payment collection, and runtime network requests stay out of the core path. The optional Claude extension adds a local stdio MCP adapter around that same engine; it is not a hosted service.
|
|
16
16
|
|
|
17
|
-
> **Status:**
|
|
17
|
+
> **Status:** [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv) **3.3.2** is the public founder-aware rewrite. It runs locally and makes no runtime network requests. The package includes Profile v3 policy, pre-edit SHIP/EDIT/REBUILD judgments, contiguous range edits, authorized rebuild, and a signed semantic lifecycle.
|
|
18
18
|
|
|
19
19
|
## Why it exists
|
|
20
20
|
|
|
@@ -27,8 +27,9 @@ Hold Your Voice keeps the work visible:
|
|
|
27
27
|
| Does the draft still resemble this writer’s observable mechanics? | VoiceDNA | A profile-based score, findings, and pass state. |
|
|
28
28
|
| Does the draft contain a configured editorial pattern worth inspecting? | AI Editor | A rule-based score, sentence findings, and pass state. |
|
|
29
29
|
| Did the rewrite introduce a new blocker or replace too much? | Verification | Regressions, preservation score, and a release decision. |
|
|
30
|
+
| Should this draft ship, take a bounded edit, or rebuild? | Judgment | A SHIP, EDIT, or REBUILD recommendation bound to the draft and profile. |
|
|
30
31
|
|
|
31
|
-
Its scope is a local writing gate.
|
|
32
|
+
Its scope is a local writing gate. It includes a source-consistency fact linter, not a truth engine: it checks a final draft against the evidence you provide. Authorship detection, plagiarism review, and hosted generation each need their own tools.
|
|
32
33
|
|
|
33
34
|
## Start here
|
|
34
35
|
|
|
@@ -93,9 +94,9 @@ producer | hyv final-check -
|
|
|
93
94
|
hyv final-check final-response.md
|
|
94
95
|
```
|
|
95
96
|
|
|
96
|
-
Clean text is written to stdout byte-for-byte.
|
|
97
|
+
Clean text is written to stdout byte-for-byte. The gate removes only non-semantic ASCII controls and byte-order marks. When any other hidden Unicode remains, stdout stays empty, the report goes to stderr, and the command exits `2`. Put this command immediately before display, copy, export, posting, or an API response. The producer and the presence of a VoiceDNA profile do not change the policy.
|
|
97
98
|
|
|
98
|
-
This is an integration boundary, not a background interceptor. A GUI, agent host, or external tool must pass
|
|
99
|
+
This is an integration boundary, not a background interceptor. HYV applies the same gate by default during rewrite and rebuild verification; unresolved output is withheld from their CLI and MCP evaluation result. A GUI, agent host, or external tool must still pass any later changed text to `hyv final-check -` or the read-only `hyv_final_check` MCP tool and deliver only accepted output. Run it after the last rewrite, formatter, template expansion, or manual edit; checking an earlier draft does not cover later changes.
|
|
99
100
|
|
|
100
101
|
### Inspect and clean hidden Unicode
|
|
101
102
|
|
|
@@ -112,7 +113,16 @@ hyv hygiene draft.md --fix
|
|
|
112
113
|
hyv hygiene draft.md --fix --output=review-copy.md
|
|
113
114
|
```
|
|
114
115
|
|
|
115
|
-
The fix receipt lists every changed UTF-16 offset and code point. The conservative cleaner removes
|
|
116
|
+
The fix receipt lists every changed UTF-16 offset and code point. The conservative cleaner removes ASCII controls and byte-order marks. It reports other zero-width characters, unusual spaces, bidirectional controls, and tag characters without changing them because they can carry legitimate language, typography, or emoji behavior. Existing output files are never overwritten.
|
|
117
|
+
|
|
118
|
+
For a deliberately narrow, policy-backed cleanup of non-semantic ASCII controls and mid-document byte-order marks, inspect first and write a separate result:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
hyv inspect-hidden-text draft.md policy.json
|
|
122
|
+
hyv apply-hidden-text-policy draft.md policy.json draft.sanitized.md
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The receipt carries the input/output hashes, exact changes, remaining review findings, and an idempotence result. It does not label Unicode findings as watermarks or claim that any provider watermark was removed.
|
|
116
126
|
|
|
117
127
|
### Add contextual editorial guidance
|
|
118
128
|
|
|
@@ -195,7 +205,36 @@ Use `verify-spec` when a draft has claims that must remain verbatim unless they
|
|
|
195
205
|
hyv verify-spec original.md candidate.md profile.json copy-spec.json
|
|
196
206
|
```
|
|
197
207
|
|
|
198
|
-
The check is deterministic. Without `atoms`, an immutable claim remains a verbatim sentence check. With `atoms`, every declared phrase must remain somewhere in the candidate, allowing independent facts to be split or reordered. Atoms are lexical-presence checks, not factual validation
|
|
208
|
+
The check is deterministic. Without `atoms`, an immutable claim remains a verbatim sentence check. With `atoms`, every declared phrase must remain somewhere in the candidate, allowing independent facts to be split or reordered. Atoms are lexical-presence checks, not factual validation.
|
|
209
|
+
|
|
210
|
+
### Check factual consistency with supplied sources
|
|
211
|
+
|
|
212
|
+
`fact-lint` compares a final draft with local evidence. It extracts claims with sentence and UTF-16 offsets, checks dates, names, quotes, capabilities, causal/comparative escalation, and draft contradictions, then returns JSON with exact local evidence.
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
hyv fact-lint final.md --source=release:release-notes.md --source=research:research.md
|
|
216
|
+
hyv fact-lint final.md --source=release:release-notes.md --human
|
|
217
|
+
hyv fact-lint final.md --source=release:release-notes.md --strict
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The default is report-only and exits `0`; `--strict` exits `2` for error findings. Known conflicts such as a CSV-to-PDF change are errors. A new or unclear capability, or weak evidence such as “exists” versus “grows”, becomes `needs_human_review`. No source text leaves the process by default. The linter checks consistency with supplied evidence; it does not prove the sources are true. See the [fact linter guide](docs/wiki/Fact-Linter.md).
|
|
221
|
+
|
|
222
|
+
When a `WritingBrief` includes `factSources`, HYV runs the same local fact lint automatically during `verify`, `verify-spec`, rewrite evaluation, and their MCP equivalents. Error findings block verification. Source-free flows remain unchanged.
|
|
223
|
+
|
|
224
|
+
Use `requiredFacts` for facts that must appear in the final draft. Each required fact must be supported by its source text or declared atoms in `factSources`; HYV fails verification if it is missing, negated, or denied. It does not assume every fact from every source belongs in every output.
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"version": "1",
|
|
229
|
+
"audience": "founders",
|
|
230
|
+
"intent": "write a post",
|
|
231
|
+
"format": "social",
|
|
232
|
+
"factSources": [{ "id": "bio", "text": "Shashank is a LinkedIn Top Voice." }],
|
|
233
|
+
"requiredFacts": [{ "id": "linkedin-top-voice", "text": "Shashank is a LinkedIn Top Voice." }]
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
HYV does not infer trusted evidence from ordinary prompt prose. Pass source material through `factSources`, then mark only the inclusion-critical statements in `requiredFacts`. Run source-backed `verify` after the last substantive edit. `final-check` is a hygiene gate; it does not re-run evidence checks. Use source material that you are allowed to include in a rewrite task; task handoff is controlled by the calling host.
|
|
199
238
|
|
|
200
239
|
### Local voice memory
|
|
201
240
|
|
|
@@ -225,10 +264,35 @@ flowchart LR
|
|
|
225
264
|
T --> G[Verify candidate]
|
|
226
265
|
C --> G
|
|
227
266
|
D --> G
|
|
228
|
-
|
|
267
|
+
F[Optional factSources + requiredFacts] --> L[Local fact lint]
|
|
268
|
+
L --> G
|
|
269
|
+
G --> R[Errors block; review findings stay visible]
|
|
270
|
+
R --> H[final-check: hygiene before output]
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The tool never applies changes to your draft. You decide which findings are valid, apply replacement sentences or an authorized rebuild deliberately, and run the final check.
|
|
274
|
+
|
|
275
|
+
## Founder-aware rewrite
|
|
276
|
+
|
|
277
|
+
3.3.0 keeps the original analyze → brief → verify loop and adds a structured rewrite path.
|
|
278
|
+
|
|
279
|
+
1. Prepare a pre-edit judgment. Findings reduce to **SHIP**, bounded **EDIT**, or **REBUILD**.
|
|
280
|
+
2. **SHIP** returns the original bytes. No model call.
|
|
281
|
+
3. **EDIT** applies eligible sentence replacements or contiguous range edits through `prepare-rewrite` / `apply-rewrite`. Clean and unflagged text stays in place. Overlapping, out-of-order, or partly locked ranges fail before a candidate is built.
|
|
282
|
+
4. **REBUILD** prepares a whole-document candidate only after a matching REBUILD recommendation, a CopySpec, and a signed `hyv.rebuild-authorization` capability. `prepare-rebuild` / `apply-rebuild` re-check that capability and the bound profile. Claim, polarity, hygiene, and semantic gates stay in force. Edit and rebuild responses are mutually incompatible.
|
|
283
|
+
|
|
284
|
+
For a meaning-first recomposition, pass an explicit lexical-residual policy to `prepare-rebuild`. HYV gives the external writer structured facts and constraints rather than automatically including the source draft in its prompt, then measures shared wording after the candidate returns. A passed residual report means only that the candidate meets the configured overlap policy. It does not detect, remove, or prove the absence of a provider watermark, and it does not establish authorship.
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
hyv prepare-judgment pre-edit argument draft.md profile.json task.json
|
|
288
|
+
hyv reduce-judgment envelope-a.json envelope-b.json envelope-c.json
|
|
289
|
+
hyv prepare-rewrite draft.md profile.json task.json
|
|
290
|
+
hyv apply-rewrite task.json response.json profile.json
|
|
291
|
+
hyv prepare-rebuild draft.md profile.json reduction.json copy-spec.json task.json --recomposition-policy policy.json --capability-file capability.json
|
|
292
|
+
hyv apply-rebuild task.json response.json profile.json --capability-file capability.json
|
|
229
293
|
```
|
|
230
294
|
|
|
231
|
-
|
|
295
|
+
CLI and MCP expose the same contracts. The engine never calls a model. An editor or chosen model still sits outside the package.
|
|
232
296
|
|
|
233
297
|
## The five rewrite tiers
|
|
234
298
|
|
|
@@ -240,7 +304,7 @@ The prompt has an order. Lower tiers can refine a higher tier; they cannot overr
|
|
|
240
304
|
4. **Tier 3: AI Editor.** Inspect yellow findings. Change a line only when the repair helps.
|
|
241
305
|
5. **Tier 4: output.** Return replacement sentences keyed by sentence number.
|
|
242
306
|
|
|
243
|
-
This order protects meaning before style. Read the complete [prompt contract](docs/PROMPT-CONTRACT.md) before changing
|
|
307
|
+
This order protects meaning before style. Rebuild is a separate whole-document contract; it does not use sentence-number replacements. Read the complete [prompt contract](docs/PROMPT-CONTRACT.md) before changing either path.
|
|
244
308
|
|
|
245
309
|
## VoiceDNA: 13 observable elements
|
|
246
310
|
|
|
@@ -302,8 +366,12 @@ The preservation score is a guardrail based on retained original words longer th
|
|
|
302
366
|
| `hyv hygiene <draft> [--fix] [--output=path]` | Draft | Hygiene report or cleaned copy plus receipt | You need to inspect or conservatively clean hidden Unicode. |
|
|
303
367
|
| `hyv final-check <path\|->` | Any final text | Exact accepted text on stdout or a withheld-output report on stderr | Text is about to cross a user-facing boundary. |
|
|
304
368
|
| `hyv rewrite-prompt <draft> <profile.json>` | Draft and profile | Markdown editing brief | You need a constrained request for an editor or model. |
|
|
305
|
-
| `hyv prepare-rewrite <draft> <profile.json> <task.json>` | Draft and profile | Versioned task file plus metadata | A host needs a fingerprint-bound sentence-edit task. |
|
|
369
|
+
| `hyv prepare-rewrite <draft> <profile.json> <task.json>` | Draft and profile | Versioned task file plus metadata | A host needs a fingerprint-bound sentence-edit or range-edit task. |
|
|
306
370
|
| `hyv apply-rewrite <task.json> <response.json> <profile.json>` | Task, response, and profile | Candidate evaluation JSON | A host needs to apply and recheck eligible sentence replacements. |
|
|
371
|
+
| `hyv prepare-judgment <pre-edit\|post-candidate> <kind> <draft> <profile.json> <task.json> [candidate.md]` | Draft, profile, and optional candidate | Versioned judgment task | Findings need a SHIP, EDIT, or REBUILD recommendation. |
|
|
372
|
+
| `hyv reduce-judgment <envelope.json> <envelope.json> [envelope.json...]` | Signed judgment envelopes | Recommendation JSON | Multiple judgment envelopes must reduce to one decision. |
|
|
373
|
+
| `hyv prepare-rebuild <draft> <profile.json> <reduction.json> <copy-spec.json> <task.json> [--recomposition-policy policy.json]` | Draft, recommendation, CopySpec, capability, and optional policy | Versioned rebuild task | An upstream REBUILD recommendation needs a whole-document candidate; an optional policy makes it meaning-first and adds lexical-residual evidence. |
|
|
374
|
+
| `hyv apply-rebuild <task.json> <response.json> <profile.json>` | Task, response, profile, and capability | Candidate evaluation JSON | A host needs to apply and recheck an authorized rebuild. |
|
|
307
375
|
| `hyv verify <original> <candidate> <profile.json>` | Original, candidate, profile | Verification JSON and exit code | You need the candidate gate. |
|
|
308
376
|
| `hyv verify-spec <original> <candidate> <profile.json> <copy-spec.json>` | Original, candidate, profile, CopySpec | Verification JSON with hard claim gate | A brief contains locked facts or prohibited claims. |
|
|
309
377
|
| `hyv learning <show\|inspect\|add\|record\|ratify\|supersede\|migrate\|clear> ...` | Profile, operation value, and bounded metadata options | Preferences or a text-free mutation receipt | You need to inspect, migrate, or manage profile-scoped learning. |
|
|
@@ -314,7 +382,7 @@ Every file argument can be `-` when the command accepts text input from standard
|
|
|
314
382
|
|
|
315
383
|
Profile v3 learning is keyed by its stable local profile ID, so compatible history survives profile revisions. `record`, `ratify`, and `supersede` accept bounded `--mutation-id`, `--authority`, `--provenance`, `--weight`, and `--compatibility` options. `ratify` and `supersede` require Profile v3. `migrate` explicitly copies compatible legacy Profile v2 learning into one Profile v3 identity. Replaying an identical mutation is idempotent; reusing its ID for a different operation returns a conflict. Inspection and receipts expose event metadata only, never stored instructions or draft text.
|
|
316
384
|
|
|
317
|
-
The standalone CLI supports normal-policy semantic review. High-assurance review requires a trusted embedding and is rejected by the CLI. Approval capabilities are accepted only through `--capability-stdin` or a permission-checked `--capability-file`; adapters validate capabilities but never mint them. Rejection needs no capability. Approval and `learning record-approved` require the matching signed final-approval capability. `apply-rewrite`, `lifecycle submit-verdict`, and `lifecycle finalize` exit `2` when the candidate or transition is not accepted, while usage and runtime failures exit `1`.
|
|
385
|
+
The standalone CLI supports normal-policy semantic review. High-assurance review requires a trusted embedding and is rejected by the CLI. Approval and rebuild capabilities are accepted only through `--capability-stdin` or a permission-checked `--capability-file`; adapters validate capabilities but never mint them. Rejection needs no capability. Approval and `learning record-approved` require the matching signed final-approval capability. `apply-rewrite`, `apply-rebuild`, `lifecycle submit-verdict`, and `lifecycle finalize` exit `2` when the candidate or transition is not accepted, while usage and runtime failures exit `1`.
|
|
318
386
|
|
|
319
387
|
## Project map
|
|
320
388
|
|
|
@@ -328,7 +396,10 @@ The standalone CLI supports normal-policy semantic review. High-assurance review
|
|
|
328
396
|
| `src/editorial-packs.ts` | Parses WritingBrief context and runs format and batch checks. |
|
|
329
397
|
| `src/learning.ts` | Stores text-free, profile-scoped verified repairs and composes bounded local preferences. |
|
|
330
398
|
| `src/pipeline.ts` | Combines scored pass states, makes briefs, and verifies candidates. |
|
|
331
|
-
| `src/rewrite-task.ts` | Prepares and evaluates fingerprint-bound sentence-replacement tasks. |
|
|
399
|
+
| `src/rewrite-task.ts` | Prepares and evaluates fingerprint-bound sentence-replacement and range-edit tasks. |
|
|
400
|
+
| `src/judgment-task.ts` | Reduces pre-edit SHIP/EDIT/REBUILD recommendations and post-candidate clearance. |
|
|
401
|
+
| `src/rebuild-task.ts` | Prepares whole-document rebuild after a matching recommendation, CopySpec, and signed capability. |
|
|
402
|
+
| `src/recomposition.ts` | Builds meaning-first rebuild briefs and measures declared lexical-residual evidence. |
|
|
332
403
|
| `src/semantic-review.ts` | Defines and reduces semantic and human-review lifecycle artifacts. |
|
|
333
404
|
| `src/approval-capability.ts` | Verifies canonical signed approval capabilities. |
|
|
334
405
|
| `src/approval-context.ts` | Loads permission-checked trust roots and evaluator authorization. |
|
|
@@ -378,7 +449,7 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Keep chan
|
|
|
378
449
|
|
|
379
450
|
## npm releases
|
|
380
451
|
|
|
381
|
-
`@holdyourvoice/hyv` is published automatically after a change to the package source reaches `main`. The workflow publishes only when the version in `package.json` is not already on npm, so bump that version in the same pull request as a release-worthy change. It runs the tests and release audit before publishing, then verifies that npm reports the package as MIT licensed.
|
|
452
|
+
`@holdyourvoice/hyv` is published automatically after a change to the package source reaches `main`. The workflow publishes only when the version in `package.json` is not already on npm, so bump that version in the same pull request as a release-worthy change. It runs the tests and release audit before publishing, then verifies that npm reports the package as MIT licensed.
|
|
382
453
|
|
|
383
454
|
## Support
|
|
384
455
|
|
package/dist/cli.js
CHANGED
|
@@ -6,16 +6,18 @@ import { parseCopySpec } from './copy-spec.js';
|
|
|
6
6
|
import { analyzeBatch, parseWritingBrief } from './editorial-packs.js';
|
|
7
7
|
import { clearLearning, composeLearning, inspectLearning, migrateLearningV2ToV3, profileFingerprint, ratifyLearningEvent, recordLearningInstruction, supersedeLearningEvent } from './learning.js';
|
|
8
8
|
import { cleanHygiene, finalOutputCheck, inspectHygiene } from './hygiene.js';
|
|
9
|
+
import { applyHiddenTextPolicy, inspectHiddenText, parseHiddenTextPolicy } from './hidden-text.js';
|
|
9
10
|
import { analyze, rewritePrompt, verify, verifyWithCopySpec } from './pipeline.js';
|
|
10
11
|
import { parseProfile } from './profile.js';
|
|
11
12
|
import { evaluateRewriteResponse, parseRewriteTask, prepareRewriteTask } from './rewrite-task.js';
|
|
12
13
|
import { parseJudgmentEnvelope, preparePostCandidateJudgment, preparePreEditJudgment, reducePostCandidate, reducePreEdit } from './judgment-task.js';
|
|
13
|
-
import { evaluateRebuildResponse, parseRebuildTask, prepareRebuildTask } from './rebuild-task.js';
|
|
14
|
+
import { evaluateRebuildResponse, parseRebuildTask, prepareRebuildTask, writerRequestForRebuild } from './rebuild-task.js';
|
|
14
15
|
import { canonicalJson, parseCanonicalJson } from './canonical-json.js';
|
|
15
16
|
import { finalizeLifecycle, inspectLifecycle, prepareLifecycle, recordApprovedLearning, submitSemanticVerdict, validateFinalApproval } from './lifecycle-adapter.js';
|
|
16
17
|
import { buildProfile } from './voice-dna.js';
|
|
17
18
|
import { loadApprovalContext } from './approval-context.js';
|
|
18
|
-
|
|
19
|
+
import { formatFactLintReport, lintFacts } from './fact-linter.js';
|
|
20
|
+
const usage = 'Commands: profile, analyze, hygiene, inspect-hidden-text, apply-hidden-text-policy, final-check, fact-lint, batch-analyze, rewrite-prompt, prepare-rewrite, apply-rewrite, prepare-judgment, reduce-judgment, prepare-rebuild, rebuild-writer-request, apply-rebuild, verify, verify-spec, lifecycle, learning, patterns, mcp';
|
|
19
21
|
const MAX_JSON_BYTES = 1024 * 1024;
|
|
20
22
|
function input(path) {
|
|
21
23
|
return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
|
|
@@ -106,6 +108,23 @@ function capabilityArguments(args) {
|
|
|
106
108
|
throw new Error('JSON input exceeds the byte limit.');
|
|
107
109
|
return { values, capability: parseCanonicalJson(Buffer.from(raw, 'utf8')) };
|
|
108
110
|
}
|
|
111
|
+
function rebuildArguments(args) {
|
|
112
|
+
const values = [];
|
|
113
|
+
let policyPath;
|
|
114
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
115
|
+
if (args[index] !== '--recomposition-policy') {
|
|
116
|
+
values.push(args[index]);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const path = args[index + 1];
|
|
120
|
+
if (policyPath || !path || path === '-' || path.startsWith('--'))
|
|
121
|
+
throw new Error('Choose one recomposition policy file.');
|
|
122
|
+
policyPath = path;
|
|
123
|
+
index += 1;
|
|
124
|
+
}
|
|
125
|
+
const capability = capabilityArguments(values);
|
|
126
|
+
return { ...capability, ...(policyPath ? { recompositionPolicy: readJson(policyPath) } : {}) };
|
|
127
|
+
}
|
|
109
128
|
function readProfile(path) {
|
|
110
129
|
return parseProfile(JSON.parse(input(path)));
|
|
111
130
|
}
|
|
@@ -280,6 +299,24 @@ export async function runCli(args) {
|
|
|
280
299
|
json({ ...result.report, changed: result.changed, changes: result.changes, outputPath });
|
|
281
300
|
return 0;
|
|
282
301
|
}
|
|
302
|
+
if (command === 'inspect-hidden-text') {
|
|
303
|
+
const [path, policyPath, ...extra] = rest;
|
|
304
|
+
if (!path || extra.length)
|
|
305
|
+
throw new Error('Usage: hyv inspect-hidden-text draft.md [policy.json]');
|
|
306
|
+
json(inspectHiddenText(input(path), policyPath ? parseHiddenTextPolicy(readJson(policyPath)) : undefined));
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
if (command === 'apply-hidden-text-policy') {
|
|
310
|
+
const [path, policyPath, output, ...extra] = rest;
|
|
311
|
+
if (!path || !policyPath || !output || extra.length)
|
|
312
|
+
throw new Error('Usage: hyv apply-hidden-text-policy draft.md policy.json output.md');
|
|
313
|
+
if (path === '-' || resolve(path) === resolve(output))
|
|
314
|
+
throw new Error('Hidden-text output must differ from the input path.');
|
|
315
|
+
const result = applyHiddenTextPolicy(input(path), parseHiddenTextPolicy(readJson(policyPath)));
|
|
316
|
+
writeNewFileAtomically(output, result.output);
|
|
317
|
+
json({ ...result, outputPath: output });
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
283
320
|
if (command === 'final-check') {
|
|
284
321
|
const [path, ...options] = rest;
|
|
285
322
|
if (!path || options.length)
|
|
@@ -294,6 +331,46 @@ export async function runCli(args) {
|
|
|
294
331
|
process.stdout.write(result.output);
|
|
295
332
|
return 0;
|
|
296
333
|
}
|
|
334
|
+
if (command === 'fact-lint') {
|
|
335
|
+
const [draftPath, ...options] = rest;
|
|
336
|
+
const sources = [];
|
|
337
|
+
let metadata;
|
|
338
|
+
let strict = false;
|
|
339
|
+
let human = false;
|
|
340
|
+
if (!draftPath)
|
|
341
|
+
throw new Error('Usage: hyv fact-lint <draft|-> --source=id:path [--source=id:path] [--metadata=metadata.json] [--strict] [--human]');
|
|
342
|
+
for (const option of options) {
|
|
343
|
+
if (option === '--strict') {
|
|
344
|
+
strict = true;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (option === '--human') {
|
|
348
|
+
human = true;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (option.startsWith('--source=')) {
|
|
352
|
+
const value = option.slice('--source='.length);
|
|
353
|
+
const separator = value.indexOf(':');
|
|
354
|
+
const id = value.slice(0, separator).trim();
|
|
355
|
+
const path = value.slice(separator + 1);
|
|
356
|
+
if (separator < 1 || !id || !path)
|
|
357
|
+
throw new Error('Sources must use --source=id:path.');
|
|
358
|
+
sources.push({ id, text: input(path) });
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (option.startsWith('--metadata=')) {
|
|
362
|
+
metadata = JSON.parse(input(option.slice('--metadata='.length)));
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
throw new Error('Usage: hyv fact-lint <draft|-> --source=id:path [--source=id:path] [--metadata=metadata.json] [--strict] [--human]');
|
|
366
|
+
}
|
|
367
|
+
const report = lintFacts({ sources, draft: input(draftPath), metadata });
|
|
368
|
+
if (human)
|
|
369
|
+
console.log(formatFactLintReport(report));
|
|
370
|
+
else
|
|
371
|
+
json(report);
|
|
372
|
+
return strict && report.findings.some((item) => item.severity === 'error') ? 2 : 0;
|
|
373
|
+
}
|
|
297
374
|
if (command === 'batch-analyze') {
|
|
298
375
|
if (rest.length < 2)
|
|
299
376
|
throw new Error('Usage: hyv batch-analyze draft-a.md draft-b.md [draft-c.md]');
|
|
@@ -349,15 +426,15 @@ export async function runCli(args) {
|
|
|
349
426
|
return 0;
|
|
350
427
|
}
|
|
351
428
|
if (command === 'prepare-rebuild') {
|
|
352
|
-
const { values, capability } =
|
|
429
|
+
const { values, capability, recompositionPolicy } = rebuildArguments(rest);
|
|
353
430
|
const [draft, profilePath, reductionPath, specPath, output, briefPath] = values;
|
|
354
431
|
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)');
|
|
432
|
+
throw new Error('Usage: hyv prepare-rebuild draft.md profile.json reduction.json copy-spec.json task.json [writing-brief.json] [--recomposition-policy policy.json] (--capability-stdin|--capability-file path)');
|
|
356
433
|
}
|
|
357
434
|
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);
|
|
435
|
+
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, recompositionPolicy);
|
|
359
436
|
writeFileSync(output, `${JSON.stringify(task, null, 2)}\n`);
|
|
360
|
-
json({ version: task.version, fingerprint: task.fingerprint, recommendationFingerprint: task.recommendationFingerprint, authorizationFingerprint: task.authorizationFingerprint });
|
|
437
|
+
json({ version: task.version, fingerprint: task.fingerprint, recommendationFingerprint: task.recommendationFingerprint, authorizationFingerprint: task.authorizationFingerprint, ...(task.recompositionPolicy ? { recompositionPolicy: task.recompositionPolicy } : {}) });
|
|
361
438
|
return 0;
|
|
362
439
|
}
|
|
363
440
|
if (command === 'apply-rebuild') {
|
|
@@ -370,6 +447,15 @@ export async function runCli(args) {
|
|
|
370
447
|
json(result);
|
|
371
448
|
return result.status === 'accepted' ? 0 : 2;
|
|
372
449
|
}
|
|
450
|
+
if (command === 'rebuild-writer-request') {
|
|
451
|
+
const [taskPath, output, ...extra] = rest;
|
|
452
|
+
if (!taskPath || !output || extra.length)
|
|
453
|
+
throw new Error('Usage: hyv rebuild-writer-request task.json writer-request.json');
|
|
454
|
+
const request = writerRequestForRebuild(parseRebuildTask(JSON.parse(input(taskPath))));
|
|
455
|
+
writeFileSync(output, `${JSON.stringify(request, null, 2)}\n`);
|
|
456
|
+
json({ version: request.version, taskFingerprint: request.taskFingerprint, copySpecFingerprint: request.copySpecFingerprint, ...(request.recompositionPolicyFingerprint ? { recompositionPolicyFingerprint: request.recompositionPolicyFingerprint } : {}) });
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
373
459
|
if (command === 'verify') {
|
|
374
460
|
const [original, candidate, profilePath, briefPath] = rest;
|
|
375
461
|
if (!original || !candidate || !profilePath)
|
package/dist/cli.test.js
CHANGED
|
@@ -128,6 +128,30 @@ test('inspects stdin and refuses to clean it without a preservable input file',
|
|
|
128
128
|
assert.equal(refused.status, 1);
|
|
129
129
|
assert.match(refused.stderr, /requires a file path/);
|
|
130
130
|
});
|
|
131
|
+
test('applies the explicit hidden-text policy through CLI without touching review-only Unicode', () => {
|
|
132
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-hidden-text-'));
|
|
133
|
+
try {
|
|
134
|
+
const draft = join(directory, 'draft.md');
|
|
135
|
+
const policy = join(directory, 'hidden-text-policy.json');
|
|
136
|
+
const output = join(directory, 'draft.sanitized.md');
|
|
137
|
+
const original = 'before\u0007middle\uFEFFafter\u200D';
|
|
138
|
+
writeFileSync(draft, original);
|
|
139
|
+
writeFileSync(policy, JSON.stringify({ version: '1', name: 'minimal-text-control-cleanup', approvedRemovals: ['ascii_control', 'mid_document_bom'], acknowledgement: 'Removes only listed non-semantic controls; all other findings remain review-only.' }));
|
|
140
|
+
const inspected = run(['inspect-hidden-text', draft, policy]);
|
|
141
|
+
assert.equal(inspected.status, 0, inspected.stderr);
|
|
142
|
+
assert.deepEqual(JSON.parse(inspected.stdout).proposedChanges.map((change) => change.codepoint), ['U+0007', 'U+FEFF']);
|
|
143
|
+
const applied = run(['apply-hidden-text-policy', draft, policy, output]);
|
|
144
|
+
assert.equal(applied.status, 0, applied.stderr);
|
|
145
|
+
const receipt = JSON.parse(applied.stdout);
|
|
146
|
+
assert.equal(receipt.idempotent, true);
|
|
147
|
+
assert.equal(readFileSync(draft, 'utf8'), original);
|
|
148
|
+
assert.equal(readFileSync(output, 'utf8'), 'beforemiddleafter\u200D');
|
|
149
|
+
assert.equal(receipt.remaining[0].codepoint, 'U+200D');
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
rmSync(directory, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
131
155
|
test('gates final output from any producer without a voice profile', () => {
|
|
132
156
|
const clean = run(['final-check', '-'], process.env, 'exact output\n');
|
|
133
157
|
assert.equal(clean.status, 0, clean.stderr);
|
|
@@ -157,7 +181,7 @@ test('uses exit code 2 for a failed candidate gate and 1 for misuse', () => {
|
|
|
157
181
|
assert.equal(run(['profile', profile, first, second, '--avoid=unlock']).status, 0);
|
|
158
182
|
const verification = run(['verify', original, candidate, profile]);
|
|
159
183
|
assert.equal(verification.status, 2);
|
|
160
|
-
assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'original', 'passed', 'preservationScore', 'regressions', 'version']);
|
|
184
|
+
assert.deepEqual(Object.keys(JSON.parse(verification.stdout)).sort(), ['candidate', 'finalOutput', 'original', 'passed', 'preservationScore', 'regressions', 'version']);
|
|
161
185
|
assert.equal(run(['unknown-command']).status, 1);
|
|
162
186
|
assert.equal(run(['mcp', 'unexpected']).status, 1);
|
|
163
187
|
}
|
|
@@ -188,6 +212,25 @@ test('fails the CopySpec gate when a locked claim changes', () => {
|
|
|
188
212
|
rmSync(directory, { recursive: true, force: true });
|
|
189
213
|
}
|
|
190
214
|
});
|
|
215
|
+
test('reports fact consistency as JSON or compact text and only fails strict mode on errors', () => {
|
|
216
|
+
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-fact-lint-'));
|
|
217
|
+
try {
|
|
218
|
+
const source = join(directory, 'source.md');
|
|
219
|
+
const draft = join(directory, 'draft.md');
|
|
220
|
+
writeFileSync(source, 'Atlas launched on 14 August 2026 and exports CSV reports.');
|
|
221
|
+
writeFileSync(draft, 'Atlas launched on 15 August 2026 and exports CSV reports.');
|
|
222
|
+
const report = run(['fact-lint', draft, `--source=release:${source}`]);
|
|
223
|
+
assert.equal(report.status, 0, report.stderr);
|
|
224
|
+
assert.equal(JSON.parse(report.stdout).findings[0].kind, 'date_drift');
|
|
225
|
+
assert.equal(run(['fact-lint', draft, `--source=release:${source}`, '--strict']).status, 2);
|
|
226
|
+
const compact = run(['fact-lint', draft, `--source=release:${source}`, '--human']);
|
|
227
|
+
assert.equal(compact.status, 0, compact.stderr);
|
|
228
|
+
assert.match(compact.stdout, /date_drift/);
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
rmSync(directory, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
191
234
|
test('prepares and applies the same constrained rewrite task without a provider call', () => {
|
|
192
235
|
const directory = mkdtempSync(join(tmpdir(), 'holdyourvoice-cli-'));
|
|
193
236
|
try {
|
|
@@ -437,14 +480,17 @@ test('prepares and applies an authorized rebuild through CLI', () => {
|
|
|
437
480
|
const profile = join(directory, 'profile.json');
|
|
438
481
|
const draft = join(directory, 'draft.md');
|
|
439
482
|
const spec = join(directory, 'copy-spec.json');
|
|
483
|
+
const policy = join(directory, 'recomposition-policy.json');
|
|
440
484
|
const reduction = join(directory, 'reduction.json');
|
|
441
485
|
const task = join(directory, 'rebuild-task.json');
|
|
442
486
|
const response = join(directory, 'response.json');
|
|
443
487
|
const capability = join(directory, 'capability.json');
|
|
488
|
+
const writerRequest = join(directory, 'writer-request.json');
|
|
444
489
|
writeFileSync(first, 'I write plainly. I name the work.');
|
|
445
490
|
writeFileSync(second, 'I keep the mechanism clear. I avoid filler.');
|
|
446
491
|
writeFileSync(draft, 'I leverage the answer. The launch is on 14 August.');
|
|
447
492
|
writeFileSync(spec, JSON.stringify({ version: '1', audience: 'operators', intent: 'explain', channel: 'email', claims: [{ id: 'launch-date', text: 'The launch is on 14 August.', evidence: 'Release calendar, 7 August.' }] }));
|
|
493
|
+
writeFileSync(policy, JSON.stringify({ version: '1', mode: 'meaning-first', lexicalResidual: { ngramSize: 5, maxSharedNgramFraction: 0, maxLongestSharedRunTokens: 4 }, acknowledgement: 'Measures shared wording only; does not detect or prove removal of a watermark.' }));
|
|
448
494
|
assert.equal(run(['profile', profile, first, second, '--avoid=leverage']).status, 0);
|
|
449
495
|
const envelopes = ['triage', 'argument', 'form'].map((kind) => {
|
|
450
496
|
const taskPath = join(directory, `${kind}.json`);
|
|
@@ -476,8 +522,13 @@ test('prepares and applies an authorized rebuild through CLI', () => {
|
|
|
476
522
|
const payload = Buffer.from(canonicalJson(claims));
|
|
477
523
|
writeFileSync(capability, canonicalJson({ payload: payload.toString('base64url'), signature: sign(null, payload, privateKey).toString('base64url') }));
|
|
478
524
|
chmodSync(capability, 0o600);
|
|
479
|
-
const prepared = run(['prepare-rebuild', draft, profile, reduction, spec, task, '--capability-file', capability], env);
|
|
525
|
+
const prepared = run(['prepare-rebuild', draft, profile, reduction, spec, task, '--recomposition-policy', policy, '--capability-file', capability], env);
|
|
480
526
|
assert.equal(prepared.status, 0, prepared.stderr);
|
|
527
|
+
assert.match(JSON.parse(prepared.stdout).recompositionPolicy.acknowledgement, /not detect/);
|
|
528
|
+
assert.doesNotMatch(JSON.parse(readFileSync(task, 'utf8')).prompt, /I leverage the answer/);
|
|
529
|
+
const writer = run(['rebuild-writer-request', task, writerRequest]);
|
|
530
|
+
assert.equal(writer.status, 0, writer.stderr);
|
|
531
|
+
assert.doesNotMatch(readFileSync(writerRequest, 'utf8'), /I leverage the answer|capability|profile/i);
|
|
481
532
|
writeFileSync(response, JSON.stringify({
|
|
482
533
|
version: '1', mode: 'REBUILD', taskFingerprint: JSON.parse(readFileSync(task, 'utf8')).fingerprint,
|
|
483
534
|
candidate: '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.',
|
|
@@ -485,6 +536,7 @@ test('prepares and applies an authorized rebuild through CLI', () => {
|
|
|
485
536
|
const applied = run(['apply-rebuild', task, response, profile, '--capability-file', capability], env);
|
|
486
537
|
assert.equal(applied.status, 2, applied.stderr);
|
|
487
538
|
assert.equal(JSON.parse(applied.stdout).status, 'needs_semantic_review');
|
|
539
|
+
assert.equal(JSON.parse(applied.stdout).receipt.lexicalResidual.passed, true);
|
|
488
540
|
assert.equal(run(['apply-rebuild', task, response, profile], env).status, 1);
|
|
489
541
|
}
|
|
490
542
|
finally {
|
package/dist/editorial-packs.js
CHANGED
|
@@ -7,6 +7,36 @@ function isText(value, limit) {
|
|
|
7
7
|
function isTerms(value) {
|
|
8
8
|
return Array.isArray(value) && value.length <= 100 && value.every((term) => isText(term, 200));
|
|
9
9
|
}
|
|
10
|
+
function isFactSources(value) {
|
|
11
|
+
return Array.isArray(value) && value.length > 0 && value.length <= 20 && value.every((source) => !!source && typeof source === 'object' && isText(source.id, 100) && isText(source.text, 40_000)) && value.reduce((total, source) => total + source.id.length + source.text.length, 0) <= 40_000;
|
|
12
|
+
}
|
|
13
|
+
function isFactMetadata(value) {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
15
|
+
return false;
|
|
16
|
+
const metadata = value;
|
|
17
|
+
return (metadata.allowedAssumptions === undefined || isTerms(metadata.allowedAssumptions)) && (metadata.approvedHypotheses === undefined || isTerms(metadata.approvedHypotheses));
|
|
18
|
+
}
|
|
19
|
+
function isRequiredFacts(value) {
|
|
20
|
+
return Array.isArray(value) && value.length > 0 && value.length <= 50 && value.every((fact) => !!fact && typeof fact === 'object' && isText(fact.id, 100) && /^[A-Za-z0-9._-]+$/.test(fact.id) && isText(fact.text, 2_000) && (fact.atoms === undefined || isTerms(fact.atoms)));
|
|
21
|
+
}
|
|
22
|
+
function isDenied(text) {
|
|
23
|
+
return /\b(?:not|false|untrue|incorrect)\b/i.test(text);
|
|
24
|
+
}
|
|
25
|
+
function isFollowUpDenial(text) {
|
|
26
|
+
return /^(?:that|this) (?:statement|claim|fact|assertion|point) (?:is|was) (?:not|false|untrue|incorrect)\b/i.test(text.trim());
|
|
27
|
+
}
|
|
28
|
+
function hasAffirmedSourceText(source, text) {
|
|
29
|
+
const expected = text.toLowerCase();
|
|
30
|
+
const sourceSentences = sentences(source);
|
|
31
|
+
return sourceSentences.some((sentence, index) => sentence.text.toLowerCase().includes(expected) && !isDenied(sentence.text) && !isFollowUpDenial(sourceSentences[index + 1]?.text ?? ''));
|
|
32
|
+
}
|
|
33
|
+
function requiredFactsAreSourced(brief) {
|
|
34
|
+
if (!brief.requiredFacts?.length)
|
|
35
|
+
return true;
|
|
36
|
+
if (!brief.factSources?.length)
|
|
37
|
+
return false;
|
|
38
|
+
return brief.requiredFacts.every((fact) => brief.factSources?.some((source) => hasAffirmedSourceText(source.text, fact.text) || (fact.atoms?.length && fact.atoms.every((atom) => hasAffirmedSourceText(source.text, atom)))));
|
|
39
|
+
}
|
|
10
40
|
function isArgumentMap(value) {
|
|
11
41
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
12
42
|
return false;
|
|
@@ -23,7 +53,11 @@ export function parseWritingBrief(value) {
|
|
|
23
53
|
|| (brief.prohibitedTerms !== undefined && !isTerms(brief.prohibitedTerms))
|
|
24
54
|
|| (brief.title !== undefined && !isText(brief.title, 500))
|
|
25
55
|
|| (brief.evidenceStatus !== undefined && !evidenceStatuses.includes(brief.evidenceStatus))
|
|
26
|
-
|| (brief.argumentMap !== undefined && !isArgumentMap(brief.argumentMap))
|
|
56
|
+
|| (brief.argumentMap !== undefined && !isArgumentMap(brief.argumentMap))
|
|
57
|
+
|| (brief.factSources !== undefined && !isFactSources(brief.factSources))
|
|
58
|
+
|| (brief.factMetadata !== undefined && !isFactMetadata(brief.factMetadata))
|
|
59
|
+
|| (brief.requiredFacts !== undefined && !isRequiredFacts(brief.requiredFacts))
|
|
60
|
+
|| !requiredFactsAreSourced(brief)) {
|
|
27
61
|
throw new Error('WritingBrief needs version "1", audience, intent, a known format, and optional bounded context fields.');
|
|
28
62
|
}
|
|
29
63
|
return brief;
|