@holdyourvoice/hyv 3.3.1 → 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:** [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv) **3.3.1** 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.
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
 
@@ -29,7 +29,7 @@ Hold Your Voice keeps the work visible:
29
29
  | Did the rewrite introduce a new blocker or replace too much? | Verification | Regressions, preservation score, and a release decision. |
30
30
  | Should this draft ship, take a bounded edit, or rebuild? | Judgment | A SHIP, EDIT, or REBUILD recommendation bound to the draft and profile. |
31
31
 
32
- 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.
33
33
 
34
34
  ## Start here
35
35
 
@@ -94,9 +94,9 @@ producer | hyv final-check -
94
94
  hyv final-check final-response.md
95
95
  ```
96
96
 
97
- 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.
98
98
 
99
- 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.
100
100
 
101
101
  ### Inspect and clean hidden Unicode
102
102
 
@@ -113,7 +113,16 @@ hyv hygiene draft.md --fix
113
113
  hyv hygiene draft.md --fix --output=review-copy.md
114
114
  ```
115
115
 
116
- 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.
117
126
 
118
127
  ### Add contextual editorial guidance
119
128
 
@@ -196,7 +205,36 @@ Use `verify-spec` when a draft has claims that must remain verbatim unless they
196
205
  hyv verify-spec original.md candidate.md profile.json copy-spec.json
197
206
  ```
198
207
 
199
- 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.
200
238
 
201
239
  ### Local voice memory
202
240
 
@@ -226,7 +264,10 @@ flowchart LR
226
264
  T --> G[Verify candidate]
227
265
  C --> G
228
266
  D --> G
229
- 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]
230
271
  ```
231
272
 
232
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.
@@ -240,12 +281,14 @@ The tool never applies changes to your draft. You decide which findings are vali
240
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.
241
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.
242
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
+
243
286
  ```bash
244
287
  hyv prepare-judgment pre-edit argument draft.md profile.json task.json
245
288
  hyv reduce-judgment envelope-a.json envelope-b.json envelope-c.json
246
289
  hyv prepare-rewrite draft.md profile.json task.json
247
290
  hyv apply-rewrite task.json response.json profile.json
248
- hyv prepare-rebuild draft.md profile.json reduction.json copy-spec.json task.json --capability-file capability.json
291
+ hyv prepare-rebuild draft.md profile.json reduction.json copy-spec.json task.json --recomposition-policy policy.json --capability-file capability.json
249
292
  hyv apply-rebuild task.json response.json profile.json --capability-file capability.json
250
293
  ```
251
294
 
@@ -327,7 +370,7 @@ The preservation score is a guardrail based on retained original words longer th
327
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. |
328
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. |
329
372
  | `hyv reduce-judgment <envelope.json> <envelope.json> [envelope.json...]` | Signed judgment envelopes | Recommendation JSON | Multiple judgment envelopes must reduce to one decision. |
330
- | `hyv prepare-rebuild <draft> <profile.json> <reduction.json> <copy-spec.json> <task.json>` | Draft, recommendation, CopySpec, and capability | Versioned rebuild task | An upstream REBUILD recommendation needs a whole-document candidate. |
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. |
331
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. |
332
375
  | `hyv verify <original> <candidate> <profile.json>` | Original, candidate, profile | Verification JSON and exit code | You need the candidate gate. |
333
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. |
@@ -356,6 +399,7 @@ The standalone CLI supports normal-policy semantic review. High-assurance review
356
399
  | `src/rewrite-task.ts` | Prepares and evaluates fingerprint-bound sentence-replacement and range-edit tasks. |
357
400
  | `src/judgment-task.ts` | Reduces pre-edit SHIP/EDIT/REBUILD recommendations and post-candidate clearance. |
358
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. |
359
403
  | `src/semantic-review.ts` | Defines and reduces semantic and human-review lifecycle artifacts. |
360
404
  | `src/approval-capability.ts` | Verifies canonical signed approval capabilities. |
361
405
  | `src/approval-context.ts` | Loads permission-checked trust roots and evaluator authorization. |
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;