@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 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:** the public CLI is published as [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv). It runs locally and makes no runtime network requests. Version 3.3.0 adds pre-edit judgments, range edits, and authorized rebuild. Writer-study kits remain blocked optional research. Product publish uses the version bump and CI.
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. Authorship detection, fact checking, plagiarism review, and hosted generation each need their own tools. Hold Your Voice gives a writer or chosen model a narrow editing brief, then asks the same two engines to inspect the result.
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. A leading U+FEFF byte-order mark is removed before output. When 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
+ 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 its exact final 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
+ 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 only a leading U+FEFF byte-order mark. 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.
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: put the whole relationship in one atom when it must stay true (for example, `Kimi K2.6 uses INT4 weights` rather than `INT4`). It covers declared claims and prohibited text; arbitrary unsupported assertions need a separate factual evaluator.
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
- G --> R[Pass or inspect regressions]
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
- The tool never applies changes to your draft. You decide which findings are valid, apply replacement sentences deliberately, and run the final check.
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 it.
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. Writer-study kits stay optional research. Product publish uses the version bump and CI. Keep writer-checkpoint claims off the publish.
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
- 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
+ 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 } = capabilityArguments(rest);
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 {
@@ -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;