oh-my-opencode 5.1.47 → 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 (100) hide show
  1. package/dist/cli/index.js +15 -15
  2. package/dist/cli-node/index.js +15 -15
  3. package/dist/index.js +14 -14
  4. package/dist/skills/browser/runtime/omowright/manifest.json +1 -1
  5. package/dist/skills/ulw-research/SKILL.md +58 -1
  6. package/dist/skills/ulw-research/references/deliverable-phase.md +51 -9
  7. package/dist/skills/ulw-research/references/report-gates.md +2 -1
  8. package/dist/skills/ulw-research/scripts/contracts.mjs +3 -11
  9. package/dist/skills/ulw-research/scripts/contracts.test.ts +1 -0
  10. package/dist/skills/ulw-research/scripts/copy-returns-fidelity-policy.test.ts +156 -0
  11. package/dist/skills/ulw-research/scripts/copy-returns-polish.test.ts +6 -6
  12. package/dist/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
  13. package/dist/skills/ulw-research/scripts/fact-commands-meaning.mjs +14 -22
  14. package/dist/skills/ulw-research/scripts/fact-commands-polish.mjs +7 -2
  15. package/dist/skills/ulw-research/scripts/fact-commands-verify.mjs +18 -4
  16. package/dist/skills/ulw-research/scripts/fact-commands.mjs +6 -47
  17. package/dist/skills/ulw-research/scripts/fact-fill.mjs +3 -2
  18. package/dist/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
  19. package/dist/skills/ulw-research/scripts/report-tools.test.ts +245 -4
  20. package/dist/skills/ulw-research/scripts/source-commands.mjs +8 -3
  21. package/dist/tui.js +14 -14
  22. package/package.json +14 -14
  23. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  24. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  25. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  26. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  27. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  28. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  29. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  30. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  31. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  33. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  34. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  35. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  36. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  37. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  38. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  39. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  40. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  41. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  42. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  43. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  44. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  45. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  46. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  47. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  48. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  49. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  50. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  51. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  52. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  53. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  54. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  55. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  68. package/packages/omo-codex/plugin/package-lock.json +12 -12
  69. package/packages/omo-codex/plugin/package.json +1 -1
  70. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  71. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +58 -1
  72. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +51 -9
  73. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +2 -1
  74. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +3 -11
  75. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
  76. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-meaning.mjs +14 -22
  77. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-polish.mjs +7 -2
  78. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-verify.mjs +18 -4
  79. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands.mjs +6 -47
  80. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-fill.mjs +3 -2
  81. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
  82. package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-commands.mjs +8 -3
  83. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  84. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
  85. package/packages/shared-skills/skills/ulw-research/SKILL.md +58 -1
  86. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +51 -9
  87. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +2 -1
  88. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +3 -11
  89. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +1 -0
  90. package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-fidelity-policy.test.ts +156 -0
  91. package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-polish.test.ts +6 -6
  92. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
  93. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-meaning.mjs +14 -22
  94. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-polish.mjs +7 -2
  95. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-verify.mjs +18 -4
  96. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands.mjs +6 -47
  97. package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.mjs +3 -2
  98. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
  99. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +245 -4
  100. package/packages/shared-skills/skills/ulw-research/scripts/source-commands.mjs +8 -3
@@ -78,7 +78,7 @@ describe("the polish edit contract is polish-apply's", () => {
78
78
  return "refused"
79
79
  }
80
80
  })()
81
- const cli = outcome(() => runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--out", join(dir, "out.html")]))
81
+ const cli = outcome(() => runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--brief", BRIEF, "--out", join(dir, "out.html")]))
82
82
  expect(cli === "ok" ? "ok" : "refused").toBe(direct)
83
83
  if (cli !== "ok") expect(cli).toContain(editsPath)
84
84
  })
@@ -87,7 +87,7 @@ describe("the polish edit contract is polish-apply's", () => {
87
87
 
88
88
  describe("the ledger loader is one shared helper", () => {
89
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])
90
+ const diff = (extra: string[]) => () => runFactPolish("polish-diff", ["--before", reportPath, "--after", reportPath, "--brief", BRIEF, ...extra])
91
91
 
92
92
  test("#given the command modules #when read #then neither defines its own ledger loader or session resolver", () => {
93
93
  expect(polishCommand.ledgerFrom).toBe(ledgerFrom)
@@ -122,7 +122,7 @@ describe("the edition format comes from verify's grammar", () => {
122
122
  expect(source("fact-commands-polish.mjs")).not.toMatch(/md\|markdown/u)
123
123
  for (const name of ["r.md", "R.MD", "r.markdown", "R.Markdown", "r.html", "r.HTML"]) {
124
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")]))
125
+ const result = outcome(() => runFactPolish("polish-apply", [path, "--edits", editsPath, "--ledger", ledgerPath, "--brief", BRIEF, "--out", join(dir, "out.html")]))
126
126
  expect(result.includes("takes the filled HTML edition"), name).toBe(editionFormatOf(path, undefined) === "md")
127
127
  }
128
128
  })
@@ -130,7 +130,7 @@ describe("the edition format comes from verify's grammar", () => {
130
130
 
131
131
  describe("polish-diff counts with contracts and exits on blockers only", () => {
132
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
133
+ const run = (before: string, after: string) => runFactPolish("polish-diff", ["--before", file("b.html", doc(before)), "--after", file("a.html", doc(after)), "--ledger", ledgerPath, "--brief", BRIEF]) as unknown as DiffRun
134
134
 
135
135
  test("#given every registry code #when polishDiffFails classifies it #then only registry blockers fail", () => {
136
136
  for (const [code, entry] of Object.entries(DEFECT_CODES) as [string, { severity: string }][]) expect(polishDiffFails([{ code, severity: "minor" }]), code).toBe(entry.severity === "blocker")
@@ -161,7 +161,7 @@ describe("fact polish-apply prints every rejection reason verbatim", () => {
161
161
  test("#given an edit holding & #when fact polish-apply runs #then the printed line carries polish-apply's own markup reason", () => {
162
162
  const before = "Revenue & profit rose across the year"
163
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
164
+ const run = runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--brief", BRIEF, "--out", join(dir, "ampersand.html")]) as unknown as ApplyRun
165
165
 
166
166
  expect(run.exitCode).toBe(0)
167
167
  expect(run.json.data.rejected).toBe(1)
@@ -203,7 +203,7 @@ describe("polish-apply writes atomically and honours --format", () => {
203
203
 
204
204
  test("#given --format md on an .html report #when polish-apply runs #then it refuses the Markdown edition", () => {
205
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"]))
206
+ const result = outcome(() => runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--brief", BRIEF, "--out", join(dir, "fmt.html"), "--format", "md"]))
207
207
  expect(result).toContain("takes the filled HTML edition")
208
208
  })
209
209
  })
@@ -0,0 +1,86 @@
1
+ // fact-commands-fill.mjs - `fact fill`, the reader-facing fill of a placeholder source.
2
+ //
3
+ // It refuses an unlocked brief (fidelity_interview_incomplete) and applies the locked `## Fidelity`
4
+ // fields: `derived numbers: none` and the source floor refuse rows (derived_not_allowed,
5
+ // source_outside_floor, evaluated at every fill), `languages` decides whether a translation after a
6
+ // quote binds to it, and `fallback` picks the block quote or omission for --replace-failed. Any
7
+ // error writes no edition, fill map, or state; the one write that survives a refusal is the
8
+ // ledger's cached outside_floor per row (skipped under --view). The fill map records brief_sha256,
9
+ // the locked block fact verify checks it against. node: builtins and sibling imports only.
10
+
11
+ import { readFileSync, writeFileSync } from "node:fs"
12
+ import { dirname, extname, resolve } from "node:path"
13
+
14
+ import { CliError, sha256Of, writeJsonAtomic } from "./cli-support.mjs"
15
+ import { DEFECT_CODES } from "./contracts.mjs"
16
+ import { FILL_FORMATS, fill } from "./fact-fill.mjs"
17
+ import { readLedger, writeLedger } from "./fact-ledger.mjs"
18
+ import { briefHashOf, fillPolicy, lockedBrief, policyErrors } from "./fidelity-policy.mjs"
19
+ import { readState, writeState } from "./fidelity-state.mjs"
20
+ import { findPlaceholders } from "./placeholder.mjs"
21
+ import { sessionDirOf } from "./source-commands.mjs"
22
+
23
+ /** The CLI's input vocabulary: fill's output formats plus aliases (tex is filled as md), and the extension defaults. */
24
+ export const FILL_INPUT_FORMAT_RULES = Object.freeze({
25
+ aliases: Object.freeze({ tex: "md" }),
26
+ extensions: Object.freeze({ ".md": "md", ".tex": "tex" }),
27
+ })
28
+ export const FILL_INPUT_FORMATS = Object.freeze([...FILL_FORMATS, ...Object.keys(FILL_INPUT_FORMAT_RULES.aliases)])
29
+ const fillFormatOf = (format) => FILL_INPUT_FORMAT_RULES.aliases[format] ?? format
30
+
31
+ function readText(path) {
32
+ try {
33
+ return readFileSync(path, "utf8")
34
+ } catch (error) {
35
+ throw new CliError(`cannot read ${path}: ${error.code ?? error.message}`)
36
+ }
37
+ }
38
+
39
+ /** The inline edition: local stylesheets and scripts inlined, relative image paths made absolute. */
40
+ function inlineAssets(html, baseDir) {
41
+ const local = (ref) => !/^[a-z][a-z0-9+.-]*:|^\/\//i.test(ref)
42
+ return html
43
+ .replace(/<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi, (tag) => {
44
+ const href = /\bhref=["']([^"']+)["']/i.exec(tag)?.[1]
45
+ return href && local(href) ? `<style>${readText(resolve(baseDir, href))}</style>` : tag
46
+ })
47
+ .replace(/<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)><\/script>/gi, (tag, pre, src, post) => (local(src) ? `<script${pre}${post}>${readText(resolve(baseDir, src))}</script>` : tag))
48
+ .replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (tag, pre, src, post) => (local(src) && !src.startsWith("/") ? `${pre}${resolve(baseDir, src)}${post}` : tag))
49
+ }
50
+
51
+ /** @param {{ positionals: string[], flags: Record<string, any> }} args parsed by runFact */
52
+ export function runFactFill(args) {
53
+ const [source] = args.positionals
54
+ const { flags } = args
55
+ if (!source || (flags.out === undefined && !flags.view)) throw new CliError("usage: fact fill <src> (--out <path> | --view) [--format html|md|tex] [--inline] [--replace-failed] [--brief brief.md]")
56
+ const format = flags.format ?? FILL_INPUT_FORMAT_RULES.extensions[extname(source).toLowerCase()] ?? "html"
57
+ if (!FILL_INPUT_FORMATS.includes(format)) throw new CliError(`--format must be ${FILL_INPUT_FORMATS.slice(0, -1).join(", ")}, or ${FILL_INPUT_FORMATS.at(-1)}`)
58
+ if (flags.inline && format !== "html") throw new CliError("--inline applies to the html edition only")
59
+ const sessionDir = sessionDirOf(flags)
60
+ const { parsed, refusal } = lockedBrief("fact fill", flags)
61
+ if (refusal) return refusal
62
+ const policy = fillPolicy(parsed)
63
+ const text = readText(source)
64
+ const ledger = readLedger(sessionDir)
65
+ const refused = policyErrors(text, findPlaceholders(text), ledger, sessionDir, policy)
66
+ if (refused.cacheChanged && !flags.view) writeLedger(sessionDir, ledger)
67
+ const state = flags["replace-failed"] ? readState(sessionDir) : null
68
+ const result = fill(text, ledger, { format: fillFormatOf(format), view: flags.view === true, fallback: policy.fallback, ...(state ? { replaceFailed: state } : {}) })
69
+ const warnings = result.warnings
70
+ if (result.out === null || refused.errors.length > 0) {
71
+ const filled = result.errors.map((error) => ({ ...error, severity: DEFECT_CODES[error.code]?.severity ?? "blocker", hint: DEFECT_CODES[error.code]?.hint ?? null }))
72
+ const defects = [...refused.errors, ...filled]
73
+ return { json: { command: "fact fill", status: "fail", exitCode: 1, defects, warnings }, summary: `fact fill: FAIL - ${defects.length} error(s), nothing written`, exitCode: 1 }
74
+ }
75
+ const entries = policy.translations ? result.fillMap : result.fillMap.filter((entry) => entry.translates === undefined)
76
+ const out = flags.inline ? inlineAssets(result.out, dirname(resolve(source))) : result.out
77
+ if (flags.view) return { text: out, summary: `fact fill --view: ${entries.length} pointer(s), ${warnings.length} warning(s)`, exitCode: 0 }
78
+ writeFileSync(flags.out, out)
79
+ if (state) writeState(sessionDir, state)
80
+ const artifact = sha256Of(out)
81
+ const map = `${resolve(flags.out)}.fill-map.json`
82
+ const brief = briefHashOf(parsed)
83
+ writeJsonAtomic(map, { schemaVersion: 1, format, artifact: resolve(flags.out), artifact_sha256: artifact, brief_sha256: brief, entries })
84
+ const data = { out: resolve(flags.out), map, artifact_sha256: artifact, brief_sha256: brief, occurrences: entries.length, warnings }
85
+ return { json: { command: "fact fill", status: "pass", exitCode: 0, defects: [], data }, summary: `fact fill: wrote ${flags.out} (${entries.length} pointer(s))`, exitCode: 0 }
86
+ }
@@ -8,7 +8,8 @@
8
8
  // fidelity-calibration.jsonl: a header with the promotion criterion on first write, then per run
9
9
  // one run line plus one line per flagged pair. The run id hashes the inputs only, so a rerun or a
10
10
  // --dispute rerun of the same inputs replaces that run's lines in place (disputes carry forward)
11
- // instead of counting a second run. Neither command calls a model or the network.
11
+ // instead of counting a second run. Both refuse an unlocked brief (fidelity_interview_incomplete) before
12
+ // reading anything else. Neither command calls a model or the network.
12
13
 
13
14
  import { existsSync, readFileSync } from "node:fs"
14
15
  import { dirname, join, resolve } from "node:path"
@@ -18,7 +19,8 @@ import { countDefects } from "./contracts.mjs"
18
19
  import { ledgerFrom, markdownAsHtml, readEdition, sessionOf } from "./fact-commands-verify.mjs"
19
20
  import { pairsChangedSince } from "./fact-commands-polish.mjs"
20
21
  import { extractPairs, PAIR_KINDS } from "./fact-pairs.mjs"
21
- import { FIDELITY_INCOMPLETE, meaningCheckEnabled, requireLockedBrief } from "./fidelity-brief.mjs"
22
+ import { FIDELITY_INCOMPLETE } from "./fidelity-brief.mjs"
23
+ import { briefPathOf, lockedBrief, meaningCheckEnabled } from "./fidelity-policy.mjs"
22
24
  import { MEANING_RECORD, writeFidelityRecord } from "./fidelity-records.mjs"
23
25
  import { blocksDelivery } from "./gates-fidelity.mjs"
24
26
  import { ingestVerdicts, unverifiedDefects, VERDICT_SCHEMA } from "./meaning-check.mjs"
@@ -36,25 +38,15 @@ const inSession = (session, name, flag) => {
36
38
  return join(session, name)
37
39
  }
38
40
 
39
- function briefState(flags, session) {
40
- const path = flags.brief ?? inSession(session, "brief.md", "--brief")
41
- const lock = requireLockedBrief(path)
42
- if (lock.exitCode === 2) throw new CliError(`${path}: malformed ## Fidelity block: ${lock.errors.join("; ")}`)
43
- return { path, locked: lock.locked, off: !meaningCheckEnabled(lock.parsed) }
44
- }
45
-
46
41
  function runPairs(args) {
47
42
  const [report] = args.positionals
48
43
  const { flags } = args
49
44
  if (!report) throw new CliError("usage: fact pairs <report> [--format html|md] [--ledger F] [--claims claims.json] [--changed-since <fill-map.json>] [--brief brief.md] [--out pairs.json]")
50
45
  const session = sessionOf(flags)
51
46
  const out = resolve(flags.out ?? inSession(session, PAIRS_FILE, "--out"))
52
- const brief = briefState(flags, session)
53
- if (!brief.locked) {
54
- const json = { command: "fact pairs", status: "fail", exitCode: 1, error: "fidelity_interview_incomplete", message: `${brief.path} has no locked ## Fidelity block` }
55
- return { json, summary: `fact pairs: ${json.message}`, exitCode: 1 }
56
- }
57
- if (brief.off) {
47
+ const { parsed, refusal } = lockedBrief("fact pairs", flags)
48
+ if (refusal) return refusal
49
+ if (!meaningCheckEnabled(parsed)) {
58
50
  const record = { schemaVersion: 1, skipped: true, reason: "meaning check: off", pairs: [] }
59
51
  writeJsonAtomic(out, record)
60
52
  return { json: { command: "fact pairs", status: "skipped", exitCode: 0, data: { out, ...record } }, summary: "fact pairs: skipped (meaning check: off)", exitCode: 0 }
@@ -125,23 +117,23 @@ function recordCalibration(path, lines, runId, pairsSha) {
125
117
  }
126
118
 
127
119
  /** A `{ skipped: true }` pairs file is accepted only when the locked brief turns the meaning check off. */
128
- function skippedRefusal(flags, session) {
129
- const brief = briefState(flags, session)
130
- if (!brief.locked) return { error: FIDELITY_INCOMPLETE, message: `${brief.path} has no locked ## Fidelity block, so a skipped meaning check cannot be accepted` }
131
- if (!brief.off) return { error: "meaning_check_required", message: `${flags.pairs} says skipped but ${brief.path} has meaning check on; rerun fact pairs` }
120
+ function skippedRefusal(flags, parsed) {
121
+ if (meaningCheckEnabled(parsed)) return { error: "meaning_check_required", message: `${flags.pairs} says skipped but ${briefPathOf(flags)} has meaning check on; rerun fact pairs` }
132
122
  return null
133
123
  }
134
124
 
135
125
  function runMeaningCheck(args) {
136
126
  const { flags } = args
137
- const usage = "usage: fact meaning-check --pairs pairs.json --verdicts cheap.json [--verdicts strong.json] [--dispute P<n> --reason TEXT] [--run-id ID] [--calibration F] [--out F]"
127
+ const usage = "usage: fact meaning-check --pairs pairs.json --verdicts cheap.json [--verdicts strong.json] [--dispute P<n> --reason TEXT] [--run-id ID] [--calibration F] [--brief brief.md] [--out F]"
138
128
  if (flags.pairs === undefined || !Array.isArray(flags.verdicts)) throw new CliError(usage)
139
129
  const session = sessionOf(flags)
130
+ const { parsed, refusal } = lockedBrief("fact meaning-check", flags)
131
+ if (refusal) return { ...refusal, json: { ...refusal.json, error: FIDELITY_INCOMPLETE } }
140
132
  const pairsText = readFileSync(flags.pairs, "utf8")
141
133
  const pairsDoc = readJsonFile(flags.pairs)
142
134
  if (pairsDoc?.skipped === true) {
143
- const refusal = skippedRefusal(flags, session)
144
- if (refusal !== null) return { json: { command: "fact meaning-check", status: "fail", exitCode: 1, ...refusal }, summary: `fact meaning-check: ${refusal.message}`, exitCode: 1 }
135
+ const skipped = skippedRefusal(flags, parsed)
136
+ if (skipped !== null) return { json: { command: "fact meaning-check", status: "fail", exitCode: 1, ...skipped }, summary: `fact meaning-check: ${skipped.message}`, exitCode: 1 }
145
137
  return { json: { command: "fact meaning-check", status: "skipped", exitCode: 0, defects: [] }, summary: JSON.stringify({ command: "fact meaning-check", status: "skipped" }), exitCode: 0 }
146
138
  }
147
139
  if (typeof pairsDoc?.artifact_sha256 !== "string" || typeof pairsDoc.report !== "string") throw new CliError(`${flags.pairs} names no artifact_sha256 and report, so the meaning record cannot be bound to an edition; rerun fact pairs`)
@@ -19,6 +19,7 @@ import { CliError, parseArgs, readJsonFile, sha256Of, writeJsonAtomic, writeText
19
19
  import { countDefects, DEFECT_CODES } from "./contracts.mjs"
20
20
  import { editionFormatOf, ledgerFrom, markdownAsHtml, sessionOf } from "./fact-commands-verify.mjs"
21
21
  import { extractPairs } from "./fact-pairs.mjs"
22
+ import { lockedBrief } from "./fidelity-policy.mjs"
22
23
  import { applyEdits, POLISH_EDIT_FIELDS, validatePolishEdits } from "./polish-apply.mjs"
23
24
  import { diffPolish } from "./polish-diff.mjs"
24
25
  import { normalizeText } from "./text-normalize.mjs"
@@ -57,8 +58,10 @@ const rejectionLine = (entry, edit) => ` rejected edit ${entry.index} ("${befor
57
58
  function runPolishApply(args) {
58
59
  const [report] = args.positionals
59
60
  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 (!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] [--brief brief.md] [--session-dir D]")
61
62
  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")
63
+ const { refusal } = lockedBrief("fact polish-apply", flags)
64
+ if (refusal) return refusal
62
65
  const html = readText(report)
63
66
  const editsText = readText(flags.edits)
64
67
  const edits = readJsonFile(flags.edits)
@@ -79,7 +82,9 @@ function runPolishApply(args) {
79
82
 
80
83
  function runPolishDiff(args) {
81
84
  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]")
85
+ 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] [--brief brief.md] [--session-dir D]")
86
+ const { refusal } = lockedBrief("fact polish-diff", flags)
87
+ if (refusal) return refusal
83
88
  const after = readText(flags.after)
84
89
  const defects = diffPolish(readText(flags.before), after, ledgerFrom(flags)).map((defect) => ({ ...defect, hint: DEFECT_CODES[defect.code]?.hint ?? null }))
85
90
  const counts = countDefects(defects)
@@ -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
 
@@ -19,6 +24,7 @@ import { countDefects } from "./contracts.mjs"
19
24
  import { recompute } from "./fact-derive.mjs"
20
25
  import { readLedger } from "./fact-ledger.mjs"
21
26
  import { validateLedger } from "./fact-ledger-schema.mjs"
27
+ import { briefBindingErrors, fillPolicy, lockedBrief, policyErrors } from "./fidelity-policy.mjs"
22
28
  import { FIDELITY_RECORD, writeFidelityRecord } from "./fidelity-records.mjs"
23
29
  import { blocksDelivery, checkFidelity } from "./gates-fidelity.mjs"
24
30
  import { parseMarkdown } from "./md-lite.mjs"
@@ -159,8 +165,11 @@ export function runFactVerify(args) {
159
165
  const [report] = args.positionals
160
166
  const { flags } = args
161
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]")
162
- const edition = readEdition(report, flags)
163
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)
164
173
  const claimsPath = flags.claims ?? join(sessionDir, "claims.json")
165
174
  const briefPath = flags.brief ?? join(sessionDir, "brief.md")
166
175
  const ledger = readLedger(sessionDir)
@@ -174,10 +183,15 @@ export function runFactVerify(args) {
174
183
  baseDir: dirname(resolve(report)),
175
184
  recompute,
176
185
  })
177
- const counts = countDefects(result.defects)
178
- 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"
179
193
  const exitCode = status === "fail" ? 1 : 0
180
194
  const out = resolve(flags.out ?? join(sessionDir, FIDELITY_RECORD))
181
- 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 })
182
196
  return { json: { ...record, out }, summary: `fact verify: ${status.toUpperCase()} - ${counts.blocker} blocker, ${counts.major} major, ${counts.minor} minor`, exitCode }
183
197
  }
@@ -1,22 +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), polish-apply and
3
- // polish-diff (fact-commands-polish.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
4
4
  // snapshot through a deterministic locator or from a declared derivation; nothing here accepts a
5
5
  // typed value. add and quote print the row with 60 chars of source context on each side (JSON
6
6
  // with --json). Exit 0 ok, 1 semantic failure (fact_not_located, fill errors, a refused
7
7
  // derivation), 2 usage. node: builtins and sibling imports only.
8
8
 
9
9
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
10
- import { dirname, extname, join, resolve } from "node:path"
10
+ import { join, resolve } from "node:path"
11
11
 
12
- import { CliError, parseArgs, sha256Of, writeJsonAtomic } from "./cli-support.mjs"
13
- import { DEFECT_CODES } from "./contracts.mjs"
12
+ import { CliError, parseArgs, sha256Of } from "./cli-support.mjs"
14
13
  import { derive } from "./fact-derive.mjs"
15
- import { fill } from "./fact-fill.mjs"
16
14
  import { addRow, readLedger, retractRow, supersedeRow, writeLedger } from "./fact-ledger.mjs"
17
15
  import { locateFind, locateMatch, locatePageLine, locateTable } from "./fact-locate.mjs"
18
16
  import { QUALIFIER_FORMS } from "./fidelity-lexicon.mjs"
19
- import { readState, writeState } from "./fidelity-state.mjs"
20
17
  import { exportRows } from "./fidelity-values.mjs"
21
18
  import { pointedFlag, sessionDirOf, snapshotOf } from "./source-commands.mjs"
22
19
 
@@ -157,44 +154,6 @@ function runExport(args) {
157
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 }
158
155
  }
159
156
 
160
- /** The inline edition: local stylesheets and scripts inlined, relative image paths made absolute. */
161
- function inlineAssets(html, baseDir) {
162
- const local = (ref) => !/^[a-z][a-z0-9+.-]*:|^\/\//i.test(ref)
163
- return html
164
- .replace(/<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi, (tag) => {
165
- const href = /\bhref=["']([^"']+)["']/i.exec(tag)?.[1]
166
- return href && local(href) ? `<style>${readText(resolve(baseDir, href))}</style>` : tag
167
- })
168
- .replace(/<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)><\/script>/gi, (tag, pre, src, post) => (local(src) ? `<script${pre}${post}>${readText(resolve(baseDir, src))}</script>` : tag))
169
- .replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (tag, pre, src, post) => (local(src) && !src.startsWith("/") ? `${pre}${resolve(baseDir, src)}${post}` : tag))
170
- }
171
-
172
- function runFill(args) {
173
- const [source] = args.positionals
174
- const { flags } = args
175
- if (!source || (flags.out === undefined && !flags.view)) throw new CliError("usage: fact fill <src> (--out <path> | --view) [--format html|md|tex] [--inline] [--replace-failed]")
176
- const format = flags.format ?? ({ ".md": "md", ".tex": "tex" }[extname(source).toLowerCase()] ?? "html")
177
- if (!["html", "md", "tex"].includes(format)) throw new CliError("--format must be html, md, or tex")
178
- if (flags.inline && format !== "html") throw new CliError("--inline applies to the html edition only")
179
- const sessionDir = sessionDirOf(flags)
180
- const state = flags["replace-failed"] ? readState(sessionDir) : null
181
- const result = fill(readText(source), readLedger(sessionDir), { format: format === "tex" ? "md" : format, view: flags.view === true, ...(state ? { replaceFailed: state } : {}) })
182
- const warnings = result.warnings
183
- if (result.out === null) {
184
- const defects = result.errors.map((error) => ({ ...error, severity: DEFECT_CODES[error.code]?.severity ?? "blocker", hint: DEFECT_CODES[error.code]?.hint ?? null }))
185
- return { json: { command: "fact fill", status: "fail", exitCode: 1, defects, warnings }, summary: `fact fill: FAIL - ${defects.length} error(s), nothing written`, exitCode: 1 }
186
- }
187
- const out = flags.inline ? inlineAssets(result.out, dirname(resolve(source))) : result.out
188
- if (flags.view) return { text: out, summary: `fact fill --view: ${result.fillMap.length} pointer(s), ${warnings.length} warning(s)`, exitCode: 0 }
189
- writeFileSync(flags.out, out)
190
- if (state) writeState(sessionDir, state)
191
- const artifact = sha256Of(out)
192
- const map = `${resolve(flags.out)}.fill-map.json`
193
- writeJsonAtomic(map, { schemaVersion: 1, format, artifact: resolve(flags.out), artifact_sha256: artifact, entries: result.fillMap })
194
- const data = { out: resolve(flags.out), map, artifact_sha256: artifact, occurrences: result.fillMap.length, warnings }
195
- return { json: { command: "fact fill", status: "pass", exitCode: 0, defects: [], data }, summary: `fact fill: wrote ${flags.out} (${result.fillMap.length} pointer(s))`, exitCode: 0 }
196
- }
197
-
198
157
  function runDerive(args) {
199
158
  const { flags } = args
200
159
  const op = args.required("op")
@@ -230,7 +189,7 @@ export async function runFact(argv) {
230
189
  case "export":
231
190
  return runExport(args)
232
191
  case "fill":
233
- return runFill(args)
192
+ return (await import("./fact-commands-fill.mjs")).runFactFill(args)
234
193
  case "derive":
235
194
  return runDerive(args)
236
195
  case "verify":
@@ -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 = []
@@ -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
+ }