oh-my-opencode 5.1.46 → 5.1.48

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 (124) 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 +30 -22
  13. package/dist/cli/service-auth/keystore.d.ts +8 -3
  14. package/dist/cli-node/index.js +30 -22
  15. package/dist/index.js +635 -18
  16. package/dist/skills/browser/runtime/omowright/manifest.json +1 -1
  17. package/dist/skills/ulw-research/SKILL.md +59 -2
  18. package/dist/skills/ulw-research/references/deliverable-phase.md +57 -8
  19. package/dist/skills/ulw-research/references/report-gates.md +2 -1
  20. package/dist/skills/ulw-research/scripts/contracts.mjs +3 -11
  21. package/dist/skills/ulw-research/scripts/contracts.test.ts +1 -0
  22. package/dist/skills/ulw-research/scripts/copy-returns-fidelity-policy.test.ts +156 -0
  23. package/dist/skills/ulw-research/scripts/copy-returns-polish.test.ts +209 -0
  24. package/dist/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
  25. package/dist/skills/ulw-research/scripts/fact-commands-meaning.mjs +21 -44
  26. package/dist/skills/ulw-research/scripts/fact-commands-polish.mjs +132 -0
  27. package/dist/skills/ulw-research/scripts/fact-commands-verify.mjs +37 -4
  28. package/dist/skills/ulw-research/scripts/fact-commands.mjs +8 -47
  29. package/dist/skills/ulw-research/scripts/fact-fill.mjs +3 -2
  30. package/dist/skills/ulw-research/scripts/fact-pairs.mjs +7 -9
  31. package/dist/skills/ulw-research/scripts/fact-pairs.test.ts +7 -23
  32. package/dist/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
  33. package/dist/skills/ulw-research/scripts/references-fences.test.ts +25 -0
  34. package/dist/skills/ulw-research/scripts/report-tools.test.ts +329 -1
  35. package/dist/skills/ulw-research/scripts/source-commands.mjs +8 -3
  36. package/dist/skills/ulw-research/scripts/tests/fixtures/fidelity/polish-edits.json +12 -0
  37. package/dist/tui.js +14 -14
  38. package/package.json +14 -14
  39. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  40. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +1 -2
  41. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  42. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  43. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  44. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  45. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  46. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  47. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  48. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  49. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  50. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  51. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  52. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  53. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  54. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  55. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  56. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  57. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  58. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  59. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  60. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  61. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  62. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  63. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  68. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  69. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  70. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  71. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  72. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  73. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  74. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  75. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  76. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  77. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  78. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  79. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  80. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  81. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  82. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  83. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  84. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  85. package/packages/omo-codex/plugin/package-lock.json +12 -12
  86. package/packages/omo-codex/plugin/package.json +1 -1
  87. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +1 -1
  88. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  89. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +58 -1
  90. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +57 -8
  91. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +2 -1
  92. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +3 -11
  93. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
  94. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-meaning.mjs +21 -44
  95. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-polish.mjs +132 -0
  96. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-verify.mjs +37 -4
  97. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands.mjs +8 -47
  98. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-fill.mjs +3 -2
  99. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-pairs.mjs +7 -9
  100. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
  101. package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-commands.mjs +8 -3
  102. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +1 -1
  103. package/packages/omo-codex/scripts/install-dist/install-local.mjs +4 -5
  104. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
  105. package/packages/shared-skills/skills/ulw-research/SKILL.md +59 -2
  106. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +57 -8
  107. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +2 -1
  108. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +3 -11
  109. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +1 -0
  110. package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-fidelity-policy.test.ts +156 -0
  111. package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-polish.test.ts +209 -0
  112. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
  113. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-meaning.mjs +21 -44
  114. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-polish.mjs +132 -0
  115. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-verify.mjs +37 -4
  116. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands.mjs +8 -47
  117. package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.mjs +3 -2
  118. package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.mjs +7 -9
  119. package/packages/shared-skills/skills/ulw-research/scripts/fact-pairs.test.ts +7 -23
  120. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
  121. package/packages/shared-skills/skills/ulw-research/scripts/references-fences.test.ts +25 -0
  122. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +329 -1
  123. package/packages/shared-skills/skills/ulw-research/scripts/source-commands.mjs +8 -3
  124. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/fidelity/polish-edits.json +12 -0
@@ -8,6 +8,11 @@
8
8
  // escaped HTML projection (same line numbers) and checked with its fill map, which is the only
9
9
  // record of where its values sit; LaTeX is verified through its Markdown source.
10
10
  //
11
+ // The locked brief is re-judged here, not only at fill: the edition's fill map (--fill-map, else
12
+ // `<report>.fill-map.json` when present) must carry the current brief's brief_sha256
13
+ // (brief_changed), and policyErrors re-runs over every data-fact the edition carries, so an
14
+ // edition that violates the current derived-numbers or source-floor rule fails here too.
15
+ //
11
16
  // The delivery barrier that reads these records (and every other fidelity record) is
12
17
  // fidelity-records.mjs.
13
18
 
@@ -18,6 +23,8 @@ import { CliError, readJsonFile, sha256Of } from "./cli-support.mjs"
18
23
  import { countDefects } from "./contracts.mjs"
19
24
  import { recompute } from "./fact-derive.mjs"
20
25
  import { readLedger } from "./fact-ledger.mjs"
26
+ import { validateLedger } from "./fact-ledger-schema.mjs"
27
+ import { briefBindingErrors, fillPolicy, lockedBrief, policyErrors } from "./fidelity-policy.mjs"
21
28
  import { FIDELITY_RECORD, writeFidelityRecord } from "./fidelity-records.mjs"
22
29
  import { blocksDelivery, checkFidelity } from "./gates-fidelity.mjs"
23
30
  import { parseMarkdown } from "./md-lite.mjs"
@@ -120,6 +127,24 @@ export function markdownAsHtml(text, entries, ledger) {
120
127
  return { html: `${html}${open ? MD_HTML_BLOCK_RULES[open].group[1] : ""}</body></html>\n`, stale: [...stale] }
121
128
  }
122
129
 
130
+ /** The session dir from --session-dir or SESSION_DIR, or null; a bare flag names no session. */
131
+ export const sessionOf = (flags) => {
132
+ const dir = flags["session-dir"] ?? process.env.SESSION_DIR
133
+ return dir && dir !== true ? resolve(dir) : null
134
+ }
135
+
136
+ /** The ledger from --ledger (validated) or from the session dir; every fact command loads it here. */
137
+ export function ledgerFrom(flags, session = sessionOf(flags)) {
138
+ if (flags.ledger === undefined) {
139
+ if (session === null) throw new CliError("--ledger is required without --session-dir (or SESSION_DIR)")
140
+ return readLedger(session)
141
+ }
142
+ const ledger = readJsonFile(flags.ledger)
143
+ const errors = validateLedger(ledger)
144
+ if (errors.length > 0) throw new CliError(`${flags.ledger} is not a valid fact ledger: ${errors[0]}`)
145
+ return ledger
146
+ }
147
+
123
148
  const MARKDOWN_EXTENSION = /\.(md|markdown)$/iu
124
149
  /** The edition format: the explicit flag, else md for a .md or .markdown path (any case), else html. */
125
150
  export const editionFormatOf = (path, flag) => flag ?? (MARKDOWN_EXTENSION.test(path) ? "md" : "html")
@@ -140,8 +165,11 @@ export function runFactVerify(args) {
140
165
  const [report] = args.positionals
141
166
  const { flags } = args
142
167
  if (!report) throw new CliError("usage: fact verify <report> [--format html|md] [--sources D] [--claims claims.json] [--fill-map F] [--brief brief.md] [--out fidelity-defects.json]")
143
- const edition = readEdition(report, flags)
144
168
  const sessionDir = sessionDirOf(flags)
169
+ const { parsed, refusal } = lockedBrief("fact verify", flags)
170
+ if (refusal) return refusal
171
+ const policy = fillPolicy(parsed)
172
+ const edition = readEdition(report, flags)
145
173
  const claimsPath = flags.claims ?? join(sessionDir, "claims.json")
146
174
  const briefPath = flags.brief ?? join(sessionDir, "brief.md")
147
175
  const ledger = readLedger(sessionDir)
@@ -155,10 +183,15 @@ export function runFactVerify(args) {
155
183
  baseDir: dirname(resolve(report)),
156
184
  recompute,
157
185
  })
158
- const counts = countDefects(result.defects)
159
- const status = result.defects.some(blocksDelivery) ? "fail" : "pass"
186
+ const checked = projected?.html ?? edition.text
187
+ const pointers = [...checked.matchAll(/\bdata-fact="([^"]+)"/gu)].map((match) => ({ token: match[0], start: match.index, parsed: { id: match[1] } }))
188
+ const mapPath = flags["fill-map"] ?? `${report}.fill-map.json`
189
+ const bound = existsSync(mapPath) ? briefBindingErrors(readJsonFile(mapPath), mapPath, parsed) : []
190
+ const defects = [...bound, ...policyErrors(checked, pointers, ledger, sessionDir, policy).errors, ...result.defects]
191
+ const counts = countDefects(defects)
192
+ const status = defects.some(blocksDelivery) ? "fail" : "pass"
160
193
  const exitCode = status === "fail" ? 1 : 0
161
194
  const out = resolve(flags.out ?? join(sessionDir, FIDELITY_RECORD))
162
- const record = writeFidelityRecord(out, "fidelity", { command: "fact verify", status, exitCode, format: edition.format, report: resolve(report), artifact_sha256: sha256Of(edition.text), defects: result.defects, counts, gatesRun: result.gatesRun })
195
+ const record = writeFidelityRecord(out, "fidelity", { command: "fact verify", status, exitCode, format: edition.format, report: resolve(report), artifact_sha256: sha256Of(edition.text), defects, counts, gatesRun: result.gatesRun })
163
196
  return { json: { ...record, out }, summary: `fact verify: ${status.toUpperCase()} - ${counts.blocker} blocker, ${counts.major} major, ${counts.minor} minor`, exitCode }
164
197
  }
@@ -1,21 +1,19 @@
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
1
+ // fact-commands.mjs - the `fact` subcommands of report-tools: add, quote, retract, export, derive,
2
+ // fill (fact-commands-fill.mjs), verify (fact-commands-verify.mjs), pairs and meaning-check
3
+ // (fact-commands-meaning.mjs), polish-apply and 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
6
7
  // derivation), 2 usage. node: builtins and sibling imports only.
7
8
 
8
9
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
9
- import { dirname, extname, join, resolve } from "node:path"
10
+ import { join, resolve } from "node:path"
10
11
 
11
- import { CliError, parseArgs, sha256Of, writeJsonAtomic } from "./cli-support.mjs"
12
- import { DEFECT_CODES } from "./contracts.mjs"
12
+ import { CliError, parseArgs, sha256Of } from "./cli-support.mjs"
13
13
  import { derive } from "./fact-derive.mjs"
14
- import { fill } from "./fact-fill.mjs"
15
14
  import { addRow, readLedger, retractRow, supersedeRow, writeLedger } from "./fact-ledger.mjs"
16
15
  import { locateFind, locateMatch, locatePageLine, locateTable } from "./fact-locate.mjs"
17
16
  import { QUALIFIER_FORMS } from "./fidelity-lexicon.mjs"
18
- import { readState, writeState } from "./fidelity-state.mjs"
19
17
  import { exportRows } from "./fidelity-values.mjs"
20
18
  import { pointedFlag, sessionDirOf, snapshotOf } from "./source-commands.mjs"
21
19
 
@@ -156,44 +154,6 @@ function runExport(args) {
156
154
  return { json: { command: "fact export", status: "pass", exitCode: 0, data: { out: resolve(args.flags.out), format: args.flags.csv ? "csv" : "json", sha256: sha256Of(text), coverage } }, summary: coverage, exitCode: 0 }
157
155
  }
158
156
 
159
- /** The inline edition: local stylesheets and scripts inlined, relative image paths made absolute. */
160
- function inlineAssets(html, baseDir) {
161
- const local = (ref) => !/^[a-z][a-z0-9+.-]*:|^\/\//i.test(ref)
162
- return html
163
- .replace(/<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi, (tag) => {
164
- const href = /\bhref=["']([^"']+)["']/i.exec(tag)?.[1]
165
- return href && local(href) ? `<style>${readText(resolve(baseDir, href))}</style>` : tag
166
- })
167
- .replace(/<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)><\/script>/gi, (tag, pre, src, post) => (local(src) ? `<script${pre}${post}>${readText(resolve(baseDir, src))}</script>` : tag))
168
- .replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (tag, pre, src, post) => (local(src) && !src.startsWith("/") ? `${pre}${resolve(baseDir, src)}${post}` : tag))
169
- }
170
-
171
- function runFill(args) {
172
- const [source] = args.positionals
173
- const { flags } = args
174
- if (!source || (flags.out === undefined && !flags.view)) throw new CliError("usage: fact fill <src> (--out <path> | --view) [--format html|md|tex] [--inline] [--replace-failed]")
175
- const format = flags.format ?? ({ ".md": "md", ".tex": "tex" }[extname(source).toLowerCase()] ?? "html")
176
- if (!["html", "md", "tex"].includes(format)) throw new CliError("--format must be html, md, or tex")
177
- if (flags.inline && format !== "html") throw new CliError("--inline applies to the html edition only")
178
- const sessionDir = sessionDirOf(flags)
179
- const state = flags["replace-failed"] ? readState(sessionDir) : null
180
- const result = fill(readText(source), readLedger(sessionDir), { format: format === "tex" ? "md" : format, view: flags.view === true, ...(state ? { replaceFailed: state } : {}) })
181
- const warnings = result.warnings
182
- if (result.out === null) {
183
- const defects = result.errors.map((error) => ({ ...error, severity: DEFECT_CODES[error.code]?.severity ?? "blocker", hint: DEFECT_CODES[error.code]?.hint ?? null }))
184
- return { json: { command: "fact fill", status: "fail", exitCode: 1, defects, warnings }, summary: `fact fill: FAIL - ${defects.length} error(s), nothing written`, exitCode: 1 }
185
- }
186
- const out = flags.inline ? inlineAssets(result.out, dirname(resolve(source))) : result.out
187
- if (flags.view) return { text: out, summary: `fact fill --view: ${result.fillMap.length} pointer(s), ${warnings.length} warning(s)`, exitCode: 0 }
188
- writeFileSync(flags.out, out)
189
- if (state) writeState(sessionDir, state)
190
- const artifact = sha256Of(out)
191
- const map = `${resolve(flags.out)}.fill-map.json`
192
- writeJsonAtomic(map, { schemaVersion: 1, format, artifact: resolve(flags.out), artifact_sha256: artifact, entries: result.fillMap })
193
- const data = { out: resolve(flags.out), map, artifact_sha256: artifact, occurrences: result.fillMap.length, warnings }
194
- return { json: { command: "fact fill", status: "pass", exitCode: 0, defects: [], data }, summary: `fact fill: wrote ${flags.out} (${result.fillMap.length} pointer(s))`, exitCode: 0 }
195
- }
196
-
197
157
  function runDerive(args) {
198
158
  const { flags } = args
199
159
  const op = args.required("op")
@@ -217,6 +177,7 @@ function runDerive(args) {
217
177
  export async function runFact(argv) {
218
178
  const [sub, ...rest] = argv
219
179
  if (sub === "pairs" || sub === "meaning-check") return (await import("./fact-commands-meaning.mjs")).runFactMeaning(sub, rest)
180
+ if (sub === "polish-apply" || sub === "polish-diff") return (await import("./fact-commands-polish.mjs")).runFactPolish(sub, rest)
220
181
  const args = parseArgs(rest, { booleans: BOOLEANS })
221
182
  switch (sub) {
222
183
  case "add":
@@ -228,12 +189,12 @@ export async function runFact(argv) {
228
189
  case "export":
229
190
  return runExport(args)
230
191
  case "fill":
231
- return runFill(args)
192
+ return (await import("./fact-commands-fill.mjs")).runFactFill(args)
232
193
  case "derive":
233
194
  return runDerive(args)
234
195
  case "verify":
235
196
  return (await import("./fact-commands-verify.mjs")).runFactVerify(args)
236
197
  default:
237
- throw new CliError("usage: fact <add|quote|retract|export|fill|verify|derive|pairs|meaning-check> ...")
198
+ throw new CliError("usage: fact <add|quote|retract|export|fill|verify|derive|pairs|meaning-check|polish-apply|polish-diff> ...")
238
199
  }
239
200
  }
@@ -10,7 +10,8 @@ import { failureCount, markReplaced, sentenceKey } from "./fidelity-state.mjs"
10
10
  import { findPlaceholders, lead, qualifiedText, renderBlockquote, renderQuote, renderValueHtml, subSpan, valueParts } from "./placeholder.mjs"
11
11
  import { splitSentences } from "./sentence-split.mjs"
12
12
 
13
- const FORMATS = Object.freeze(["html", "md"])
13
+ /** The output formats fill renders. */
14
+ export const FILL_FORMATS = Object.freeze(["html", "md"])
14
15
  const TRANSLATION = Object.freeze({
15
16
  html: /^\s*<span data-translates="(Q\d+)">[\s\S]*?<\/span>/u,
16
17
  md: /^\s*<!-- translates: (Q\d+) -->[^\n]*/u,
@@ -166,7 +167,7 @@ function assemble(text, parts, context) {
166
167
  */
167
168
  export function fill(sourceText, ledger, options = {}) {
168
169
  const format = options.format ?? "html"
169
- if (!FORMATS.includes(format)) throw new TypeError(`format must be one of ${FORMATS.join(", ")}`)
170
+ if (!FILL_FORMATS.includes(format)) throw new TypeError(`format must be one of ${FILL_FORMATS.join(", ")}`)
170
171
  const rows = new Map(ledger.rows.map((row) => [row.fact_id, row]))
171
172
  const found = findPlaceholders(sourceText)
172
173
  const errors = []
@@ -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
  }
@@ -0,0 +1,183 @@
1
+ // fidelity-policy.mjs - what the locked `## Fidelity` block of brief.md means to the CLI stages.
2
+ //
3
+ // lockedBrief is the gate the six reader-facing stages (fact fill, verify, pairs, meaning-check,
4
+ // polish-apply, polish-diff) run before reading or writing anything: an unlocked brief returns
5
+ // one fidelity_interview_incomplete defect (exit 1), a malformed block is a usage error (exit 2).
6
+ // fillPolicy reads the fields fact fill consumes (a value off its table is a usage error, exit 2),
7
+ // and policyErrors refuses a placeholder whose row the brief forbids: derived_not_allowed
8
+ // (`derived numbers: none` against a derived, converted, or assumed row) and source_outside_floor
9
+ // (the source floor is evaluated live against each source's meta.json at every fill and verify;
10
+ // under any floor but `standard` a row resting on no source is refused too; a row's outside_floor
11
+ // is only the cached result). briefHashOf binds the locked block into the fill map, and
12
+ // briefBindingErrors refuses an edition whose map was filled under another brief (brief_changed).
13
+ // Collection stages never call this module. node: builtins and sibling imports only.
14
+
15
+ import { existsSync, readFileSync } from "node:fs"
16
+ import { join, resolve } from "node:path"
17
+
18
+ import { CliError, sha256Of } from "./cli-support.mjs"
19
+ import { DEFECT_CODES } from "./contracts.mjs"
20
+ import { KIND_RULES } from "./fact-ledger-kinds.mjs"
21
+ import { FIDELITY_FIELDS, meaningCheckEnabled, requireLockedBrief } from "./fidelity-brief.mjs"
22
+
23
+ export { meaningCheckEnabled }
24
+
25
+ /**
26
+ * Ledger kinds a brief with `derived numbers: none` refuses. A policy list, not the ledger's
27
+ * derivation set: it also names assumed. Every name must be a KIND_RULES kind (checked at load).
28
+ */
29
+ const DERIVED_KINDS = Object.freeze(["derived", "converted", "assumed"])
30
+ for (const kind of DERIVED_KINDS) {
31
+ if (!Object.hasOwn(KIND_RULES, kind)) throw new Error(`fidelity-policy: ${kind} is not a KIND_RULES kind`)
32
+ }
33
+
34
+ /** Fields whose only legal values are a closed table: value (any case) -> its reading. */
35
+ const VALUE_TABLES = Object.freeze({
36
+ "derived numbers": Object.freeze({ allowed: false, none: true }),
37
+ fallback: Object.freeze({ "block-quote": "quote", omit: "omit" }),
38
+ })
39
+
40
+ /** The reading of a table value; any other text is a usage error naming the field and the value. */
41
+ function tableValue(field, value) {
42
+ const table = VALUE_TABLES[field]
43
+ const key = value.trim().toLowerCase()
44
+ if (Object.hasOwn(table, key)) return table[key]
45
+ throw new CliError(`${field} "${value}" is not ${Object.keys(table).join(", ")}`)
46
+ }
47
+
48
+ /** How each field value the fill stages consume is read; the single owner of those readings. */
49
+ export const FIDELITY_VALUE_RULES = Object.freeze({
50
+ tables: VALUE_TABLES,
51
+ derivedKinds: DERIVED_KINDS,
52
+ derivedRefused: (kind) => DERIVED_KINDS.includes(kind),
53
+ derivedNone: (value) => tableValue("derived numbers", value),
54
+ fallback: (value) => tableValue("fallback", value),
55
+ translations: (value) => /with translation beside/iu.test(value),
56
+ meaningCheck: (value) => meaningCheckEnabled({ fields: { "meaning check": value } }),
57
+ })
58
+
59
+ /** The `source floor` forms: `pattern` reads one, `listed` forms carry a comma list. */
60
+ export const SOURCE_FLOOR_RULES = Object.freeze([
61
+ Object.freeze({ kind: "standard", form: "standard", pattern: /^standard$/iu, listed: false }),
62
+ Object.freeze({ kind: "primary", form: "primary only", pattern: /^primary only$/iu, listed: false }),
63
+ Object.freeze({ kind: "pointed", form: "pointed only: <list>", pattern: /^pointed only:\s*(.+)$/iu, listed: true }),
64
+ Object.freeze({ kind: "classes", form: "classes: <list>", pattern: /^classes:\s*(.+)$/iu, listed: true }),
65
+ ])
66
+ const lineOf = (text, at) => text.slice(0, at).split("\n").length
67
+ const listOf = (text) => text.split(",").map((entry) => entry.trim()).filter(Boolean)
68
+
69
+ /** brief.md from --brief, else the session's; a usage error when neither is known. */
70
+ export function briefPathOf(flags) {
71
+ if (flags.brief !== undefined && flags.brief !== true) return resolve(flags.brief)
72
+ const dir = flags["session-dir"] ?? process.env.SESSION_DIR
73
+ if (!dir || dir === true) throw new CliError("--brief is required without --session-dir (or SESSION_DIR)")
74
+ return join(resolve(dir), "brief.md")
75
+ }
76
+
77
+ /**
78
+ * @returns {{ parsed: object, refusal: null } | { parsed: null, refusal: { json: object, summary: string, exitCode: 1 } }}
79
+ */
80
+ export function lockedBrief(command, flags) {
81
+ const path = briefPathOf(flags)
82
+ const lock = requireLockedBrief(path)
83
+ if (lock.exitCode === 2) throw new CliError(`${path}: ${lock.errors.join("; ")}`)
84
+ if (lock.locked) return { parsed: lock.parsed, refusal: null }
85
+ const json = { command, status: "fail", exitCode: 1, defects: lock.defects }
86
+ return { parsed: null, refusal: { json, summary: `${command}: ${lock.defects[0].message}`, exitCode: 1 } }
87
+ }
88
+
89
+ /**
90
+ * The source floor as a rule, or null for `standard`; a usage error for any other text.
91
+ * @returns {null | { kind: "primary" } | { kind: "pointed" | "classes", list: string[] }}
92
+ */
93
+ export function parseSourceFloor(value) {
94
+ const text = value.trim()
95
+ for (const rule of SOURCE_FLOOR_RULES) {
96
+ const match = rule.pattern.exec(text)
97
+ if (!match) continue
98
+ if (rule.kind === "standard") return null
99
+ return rule.listed ? { kind: rule.kind, list: listOf(match[1]) } : { kind: rule.kind }
100
+ }
101
+ throw new CliError(`source floor "${value}" is not ${SOURCE_FLOOR_RULES.map((rule) => rule.form).join(", ")}`)
102
+ }
103
+
104
+ function hostOf(url) {
105
+ return typeof url === "string" && URL.canParse(url) ? new URL(url).hostname.toLowerCase() : null
106
+ }
107
+
108
+ /** Whether one source (id and meta.json) satisfies a parsed floor rule. */
109
+ export function sourceInsideFloor(rule, sourceId, meta) {
110
+ if (rule.kind === "primary") return meta.provenance === "primary"
111
+ if (rule.kind === "pointed") return rule.list.includes(sourceId) || rule.list.includes(meta.url)
112
+ const host = hostOf(meta.url)
113
+ return host !== null && rule.list.some((entry) => host === entry.toLowerCase() || host.endsWith(`.${entry.toLowerCase()}`))
114
+ }
115
+
116
+ /** The fields fact fill consumes: the fallback form and whether a translation binds to its quote. */
117
+ export function fillPolicy(parsed) {
118
+ const field = (name) => parsed.fields[name]
119
+ return {
120
+ fallback: FIDELITY_VALUE_RULES.fallback(field("fallback")),
121
+ translations: FIDELITY_VALUE_RULES.translations(field("languages")),
122
+ derivedNone: FIDELITY_VALUE_RULES.derivedNone(field("derived numbers")),
123
+ floor: parseSourceFloor(field("source floor")),
124
+ }
125
+ }
126
+
127
+ /** Source ids behind a row: its own, or for a derived row its operands' sources; null marks a leaf with no source. */
128
+ function sourcesOf(row, rows, seen = new Set()) {
129
+ if (seen.has(row.fact_id)) return []
130
+ seen.add(row.fact_id)
131
+ if (row.source_id) return [row.source_id]
132
+ const inputs = [...(row.operands ?? []), ...(row.derived_from ?? [])]
133
+ if (inputs.length === 0) return [null]
134
+ return inputs.flatMap((id) => (rows.has(id) ? sourcesOf(rows.get(id), rows, seen) : [null]))
135
+ }
136
+
137
+ /** sha256 of the locked `## Fidelity` block normalized: the seven fields in FIDELITY_FIELDS order, `field: value` per line. */
138
+ export const briefHashOf = (parsed) => sha256Of(FIDELITY_FIELDS.map((field) => `${field}: ${parsed.fields[field]}`).join("\n"))
139
+
140
+ /** brief_changed when a fill map was not written under the current brief (or predates the binding). */
141
+ export function briefBindingErrors(map, mapPath, parsed) {
142
+ if (map.brief_sha256 === briefHashOf(parsed)) return []
143
+ const why = typeof map.brief_sha256 === "string" ? "was filled under a different ## Fidelity block" : "records no brief_sha256"
144
+ return [{ code: "brief_changed", token: null, line: null, message: `${mapPath} ${why}; re-run fact fill under the current brief`, severity: DEFECT_CODES.brief_changed.severity, integrity: DEFECT_CODES.brief_changed.integrity, hint: DEFECT_CODES.brief_changed.hint }]
145
+ }
146
+
147
+ function metaOf(sessionDir, sourceId, cache) {
148
+ if (!cache.has(sourceId)) {
149
+ const file = join(sessionDir, "sources", sourceId, "meta.json")
150
+ cache.set(sourceId, existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { url: null, provenance: "unknown" })
151
+ }
152
+ return cache.get(sourceId)
153
+ }
154
+
155
+ const defect = (code, pointer, text, message) => ({ code, token: pointer.token, line: lineOf(text, pointer.start), message, severity: DEFECT_CODES[code].severity, hint: DEFECT_CODES[code].hint })
156
+
157
+ /**
158
+ * Refusals for the placeholders of one fill source (or the data-fact elements of one edition at
159
+ * verify). Sets each referenced row's outside_floor to this evaluation and reports whether any
160
+ * cached value changed.
161
+ * @param {{ token: string, start: number, parsed: { id?: string } }[]} found findPlaceholders output
162
+ * @returns {{ errors: object[], cacheChanged: boolean }}
163
+ */
164
+ export function policyErrors(text, found, ledger, sessionDir, policy) {
165
+ const rows = new Map(ledger.rows.map((row) => [row.fact_id, row]))
166
+ const metas = new Map()
167
+ const errors = []
168
+ let cacheChanged = false
169
+ for (const pointer of found) {
170
+ const row = rows.get(pointer.parsed?.id)
171
+ if (row === undefined || row.status !== "active") continue
172
+ if (policy.derivedNone && FIDELITY_VALUE_RULES.derivedRefused(row.kind)) {
173
+ errors.push(defect("derived_not_allowed", pointer, text, `${row.fact_id} is ${row.kind}; the brief records derived numbers: none`))
174
+ }
175
+ const sources = sourcesOf(row, rows)
176
+ const outside = policy.floor === null ? [] : [...new Set(sources.filter((id) => id === null || !sourceInsideFloor(policy.floor, id, metaOf(sessionDir, id, metas))))]
177
+ if (row.outside_floor !== outside.length > 0) cacheChanged = true
178
+ row.outside_floor = outside.length > 0
179
+ const named = outside.map((id) => id ?? "no source").join(", ")
180
+ if (outside.length > 0) errors.push(defect("source_outside_floor", pointer, text, `${row.fact_id} rests on ${named}, outside the source floor`))
181
+ }
182
+ return { errors, cacheChanged }
183
+ }
@@ -14,6 +14,7 @@ import { join, resolve } from "node:path"
14
14
  import { indexClaims } from "./claims-index.mjs"
15
15
  import { CliError, parseArgs, sha256Of, writeJsonAtomic } from "./cli-support.mjs"
16
16
  import { parseFidelityBlock } from "./fidelity-brief.mjs"
17
+ import { parseSourceFloor } from "./fidelity-policy.mjs"
17
18
  import { writeSnapshot } from "./source-snapshot.mjs"
18
19
 
19
20
  const FETCH_TIMEOUT_MS = 30_000
@@ -44,12 +45,16 @@ export function snapshotOf(sessionDir, id) {
44
45
  return { meta: JSON.parse(readInput(join(dir, "meta.json"))), text: readInput(join(dir, "text.txt")), html: existsSync(html) ? readInput(html) : null }
45
46
  }
46
47
 
47
- /** Whether the brief's source floor lists this source as pointed at (`pointed only: a, b`). */
48
+ /**
49
+ * Whether the brief's source floor lists this source as pointed at (`pointed only: a, b`). An absent
50
+ * or pending floor points at nothing; a malformed floor is a usage error (parseSourceFloor's CliError).
51
+ */
48
52
  export function pointedFlag(sessionDir, url) {
49
53
  const brief = join(sessionDir, "brief.md")
50
54
  const floor = existsSync(brief) ? (parseFidelityBlock(readInput(brief)).fields?.["source floor"] ?? "") : ""
51
- const match = /^pointed only:\s*(.+)$/i.exec(floor.trim())
52
- return match ? match[1].split(",").map((entry) => entry.trim()).includes(url) : false
55
+ if (floor === "" || floor === "pending") return false
56
+ const rule = parseSourceFloor(floor)
57
+ return rule?.kind === "pointed" && rule.list.includes(url)
53
58
  }
54
59
 
55
60
  async function fetchSource(url) {
@@ -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. 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.";
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 &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.";
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:1d099ec7e89e45d9a749b3119fafbd06726b589bba756c5a72ea56b563a2b9ea:0766ee449be1f634fa5ed2f988eb9b3473567708af6ab5bb8a716ce419ce101f
2
+ // omo-codex-install:59bc5768b3b3ef6732c97bec7583a523952e5e1d9fdf5d8e5fb245658043bfca:fadb66139b4d0de34630f6b4bddc87783374d2be2659a207a4bb01c2c2f852d0
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.46",
10126
+ version: "5.1.48",
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
- ' if "!OMO_NODE_BINARY:~0,1!"=="^"" set "OMO_NODE_BINARY=!OMO_NODE_BINARY:~1!"',
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"'
@@ -5,5 +5,5 @@
5
5
  "index.js": "d67d3a847fe0bbc43e824feded48b280890d31879538309e671cd5f671133995",
6
6
  "page-bundle.js": "bfd51e8383cf47200d57387bbbed258bce5a3a3d00c38fc108034d2888e23f0c"
7
7
  },
8
- "stagedAtUtc": "2026-10-25T01:02:39.752Z"
8
+ "stagedAtUtc": "2026-10-25T15:20:18.935Z"
9
9
  }
@@ -164,7 +164,64 @@ Never block collection on the shape of the deliverable, and never guess it eithe
164
164
  1. **Derive first.** Name the lane (`template-strict`, `template-vibe`, `no-format`, `edit-existing`) and the promised formats from the request and its destination (the reference's section 3). When the request refers to an existing deliverable, decide its state with `node "$SKILL_DIR/scripts/report-tools.mjs" outcome state --deliverable <path> --session-dir "$SESSION_DIR"`: `partial` resumes the skeleton on disk, `complete` means edit-existing and never a regenerated report. `$SKILL_DIR` is this skill's own directory, the folder containing this SKILL.md.
165
165
  2. **Read the requester's format memory** with the memory tool when the harness exposes one: the pointer `system/human/report-style.md`, then `reference/human-report-style.md`; take the choice recorded for the most similar context (same destination kind and audience) as the first option.
166
166
  3. **Ask only what is still missing**: at most the reference's three questions (destination and format, audience and length, template lineage) in one call to the harness question tool (`ask_user_question` or `request_user_input`), non-blocking where the harness supports it, each with its default first, a free-text "describe the format" path, and "don't care, you decide". Collection starts without waiting; a late answer is folded in until the assembly worker starts, and after that it becomes a re-render request.
167
- 4. **Record it.** Write `## Deliverable` into `brief.md` (lane, state, formats, destination, audience, template, format description, each with `answered_by: user|default|request`) and open the manifest in the same step: `node "$SKILL_DIR/scripts/report-tools.mjs" outcome init --promised <formats> --lane <lane> --session-dir "$SESSION_DIR"`. When the ask is to recreate, reproduce, or rebuild something visual, add `Reference compare: required` with the source (URL or file) and the moments to compare; the comparison runs on every round of the build, not only at the end.
167
+ 4. **Settle source fidelity in the same step.** Seven fields under `## Fidelity` in `brief.md` say what the report must stay faithful to; the reference's section 4 lists each field's values and default. Infer every field before asking anything; an explicit word in the request decides a field (`answered_by: request`), the destination, the sources, and the Phase 0 axes supply the rest (`answered_by: default`):
168
+ - `as of`: a version, release, tag, commit, date, quarter, "as of", "current", or "latest" in the request decides it; a pointed-at repository anchors on its HEAD or tag at run start, pointed-at documents on their own revisions, and a comparison of two anchors records `multiple: <a>, <b>; rows tagged`. Default `latest at run start, <run date>`, the date of the `$SESSION_DIR` timestamp. Ask when the topic is version-bound or time-sensitive (software, APIs, standards, prices, rates, rankings, laws, programs, schedules) and nothing names an anchor; a stable topic takes the default silently.
169
+ - `meaning check`: the QA tier decides (full: `on`, light: `off`); a chat answer on a legal, medical, financial, procurement, or public-facing question with no speed word is `on`. Ask when two explicit signals disagree: a document deliverable under a literal "quick" or "fast", or a light-tier answer that carries both a speed word and a precision demand; `on` goes first.
170
+ - `source floor`: "official docs only", "peer-reviewed only", "SEC filings" give `primary only` or `classes: <host suffixes>`; "from these only" gives `pointed only: <source ids, URLs, or paths>`; pointed-at sources with no restricting word are seeds, not a floor. Default `standard`. Ask when a restricting phrase sits beside an open-ended question whose answer lives outside that floor. Declare each source's provenance when you snapshot it (`source snapshot --provenance primary|secondary`, by the Phase 3b primary-source rule); `primary only` refuses every source not declared `primary`.
171
+ - `derived numbers`: "compare", "growth", "share", "estimate" give `allowed`; "only the sources' own numbers", "no estimates" give `none`. Default `allowed`. Ask when both kinds of phrase share one request. Record only `allowed` or `none` (any other value stops `fact fill` and `fact verify` with a usage error); a partial or free-text answer records `none`.
172
+ - `languages` (never asked): the report in the user's language or the one they named; sources `en`, plus the user's language for a local topic, plus the pointed-at documents' languages; quotes `original` when every source language equals the report language, else `original with translation beside`.
173
+ - `identifier classes` (never asked): from the Phase 0 axes' domain (software: version strings, package names, commit ids; finance: tickers, filing ids, fiscal periods; law: statute, article, and case numbers; science: gene names, DOIs, trial ids), else `none`. It is recorded only; no gate enforces it yet.
174
+ - `fallback` (never asked): `block-quote`, or `omit` on "drop anything you cannot verify"; no other value is accepted.
175
+
176
+ Ask nothing when the request, the destination, and the sources decide every field. Otherwise ask only the fields whose trigger fired, in the same question-tool call as the format questions (`ask_user_question` or `request_user_input`), non-blocking where the harness supports it, each with its default first, a free-text path, and "don't care, you decide" (it resolves to the first option, recorded `answered_by: user`). At most four questions ride in that call: `pending` fidelity fields first, then format Q1, Q2, Q3, then `as of`, `meaning check`, `source floor`, `derived numbers`; a question that loses its slot runs on its default and is named, with that default, in the reply that opens collection. When each reading of a field would make part of the request undeliverable, record it `pending` with no default. Collection starts regardless: `source snapshot`, `fact add`, `fact quote`, `fact derive`, `fact retract`, and `fact export` keep working while answers are pending; only the six reader-facing commands (`fact fill`, `fact verify`, `fact pairs`, `fact meaning-check`, `fact polish-apply`, `fact polish-diff`) and the assembly lane wait for the lock, and those commands exit 1 with `fidelity_interview_incomplete` while a field is `pending` or missing. When the assembly lane is about to start, a field still awaiting its answer keeps its default; a `pending` field is asked once more, blocking this time.
177
+ 5. **Record it.** Write `## Deliverable` into `brief.md` (lane, state, formats, destination, audience, template, format description, each with `answered_by: user|default|request`) plus `## Fidelity` (the block below), and open the manifest in the same step: `node "$SKILL_DIR/scripts/report-tools.mjs" outcome init --promised <formats> --lane <lane> --session-dir "$SESSION_DIR"`. When the ask is to recreate, reproduce, or rebuild something visual, add `Reference compare: required` with the source (URL or file) and the moments to compare; the comparison runs on every round of the build, not only at the end.
178
+
179
+ #### The fidelity questions
180
+
181
+ Ask them in the user's language, translating these English texts faithfully and keeping the option order; `<run date>` is the `$SESSION_DIR` date.
182
+
183
+ - **`as of`**, header `As of`: "Which version or date must the facts hold for? Sources are snapshotted and quoted against that anchor."
184
+ 1. `Latest as of today, <run date>` - The state published at the run's start; anything older is flagged as stale.
185
+ 2. `Other: a named version, release, or date` - Type it (v3.2, 2026-Q3, a commit or tag). Every quote and number is checked against sources valid at that anchor.
186
+ 3. `Don't care, you decide` - Resolves to option 1.
187
+ - **`meaning check`**, header `Drift check`: "Numbers and quotes are always script-checked against source snapshots. Should every explanatory sentence also be read against its source quote by a separate reader? Slower and more tokens; it catches widened scope and dropped hedges."
188
+ 1. `On` - Every sentence is paired with its source and read; confirmed drift is rewritten from the quote or listed in the briefing.
189
+ 2. `Off (faster)` - Script checks only: numbers, units, quotes, qualifiers, bindings. A paraphrase that shifts meaning is not read.
190
+ 3. `Other: where to focus` - Type it ("the financial sections", "anything about the competitor"); those pairs go to the strongest reader.
191
+ 4. `Don't care, you decide` - Resolves to option 1.
192
+ - **`source floor`**, header `Sources`: "Which sources may back a statement of fact?"
193
+ 1. `Standard: primary source required for high-risk claims` - Numeric, legal, financial, dated, and causal claims need a primary source plus two independent domains; other claims may cite attributed secondary sources.
194
+ 2. `Primary only` - Every fact traces to a first-party source (standard, filing, dataset, official doc); commentary appears only as attributed opinion.
195
+ 3. `Only the sources I pointed at` - Ledger rows come from the named files and URLs only; anything else is context, not evidence.
196
+ 4. `Other: name the source classes` - Type it ("peer-reviewed only", "no vendor blogs").
197
+ 5. `Don't care, you decide` - Resolves to option 1.
198
+ - **`derived numbers`**, header `Derived`: "May the report state numbers no source states (sums, growth rates, shares, unit conversions, estimates)?"
199
+ 1. `Yes, computed by script and labeled DERIVED` - Each one names its operation and input rows; inputs are checked for unit, period, and population compatibility; an estimate carries its assumptions.
200
+ 2. `No, sourced numbers only` - Every number is a MEASURED ledger value; comparisons are stated in words or shown side by side.
201
+ 3. `Other: describe` - Type it ("growth rates yes, estimates no"). A partial allowance is recorded as `none`, and the reply that opens collection says so.
202
+ 4. `Don't care, you decide` - Resolves to option 1.
203
+
204
+ #### The `## Fidelity` block
205
+
206
+ Write it in the same step as `## Deliverable`, provisional defaults included, one line per field with its `answered_by`, plus an `asked:` line naming the fidelity questions sent:
207
+
208
+ ```text
209
+ ## Fidelity
210
+ as of: latest at run start, 2026-10-23 (answered_by: default)
211
+ meaning check: on (answered_by: default)
212
+ source floor: standard (answered_by: default)
213
+ derived numbers: pending (answered_by: pending)
214
+ languages: report en; sources en; quotes original (answered_by: default)
215
+ identifier classes: version strings, package names, commit ids (answered_by: default)
216
+ fallback: block-quote (answered_by: default)
217
+ asked: derived numbers
218
+ ```
219
+
220
+ Values carry no parentheses; `pending` goes only with `answered_by: pending`. When an answer lands, rewrite that one line with `answered_by: user` and leave `asked:` as history; a late answer after `fact fill` is a re-render: fill again, then verify.
221
+
222
+ **Re-entry.** An `edit-existing` session, or any session created before this block existed, has no `## Fidelity`. Before any `fact` command, run the inference step above and write the block with `answered_by: default` on every field. `claims index` on a free-form `claim-graph.md` (no claim table) returns `{claims: [], errors: [...]}`, exits 2, and writes no `claims.json`; run `fact verify` without it, and the gate reports each unbound sentence as `unbound_sentence` (major) instead of blocking.
223
+
224
+ **Calibration.** `fact meaning-check` appends one line per flagged pair to `--calibration` (default `$SESSION_DIR/fidelity-calibration.jsonl`). After each run, also append that run's new lines to the workspace-level `.omo/ulw-research/fidelity-calibration.jsonl`; promotion of `meaning_drift_confirmed` is decided from that log.
168
225
 
169
226
  Phase 5 opens by turning the recorded fields into `design-spec.md`.
170
227
 
@@ -373,7 +430,7 @@ Next, write `$SESSION_DIR/pairs-strong.json` (the same file, keeping only the pa
373
430
 
374
431
  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
432
  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.
433
+ 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 &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.
377
434
 
378
435
  **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
436