oh-my-opencode 5.1.46 → 5.1.47
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/.agents/command/publish.md +3 -4
- package/.agents/skills/publish/SKILL.md +3 -4
- package/.opencode/command/publish.md +3 -4
- package/dist/agents/atlas/agent.d.ts +3 -2
- package/dist/agents/sisyphus/index.d.ts +1 -0
- package/dist/agents/sisyphus/kimi-k3-1.d.ts +9 -0
- package/dist/agents/sisyphus-agent-factory.d.ts +1 -1
- package/dist/agents/sisyphus-junior/agent.d.ts +1 -1
- package/dist/agents/sisyphus-junior/index.d.ts +1 -0
- package/dist/agents/sisyphus-junior/kimi-k3-1.d.ts +6 -0
- package/dist/agents/types.d.ts +2 -2
- package/dist/cli/index.js +29 -21
- package/dist/cli/service-auth/keystore.d.ts +8 -3
- package/dist/cli-node/index.js +29 -21
- package/dist/index.js +634 -17
- package/dist/skills/browser/runtime/omowright/manifest.json +1 -1
- package/dist/skills/ulw-research/SKILL.md +1 -1
- package/dist/skills/ulw-research/references/deliverable-phase.md +9 -2
- package/dist/skills/ulw-research/scripts/copy-returns-polish.test.ts +209 -0
- package/dist/skills/ulw-research/scripts/fact-commands-meaning.mjs +7 -22
- package/dist/skills/ulw-research/scripts/fact-commands-polish.mjs +127 -0
- package/dist/skills/ulw-research/scripts/fact-commands-verify.mjs +19 -0
- package/dist/skills/ulw-research/scripts/fact-commands.mjs +4 -2
- package/dist/skills/ulw-research/scripts/fact-pairs.mjs +7 -9
- package/dist/skills/ulw-research/scripts/fact-pairs.test.ts +7 -23
- package/dist/skills/ulw-research/scripts/references-fences.test.ts +25 -0
- package/dist/skills/ulw-research/scripts/report-tools.test.ts +87 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/fidelity/polish-edits.json +12 -0
- package/dist/tui.js +13 -13
- package/package.json +13 -13
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +1 -2
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +12 -12
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +1 -1
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
- package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +9 -2
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-meaning.mjs +7 -22
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-polish.mjs +127 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-verify.mjs +19 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands.mjs +4 -2
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-pairs.mjs +7 -9
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +1 -1
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +4 -5
- package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
- package/packages/shared-skills/skills/ulw-research/SKILL.md +1 -1
- package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +9 -2
- package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-polish.test.ts +209 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-meaning.mjs +7 -22
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-polish.mjs +127 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-verify.mjs +19 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands.mjs +4 -2
- package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.mjs +7 -9
- package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.test.ts +7 -23
- package/packages/shared-skills/skills/ulw-research/scripts/references-fences.test.ts +25 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +87 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/polish-edits.json +12 -0
|
@@ -158,7 +158,7 @@ prefixes when identity is needed.
|
|
|
158
158
|
`;
|
|
159
159
|
const reviewWorkCodexGatePattern = new RegExp(reviewWorkCodexGate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
160
160
|
|
|
161
|
-
const ulwResearchProofreadGate = "**Proofread gate: `task(category=\"writing\", ...)`.** Hand the final text to a dedicated `writing` worker whose only job is language: grammar, spelling, punctuation, terminology consistency, and whether the prose reads NATIVELY in the report's own language. It proofreads only and never composes
|
|
161
|
+
const ulwResearchProofreadGate = "**Proofread gate: `task(category=\"writing\", ...)`.** Hand the final text to a dedicated `writing` worker whose only job is language: grammar, spelling, punctuation, terminology consistency, and whether the prose reads NATIVELY in the report's own language. It proofreads only and never composes, and it never edits the file: it returns `$SESSION_DIR/polish-edits.json`, an array of `{\"before\": ..., \"after\": ..., \"reason\": ...}` where `before` is the exact current text of one or more adjacent sentences. Edits change text only; an edit whose before/after would alter any tag, attribute, comment, or script/style content is rejected, and markup changes go through the full fidelity gates instead. Apply it with `node \"$SKILL_DIR/scripts/report-tools.mjs\" fact polish-apply \"$SESSION_DIR/report.html\" --edits \"$SESSION_DIR/polish-edits.json\" --out \"$SESSION_DIR/report.polished.html\" --session-dir \"$SESSION_DIR\"`: each edit is applied alone, and an edit that changes a fact span, a numeral, a link, or a claim id, or drops a hedge or qualifier the source carries, is `polish_edit_rejected` (a `before` not found exactly once is `polish_edit_unlocated`) and leaves its text unchanged. Send the rejected and unlocated edits back to the same worker with their reasons, plus `$SESSION_DIR/polish-defects.json` and the pre-polish `report.html` the backstop compared against, so the worker sees why each edit was refused; it returns either a rewritten sentence or a new edit list, and the repair round re-runs `fact polish-apply` with `\"$SESSION_DIR/report.polished.html\"` as both input and output, within the repair budget. Then run the backstop `fact polish-diff --before \"$SESSION_DIR/report.html\" --after \"$SESSION_DIR/report.polished.html\" --session-dir \"$SESSION_DIR\"` (exit `1` on `polish_diff_fail`), `fact verify` on `report.polished.html`, and `fact pairs \"$SESSION_DIR/report.polished.html\" --changed-since \"$SESSION_DIR/report.html.fill-map.json\" --session-dir \"$SESSION_DIR\"`, which keeps only the quote and claim pairs whose sentence text changed during polish; run the meaning readers above (cheap, then strong on the flagged pairs) on exactly those pairs and `fact meaning-check` before recording `outcome gate fidelity`. A rewording that keeps every number, hedge, qualifier, and link but swaps who did what to whom therefore still reaches a meaning reader on the delivered text. `report.polished.html` is the report that is delivered. Deliver only on a clean pass. This gate runs BEFORE the first delivery, not after the user finds the typo. Edits must not span entities (such as & or <) or markup; narrow the edit to the surrounding words instead, and a rejected edit comes back with the reason to act on.";
|
|
162
162
|
// The shared skill numbers the proofread gate right after the Visual QA item the Codex edition ends on.
|
|
163
163
|
const ulwResearchVisualQaItemPattern = /^(\d+)\. \*\*Visual QA\.\*\*[^\n]*inspect the pixels\.$/m;
|
|
164
164
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// omo-codex-install:
|
|
2
|
+
// omo-codex-install:3bfe60748136282b06d702bcba5697d924bfdeb7b8416acf24716fd7741de83d:1b18a3321d7501168ee91e2d0c0a937af0f36e681e8bfdfef274f2fc5ce9c26e
|
|
3
3
|
var __esm = (fn, res, err) => () => {
|
|
4
4
|
if (fn)
|
|
5
5
|
try {
|
|
@@ -10123,7 +10123,7 @@ var package_default;
|
|
|
10123
10123
|
var init_package = __esm(() => {
|
|
10124
10124
|
package_default = {
|
|
10125
10125
|
name: "@oh-my-opencode/omo-codex",
|
|
10126
|
-
version: "5.1.
|
|
10126
|
+
version: "5.1.47",
|
|
10127
10127
|
type: "module",
|
|
10128
10128
|
private: true,
|
|
10129
10129
|
description: "Codex harness adapter for oh-my-openagent. Vendored Codex plugin namespace (omo) + TypeScript installer + telemetry.",
|
|
@@ -10380,7 +10380,7 @@ function resolveStdio(options) {
|
|
|
10380
10380
|
return [options.stdin ?? "ignore", options.stdout ?? "pipe", options.stderr ?? "inherit"];
|
|
10381
10381
|
}
|
|
10382
10382
|
function createBunSpawnOptions(options) {
|
|
10383
|
-
return { ...options, windowsHide: true };
|
|
10383
|
+
return { ...options, env: options.env ?? process.env, windowsHide: true };
|
|
10384
10384
|
}
|
|
10385
10385
|
function createNodeSpawnOptions(options, platform = process.platform) {
|
|
10386
10386
|
const nodeOptions = {
|
|
@@ -10612,8 +10612,7 @@ function windowsNodeDiscoveryLines() {
|
|
|
10612
10612
|
' for /f "tokens=* delims= " %%N in ("!OMO_NODE_BINARY!") do set "OMO_NODE_BINARY=%%N"',
|
|
10613
10613
|
` if "!OMO_NODE_BINARY:~0,1!"=="'" set "OMO_NODE_BINARY=!OMO_NODE_BINARY:~1!"`,
|
|
10614
10614
|
` if "!OMO_NODE_BINARY:~-1!"=="'" set "OMO_NODE_BINARY=!OMO_NODE_BINARY:~0,-1!"`,
|
|
10615
|
-
'
|
|
10616
|
-
' if "!OMO_NODE_BINARY:~-1!"=="^"" set "OMO_NODE_BINARY=!OMO_NODE_BINARY:~0,-1!"',
|
|
10615
|
+
' set "OMO_NODE_BINARY=!OMO_NODE_BINARY:"=!"',
|
|
10617
10616
|
' if defined OMO_NODE_BINARY if not exist "!OMO_NODE_BINARY!" set "OMO_NODE_BINARY="',
|
|
10618
10617
|
")",
|
|
10619
10618
|
'if not defined OMO_NODE_BINARY where node >nul 2>nul && set "OMO_NODE_BINARY=node"'
|
|
@@ -373,7 +373,7 @@ Next, write `$SESSION_DIR/pairs-strong.json` (the same file, keeping only the pa
|
|
|
373
373
|
|
|
374
374
|
3. **Layout gates.** Print the probe with `node "$SKILL_DIR/scripts/report-tools.mjs" layout-probe --json`, evaluate its `source` in the rendered page through the `browser` skill's owned headless engine, save the result as `$SESSION_DIR/boxes.json`, and re-run `check` with `--layout "$SESSION_DIR/boxes.json"` (overflow, clipped text, distorted images, overlapping siblings).
|
|
375
375
|
4. **Visual QA.** Render the produced artifact back to images — PDF pages to PNG, the HTML through the browser skill at desktop and phone widths — and look at them: missing or broken figures, images stretched or spilling their containers, diagram or chart text rendered off the spec's font or palette, clipped tables, overflowing CJK text, blank pages, unlabeled chart values, wrong page breaks. Reading the source markup is not visual QA; inspect the pixels.
|
|
376
|
-
5. **Proofread gate: `task(category="writing", ...)`.** Hand the final text to a dedicated `writing` worker whose only job is language: grammar, spelling, punctuation, terminology consistency, and whether the prose reads NATIVELY in the report's own language. It proofreads only and never composes
|
|
376
|
+
5. **Proofread gate: `task(category="writing", ...)`.** Hand the final text to a dedicated `writing` worker whose only job is language: grammar, spelling, punctuation, terminology consistency, and whether the prose reads NATIVELY in the report's own language. It proofreads only and never composes, and it never edits the file: it returns `$SESSION_DIR/polish-edits.json`, an array of `{"before": ..., "after": ..., "reason": ...}` where `before` is the exact current text of one or more adjacent sentences. Edits change text only; an edit whose before/after would alter any tag, attribute, comment, or script/style content is rejected, and markup changes go through the full fidelity gates instead. Apply it with `node "$SKILL_DIR/scripts/report-tools.mjs" fact polish-apply "$SESSION_DIR/report.html" --edits "$SESSION_DIR/polish-edits.json" --out "$SESSION_DIR/report.polished.html" --session-dir "$SESSION_DIR"`: each edit is applied alone, and an edit that changes a fact span, a numeral, a link, or a claim id, or drops a hedge or qualifier the source carries, is `polish_edit_rejected` (a `before` not found exactly once is `polish_edit_unlocated`) and leaves its text unchanged. Send the rejected and unlocated edits back to the same worker with their reasons, plus `$SESSION_DIR/polish-defects.json` and the pre-polish `report.html` the backstop compared against, so the worker sees why each edit was refused; it returns either a rewritten sentence or a new edit list, and the repair round re-runs `fact polish-apply` with `"$SESSION_DIR/report.polished.html"` as both input and output, within the repair budget. Then run the backstop `fact polish-diff --before "$SESSION_DIR/report.html" --after "$SESSION_DIR/report.polished.html" --session-dir "$SESSION_DIR"` (exit `1` on `polish_diff_fail`), `fact verify` on `report.polished.html`, and `fact pairs "$SESSION_DIR/report.polished.html" --changed-since "$SESSION_DIR/report.html.fill-map.json" --session-dir "$SESSION_DIR"`, which keeps only the quote and claim pairs whose sentence text changed during polish; run the meaning readers above (cheap, then strong on the flagged pairs) on exactly those pairs and `fact meaning-check` before recording `outcome gate fidelity`. A rewording that keeps every number, hedge, qualifier, and link but swaps who did what to whom therefore still reaches a meaning reader on the delivered text. `report.polished.html` is the report that is delivered. Deliver only on a clean pass. This gate runs BEFORE the first delivery, not after the user finds the typo. Edits must not span entities (such as & or <) or markup; narrow the edit to the surrounding words instead, and a rejected edit comes back with the reason to act on.
|
|
377
377
|
|
|
378
378
|
**Repair is bounded.** After every gate run that found defects, ask `node "$SKILL_DIR/scripts/report-tools.mjs" repair decide --state "$SESSION_DIR/repair-state.json" --defects "$SESSION_DIR/defects.json" --defects "$SESSION_DIR/fidelity-defects.json" --artifact-bytes <bytes> --renders <pages> --session-dir "$SESSION_DIR"` and obey it: `repair` means fix and re-run; `deliver` means stop and ship with the residual defects the manifest now lists; `block` means a content-integrity defect remains (or there is no usable artifact), so do not deliver: tell the user what blocks it and record the row as `failed` with the reason. Never repair past a stop decision (3 attempts, a 2-attempt plateau, an oscillation, or 15 minutes; the reference's section 8). Fidelity defects ride the same loop (`--defects "$SESSION_DIR/defects.json" --defects "$SESSION_DIR/fidelity-defects.json"`), but a `deliver` decision does not release them: while `fidelity-defects.json` lists `untraceable_number`, `value_mismatch`, `unit_mismatch`, `quote_not_verbatim`, `quote_unbound`, `unbound_sentence`, or `chart_value_untraceable`, `outcome verify` fails with `fidelity_unresolved` and `outcome finish` refuses, even with the repair budget spent. The only ways out are a ledger row, an `assumed` row with its rationale, or removing the sentence.
|
|
379
379
|
|
|
@@ -133,7 +133,12 @@ Nothing reaches the requester until the gates for its QA tier pass, in this orde
|
|
|
133
133
|
node "$SKILL_DIR/scripts/report-tools.mjs" layout-probe --json # .source is one async expression
|
|
134
134
|
```
|
|
135
135
|
4. **Visual QA.** Render the artifact to images (PDF pages to PNG, the HTML in the browser skill's engine at desktop and phone widths) and look at the pixels for what a machine cannot see: composition, hierarchy, page breaks, figure legibility.
|
|
136
|
-
5. **Proofread.** A dedicated writing lane proofreads the final text only (grammar, spelling, punctuation, terminology, native register); it never composes or rewrites content.
|
|
136
|
+
5. **Proofread.** A dedicated writing lane proofreads the final text only (grammar, spelling, punctuation, terminology, native register); it never composes or rewrites content and never edits the file. Edits change text only; an edit whose before/after would alter any tag, attribute, comment, or script/style content is rejected, and markup changes go through the full fidelity gates instead. It returns `polish-edits.json` (`[{before, after, reason}]`), which the orchestrator applies edit by edit; a rejected or unlocated edit goes back to the lane with its reason, within the repair budget. The backstop diff, `fact verify` on the polished file, and the meaning readers on the pairs whose sentences polish changed run before the fidelity gate is recorded again. Edits must not span entities (such as & or <) or markup; narrow the edit to the surrounding words instead, and a rejected edit comes back with the reason to act on.
|
|
137
|
+
```bash
|
|
138
|
+
node "$SKILL_DIR/scripts/report-tools.mjs" fact polish-apply "$SESSION_DIR/report.html" --edits "$SESSION_DIR/polish-edits.json" --out "$SESSION_DIR/report.polished.html" --session-dir "$SESSION_DIR"
|
|
139
|
+
node "$SKILL_DIR/scripts/report-tools.mjs" fact polish-diff --before "$SESSION_DIR/report.html" --after "$SESSION_DIR/report.polished.html" --session-dir "$SESSION_DIR" # writes polish-defects.json
|
|
140
|
+
node "$SKILL_DIR/scripts/report-tools.mjs" fact pairs "$SESSION_DIR/report.polished.html" --changed-since "$SESSION_DIR/report.html.fill-map.json" --session-dir "$SESSION_DIR"
|
|
141
|
+
```
|
|
137
142
|
|
|
138
143
|
**Every reader-facing edition is verified.** The main report, the inline edition (section 9), LaTeX per-section sources, and slide sources are filled by `fact fill` from one placeholder source and each is verified by `fact verify` into its own record; PDF and DOCX are renders of a verified source. A delivered row is recorded with `outcome set <format> delivered --path <file> --verified-by <record>`, where the record's `artifact_sha256` equals the sha256 of the delivered source; a PDF or DOCX adds `--rendered-from <source>` and is matched against that source. In a session with a fact ledger, `outcome verify` refuses a delivered row without a matching record (`edition_unverified`, blocker, integrity). Markdown and LaTeX verification arrive with the Markdown stage; until then `fact verify --format md|tex` exits `2`, and such an edition is recorded `blocked_capability` with that reason rather than delivered unverified.
|
|
139
144
|
|
|
@@ -216,7 +221,9 @@ Invoke every command as `node "$SKILL_DIR/scripts/report-tools.mjs" <command>`.
|
|
|
216
221
|
| `fact export` | `--csv`, `--out <file>`, `--session-dir <dir>` | never |
|
|
217
222
|
| `fact fill <src>` | `--out <file>` or `--view`, `--format html\|md\|tex`, `--inline`, `--replace-failed`, `--session-dir <dir>` | a fill error (nothing is written) |
|
|
218
223
|
| `fact verify <report>` | `--format html\|md` (from the extension by default), `--sources <dir>`, `--claims <claims.json>`, `--fill-map <file>` (a Markdown edition reads `<report>.fill-map.json` by default), `--brief <brief.md>`, `--out <record>`, `--session-dir <dir>` | any blocker or major defect |
|
|
219
|
-
| `fact pairs <report>` | `--format html\|md`, `--ledger <fact-ledger.json>`, `--claims <claims.json>`, `--changed-since <fill-map.json>` (
|
|
224
|
+
| `fact pairs <report>` | `--format html\|md`, `--ledger <fact-ledger.json>`, `--claims <claims.json>`, `--changed-since <fill-map.json>` (keep only the pairs whose sentence text is not in the pre-polish artifact that map names, so the readers re-check exactly what polish rewrote; the map must carry `artifact_sha256` and the artifact must still hash to it, otherwise the command exits `2`), `--brief <brief.md>`, `--out <pairs.json>`, `--session-dir <dir>` | the brief has no locked `## Fidelity` block (with `meaning check: off` it writes `{"skipped": true}` and exits `0`) |
|
|
225
|
+
| `fact polish-apply <report.html>` | `--edits <polish-edits.json>` and `--out <report.polished.html>` (both required; a missing flag or a Markdown edition exits `2`), `--format html`, `--ledger <fact-ledger.json>`, `--session-dir <dir>` | never: it exits `0` whenever it wrote the output. Each rejected (`polish_edit_rejected`) or unlocated (`polish_edit_unlocated`) edit leaves its text unchanged and is printed verbatim, one line per rejection, in the summary and in `data.rejected_reasons`; `data` also carries `applied`, `rejected`, `unlocated`, `applied_indexes`, `input_sha256`, `edits_sha256`, and `out_sha256` |
|
|
226
|
+
| `fact polish-diff` | `--before <pre-polish.html>` and `--after <polished.html>` (both required; a missing flag exits `2`), `--ledger <fact-ledger.json>`, `--out <record>` (default `$SESSION_DIR/polish-defects.json`), `--session-dir <dir>` | a registry blocker in the diff (majors and minors are reported and exit `0`; a defect code the registry does not know refuses with `unknown defect code <code>`). The record is `{command, status, exitCode, before, artifact, artifact_sha256, defects[], counts}`, with `counts` in contract buckets |
|
|
220
227
|
| `fact meaning-check` | `--pairs <pairs.json>` and `--verdicts <file>` (required; repeatable, one per reader tier), `--dispute P<n> --reason <text>`, `--run-id <id>`, `--calibration <file>` (default `fidelity-calibration.jsonl`), `--out <record>` (default `fidelity-meaning-defects.json`), `--session-dir <dir>` | `meaning_drift_confirmed` (stderr carries one JSON line with the defect and dropped-verdict counts) |
|
|
221
228
|
| `fact derive` | `--op <op>` (required) with the operation's operands (section 11), `--unit`, `--to`, `--rationale`, `--disjoint`, `--label`, `--json`, `--session-dir <dir>` | `incompatible_operands` (no row written) |
|
|
222
229
|
| `claims index <claim-graph.md>` | `--out <claims.json>` (required) | never (a malformed claim table exits `2`) |
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Architecture contract for the polish command (#11160 sweep): the edit contract, the ledger
|
|
2
|
+
// loader, the edition-format grammar, and the severity buckets each have one owner export, so a
|
|
3
|
+
// reintroduced local copy in fact-commands-polish.mjs fails one of these tests.
|
|
4
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
5
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
6
|
+
import { tmpdir } from "node:os"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
|
|
9
|
+
import { CliError } from "./cli-support.mjs"
|
|
10
|
+
import { countDefects, DEFECT_CODES, SEVERITIES } from "./contracts.mjs"
|
|
11
|
+
import { runFactMeaning } from "./fact-commands-meaning.mjs"
|
|
12
|
+
import * as polishCommand from "./fact-commands-polish.mjs"
|
|
13
|
+
import { editionFormatOf, ledgerFrom, sessionOf } from "./fact-commands-verify.mjs"
|
|
14
|
+
import { applyEdits, POLISH_EDIT_FIELDS, POLISH_MARKUP_REASON, validatePolishEdits } from "./polish-apply.mjs"
|
|
15
|
+
|
|
16
|
+
const { polishDiffFails, runFactPolish } = polishCommand
|
|
17
|
+
const source = (name: string) => readFileSync(join(import.meta.dir, name), "utf8")
|
|
18
|
+
const LEDGER = { schemaVersion: 1, rows: [], events: [] }
|
|
19
|
+
const doc = (body: string) => `<html><body>\n${body}\n</body></html>`
|
|
20
|
+
const BRIEF = join(import.meta.dir, "tests", "fixtures", "fidelity-brief-locked.md")
|
|
21
|
+
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "polish-copy-returns-"))
|
|
23
|
+
const file = (name: string, content: string) => {
|
|
24
|
+
const path = join(dir, name)
|
|
25
|
+
writeFileSync(path, content)
|
|
26
|
+
return path
|
|
27
|
+
}
|
|
28
|
+
const ledgerPath = file("ledger.json", JSON.stringify(LEDGER))
|
|
29
|
+
const badLedgerPath = file("bad-ledger.json", JSON.stringify({ schemaVersion: 1, rows: "nope" }))
|
|
30
|
+
const reportPath = file("report.html", doc("<p>The span is 320 m [S1].</p>"))
|
|
31
|
+
const savedSession = process.env.SESSION_DIR
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
delete process.env.SESSION_DIR
|
|
34
|
+
})
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
if (savedSession === undefined) delete process.env.SESSION_DIR
|
|
37
|
+
else process.env.SESSION_DIR = savedSession
|
|
38
|
+
})
|
|
39
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }))
|
|
40
|
+
|
|
41
|
+
function outcome(run: () => unknown) {
|
|
42
|
+
try {
|
|
43
|
+
run()
|
|
44
|
+
return "ok"
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!(error instanceof CliError)) throw error
|
|
47
|
+
return error.message
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("the polish edit contract is polish-apply's", () => {
|
|
52
|
+
const SHAPES = [
|
|
53
|
+
["not an array", { before: "a", after: "b" }],
|
|
54
|
+
["a null edit", [null]],
|
|
55
|
+
["an array edit", [["a", "b"]]],
|
|
56
|
+
["a missing after", [{ before: "span" }]],
|
|
57
|
+
["a numeric before", [{ before: 3, after: "b" }]],
|
|
58
|
+
["a numeric reason", [{ before: "span", after: "span", reason: 5 }]],
|
|
59
|
+
["a valid edit", [{ before: "The span is", after: "The span is", reason: "none" }]],
|
|
60
|
+
["a valid edit without reason", [{ before: "The span is", after: "The span is" }]],
|
|
61
|
+
] as const
|
|
62
|
+
|
|
63
|
+
test("#given the command module #when its edit contract is read #then it is the polish-apply export", () => {
|
|
64
|
+
expect(polishCommand.validatePolishEdits).toBe(validatePolishEdits)
|
|
65
|
+
expect(polishCommand.POLISH_EDIT_FIELDS).toBe(POLISH_EDIT_FIELDS)
|
|
66
|
+
expect(source("fact-commands-polish.mjs")).not.toMatch(/typeof edit\?\.(before|after)/u)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
for (const [name, edits] of SHAPES) {
|
|
70
|
+
test(`#given ${name} #when applied directly and through the CLI #then both accept or both refuse`, () => {
|
|
71
|
+
const editsPath = file(`edits-${name.replaceAll(" ", "-")}.json`, JSON.stringify(edits))
|
|
72
|
+
const direct = (() => {
|
|
73
|
+
try {
|
|
74
|
+
applyEdits(readFileSync(reportPath, "utf8"), edits as never, LEDGER)
|
|
75
|
+
return "ok"
|
|
76
|
+
} catch (error) {
|
|
77
|
+
expect(error).toBeInstanceOf(TypeError)
|
|
78
|
+
return "refused"
|
|
79
|
+
}
|
|
80
|
+
})()
|
|
81
|
+
const cli = outcome(() => runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--out", join(dir, "out.html")]))
|
|
82
|
+
expect(cli === "ok" ? "ok" : "refused").toBe(direct)
|
|
83
|
+
if (cli !== "ok") expect(cli).toContain(editsPath)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe("the ledger loader is one shared helper", () => {
|
|
89
|
+
const pairs = (extra: string[]) => () => runFactMeaning("pairs", [reportPath, "--brief", BRIEF, "--out", join(dir, "pairs.json"), ...extra])
|
|
90
|
+
const diff = (extra: string[]) => () => runFactPolish("polish-diff", ["--before", reportPath, "--after", reportPath, ...extra])
|
|
91
|
+
|
|
92
|
+
test("#given the command modules #when read #then neither defines its own ledger loader or session resolver", () => {
|
|
93
|
+
expect(polishCommand.ledgerFrom).toBe(ledgerFrom)
|
|
94
|
+
for (const name of ["fact-commands-meaning.mjs", "fact-commands-polish.mjs"]) {
|
|
95
|
+
const text = source(name)
|
|
96
|
+
expect(text, name).not.toMatch(/function (readLedgerFrom|ledgerFrom)\b|validateLedger\(/u)
|
|
97
|
+
expect(text, name).not.toMatch(/const sessionOf\s*=/u)
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("#given the same valid, invalid, and missing ledgers #when fact pairs and fact polish-diff load them #then both give the same outcome", () => {
|
|
102
|
+
for (const extra of [["--ledger", ledgerPath], ["--ledger", badLedgerPath], []]) {
|
|
103
|
+
const expected = outcome(() => ledgerFrom({ ...(extra.length > 0 ? { ledger: extra[1] } : {}) }))
|
|
104
|
+
expect(outcome(pairs(extra)), extra.join(" ")).toBe(expected)
|
|
105
|
+
expect(outcome(diff(extra)), extra.join(" ")).toBe(expected)
|
|
106
|
+
}
|
|
107
|
+
expect(outcome(() => ledgerFrom({ ledger: badLedgerPath }))).toContain("is not a valid fact ledger")
|
|
108
|
+
expect(outcome(() => ledgerFrom({}))).toContain("--ledger is required")
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("#given a session dir flag or SESSION_DIR #when resolved #then the flag wins and a bare flag is no session", () => {
|
|
112
|
+
expect(sessionOf({ "session-dir": dir })).toBe(dir)
|
|
113
|
+
expect(sessionOf({ "session-dir": true })).toBeNull()
|
|
114
|
+
process.env.SESSION_DIR = dir
|
|
115
|
+
expect(sessionOf({})).toBe(dir)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("the edition format comes from verify's grammar", () => {
|
|
120
|
+
test("#given report paths of each case #when polish-apply reads them #then it refuses exactly the Markdown classifications", () => {
|
|
121
|
+
const editsPath = file("edits-ok.json", "[]")
|
|
122
|
+
expect(source("fact-commands-polish.mjs")).not.toMatch(/md\|markdown/u)
|
|
123
|
+
for (const name of ["r.md", "R.MD", "r.markdown", "R.Markdown", "r.html", "r.HTML"]) {
|
|
124
|
+
const path = file(name, readFileSync(reportPath, "utf8"))
|
|
125
|
+
const result = outcome(() => runFactPolish("polish-apply", [path, "--edits", editsPath, "--ledger", ledgerPath, "--out", join(dir, "out.html")]))
|
|
126
|
+
expect(result.includes("takes the filled HTML edition"), name).toBe(editionFormatOf(path, undefined) === "md")
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe("polish-diff counts with contracts and exits on blockers only", () => {
|
|
132
|
+
type DiffRun = { exitCode: number; json: { counts: Record<string, number>; defects: { severity: string }[] } }
|
|
133
|
+
const run = (before: string, after: string) => runFactPolish("polish-diff", ["--before", file("b.html", doc(before)), "--after", file("a.html", doc(after)), "--ledger", ledgerPath]) as unknown as DiffRun
|
|
134
|
+
|
|
135
|
+
test("#given every registry code #when polishDiffFails classifies it #then only registry blockers fail", () => {
|
|
136
|
+
for (const [code, entry] of Object.entries(DEFECT_CODES) as [string, { severity: string }][]) expect(polishDiffFails([{ code, severity: "minor" }]), code).toBe(entry.severity === "blocker")
|
|
137
|
+
expect(polishDiffFails([])).toBe(false)
|
|
138
|
+
expect(source("fact-commands-polish.mjs")).not.toMatch(/blocker:\s*0|counts\.blocker > 0/u)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("#given a major-only polish diff #when run #then it is reported with contract buckets and exits 0", () => {
|
|
142
|
+
const result = run("<p>The span may be 320 m [S1].</p>", "<p>The span is 320 m [S1].</p>")
|
|
143
|
+
const { defects } = result.json
|
|
144
|
+
expect(defects.length).toBeGreaterThan(0)
|
|
145
|
+
expect(defects.every((defect) => defect.severity === "major")).toBe(true)
|
|
146
|
+
expect(result.json.counts).toEqual(countDefects(defects))
|
|
147
|
+
expect(Object.keys(result.json.counts)).toEqual([...SEVERITIES, "total"])
|
|
148
|
+
expect(result.exitCode).toBe(0)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("#given a changed numeral #when run #then the blocker exits 1", () => {
|
|
152
|
+
const result = run("<p>Throughput grew 3.5 times [S1].</p>", "<p>Throughput grew 35 times [S1].</p>")
|
|
153
|
+
expect(result.json.counts).toEqual(countDefects(result.json.defects))
|
|
154
|
+
expect(result.exitCode).toBe(1)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe("fact polish-apply prints every rejection reason verbatim", () => {
|
|
159
|
+
type ApplyRun = { summary: string; exitCode: number; json: { defects: { index: number; code: string; reason: string }[]; data: { rejected: number; rejected_reasons: string[] } } }
|
|
160
|
+
|
|
161
|
+
test("#given an edit holding & #when fact polish-apply runs #then the printed line carries polish-apply's own markup reason", () => {
|
|
162
|
+
const before = "Revenue & profit rose across the year"
|
|
163
|
+
const editsPath = file("edits-markup-ampersand.json", JSON.stringify([{ before, after: "Revenue and profit rose across the year", reason: "wording" }]))
|
|
164
|
+
const run = runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--out", join(dir, "ampersand.html")]) as unknown as ApplyRun
|
|
165
|
+
|
|
166
|
+
expect(run.exitCode).toBe(0)
|
|
167
|
+
expect(run.json.data.rejected).toBe(1)
|
|
168
|
+
expect(run.json.defects.map((defect) => [defect.index, defect.code, defect.reason])).toEqual([[0, "polish_edit_rejected", POLISH_MARKUP_REASON]])
|
|
169
|
+
const line = ` rejected edit 0 ("${before}"): ${POLISH_MARKUP_REASON}`
|
|
170
|
+
expect(run.json.data.rejected_reasons).toEqual([line])
|
|
171
|
+
expect(run.summary.split("\n")[1]).toBe(line)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe("--changed-since stays fail-closed", () => {
|
|
176
|
+
const { pairsChangedSince } = polishCommand as unknown as { pairsChangedSince: (pairs: unknown[], mapPath: string, ledger: unknown, claims: unknown) => unknown[] }
|
|
177
|
+
const artifact = file("pre-polish.html", doc("<p>The span is 320 m [S1].</p>"))
|
|
178
|
+
|
|
179
|
+
test("#given a fill map without artifact_sha256 #when --changed-since reads it #then it refuses", () => {
|
|
180
|
+
const mapPath = file("fill-map-no-sha.json", JSON.stringify({ artifact, format: "html", entries: [] }))
|
|
181
|
+
expect(outcome(() => pairsChangedSince([], mapPath, LEDGER, null))).toContain("fill map has no artifact_sha256")
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("#given a fill map whose artifact changed since fill #when read #then it still refuses", () => {
|
|
185
|
+
const mapPath = file("fill-map-stale.json", JSON.stringify({ artifact, artifact_sha256: "0".repeat(64), format: "html", entries: [] }))
|
|
186
|
+
expect(outcome(() => pairsChangedSince([], mapPath, LEDGER, null))).toContain("changed after fill")
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe("polish-diff refuses an unknown defect code", () => {
|
|
191
|
+
test("#given a defect whose code is not in the registry #when classified #then it refuses instead of reading its severity", () => {
|
|
192
|
+
expect(outcome(() => polishDiffFails([{ code: "not_a_real_code", severity: "minor" }]))).toContain("unknown defect code not_a_real_code")
|
|
193
|
+
expect(source("fact-commands-polish.mjs")).not.toMatch(/\?\?\s*defect\.severity/u)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe("polish-apply writes atomically and honours --format", () => {
|
|
198
|
+
test("#given the command module #when read #then it writes the edition through writeTextAtomic", () => {
|
|
199
|
+
const text = source("fact-commands-polish.mjs")
|
|
200
|
+
expect(text).not.toMatch(/writeFileSync\s*\(/u)
|
|
201
|
+
expect(text).toMatch(/writeTextAtomic\(flags\.out/u)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test("#given --format md on an .html report #when polish-apply runs #then it refuses the Markdown edition", () => {
|
|
205
|
+
const editsPath = file("edits-format.json", "[]")
|
|
206
|
+
const result = outcome(() => runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--out", join(dir, "fmt.html"), "--format", "md"]))
|
|
207
|
+
expect(result).toContain("takes the filled HTML edition")
|
|
208
|
+
})
|
|
209
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// fact-commands-meaning.mjs - `fact pairs` and `fact meaning-check`, the script half of the
|
|
2
2
|
// separate-session meaning verifier. fact pairs writes the (source, report sentence) pairs a
|
|
3
|
-
// reader lane gets and nothing else
|
|
3
|
+
// reader lane gets and nothing else (with --changed-since, only the pairs whose sentence polish
|
|
4
|
+
// rewrote); it honours the brief's `meaning check` field and writes
|
|
4
5
|
// `{ skipped: true }` when it is off. fact meaning-check ingests the readers' verdict files
|
|
5
6
|
// (meaning-check.mjs), writes fidelity-meaning-defects.json (a fidelity record bound to the pairs'
|
|
6
7
|
// artifact_sha256 and report, through writeFidelityRecord), and records the run in
|
|
@@ -14,9 +15,8 @@ import { dirname, join, resolve } from "node:path"
|
|
|
14
15
|
|
|
15
16
|
import { CliError, parseArgs, readJsonFile, sha256Of, writeJsonAtomic, writeTextAtomic } from "./cli-support.mjs"
|
|
16
17
|
import { countDefects } from "./contracts.mjs"
|
|
17
|
-
import { markdownAsHtml, readEdition } from "./fact-commands-verify.mjs"
|
|
18
|
-
import {
|
|
19
|
-
import { validateLedger } from "./fact-ledger-schema.mjs"
|
|
18
|
+
import { ledgerFrom, markdownAsHtml, readEdition, sessionOf } from "./fact-commands-verify.mjs"
|
|
19
|
+
import { pairsChangedSince } from "./fact-commands-polish.mjs"
|
|
20
20
|
import { extractPairs, PAIR_KINDS } from "./fact-pairs.mjs"
|
|
21
21
|
import { FIDELITY_INCOMPLETE, meaningCheckEnabled, requireLockedBrief } from "./fidelity-brief.mjs"
|
|
22
22
|
import { MEANING_RECORD, writeFidelityRecord } from "./fidelity-records.mjs"
|
|
@@ -31,26 +31,11 @@ export const PROMOTION = Object.freeze({ code: "meaning_drift_confirmed", to: "i
|
|
|
31
31
|
/** The command status: fail when any defect blocks delivery under the registry policy. */
|
|
32
32
|
export const meaningStatus = (defects) => (defects.some(blocksDelivery) ? "fail" : "pass")
|
|
33
33
|
|
|
34
|
-
const sessionOf = (flags) => {
|
|
35
|
-
const dir = flags["session-dir"] ?? process.env.SESSION_DIR
|
|
36
|
-
return dir && dir !== true ? resolve(dir) : null
|
|
37
|
-
}
|
|
38
34
|
const inSession = (session, name, flag) => {
|
|
39
35
|
if (session === null) throw new CliError(`${flag} is required without --session-dir (or SESSION_DIR)`)
|
|
40
36
|
return join(session, name)
|
|
41
37
|
}
|
|
42
38
|
|
|
43
|
-
function readLedgerFrom(flags, session) {
|
|
44
|
-
if (flags.ledger === undefined) {
|
|
45
|
-
if (session === null) throw new CliError("--ledger is required without --session-dir (or SESSION_DIR)")
|
|
46
|
-
return readLedger(session)
|
|
47
|
-
}
|
|
48
|
-
const ledger = readJsonFile(flags.ledger)
|
|
49
|
-
const errors = validateLedger(ledger)
|
|
50
|
-
if (errors.length > 0) throw new CliError(`${flags.ledger} is not a valid fact ledger: ${errors[0]}`)
|
|
51
|
-
return ledger
|
|
52
|
-
}
|
|
53
|
-
|
|
54
39
|
function briefState(flags, session) {
|
|
55
40
|
const path = flags.brief ?? inSession(session, "brief.md", "--brief")
|
|
56
41
|
const lock = requireLockedBrief(path)
|
|
@@ -77,10 +62,10 @@ function runPairs(args) {
|
|
|
77
62
|
const edition = readEdition(report, flags)
|
|
78
63
|
const claimsPath = flags.claims ?? (session === null ? null : join(session, "claims.json"))
|
|
79
64
|
const claims = claimsPath !== null && (flags.claims !== undefined || existsSync(claimsPath)) ? readJsonFile(claimsPath) : null
|
|
80
|
-
const
|
|
81
|
-
const ledger = readLedgerFrom(flags, session)
|
|
65
|
+
const ledger = ledgerFrom(flags, session)
|
|
82
66
|
const html = edition.format === "md" ? markdownAsHtml(edition.text, edition.entries, ledger).html : edition.text
|
|
83
|
-
const
|
|
67
|
+
const all = extractPairs(html, ledger, claims)
|
|
68
|
+
const pairs = flags["changed-since"] === undefined ? all : pairsChangedSince(all, flags["changed-since"], ledger, claims)
|
|
84
69
|
const counts = { total: pairs.length, force_strong: pairs.filter((pair) => pair.force_strong).length }
|
|
85
70
|
for (const kind of PAIR_KINDS) counts[kind] = pairs.filter((pair) => pair.kind === kind).length
|
|
86
71
|
const record = { schemaVersion: 1, skipped: false, report: resolve(report), format: edition.format, artifact_sha256: sha256Of(edition.text), pairs_sha256: sha256Of(JSON.stringify(pairs)), counts, verdict_schema: VERDICT_SCHEMA, pairs }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// fact-commands-polish.mjs - `fact polish-apply` and `fact polish-diff`, the script half of the
|
|
2
|
+
// proofread gate, plus the --changed-since filter of `fact pairs`.
|
|
3
|
+
//
|
|
4
|
+
// The writing lane returns polish-edits.json ([{before, after, reason}]) and never edits the
|
|
5
|
+
// report. polish-apply applies each edit through polish-apply.mjs and writes the result; a
|
|
6
|
+
// rejected (major) or unlocated (minor) edit leaves its region unchanged and is listed with its
|
|
7
|
+
// index and reason so the orchestrator can re-send it; every rejection reason is printed verbatim, one
|
|
8
|
+
// line per rejected edit (index and the first 60 characters of its before), in the summary and in
|
|
9
|
+
// data.rejected_reasons. It exits 0 whenever it wrote the output.
|
|
10
|
+
// polish-diff is the whole-document backstop (polish-diff.mjs) between the pre-polish artifact
|
|
11
|
+
// and any later copy; it exits 1 on a blocker. pairsChangedSince keeps only the pairs whose
|
|
12
|
+
// sentence text (normalized) is not in the pre-polish artifact that a fill map names, so the
|
|
13
|
+
// meaning readers re-check exactly what polish rewrote. No model, no network.
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
16
|
+
import { join, resolve } from "node:path"
|
|
17
|
+
|
|
18
|
+
import { CliError, parseArgs, readJsonFile, sha256Of, writeJsonAtomic, writeTextAtomic } from "./cli-support.mjs"
|
|
19
|
+
import { countDefects, DEFECT_CODES } from "./contracts.mjs"
|
|
20
|
+
import { editionFormatOf, ledgerFrom, markdownAsHtml, sessionOf } from "./fact-commands-verify.mjs"
|
|
21
|
+
import { extractPairs } from "./fact-pairs.mjs"
|
|
22
|
+
import { applyEdits, POLISH_EDIT_FIELDS, validatePolishEdits } from "./polish-apply.mjs"
|
|
23
|
+
import { diffPolish } from "./polish-diff.mjs"
|
|
24
|
+
import { normalizeText } from "./text-normalize.mjs"
|
|
25
|
+
|
|
26
|
+
export { ledgerFrom, POLISH_EDIT_FIELDS, validatePolishEdits }
|
|
27
|
+
|
|
28
|
+
export const POLISH_RECORD = "polish-defects.json"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The polish-diff exit policy: only a registry blocker fails the command; majors are reported.
|
|
32
|
+
* Fail-closed like the delivery barrier: a code the registry does not know refuses outright,
|
|
33
|
+
* because a caller-claimed severity may not demote it.
|
|
34
|
+
*/
|
|
35
|
+
export const polishDiffFails = (defects) =>
|
|
36
|
+
defects.some((defect) => {
|
|
37
|
+
if (!Object.hasOwn(DEFECT_CODES, defect?.code)) throw new CliError(`unknown defect code ${defect?.code}`)
|
|
38
|
+
return DEFECT_CODES[defect.code].severity === "blocker"
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
function readText(path) {
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(path, "utf8")
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new CliError(`cannot read ${path}: ${error.code ?? error.message}`)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const withSeverity = (entry) => ({ ...entry, severity: DEFECT_CODES[entry.code].severity, hint: DEFECT_CODES[entry.code].hint })
|
|
50
|
+
|
|
51
|
+
/** The first 60 characters of an edit's before, on one line. */
|
|
52
|
+
const beforePreview = (before) => (typeof before === "string" ? before : "").replace(/\s+/gu, " ").slice(0, 60)
|
|
53
|
+
|
|
54
|
+
/** One line per rejected edit: its index, the start of its before, and its reason verbatim. */
|
|
55
|
+
const rejectionLine = (entry, edit) => ` rejected edit ${entry.index} ("${beforePreview(edit?.before)}"): ${entry.reason}`
|
|
56
|
+
|
|
57
|
+
function runPolishApply(args) {
|
|
58
|
+
const [report] = args.positionals
|
|
59
|
+
const { flags } = args
|
|
60
|
+
if (!report || flags.edits === undefined || flags.out === undefined) throw new CliError("usage: fact polish-apply <report.html> --edits polish-edits.json --out <report.polished.html> [--ledger F] [--session-dir D]")
|
|
61
|
+
if (editionFormatOf(report, flags.format) === "md") throw new CliError("fact polish-apply takes the filled HTML edition; polish a Markdown edition through its HTML source")
|
|
62
|
+
const html = readText(report)
|
|
63
|
+
const editsText = readText(flags.edits)
|
|
64
|
+
const edits = readJsonFile(flags.edits)
|
|
65
|
+
try {
|
|
66
|
+
validatePolishEdits(edits)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new CliError(`${flags.edits} must be an array of {${Object.keys(POLISH_EDIT_FIELDS).join(", ")}} objects: ${error.message}`)
|
|
69
|
+
}
|
|
70
|
+
const result = applyEdits(html, edits, ledgerFrom(flags))
|
|
71
|
+
writeTextAtomic(flags.out, result.out)
|
|
72
|
+
const defects = [...result.rejected, ...result.unlocated].sort((a, b) => a.index - b.index).map(withSeverity)
|
|
73
|
+
const counts = { applied: result.applied.length, rejected: result.rejected.length, unlocated: result.unlocated.length }
|
|
74
|
+
const data = { out: resolve(flags.out), ...counts, applied_indexes: result.applied, input_sha256: sha256Of(html), edits_sha256: sha256Of(editsText), out_sha256: sha256Of(result.out) }
|
|
75
|
+
const rejected_reasons = result.rejected.map((entry) => rejectionLine(entry, edits[entry.index]))
|
|
76
|
+
const summary = [`fact polish-apply: ${counts.applied} applied, ${counts.rejected} rejected, ${counts.unlocated} unlocated of ${edits.length} edit(s)`, ...rejected_reasons].join("\n")
|
|
77
|
+
return { json: { command: "fact polish-apply", status: "pass", exitCode: 0, defects, data: { ...data, rejected_reasons } }, summary, exitCode: 0 }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function runPolishDiff(args) {
|
|
81
|
+
const { flags } = args
|
|
82
|
+
if (flags.before === undefined || flags.after === undefined) throw new CliError("usage: fact polish-diff --before <pre-polish.html> --after <polished.html> [--ledger F] [--out polish-defects.json] [--session-dir D]")
|
|
83
|
+
const after = readText(flags.after)
|
|
84
|
+
const defects = diffPolish(readText(flags.before), after, ledgerFrom(flags)).map((defect) => ({ ...defect, hint: DEFECT_CODES[defect.code]?.hint ?? null }))
|
|
85
|
+
const counts = countDefects(defects)
|
|
86
|
+
const status = polishDiffFails(defects) ? "fail" : "pass"
|
|
87
|
+
const exitCode = status === "fail" ? 1 : 0
|
|
88
|
+
const record = { command: "fact polish-diff", status, exitCode, before: resolve(flags.before), artifact: resolve(flags.after), artifact_sha256: sha256Of(after), defects, counts }
|
|
89
|
+
const session = sessionOf(flags)
|
|
90
|
+
const out = flags.out ?? (session === null ? null : join(session, POLISH_RECORD))
|
|
91
|
+
if (out !== null) writeJsonAtomic(resolve(out), record)
|
|
92
|
+
const summary = `fact polish-diff: ${status.toUpperCase()} - ${counts.blocker} blocker, ${counts.major} major, ${counts.minor} minor`
|
|
93
|
+
return { json: { ...record, out: out === null ? null : resolve(out) }, summary, exitCode }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pairKey = (pair) => [pair.kind, pair.sentence_key, pair.claim_id ?? "", normalizeText(pair.sentence)].join("\u0000")
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The pairs whose sentence text changed since the pre-polish artifact a fill map names: a pair is
|
|
100
|
+
* kept unless the same (kind, sentence key, normalized sentence) occurs in that artifact. The map
|
|
101
|
+
* must carry the artifact's `artifact_sha256` and the bytes must still match it, so the filter is
|
|
102
|
+
* fail-closed on a hand-made or stale map.
|
|
103
|
+
* @param {object[]} pairs extractPairs output on the polished edition
|
|
104
|
+
* @param {string} mapPath the pre-polish fill map (fact fill's `<out>.fill-map.json`)
|
|
105
|
+
*/
|
|
106
|
+
export function pairsChangedSince(pairs, mapPath, ledger, claims) {
|
|
107
|
+
const map = readJsonFile(mapPath)
|
|
108
|
+
if (typeof map.artifact !== "string" || !existsSync(map.artifact)) throw new CliError(`${mapPath} names no readable pre-polish artifact`)
|
|
109
|
+
const text = readText(map.artifact)
|
|
110
|
+
if (typeof map.artifact_sha256 !== "string") throw new CliError(`${mapPath} fill map has no artifact_sha256; --changed-since needs the pre-polish hash`)
|
|
111
|
+
if (sha256Of(text) !== map.artifact_sha256) throw new CliError(`${map.artifact} changed after fill; --changed-since needs the pre-polish bytes`)
|
|
112
|
+
const html = map.format === "md" ? markdownAsHtml(text, map.entries, ledger).html : text
|
|
113
|
+
const before = new Map()
|
|
114
|
+
for (const pair of extractPairs(html, ledger, claims)) before.set(pairKey(pair), (before.get(pairKey(pair)) ?? 0) + 1)
|
|
115
|
+
return pairs.filter((pair) => {
|
|
116
|
+
const left = before.get(pairKey(pair)) ?? 0
|
|
117
|
+
if (left === 0) return true
|
|
118
|
+
before.set(pairKey(pair), left - 1)
|
|
119
|
+
return false
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @param {string[]} argv the arguments after `fact polish-apply` or `fact polish-diff` */
|
|
124
|
+
export function runFactPolish(sub, argv) {
|
|
125
|
+
const args = parseArgs(argv)
|
|
126
|
+
return sub === "polish-apply" ? runPolishApply(args) : runPolishDiff(args)
|
|
127
|
+
}
|
|
@@ -18,6 +18,7 @@ import { CliError, readJsonFile, sha256Of } from "./cli-support.mjs"
|
|
|
18
18
|
import { countDefects } from "./contracts.mjs"
|
|
19
19
|
import { recompute } from "./fact-derive.mjs"
|
|
20
20
|
import { readLedger } from "./fact-ledger.mjs"
|
|
21
|
+
import { validateLedger } from "./fact-ledger-schema.mjs"
|
|
21
22
|
import { FIDELITY_RECORD, writeFidelityRecord } from "./fidelity-records.mjs"
|
|
22
23
|
import { blocksDelivery, checkFidelity } from "./gates-fidelity.mjs"
|
|
23
24
|
import { parseMarkdown } from "./md-lite.mjs"
|
|
@@ -120,6 +121,24 @@ export function markdownAsHtml(text, entries, ledger) {
|
|
|
120
121
|
return { html: `${html}${open ? MD_HTML_BLOCK_RULES[open].group[1] : ""}</body></html>\n`, stale: [...stale] }
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/** The session dir from --session-dir or SESSION_DIR, or null; a bare flag names no session. */
|
|
125
|
+
export const sessionOf = (flags) => {
|
|
126
|
+
const dir = flags["session-dir"] ?? process.env.SESSION_DIR
|
|
127
|
+
return dir && dir !== true ? resolve(dir) : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The ledger from --ledger (validated) or from the session dir; every fact command loads it here. */
|
|
131
|
+
export function ledgerFrom(flags, session = sessionOf(flags)) {
|
|
132
|
+
if (flags.ledger === undefined) {
|
|
133
|
+
if (session === null) throw new CliError("--ledger is required without --session-dir (or SESSION_DIR)")
|
|
134
|
+
return readLedger(session)
|
|
135
|
+
}
|
|
136
|
+
const ledger = readJsonFile(flags.ledger)
|
|
137
|
+
const errors = validateLedger(ledger)
|
|
138
|
+
if (errors.length > 0) throw new CliError(`${flags.ledger} is not a valid fact ledger: ${errors[0]}`)
|
|
139
|
+
return ledger
|
|
140
|
+
}
|
|
141
|
+
|
|
123
142
|
const MARKDOWN_EXTENSION = /\.(md|markdown)$/iu
|
|
124
143
|
/** The edition format: the explicit flag, else md for a .md or .markdown path (any case), else html. */
|
|
125
144
|
export const editionFormatOf = (path, flag) => flag ?? (MARKDOWN_EXTENSION.test(path) ? "md" : "html")
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// fact-commands.mjs - the `fact` subcommands of report-tools: add, quote, retract, export, fill,
|
|
2
|
-
// derive, verify (fact-commands-verify.mjs), pairs and meaning-check (fact-commands-meaning.mjs)
|
|
2
|
+
// derive, verify (fact-commands-verify.mjs), pairs and meaning-check (fact-commands-meaning.mjs), polish-apply and
|
|
3
|
+
// polish-diff (fact-commands-polish.mjs). Every value reaches the ledger from a source
|
|
3
4
|
// snapshot through a deterministic locator or from a declared derivation; nothing here accepts a
|
|
4
5
|
// typed value. add and quote print the row with 60 chars of source context on each side (JSON
|
|
5
6
|
// with --json). Exit 0 ok, 1 semantic failure (fact_not_located, fill errors, a refused
|
|
@@ -217,6 +218,7 @@ function runDerive(args) {
|
|
|
217
218
|
export async function runFact(argv) {
|
|
218
219
|
const [sub, ...rest] = argv
|
|
219
220
|
if (sub === "pairs" || sub === "meaning-check") return (await import("./fact-commands-meaning.mjs")).runFactMeaning(sub, rest)
|
|
221
|
+
if (sub === "polish-apply" || sub === "polish-diff") return (await import("./fact-commands-polish.mjs")).runFactPolish(sub, rest)
|
|
220
222
|
const args = parseArgs(rest, { booleans: BOOLEANS })
|
|
221
223
|
switch (sub) {
|
|
222
224
|
case "add":
|
|
@@ -234,6 +236,6 @@ export async function runFact(argv) {
|
|
|
234
236
|
case "verify":
|
|
235
237
|
return (await import("./fact-commands-verify.mjs")).runFactVerify(args)
|
|
236
238
|
default:
|
|
237
|
-
throw new CliError("usage: fact <add|quote|retract|export|fill|verify|derive|pairs|meaning-check> ...")
|
|
239
|
+
throw new CliError("usage: fact <add|quote|retract|export|fill|verify|derive|pairs|meaning-check|polish-apply|polish-diff> ...")
|
|
238
240
|
}
|
|
239
241
|
}
|
|
@@ -96,11 +96,11 @@ function lexiconRouted(group, sentence) {
|
|
|
96
96
|
return lexiconChecks(probes).forceStrong.length > 0
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
function quotePairs(sentence, rows,
|
|
99
|
+
function quotePairs(sentence, rows, slug, reportLang) {
|
|
100
100
|
const groups = new Map()
|
|
101
101
|
for (const fact of sentence.facts) {
|
|
102
102
|
const row = rows.get(fact.id)
|
|
103
|
-
if (!row?.quote
|
|
103
|
+
if (!row?.quote) continue
|
|
104
104
|
const group = groups.get(row.quote) ?? { quote: row.quote, row, rows, facts: [], ids: [] }
|
|
105
105
|
group.facts.push(fact)
|
|
106
106
|
if (!group.ids.includes(fact.id)) group.ids.push(fact.id)
|
|
@@ -136,11 +136,11 @@ function claimPairs(ctx, block, sentence, byClaim, slug, reportLang) {
|
|
|
136
136
|
}))
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
function translationPair(node, rows,
|
|
139
|
+
function translationPair(node, rows, slug, reportLang) {
|
|
140
140
|
const id = node.attrs["data-translates"]
|
|
141
141
|
const row = rows.get(id)
|
|
142
142
|
const text = proseText(node)
|
|
143
|
-
if (!row?.quote || text === ""
|
|
143
|
+
if (!row?.quote || text === "") return null
|
|
144
144
|
return {
|
|
145
145
|
kind: "translation",
|
|
146
146
|
sentence_key: sentenceKey(slug, [id]),
|
|
@@ -157,13 +157,11 @@ function translationPair(node, rows, filled, slug, reportLang) {
|
|
|
157
157
|
* @param {string} html the filled report
|
|
158
158
|
* @param {{ rows: object[] }} ledger
|
|
159
159
|
* @param {{ claims: object[] } | object[] | null} claims indexClaims(...) output or its claims array
|
|
160
|
-
* @param {{ fact_id: string }[]} [fillMap] fact fill's map; when given, only facts it filled are paired
|
|
161
160
|
* @returns {object[]} pairs with pair_id P1, P2, ... in document order
|
|
162
161
|
*/
|
|
163
|
-
export function extractPairs(html, ledger, claims
|
|
162
|
+
export function extractPairs(html, ledger, claims) {
|
|
164
163
|
const ctx = buildContext(parseHtml(html), {})
|
|
165
164
|
const rows = new Map((ledger?.rows ?? []).map((row) => [row.fact_id, row]))
|
|
166
|
-
const filled = Array.isArray(fillMap) ? new Set(fillMap.map((entry) => entry.fact_id)) : null
|
|
167
165
|
const byClaim = claimMap(claims)
|
|
168
166
|
const htmlLang = ctx.all.find((node) => node.tag === "html")?.attrs.lang
|
|
169
167
|
const reportLang = htmlLang ? htmlLang.toLowerCase().split("-")[0] : null
|
|
@@ -174,12 +172,12 @@ export function extractPairs(html, ledger, claims, fillMap) {
|
|
|
174
172
|
if (!findings) continue
|
|
175
173
|
const slug = slugOf(block.scope)
|
|
176
174
|
for (const sentence of block.sentences) {
|
|
177
|
-
out.push(...quotePairs(sentence, rows,
|
|
175
|
+
out.push(...quotePairs(sentence, rows, slug, reportLang), ...claimPairs(ctx, block, sentence, byClaim, slug, reportLang))
|
|
178
176
|
}
|
|
179
177
|
for (const node of ctx.util.descendants(block.owner)) {
|
|
180
178
|
if (node.attrs?.["data-translates"] === undefined || seenSpans.has(node)) continue
|
|
181
179
|
seenSpans.add(node)
|
|
182
|
-
const pair = translationPair(node, rows,
|
|
180
|
+
const pair = translationPair(node, rows, slug, reportLang)
|
|
183
181
|
if (pair) out.push(pair)
|
|
184
182
|
}
|
|
185
183
|
}
|