@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 +53 -9
- 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:** [`@holdyourvoice/hyv`](https://www.npmjs.com/package/@holdyourvoice/hyv) **3.3.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|