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.
Files changed (101) hide show
  1. package/.agents/command/publish.md +3 -4
  2. package/.agents/skills/publish/SKILL.md +3 -4
  3. package/.opencode/command/publish.md +3 -4
  4. package/dist/agents/atlas/agent.d.ts +3 -2
  5. package/dist/agents/sisyphus/index.d.ts +1 -0
  6. package/dist/agents/sisyphus/kimi-k3-1.d.ts +9 -0
  7. package/dist/agents/sisyphus-agent-factory.d.ts +1 -1
  8. package/dist/agents/sisyphus-junior/agent.d.ts +1 -1
  9. package/dist/agents/sisyphus-junior/index.d.ts +1 -0
  10. package/dist/agents/sisyphus-junior/kimi-k3-1.d.ts +6 -0
  11. package/dist/agents/types.d.ts +2 -2
  12. package/dist/cli/index.js +29 -21
  13. package/dist/cli/service-auth/keystore.d.ts +8 -3
  14. package/dist/cli-node/index.js +29 -21
  15. package/dist/index.js +634 -17
  16. package/dist/skills/browser/runtime/omowright/manifest.json +1 -1
  17. package/dist/skills/ulw-research/SKILL.md +1 -1
  18. package/dist/skills/ulw-research/references/deliverable-phase.md +9 -2
  19. package/dist/skills/ulw-research/scripts/copy-returns-polish.test.ts +209 -0
  20. package/dist/skills/ulw-research/scripts/fact-commands-meaning.mjs +7 -22
  21. package/dist/skills/ulw-research/scripts/fact-commands-polish.mjs +127 -0
  22. package/dist/skills/ulw-research/scripts/fact-commands-verify.mjs +19 -0
  23. package/dist/skills/ulw-research/scripts/fact-commands.mjs +4 -2
  24. package/dist/skills/ulw-research/scripts/fact-pairs.mjs +7 -9
  25. package/dist/skills/ulw-research/scripts/fact-pairs.test.ts +7 -23
  26. package/dist/skills/ulw-research/scripts/references-fences.test.ts +25 -0
  27. package/dist/skills/ulw-research/scripts/report-tools.test.ts +87 -0
  28. package/dist/skills/ulw-research/scripts/tests/fixtures/fidelity/polish-edits.json +12 -0
  29. package/dist/tui.js +13 -13
  30. package/package.json +13 -13
  31. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  32. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +1 -2
  33. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  34. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  35. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  36. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  37. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  38. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  39. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  40. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  41. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  42. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  43. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  44. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  45. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  46. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  47. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  48. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  49. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  50. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  51. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  52. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  53. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  54. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  55. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  68. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  69. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  70. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  71. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  72. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  73. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  74. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  75. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  76. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  77. package/packages/omo-codex/plugin/package-lock.json +12 -12
  78. package/packages/omo-codex/plugin/package.json +1 -1
  79. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +1 -1
  80. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  81. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +9 -2
  82. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-meaning.mjs +7 -22
  83. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-polish.mjs +127 -0
  84. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-verify.mjs +19 -0
  85. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands.mjs +4 -2
  86. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-pairs.mjs +7 -9
  87. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +1 -1
  88. package/packages/omo-codex/scripts/install-dist/install-local.mjs +4 -5
  89. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
  90. package/packages/shared-skills/skills/ulw-research/SKILL.md +1 -1
  91. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +9 -2
  92. package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-polish.test.ts +209 -0
  93. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-meaning.mjs +7 -22
  94. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-polish.mjs +127 -0
  95. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-verify.mjs +19 -0
  96. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands.mjs +4 -2
  97. package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.mjs +7 -9
  98. package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.test.ts +7 -23
  99. package/packages/shared-skills/skills/ulw-research/scripts/references-fences.test.ts +25 -0
  100. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +87 -0
  101. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/polish-edits.json +12 -0
@@ -5,5 +5,5 @@
5
5
  "index.js": "d67d3a847fe0bbc43e824feded48b280890d31879538309e671cd5f671133995",
6
6
  "page-bundle.js": "bfd51e8383cf47200d57387bbbed258bce5a3a3d00c38fc108034d2888e23f0c"
7
7
  },
8
- "stagedAtUtc": "2026-10-25T01:01:55.641Z"
8
+ "stagedAtUtc": "2026-10-25T10:57:15.107Z"
9
9
  }
@@ -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. It returns a defect list; fix every item and re-run the gate on the delta. Deliver only on a clean pass. This gate runs BEFORE the first delivery, not after the user finds the typo.
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 &amp; or &lt;) 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>` (pair only the facts that map filled), `--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`) |
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; it honours the brief's `meaning check` field and writes
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 { readLedger } from "./fact-ledger.mjs"
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 changed = flags["changed-since"] === undefined ? undefined : readJsonFile(flags["changed-since"]).entries
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 pairs = extractPairs(html, ledger, claims, changed)
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). Every value reaches the ledger from a source
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, filled, slug, reportLang) {
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 || (filled && !filled.has(fact.id))) continue
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, filled, slug, reportLang) {
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 === "" || (filled && !filled.has(id))) return null
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, fillMap) {
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, filled, slug, reportLang), ...claimPairs(ctx, block, sentence, byClaim, slug, reportLang))
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, filled, slug, reportLang)
180
+ const pair = translationPair(node, rows, slug, reportLang)
183
181
  if (pair) out.push(pair)
184
182
  }
185
183
  }
@@ -39,8 +39,8 @@ function filled(): { out: string; fillMap: { fact_id: string }[] } {
39
39
  }
40
40
 
41
41
  function pairs(): Pair[] {
42
- const { out, fillMap } = filled()
43
- return extractPairs(out, LEDGER, CLAIMS, fillMap) as Pair[]
42
+ const { out } = filled()
43
+ return extractPairs(out, LEDGER, CLAIMS) as Pair[]
44
44
  }
45
45
 
46
46
  describe("extractPairs: a filled report", () => {
@@ -110,14 +110,7 @@ describe("extractPairs: a filled report", () => {
110
110
  expect(sentences.some((text) => text.includes("Bridge authority"))).toBe(false)
111
111
  })
112
112
 
113
- test("#given a fill map that omits a fact #when extracted #then that hand-typed fact yields no pair", () => {
114
- const { out, fillMap } = filled()
115
- const kept = fillMap.filter((entry) => entry.fact_id !== "F13")
116
- const result = extractPairs(out, LEDGER, CLAIMS, kept) as Pair[]
117
- expect(result.some((pair) => Array.isArray(pair.fact_ids) && (pair.fact_ids as string[]).includes("F13"))).toBe(false)
118
- })
119
-
120
- test("#given no fill map #when extracted #then every data-fact element counts as filled", () => {
113
+ test("#given the filled fixture #when extracted #then every data-fact element yields its pair", () => {
121
114
  const { out } = filled()
122
115
  expect((extractPairs(out, LEDGER, CLAIMS) as Pair[]).length).toBe(6)
123
116
  })
@@ -125,20 +118,11 @@ describe("extractPairs: a filled report", () => {
125
118
  test("#given a Hangul quote #when extracted #then source_lang is ko", () => {
126
119
  const html = "<html><body><h2>Findings</h2><p>HBM revenue reached {{F21:hbm_rev}} [S5].</p></body></html>"
127
120
  const result = fill(html, LEDGER, { format: "html" }) as { out: string; fillMap: { fact_id: string }[] }
128
- const [pair] = extractPairs(result.out, LEDGER, CLAIMS, result.fillMap) as Pair[]
121
+ const [pair] = extractPairs(result.out, LEDGER, CLAIMS) as Pair[]
129
122
  expect(pair).toMatchObject({ fact_ids: ["F21"], source_lang: "ko", report_lang: "en" })
130
123
  })
131
124
  })
132
125
 
133
- describe("extractPairs: a fill map over translations", () => {
134
- test("#given a fill map that omits the translated fact #when extracted #then no translation pair is emitted", () => {
135
- const { out, fillMap } = filled()
136
- const kept = fillMap.filter((entry) => entry.fact_id !== "Q7")
137
- const result = extractPairs(out, LEDGER, CLAIMS, kept) as Pair[]
138
- expect(result.some((pair) => pair.kind === "translation")).toBe(false)
139
- })
140
- })
141
-
142
126
  describe("hasHedge: a month is not a hedge", () => {
143
127
  test("#given a text that starts with the month May #when checked #then it does not count as hedged", () => {
144
128
  expect(hasHedge("May 2024 sales rose.")).toBe(false)
@@ -152,9 +136,9 @@ describe("hasHedge: a month is not a hedge", () => {
152
136
 
153
137
  describe("extractPairs: determinism", () => {
154
138
  test("#given the same filled report twice #when extracted #then both results are byte-for-byte equal", () => {
155
- const { out, fillMap } = filled()
156
- const first = JSON.stringify(extractPairs(out, LEDGER, CLAIMS, fillMap))
157
- const second = JSON.stringify(extractPairs(out, LEDGER, CLAIMS, fillMap))
139
+ const { out } = filled()
140
+ const first = JSON.stringify(extractPairs(out, LEDGER, CLAIMS))
141
+ const second = JSON.stringify(extractPairs(out, LEDGER, CLAIMS))
158
142
  expect(second).toBe(first)
159
143
  })
160
144
  })