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
@@ -39,8 +39,8 @@ function filled(): { out: string; fillMap: { fact_id: string }[] } {
39
39
  }
40
40
 
41
41
  function pairs(): Pair[] {
42
- const { out, fillMap } = filled()
43
- return extractPairs(out, LEDGER, CLAIMS, fillMap) as Pair[]
42
+ const { out } = filled()
43
+ return extractPairs(out, LEDGER, CLAIMS) as Pair[]
44
44
  }
45
45
 
46
46
  describe("extractPairs: a filled report", () => {
@@ -110,14 +110,7 @@ describe("extractPairs: a filled report", () => {
110
110
  expect(sentences.some((text) => text.includes("Bridge authority"))).toBe(false)
111
111
  })
112
112
 
113
- test("#given a fill map that omits a fact #when extracted #then that hand-typed fact yields no pair", () => {
114
- const { out, fillMap } = filled()
115
- const kept = fillMap.filter((entry) => entry.fact_id !== "F13")
116
- const result = extractPairs(out, LEDGER, CLAIMS, kept) as Pair[]
117
- expect(result.some((pair) => Array.isArray(pair.fact_ids) && (pair.fact_ids as string[]).includes("F13"))).toBe(false)
118
- })
119
-
120
- test("#given no fill map #when extracted #then every data-fact element counts as filled", () => {
113
+ test("#given the filled fixture #when extracted #then every data-fact element yields its pair", () => {
121
114
  const { out } = filled()
122
115
  expect((extractPairs(out, LEDGER, CLAIMS) as Pair[]).length).toBe(6)
123
116
  })
@@ -125,20 +118,11 @@ describe("extractPairs: a filled report", () => {
125
118
  test("#given a Hangul quote #when extracted #then source_lang is ko", () => {
126
119
  const html = "<html><body><h2>Findings</h2><p>HBM revenue reached {{F21:hbm_rev}} [S5].</p></body></html>"
127
120
  const result = fill(html, LEDGER, { format: "html" }) as { out: string; fillMap: { fact_id: string }[] }
128
- const [pair] = extractPairs(result.out, LEDGER, CLAIMS, result.fillMap) as Pair[]
121
+ const [pair] = extractPairs(result.out, LEDGER, CLAIMS) as Pair[]
129
122
  expect(pair).toMatchObject({ fact_ids: ["F21"], source_lang: "ko", report_lang: "en" })
130
123
  })
131
124
  })
132
125
 
133
- describe("extractPairs: a fill map over translations", () => {
134
- test("#given a fill map that omits the translated fact #when extracted #then no translation pair is emitted", () => {
135
- const { out, fillMap } = filled()
136
- const kept = fillMap.filter((entry) => entry.fact_id !== "Q7")
137
- const result = extractPairs(out, LEDGER, CLAIMS, kept) as Pair[]
138
- expect(result.some((pair) => pair.kind === "translation")).toBe(false)
139
- })
140
- })
141
-
142
126
  describe("hasHedge: a month is not a hedge", () => {
143
127
  test("#given a text that starts with the month May #when checked #then it does not count as hedged", () => {
144
128
  expect(hasHedge("May 2024 sales rose.")).toBe(false)
@@ -152,9 +136,9 @@ describe("hasHedge: a month is not a hedge", () => {
152
136
 
153
137
  describe("extractPairs: determinism", () => {
154
138
  test("#given the same filled report twice #when extracted #then both results are byte-for-byte equal", () => {
155
- const { out, fillMap } = filled()
156
- const first = JSON.stringify(extractPairs(out, LEDGER, CLAIMS, fillMap))
157
- const second = JSON.stringify(extractPairs(out, LEDGER, CLAIMS, fillMap))
139
+ const { out } = filled()
140
+ const first = JSON.stringify(extractPairs(out, LEDGER, CLAIMS))
141
+ const second = JSON.stringify(extractPairs(out, LEDGER, CLAIMS))
158
142
  expect(second).toBe(first)
159
143
  })
160
144
  })
@@ -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
+ }
@@ -0,0 +1,25 @@
1
+ // Every references/*.md must have balanced code fences: an unclosed fence renders prose as code
2
+ // and flips every later fence in the file. md-lite is the parser the skill's own tools use.
3
+ import { describe, expect, test } from "bun:test"
4
+ import { readdirSync, readFileSync } from "node:fs"
5
+ import { join } from "node:path"
6
+
7
+ import { parseMarkdown } from "./md-lite.mjs"
8
+
9
+ const REFERENCES = join(import.meta.dir, "..", "references")
10
+ const FILES = readdirSync(REFERENCES).filter((name) => name.endsWith(".md")).sort()
11
+
12
+ describe("references/*.md fence parity", () => {
13
+ test("#given the references directory #when listed #then it holds the reference pages", () => {
14
+ expect(FILES.length).toBeGreaterThan(0)
15
+ })
16
+
17
+ for (const name of FILES) {
18
+ test(`#given ${name} #when its fences are counted #then the count is even and md-lite reports no problem`, () => {
19
+ const text = readFileSync(join(REFERENCES, name), "utf8")
20
+ const fences = text.split("\n").filter((line) => /^ {0,3}(`{3,}|~{3,})/u.test(line))
21
+ expect(fences.length % 2, `${name} has ${fences.length} fence lines`).toBe(0)
22
+ expect(parseMarkdown(text).problems).toEqual([])
23
+ })
24
+ }
25
+ })
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, onTestFinished, test } from "bun:test"
2
2
  import { createHash } from "node:crypto"
3
- import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import { copyFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
4
4
  import { tmpdir } from "node:os"
5
5
  import { delimiter, join } from "node:path"
6
6
 
@@ -8,6 +8,7 @@ import { PROMOTION } from "./fact-commands-meaning.mjs"
8
8
  import { writeFidelityRecord } from "./fidelity-records.mjs"
9
9
  import { PAIR_KINDS } from "./fact-pairs.mjs"
10
10
  import { VERDICT_SCHEMA } from "./meaning-check.mjs"
11
+ import { POLISH_MARKUP_REASON } from "./polish-apply.mjs"
11
12
  import { COMMANDS, run } from "./report-tools.mjs"
12
13
 
13
14
  const SCRIPT = join(import.meta.dir, "report-tools.mjs")
@@ -503,6 +504,92 @@ describe("meaning verifier under node", () => {
503
504
  })
504
505
  })
505
506
 
507
+ type PolishJson = { data: { out: string; applied: number; rejected: number; unlocated: number }; defects: { code: string; index: number; reason?: string }[] }
508
+
509
+ /** A filled session whose first finding names an acquirer and a target around F1. */
510
+ function polishSession() {
511
+ const session = fidelitySession()
512
+ const { dir, s } = session
513
+ const source = readFileSync(join(dir, "report.src.html"), "utf8").replace("<p>The span length is", "<p>Acme acquired Bolt before the span length reached")
514
+ writeFileSync(join(dir, "report.src.html"), source)
515
+ nodeJson(["fact", "fill", join(dir, "report.src.html"), "--out", join(dir, "report.html"), ...s])
516
+ return session
517
+ }
518
+
519
+ function polishApply(dir: string, edits: string) {
520
+ const args = ["fact", "polish-apply", join(dir, "report.html"), "--edits", edits, "--ledger", join(dir, "fact-ledger.json"), "--out", join(dir, "report.polished.html"), "--session-dir", dir]
521
+ const proc = nodeJson(args)
522
+ return { ...proc, json: proc.json as unknown as PolishJson }
523
+ }
524
+
525
+ describe("polish wiring under node", () => {
526
+ test("#given the fixture edits #when fact polish-apply runs #then it exits 0, applies the wording edit, and rejects the numeral edit", () => {
527
+ const { dir } = polishSession()
528
+ const applied = polishApply(dir, join(FIDELITY, "polish-edits.json"))
529
+ const polished = readFileSync(join(dir, "report.polished.html"), "utf8")
530
+ expect(applied.code).toBe(0)
531
+ expect(applied.json.data).toMatchObject({ applied: 1, rejected: 1, unlocated: 0 })
532
+ expect(applied.json.defects.map((d) => [d.code, d.index])).toEqual([["polish_edit_rejected", 1]])
533
+ expect(polished).toContain("Collected from a single cited public page")
534
+ expect(polished).toContain("retrieved 2026-10-01")
535
+ expect(polished).not.toContain("2026-10-02")
536
+ })
537
+
538
+ test("#given an edit that adds an attribute #when polish-apply and polish-diff run #then apply refuses it as markup changed and diff exits 1", () => {
539
+ const { dir } = polishSession()
540
+ const html = readFileSync(join(dir, "report.html"), "utf8")
541
+ const edits = join(dir, "attribute.json")
542
+ writeFileSync(edits, JSON.stringify([{ before: "<h2>Findings</h2>", after: '<h2 id="findings">Findings</h2>', reason: "anchor" }]))
543
+ const applied = polishApply(dir, edits)
544
+ expect(applied.code).toBe(0)
545
+ expect(applied.json.data).toMatchObject({ applied: 0, rejected: 1, unlocated: 0 })
546
+ expect(applied.json.defects.map((d) => [d.code, d.index])).toEqual([["polish_edit_rejected", 0]])
547
+ expect(applied.json.defects[0]?.reason).toContain("markup changed")
548
+ expect(readFileSync(join(dir, "report.polished.html"), "utf8")).toBe(html)
549
+ writeFileSync(join(dir, "attribute.html"), html.replace("<h2>Findings</h2>", '<h2 id="findings">Findings</h2>'))
550
+ const diffed = nodeJson(["fact", "polish-diff", "--before", join(dir, "report.html"), "--after", join(dir, "attribute.html"), "--ledger", join(dir, "fact-ledger.json"), "--session-dir", dir])
551
+ expect(diffed.code).toBe(1)
552
+ expect(codesOf(diffed.json)).toContain("polish_diff_fail")
553
+ expect(JSON.stringify(diffed.json)).toContain("markup changed")
554
+ })
555
+
556
+ test("#given an edit whose before holds & #when fact polish-apply runs #then it prints polish-apply's own markup reason verbatim for that edit", () => {
557
+ const { dir } = polishSession()
558
+ const before = "Acme & Bolt"
559
+ const edits = join(dir, "ampersand.json")
560
+ writeFileSync(edits, JSON.stringify([{ before, after: "Acme and Bolt", reason: "ampersand" }]))
561
+ const applied = polishApply(dir, edits)
562
+ const line = ` rejected edit 0 ("${before}"): ${POLISH_MARKUP_REASON}`
563
+ expect(applied.code).toBe(0)
564
+ expect(applied.json.data).toMatchObject({ applied: 0, rejected: 1, unlocated: 0 })
565
+ expect(applied.json.defects.map((d) => [d.code, d.index, d.reason])).toEqual([["polish_edit_rejected", 0, POLISH_MARKUP_REASON]])
566
+ expect(applied.stderr.split("\n")).toContain(line)
567
+ })
568
+
569
+ test("#given a polished copy edited by hand #when fact polish-diff runs #then it exits 1 with polish_diff_fail", () => {
570
+ const { dir } = polishSession()
571
+ const html = readFileSync(join(dir, "report.html"), "utf8")
572
+ writeFileSync(join(dir, "hand.html"), html.replace("320 m", "321 m"))
573
+ const diffed = nodeJson(["fact", "polish-diff", "--before", join(dir, "report.html"), "--after", join(dir, "hand.html"), "--ledger", join(dir, "fact-ledger.json"), "--session-dir", dir])
574
+ expect(diffed.code).toBe(1)
575
+ expect(codesOf(diffed.json)).toContain("polish_diff_fail")
576
+ })
577
+
578
+ test("#given an accepted subject and object swap #when fact pairs runs with --changed-since #then only that sentence's pair is emitted, with the new text", () => {
579
+ const { dir, s } = polishSession()
580
+ const edits = join(dir, "swap.json")
581
+ writeFileSync(edits, JSON.stringify([{ before: "Acme acquired Bolt", after: "Bolt acquired Acme", reason: "word order" }]))
582
+ const applied = polishApply(dir, edits)
583
+ const map = join(dir, "report.html.fill-map.json")
584
+ const changed = nodeJson(["fact", "pairs", join(dir, "report.polished.html"), "--changed-since", map, "--out", join(dir, "pairs-changed.json"), ...s])
585
+ const written = JSON.parse(readFileSync(join(dir, "pairs-changed.json"), "utf8"))
586
+ expect(applied.json.data).toMatchObject({ applied: 1, rejected: 0 })
587
+ expect(changed.code).toBe(0)
588
+ expect(written.pairs.map((p: Pair) => [p.kind, p.fact_ids])).toEqual([["quote", ["F1"]]])
589
+ expect(written.pairs[0].sentence).toContain("Bolt acquired Acme")
590
+ })
591
+ })
592
+
506
593
  describe("markdown editions under node", () => {
507
594
  const MD = ["# Bridge", "", "## Findings", "", "The span length is {{F1:span_len}} [S4].", "", "The operator wrote {{Q1:completion}} [S4].", "", "## How this report was made", "", "Collected from one cited public page [S4].", "", "## Sources", "", "1. [S4] Bridge page, retrieved 2026-10-01.", ""].join("\n")
508
595
 
@@ -668,3 +755,244 @@ describe("fidelity delivery barrier", () => {
668
755
  expect(matched).toEqual({ exitCode: 0, codes: [] })
669
756
  })
670
757
  })
758
+
759
+ /** Rewrite one `## Fidelity` field line of the session's brief.md. */
760
+ function setField(dir: string, field: string, value: string, answeredBy = "user") {
761
+ const path = join(dir, "brief.md")
762
+ const line = new RegExp(`^${field}: .*$`, "mu")
763
+ writeFileSync(path, readFileSync(path, "utf8").replace(line, `${field}: ${value} (answered_by: ${answeredBy})`))
764
+ }
765
+
766
+ function fillTemplate(dir: string, body: string, extra: string[] = []) {
767
+ const source = ['<!doctype html><html lang="en"><head><meta charset="utf-8"><title>T</title></head><body><h1>T</h1>', "<h2>Findings</h2>", body, "</body></html>"].join("\n")
768
+ writeFileSync(join(dir, "t.src.html"), source)
769
+ return nodeJson(["fact", "fill", join(dir, "t.src.html"), "--out", join(dir, "t.html"), "--session-dir", dir, ...extra])
770
+ }
771
+
772
+ /** Snapshot a one-line text source under `id` with the given provenance (omitted: unknown) and register its span row. */
773
+ function textSource(dir: string, id: string, provenance?: string) {
774
+ writeFileSync(join(dir, `${id}.txt`), "Commentary: the span length is 320 m according to this review.\n")
775
+ const prov = provenance === undefined ? [] : ["--provenance", provenance]
776
+ const snap = nodeJson(["source", "snapshot", "--id", id, "--text", join(dir, `${id}.txt`), "--kind", "document", ...prov, "--session-dir", dir])
777
+ const add = nodeJson(["fact", "add", "--source", id, "--find", "span length", "--label", `span_${id.toLowerCase()}`, "--json", "--session-dir", dir])
778
+ return { snap, row: add.json.data?.row }
779
+ }
780
+
781
+ const ledgerRow = (dir: string, id: string) => JSON.parse(readFileSync(join(dir, "fact-ledger.json"), "utf8")).rows.find((r: { fact_id: string }) => r.fact_id === id)
782
+
783
+ describe("fidelity brief lock and policy under node", () => {
784
+ test("#given a filled session whose brief turns pending #when each reader-facing command runs #then all six exit 1 with only fidelity_interview_incomplete and write nothing", () => {
785
+ // given
786
+ const { dir, s } = pairedSession()
787
+ copyFileSync(join(FIXTURES, "fidelity-brief-pending.md"), join(dir, "brief.md"))
788
+ for (const name of ["fidelity-defects.json", "pairs.json"]) rmSync(join(dir, name), { force: true })
789
+ const drift = verdicts(dir, "cheap.json", "cheap", [])
790
+ writeFileSync(join(dir, "edits.json"), "[]")
791
+ // when
792
+ const runs = {
793
+ fill: nodeJson(["fact", "fill", join(dir, "report.src.html"), "--out", join(dir, "again.html"), ...s]),
794
+ verify: verify(dir, "report.html"),
795
+ pairs: nodeJson(["fact", "pairs", join(dir, "report.html"), "--out", join(dir, "pairs.json"), ...s]),
796
+ meaning: nodeJson(["fact", "meaning-check", "--pairs", join(dir, "absent-pairs.json"), "--verdicts", drift, ...s]),
797
+ polishApply: nodeJson(["fact", "polish-apply", join(dir, "report.html"), "--edits", join(dir, "edits.json"), "--out", join(dir, "polished.html"), ...s]),
798
+ polishDiff: nodeJson(["fact", "polish-diff", "--before", join(dir, "report.html"), "--after", join(dir, "report.html"), "--out", join(dir, "polish.json"), ...s]),
799
+ }
800
+ // then
801
+ for (const [name, result] of Object.entries(runs)) {
802
+ expect({ name, code: result.code, codes: codesOf(result.json) }).toEqual({ name, code: 1, codes: ["fidelity_interview_incomplete"] })
803
+ }
804
+ for (const name of ["again.html", "fidelity-defects.json", "pairs.json", "polished.html", "polish.json", "fidelity-meaning-defects.json"]) expect({ name, exists: existsSync(join(dir, name)) }).toEqual({ name, exists: false })
805
+ })
806
+
807
+ test("#given derived numbers none #when a derived or a converted placeholder is filled #then derived_not_allowed, and allowed renders both", () => {
808
+ // given
809
+ const { dir, s, mm } = fidelitySession()
810
+ const expr = nodeJson(["fact", "derive", "--op", "expr", "F1 * 2", "--unit", "m", "--rationale", "two spans", "--label", "two_spans", "--json", ...s])
811
+ const twice = expr.json.data.row.fact_id
812
+ setField(dir, "derived numbers", "none")
813
+ // when
814
+ const derived = fillTemplate(dir, `<p>Two spans are {{${twice}:two_spans}} [S4].</p>`)
815
+ const converted = fillTemplate(dir, `<p>The span is {{${mm}:span_mm}} [S4].</p>`)
816
+ setField(dir, "derived numbers", "allowed")
817
+ const allowed = fillTemplate(dir, `<p>Two spans are {{${twice}:two_spans}} [S4]. The span is {{${mm}:span_mm}} [S4].</p>`)
818
+ // then
819
+ expect(expr.code).toBe(0)
820
+ expect([derived.code, codesOf(derived.json)]).toEqual([1, ["derived_not_allowed"]])
821
+ expect([converted.code, codesOf(converted.json)]).toEqual([1, ["derived_not_allowed"]])
822
+ expect(allowed.code).toBe(0)
823
+ expect(readFileSync(join(dir, "t.html"), "utf8")).toContain("320,000 mm")
824
+ })
825
+
826
+ test("#given source floor primary only #when rows from secondary, unknown, and primary sources are filled #then only the primary row renders", () => {
827
+ // given
828
+ const { dir } = fidelitySession()
829
+ const secondary = textSource(dir, "S7", "secondary")
830
+ const unknown = textSource(dir, "S8")
831
+ const primary = textSource(dir, "S6", "primary")
832
+ setField(dir, "source floor", "primary only")
833
+ // when
834
+ const fills = [secondary, unknown, primary].map(({ row }) => fillTemplate(dir, `<p>The span is {{${row.fact_id}:${row.label}}} [S4].</p>`))
835
+ // then
836
+ expect([secondary.snap.json, unknown.snap.json, primary.snap.json].map((json) => (json as unknown as { data: { provenance: string } }).data.provenance)).toEqual(["secondary", "unknown", "primary"])
837
+ expect(fills.map((f) => [f.code, codesOf(f.json)])).toEqual([
838
+ [1, ["source_outside_floor"]],
839
+ [1, ["source_outside_floor"]],
840
+ [0, []],
841
+ ])
842
+ })
843
+
844
+ test("#given a row registered while source floor is pending #when the floor locks to pointed only S4, then standard #then fill refuses, then renders, and outside_floor caches each result", () => {
845
+ // given
846
+ const { dir } = fidelitySession()
847
+ setField(dir, "source floor", "pending", "pending")
848
+ const { snap, row } = textSource(dir, "S9")
849
+ const body = `<p>The span is {{${row.fact_id}:${row.label}}} [S9].</p>`
850
+ // when
851
+ setField(dir, "source floor", "pointed only: S4")
852
+ const refused = fillTemplate(dir, body)
853
+ const cachedRefused = ledgerRow(dir, row.fact_id).outside_floor
854
+ setField(dir, "source floor", "standard")
855
+ const rendered = fillTemplate(dir, body)
856
+ // then
857
+ expect(snap.code).toBe(0)
858
+ expect(row.outside_floor).toBe(false)
859
+ expect([refused.code, codesOf(refused.json)]).toEqual([1, ["source_outside_floor"]])
860
+ expect(cachedRefused).toBe(true)
861
+ expect(rendered.code).toBe(0)
862
+ expect(ledgerRow(dir, row.fact_id).outside_floor).toBe(false)
863
+ })
864
+
865
+ test("#given a markdown translation comment after a quote #when languages is translation beside, then quotes original #then fact pairs emits a translation pair only for the first", () => {
866
+ // given
867
+ const { dir, s } = fidelitySession()
868
+ const md = ["# T", "", "## Findings", "", "The operator wrote {{Q1:completion}} <!-- translates: Q1 --> completion comes in 2027 at the earliest [S4].", ""].join("\n")
869
+ writeFileSync(join(dir, "t.src.md"), md)
870
+ const kinds = () => {
871
+ const filled = nodeJson(["fact", "fill", join(dir, "t.src.md"), "--format", "md", "--out", join(dir, "t.md"), ...s])
872
+ const pairs = nodeJson(["fact", "pairs", join(dir, "t.md"), "--out", join(dir, "pairs.json"), ...s])
873
+ return { fill: filled.code, pairs: pairs.code, kinds: JSON.parse(readFileSync(join(dir, "pairs.json"), "utf8")).pairs.map((p: Pair) => p.kind) }
874
+ }
875
+ // when
876
+ const beside = kinds()
877
+ setField(dir, "languages", "report en; sources en; quotes original")
878
+ const original = kinds()
879
+ // then
880
+ expect(beside).toEqual({ fill: 0, pairs: 0, kinds: ["quote", "translation"] })
881
+ expect(original).toEqual({ fill: 0, pairs: 0, kinds: ["quote"] })
882
+ })
883
+
884
+ test("#given a sentence that failed twice #when filled with --replace-failed #then block-quote replaces it and fallback omit removes it", () => {
885
+ // given
886
+ const { dir } = fidelitySession()
887
+ const state = { schemaVersion: 1, failures: { "findings#F1": 2 }, replaced: [] }
888
+ const body = "<p>The span length is {{F1:span_len}} [S4]. The operator wrote {{Q1:completion}} [S4].</p>"
889
+ // when
890
+ writeFileSync(join(dir, "fidelity-state.json"), JSON.stringify(state))
891
+ const quoted = fillTemplate(dir, body, ["--replace-failed"])
892
+ const quotedHtml = readFileSync(join(dir, "t.html"), "utf8")
893
+ setField(dir, "fallback", "omit")
894
+ writeFileSync(join(dir, "fidelity-state.json"), JSON.stringify(state))
895
+ const omitted = fillTemplate(dir, body, ["--replace-failed"])
896
+ const omittedHtml = readFileSync(join(dir, "t.html"), "utf8")
897
+ // then
898
+ expect(quoted.code).toBe(0)
899
+ expect(quotedHtml).toContain("<blockquote")
900
+ expect(omitted.code).toBe(0)
901
+ expect(omittedHtml).not.toContain("<blockquote")
902
+ expect(omittedHtml).not.toContain("The span length is")
903
+ expect(omittedHtml).toContain("The operator wrote")
904
+ })
905
+
906
+ test("#given --calibration #when meaning-check flags a pair #then the calibration line goes to that path, not the session default", () => {
907
+ const { dir, s } = pairedSession()
908
+ const drift = { pair_id: "P2", verdict: "drift", drift_kind: "hedge_dropped", fragment: "no earlier than 2027", source_fragment: "we estimate" }
909
+ const log = join(dir, "custom-calibration.jsonl")
910
+ const checked = nodeJson(["fact", "meaning-check", "--pairs", join(dir, "pairs.json"), "--verdicts", verdicts(dir, "cheap.json", "cheap", [drift, { pair_id: "P1", verdict: "ok" }]), "--calibration", log, ...s])
911
+ const rows = readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line)).filter((row) => row.header !== true && row.run !== true)
912
+ expect(checked.code).toBe(0)
913
+ expect(rows).toHaveLength(1)
914
+ expect(existsSync(join(dir, "fidelity-calibration.jsonl"))).toBe(false)
915
+ })
916
+ })
917
+
918
+ describe("fidelity brief values, sourceless rows, and the brief binding under node", () => {
919
+ test("#given derived numbers or fallback off its table #when fact fill runs #then a usage error names the field and value and nothing is written", () => {
920
+ // given
921
+ const { dir, s } = fidelitySession()
922
+ const twice = nodeJson(["fact", "derive", "--op", "expr", "F1 * 2", "--unit", "m", "--rationale", "two spans", "--label", "two_spans", "--json", ...s]).json.data.row.fact_id
923
+ // when
924
+ setField(dir, "derived numbers", "nope")
925
+ const derived = fillTemplate(dir, `<p>Two spans are {{${twice}:two_spans}} [S4].</p>`)
926
+ setField(dir, "derived numbers", "allowed")
927
+ setField(dir, "fallback", "drop")
928
+ const fallback = fillTemplate(dir, "<p>The span length is {{F1:span_len}} [S4].</p>")
929
+ // then
930
+ expect({ code: derived.code, named: derived.stderr.includes('derived numbers "nope"') }).toEqual({ code: 2, named: true })
931
+ expect({ code: fallback.code, named: fallback.stderr.includes('fallback "drop"') }).toEqual({ code: 2, named: true })
932
+ expect(existsSync(join(dir, "t.html"))).toBe(false)
933
+ })
934
+
935
+ test("#given an assumed row #when filled under primary only, then standard #then the sourceless row is source_outside_floor, then renders", () => {
936
+ // given
937
+ const { dir, s } = fidelitySession()
938
+ const assumed = nodeJson(["fact", "add", "--assumed", "12", "--unit", "%", "--rationale", "planning share chosen by the requester", "--label", "share_assumed", "--json", ...s]).json.data.row.fact_id
939
+ const body = `<p>The planning share is {{${assumed}:share_assumed}}.</p>`
940
+ // when
941
+ setField(dir, "source floor", "primary only")
942
+ const refused = fillTemplate(dir, body)
943
+ setField(dir, "source floor", "standard")
944
+ const rendered = fillTemplate(dir, body)
945
+ // then
946
+ expect([refused.code, codesOf(refused.json)]).toEqual([1, ["source_outside_floor"]])
947
+ expect(JSON.stringify(refused.json)).toContain("no source")
948
+ expect(rendered.code).toBe(0)
949
+ })
950
+
951
+ test("#given a derived row whose operand rests on a secondary source #when filled under primary only #then source_outside_floor names that source", () => {
952
+ // given
953
+ const { dir, s } = fidelitySession()
954
+ const { row } = textSource(dir, "S7", "secondary")
955
+ const twice = nodeJson(["fact", "derive", "--op", "expr", `${row.fact_id} * 2`, "--unit", "m", "--rationale", "two spans", "--label", "two_spans", "--json", ...s]).json.data.row.fact_id
956
+ setField(dir, "source floor", "primary only")
957
+ // when
958
+ const refused = fillTemplate(dir, `<p>Two spans are {{${twice}:two_spans}} [S7].</p>`)
959
+ // then
960
+ expect([refused.code, codesOf(refused.json)]).toEqual([1, ["source_outside_floor"]])
961
+ expect(JSON.stringify(refused.json)).toContain("rests on S7")
962
+ })
963
+
964
+ test("#given an edition filled under standard from a secondary source #when the brief tightens to primary only #then fact verify refuses with and without its fill map", () => {
965
+ // given
966
+ const { dir, s } = fidelitySession()
967
+ const { row } = textSource(dir, "S7", "secondary")
968
+ const src = readFileSync(join(dir, "report.src.html"), "utf8")
969
+ .replace("<h2>How", `<p>A review gives the span as {{${row.fact_id}:${row.label}}} [S7].</p>\n<h2>How`)
970
+ .replace("</ol>", '<li id="s7">[S7] Bridge review, retrieved 2026-10-01.</li></ol>')
971
+ writeFileSync(join(dir, "report.src.html"), src)
972
+ const filled = nodeJson(["fact", "fill", join(dir, "report.src.html"), "--out", join(dir, "report.html"), ...s])
973
+ const map = join(dir, "report.html.fill-map.json")
974
+ const before = verify(dir, "report.html")
975
+ // when
976
+ setField(dir, "source floor", "primary only")
977
+ const bound = verify(dir, "report.html")
978
+ const record = JSON.parse(readFileSync(join(dir, "fidelity-defects.json"), "utf8"))
979
+ const kept = readFileSync(map, "utf8")
980
+ rmSync(map)
981
+ const unbound = verify(dir, "report.html")
982
+ setField(dir, "source floor", "standard")
983
+ const { brief_sha256: _dropped, ...legacy } = JSON.parse(kept)
984
+ writeFileSync(map, JSON.stringify(legacy))
985
+ const unstamped = verify(dir, "report.html")
986
+ // then
987
+ expect([filled.code, before.code, codesOf(before.json)]).toEqual([0, 0, []])
988
+ expect(typeof JSON.parse(kept).brief_sha256).toBe("string")
989
+ expect(bound.code).toBe(1)
990
+ expect(codesOf(bound.json)).toContain("brief_changed")
991
+ expect(codesOf(bound.json)).toContain("source_outside_floor")
992
+ expect(record.status).toBe("fail")
993
+ expect(unbound.code).toBe(1)
994
+ expect(codesOf(unbound.json)).toContain("source_outside_floor")
995
+ expect(codesOf(unbound.json)).not.toContain("brief_changed")
996
+ expect([unstamped.code, codesOf(unstamped.json)]).toEqual([1, ["brief_changed"]])
997
+ })
998
+ })
@@ -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) {