diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 41ff9f0d..6e564a55 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.37.0", + "version": "1.38.0", "description": "Plugin-first second brain package for AI agents and humans.", "author": { "name": "Open Second Brain contributors" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2acc5c08..06032569 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.37.0", + "version": "1.38.0", "description": "Plugin-first second brain package for Codex, Hermes, Claude Code, OpenClaw, and other agent runtimes.", "skills": "./skills", "hooks": "./hooks/hooks.json", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cc9f8d8..2f992294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.38.0] - 2026-07-21 + +A semantic-health baselining release ([#147](https://github.com/itechmeat/open-second-brain/issues/147)): an optional acknowledge-before watermark lets an operator declare "I have seen and accepted the state up to this date", so a one-time bulk seed of preferences no longer pins the health verdict at `watch` forever through the `concept-gap` and `batch-concept-inflation` advisories. Detection and storage are unchanged; the watermark only filters which findings are surfaced, the verdict is computed from the surfaced set, and suppression is always reported explicitly. With no watermark set, every output is byte-identical to 1.37.0. + +### Added + +- Semantic-health baseline watermark: an optional `health.silence_before` date (date-only or full ISO-8601 timestamp) in `_brain.yaml` suppresses advisory findings whose underlying entries are entirely older than the watermark - a `batch-concept-inflation` burst whose window ended before it, a `concept-gap` term whose corpus mentions all predate it. Conservative by construction: an entry with no parseable timestamp counts as newer than any watermark, and a concept-gap term with even one post-baseline mention surfaces with its full frequency. An invalid value is an explicit config error, never silently ignored. +- `o2b brain health-baseline set |now`, `get`, and `clear` record, show, and remove the watermark without hand-editing `_brain.yaml`; the upsert preserves unrelated config content byte-for-byte under the repository's file-lock plus atomic-rename convention. Usage errors exit 2 and `--json` operational failures emit `{ok:false}` envelopes. +- Explicit suppression reporting: whenever the watermark hides at least one finding, `o2b brain health` prints `suppressed: N finding(s) older than baseline ` and the report carries an additive `suppressed` object with per-detector counts and the baseline date, mirrored on MCP `brain_health` as `suppressed: { concept_gaps, batch_inflation, baseline }`; the field is absent whenever nothing is hidden. + +### Changed + +- The four detector-local ISO timestamp parsers (stale-claim, batch-inflation, thesis, and the new baseline code) consolidated into one shared `iso-time` helper. + ## [1.37.0] - 2026-07-19 A retrieval quality and context delivery wave: nine units that make search answer relationship-shaped and summary-shaped questions deterministically, explain and plan retrieval without touching live policy, and put the right context in front of the agent while stale operational state stays out. Relationship queries traverse typed edges as a fourth RRF arm, dedup and search respect composite scopes, operational state moves to an overwrite-only lane with a retrieval-time staleness barrier, prompts gain a cadence-controlled navigation tier, an opt-in strict hook redirects the first raw vault read to the search index, and codegraph partnering covers every project in the workspace. Two shared seams carry the wave: a composite scope-key module and an index-admission predicate. The kernel still calls no LLM, and every new surface is byte-identical when its flag or param is omitted. @@ -6623,6 +6637,7 @@ plugin config (vault field)`, and exits with a clear - Sandbox vault and plugin manifest fixtures for tests. - GitHub release workflow for tag-based and manually dispatched releases. +[1.38.0]: https://github.com/itechmeat/open-second-brain/compare/v1.37.0...v1.38.0 [1.37.0]: https://github.com/itechmeat/open-second-brain/compare/v1.36.0...v1.37.0 [1.36.0]: https://github.com/itechmeat/open-second-brain/compare/v1.35.0...v1.36.0 [1.35.0]: https://github.com/itechmeat/open-second-brain/compare/v1.34.0...v1.35.0 diff --git a/README.md b/README.md index e1da8f58..2eaee77b 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Open Second Brain plugs into [Hermes Agent](https://github.com/NousResearch/herm ## What is new -Open Second Brain 1.37.0 is a retrieval quality and context delivery release. Search now answers relationship questions natively: a typed-edge relational arm (opt-in, vocabulary-driven, bounded depth 2) joins hybrid RRF, query plans route summary-shaped questions to the summary surface deterministically, and dedup plus search respect composite owner/session/project scopes so identical text in two contexts is never wrongly collapsed. Retrieval also explains itself: `o2b search rerank-fit` tells you when a reranker is out-of-domain or score-inverted on your vault, and the read-only `brain_retrieval_plan` MCP tool lays out per question the source strategy, token allocation, reliability with p95 latency, and a marginal-value stop, without touching live ranking policy. On the delivery side, a cadence-controlled navigation tier can inject a fenced vault map on top of the always-on kernel, an opt-in strict hook redirects the first raw vault read of a session to the search index and then steps back, operational state moves into an overwrite-only `o2b brain state` lane that is excluded from semantic layers and barred from resurfacing stale values through recall, and codegraph partnering covers every project in the workspace instead of the first one found. The kernel still calls no LLM. +Open Second Brain 1.38.0 adds baselining for semantic-health advisories. When a vault is bulk-seeded with many preferences at once, the `concept-gap` and `batch-concept-inflation` advisories fire permanently and pin the health verdict at `watch`, with no way to acknowledge the burst short of deleting real memory. Now an optional `health.silence_before` watermark in `_brain.yaml` (managed by `o2b brain health-baseline set |now`, `get`, `clear`) declares the state accepted up to that date: findings whose underlying entries are entirely older than the watermark are filtered from the surfaced report, the verdict reflects only what is shown, and the report always says exactly how many findings the baseline hid. Detection and storage are untouched, an entry without a parseable timestamp is never suppressed, and with no watermark set every output is byte-identical to 1.37.0. -Previous release, 1.36.0, was a knowledge intake and consolidation wave: an inbound Telegram capture bot with a report-then-apply inbox drain, keyed Brave and Tavily research providers, a count-triggered fact rollup ladder, subject diarization, an evidence-identity gate on synthesis findings, a capped idempotent link-graph repair lane, and a deterministic skill-proposal verifier. Details live in the [CHANGELOG](CHANGELOG.md). +Previous release, 1.37.0, was a retrieval quality and context delivery wave: a typed-edge relational retrieval arm, composite owner/session/project scope keys, an overwrite-only `o2b brain state` lane with a retrieval-time staleness barrier, a deterministic summary-search router, `o2b search rerank-fit`, the read-only `brain_retrieval_plan` MCP tool, a cadence-controlled navigation tier, an opt-in strict orientation hook, and workspace-wide codegraph partnering. Details live in the [CHANGELOG](CHANGELOG.md). ## Why diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d10a7a98..98fab07c 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -378,6 +378,26 @@ status across every discovered code project, threading `project_path` per query when supported (feature-detected) and degrading with an explicit note when not. +### Semantic-health baselining (since v1.38.0) + +```text +o2b brain health-baseline set |now | get | clear - record, show, or remove the health.silence_before watermark in _brain.yaml without hand-editing it; set accepts a date-only or full ISO-8601 value (or the literal now) and rejects anything else with exit 2; the upsert preserves unrelated config content byte-for-byte under a file lock with an atomic rename +``` + +The watermark acknowledges vault history up to a date: `o2b brain health` +suppresses advisory findings whose underlying entries are entirely older +than `health.silence_before` - a `batch-concept-inflation` burst whose +window ended before it, a `concept-gap` term whose corpus mentions all +predate it - and computes the verdict from the surfaced findings only. +An entry without a parseable timestamp counts as newer than any +watermark, so it is never suppressed, and a concept-gap term with even +one post-baseline mention surfaces with its full frequency. Whenever the +watermark hides at least one finding, the report prints +`suppressed: N finding(s) older than baseline ` and carries an +additive `suppressed` object with per-detector counts; nothing is ever +hidden silently. An invalid config value is an explicit error at load, +and with no watermark set every output is byte-identical to v1.37.0. + ## Stability and trust (since v1.0.0) ```text diff --git a/docs/mcp.md b/docs/mcp.md index 31b036d7..d9baed4b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -564,3 +564,10 @@ Both servers reuse the same backing CLI (`o2b mcp --scope writer` vs the default results byte-identical) and its outcome carries an advisory `surface` field when the deterministic router selects the summary surface; non-summary queries are unchanged. +- Since v1.38.0 `brain_health` additively carries a + `suppressed: { concept_gaps, batch_inflation, baseline }` object when + the optional `health.silence_before` watermark hides at least one + advisory finding whose underlying entries are entirely older than the + baseline date; the verdict is computed from the surfaced findings, the + key is absent whenever nothing is hidden, and with no watermark set + the output is byte-identical to v1.37.0. diff --git a/openclaw.plugin.json b/openclaw.plugin.json index c49af58d..4ee9ca90 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -2,7 +2,7 @@ "id": "open-second-brain", "name": "Open Second Brain", "description": "Second brain for AI agents using Obsidian-compatible Markdown vaults.", - "version": "1.37.0", + "version": "1.38.0", "activation": { "onStartup": true }, "skills": ["./skills"], "contracts": { diff --git a/package.json b/package.json index 1eb4ffb7..71231c8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.37.0", + "version": "1.38.0", "private": false, "description": "Second brain for AI agents using Obsidian-compatible Markdown vaults. Works with Hermes, Claude Code, Codex, and OpenClaw.", "keywords": [ diff --git a/plugin.yaml b/plugin.yaml index 093c8c52..253db47b 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: open-second-brain -version: "1.37.0" +version: "1.38.0" description: "Open Second Brain - native Hermes memory provider backed by an Obsidian-compatible Markdown vault." author: "Open Second Brain contributors" memory_provider: true diff --git a/plugins/codex/.codex-plugin/plugin.json b/plugins/codex/.codex-plugin/plugin.json index 41c8e615..e38cea87 100644 --- a/plugins/codex/.codex-plugin/plugin.json +++ b/plugins/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "open-second-brain", - "version": "1.37.0", + "version": "1.38.0", "description": "Plugin-first second brain package for Codex, Hermes, Claude Code, OpenClaw, and other agent runtimes.", "author": { "name": "Open Second Brain contributors", diff --git a/plugins/hermes/plugin.yaml b/plugins/hermes/plugin.yaml index 093c8c52..253db47b 100644 --- a/plugins/hermes/plugin.yaml +++ b/plugins/hermes/plugin.yaml @@ -1,5 +1,5 @@ name: open-second-brain -version: "1.37.0" +version: "1.38.0" description: "Open Second Brain - native Hermes memory provider backed by an Obsidian-compatible Markdown vault." author: "Open Second Brain contributors" memory_provider: true diff --git a/pyproject.toml b/pyproject.toml index ea180b61..91fcf403 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "setuptools.build_meta" # CLI entry points (those moved to `package.json` `bin`). [project] name = "open-second-brain" -version = "1.37.0" +version = "1.38.0" description = "Hermes Python shim for Open Second Brain. Most of the project (CLI, MCP server, OpenClaw plugin) is TypeScript on Bun; see package.json." readme = "README.md" requires-python = ">=3.11" diff --git a/src/cli/brain.ts b/src/cli/brain.ts index c7fc67e5..c02dd150 100644 --- a/src/cli/brain.ts +++ b/src/cli/brain.ts @@ -39,6 +39,7 @@ import { cmdBrainAnticipate, cmdBrainWatchdog, cmdBrainHealth, + cmdBrainHealthBaseline, cmdBrainStatus, cmdBrainHistory, cmdBrainActivation, @@ -231,6 +232,8 @@ export async function handleBrainSubcommand(argv: ReadonlyArray): Promis return await cmdBrainWatchdog(rest); case "health": return await cmdBrainHealth(rest); + case "health-baseline": + return await cmdBrainHealthBaseline(rest); case "status": return await cmdBrainStatus(rest); case "history": diff --git a/src/cli/brain/help-text.ts b/src/cli/brain/help-text.ts index 832db8c7..05cf8b78 100644 --- a/src/cli/brain/help-text.ts +++ b/src/cli/brain/help-text.ts @@ -49,6 +49,7 @@ Brain verbs (observing memory): anticipate Inspect or refresh the anticipatory context cache (--session) watchdog Probe Brain health and plan safe recovery (--remediate, --restore, --force-restore) health Semantic-health report: contradictions, concept gaps, stale claims + health-baseline Acknowledge-before watermark for advisories (set |now / get / clear) history Render a preference's edit-history timeline activation Activation event store: status and sweep truth Claim ledger: ingest, slots, conflicts, aggregate, collisions, sweep @@ -331,6 +332,13 @@ export const VERB_HELP: Record = { "Semantic-health report: contradictory confirmed preferences, recurring\n" + "concepts with no dedicated preference, and confirmed preferences on stale\n" + "evidence, plus a clean/watch/investigate verdict. Read-only.\n", + "health-baseline": + "usage: o2b brain health-baseline |set now|get|clear> [--vault ] [--json]\n" + + "Acknowledge-before watermark (health.silence_before in _brain.yaml). set\n" + + "records that state up to a date is seen and accepted, so concept-gap and\n" + + "batch-inflation advisories entirely older than it stop pinning the verdict\n" + + "at watch; get prints the current value; clear removes it. Detection and\n" + + "stored memory are untouched - only what the report surfaces changes.\n", status: "usage: o2b brain status [--vault ] [--json]\n" + "Unified operator status snapshot. Composes doctor, semantic health,\n" + diff --git a/src/cli/brain/verbs/health-baseline.ts b/src/cli/brain/verbs/health-baseline.ts new file mode 100644 index 00000000..8b4ac859 --- /dev/null +++ b/src/cli/brain/verbs/health-baseline.ts @@ -0,0 +1,106 @@ +import { + HealthBaselineError, + readHealthBaseline, + writeHealthBaseline, +} from "../../../core/brain/health-baseline.ts"; +import { isValidIsoInstant } from "../../../core/brain/health/iso-time.ts"; +import { + brainVerbContext, + fail, + normalizeFlagString, + ok, + okJson, + parse, + usageError, +} from "../helpers.ts"; + +/** + * `o2b brain health-baseline` - operate the acknowledge-before watermark + * (`health.silence_before` in `_brain.yaml`). + * + * Subcommands: + * set |now record the watermark (date-only or full ISO instant) + * get print the current watermark + * clear remove the watermark + * + * The date is a positional argument (not a `--value` flag as in + * `brain state`) because a single required value reads cleaner as + * `set 2026-01-01` than `set --date 2026-01-01`. Setting only changes + * what the semantic-health report surfaces; detection and stored memory + * are untouched. + */ +export async function cmdBrainHealthBaseline(argv: string[]): Promise { + const sub = argv[0]; + const { flags, positional } = parse(argv.slice(1), { + vault: { type: "string" }, + json: { type: "boolean" }, + }); + const json = flags["json"] === true; + + if (sub === "get") { + const { vault } = brainVerbContext(flags); + try { + const baseline = readHealthBaseline(vault); + if (json) okJson(baseline === null ? { present: false } : { present: true, baseline }); + else ok(baseline === null ? "no health baseline set" : baseline); + return 0; + } catch (exc) { + return handleBaselineError("get", exc, json); + } + } + + if (sub === "set") { + const raw = normalizeFlagString(positional[0]); + if (raw === null) return usageError("brain health-baseline set requires a or 'now'"); + const value = raw === "now" ? new Date().toISOString() : raw; + if (!isValidIsoInstant(value)) { + return usageError( + `brain health-baseline set: not an ISO-8601 date (YYYY-MM-DD) or timestamp: ${raw}`, + ); + } + const { vault } = brainVerbContext(flags); + try { + await writeHealthBaseline(vault, value); + if (json) okJson({ baseline: value }); + else ok(`health baseline set to ${value}`); + return 0; + } catch (exc) { + return handleBaselineError("set", exc, json); + } + } + + if (sub === "clear") { + const { vault } = brainVerbContext(flags); + try { + const existed = readHealthBaseline(vault) !== null; + await writeHealthBaseline(vault, null); + if (json) okJson({ cleared: existed }); + else ok(existed ? "health baseline cleared" : "no health baseline set"); + return 0; + } catch (exc) { + return handleBaselineError("clear", exc, json); + } + } + + return usageError( + `brain health-baseline: unknown subcommand '${sub ?? ""}' (expected set|get|clear)`, + ); +} + +/** + * Surface a {@link HealthBaselineError} (or a config-load failure) as an + * operational failure - the `{ ok: false, message }` envelope under + * `--json`, otherwise a plain `fail()` line. Any other error is a genuine + * bug and rethrown. + */ +function handleBaselineError(op: string, exc: unknown, json: boolean): number { + if (exc instanceof HealthBaselineError || exc instanceof Error) { + const message = `health-baseline ${op} failed: ${exc.message}`; + if (json) { + okJson({ ok: false, message }); + return 1; + } + return fail(message); + } + throw exc; +} diff --git a/src/cli/brain/verbs/health.ts b/src/cli/brain/verbs/health.ts index 9be4bea6..a752ff0a 100644 --- a/src/cli/brain/verbs/health.ts +++ b/src/cli/brain/verbs/health.ts @@ -44,6 +44,12 @@ export async function cmdBrainHealth(argv: string[]): Promise { } process.stdout.write(`verdict: ${sh.verdict}\n`); + if (sh.suppressed) { + const hidden = sh.suppressed.conceptGaps + sh.suppressed.batchInflation; + process.stdout.write( + `suppressed: ${hidden} finding(s) older than baseline ${sh.suppressed.baseline}\n`, + ); + } for (const c of sh.contradictions) { process.stdout.write( `[contradiction] ${c.aId} (${c.aSign}) vs ${c.bId} (${c.bSign})` + diff --git a/src/cli/brain/verbs/index.ts b/src/cli/brain/verbs/index.ts index 1c857cc8..662ba968 100644 --- a/src/cli/brain/verbs/index.ts +++ b/src/cli/brain/verbs/index.ts @@ -23,6 +23,7 @@ export { cmdBrainRollback } from "./rollback.ts"; export { cmdBrainDoctor } from "./doctor.ts"; export { cmdBrainWatchdog } from "./watchdog.ts"; export { cmdBrainHealth } from "./health.ts"; +export { cmdBrainHealthBaseline } from "./health-baseline.ts"; export { cmdBrainStatus } from "./status.ts"; export { cmdBrainHistory } from "./history.ts"; export { cmdBrainActivation } from "./activation.ts"; diff --git a/src/core/brain/doctor.ts b/src/core/brain/doctor.ts index ac9c0a88..83fa811f 100644 --- a/src/core/brain/doctor.ts +++ b/src/core/brain/doctor.ts @@ -515,6 +515,7 @@ interface SignalSignRecord { readonly id: string; readonly sign: import("./types.ts").BrainSignalSign; readonly principle: string; + readonly created_at: string; } /** @@ -531,7 +532,12 @@ function readAllSignalRecords(vault: string): ReadonlyArray { if (!name.endsWith(".md") || !name.startsWith("sig-")) continue; try { const sig = parseSignal(join(dir, name)); - out.push({ id: sig.id, sign: sig.signal, principle: sig.principle }); + out.push({ + id: sig.id, + sign: sig.signal, + principle: sig.principle, + created_at: sig.created_at, + }); } catch { // schema error - reported by checkSignals } @@ -560,14 +566,22 @@ function checkSemanticHealth( ...signals.map((s) => s.principle), ...prefRecords.map((r) => r.pref.principle), ]; + // Aligned index-for-index with corpusPrinciples: the authored date of + // each entry, so the baseline watermark can tell a concept gap that is + // entirely old from one still mentioned in fresh memory. + const corpusPrincipleDates: (string | null)[] = [ + ...signals.map((s) => s.created_at), + ...prefRecords.map((r) => r.pref.created_at), + ]; const coveredTopics = prefRecords.map((r) => r.pref.topic); const report = reconcileSemanticHealth( - { preferences, signSignById, corpusPrinciples, coveredTopics }, + { preferences, signSignById, corpusPrinciples, corpusPrincipleDates, coveredTopics }, { contradictionJaccard: health.contradiction_jaccard, conceptGapMinFrequency: health.concept_gap_min_frequency, staleClaimMaxAgeDays: health.stale_claim_max_age_days, + ...(health.silence_before !== null ? { silenceBefore: health.silence_before } : {}), now, }, ); diff --git a/src/core/brain/health-baseline.ts b/src/core/brain/health-baseline.ts new file mode 100644 index 00000000..3434df4a --- /dev/null +++ b/src/core/brain/health-baseline.ts @@ -0,0 +1,164 @@ +/** + * Acknowledge-before health baseline (`health.silence_before`). + * + * The semantic-health pass emits advisory `concept-gap` and + * `batch-concept-inflation` findings that have no time component, so a + * bulk-seeded vault pins the verdict at `watch` forever. This module is + * the operator-facing lane for the watermark that clears them: read the + * current value, and overwrite (or remove) the `silence_before` line + * inside the `_brain.yaml` `health:` block without hand-editing the file. + * + * Detection and storage are untouched - the watermark only changes what + * the reconcile surface shows. Parsing/validation of the instant lives in + * `health/iso-time.ts`, the single source shared with the detectors. + */ + +import { existsSync, readFileSync } from "node:fs"; + +import { atomicWriteText } from "../fs-atomic.ts"; +import { withFileLock } from "../reliability/lock.ts"; +import { brainConfigPath } from "./paths.ts"; +import { loadBrainConfig, resolveHealth } from "./policy.ts"; +import { isValidIsoInstant } from "./health/iso-time.ts"; + +/** Operational failure surfacing a bad value or an unwritable config. */ +export class HealthBaselineError extends Error { + constructor(message: string) { + super(message); + this.name = "HealthBaselineError"; + } +} + +/** The resolved watermark, or `null` when the feature is off. */ +export function readHealthBaseline(vault: string): string | null { + return resolveHealth(loadBrainConfig(vault)).silence_before; +} + +/** + * Overwrite (non-null) or remove (`null`) the `health.silence_before` + * line in `_brain.yaml`. Rejects an unparseable value loudly rather than + * writing a watermark the loader would later refuse. + * + * Locked with the same `withFileLock` + `atomicWriteText` convention + * schema-mutate.ts uses for `_brain.yaml`: read-modify-write under an + * exclusive lock so a concurrent writer (another `health-baseline set`, + * or a schema mutation) can't race this read-modify-write and lose an + * update to a last-writer-wins overwrite. + */ +export async function writeHealthBaseline(vault: string, value: string | null): Promise { + if (value !== null && !isValidIsoInstant(value)) { + throw new HealthBaselineError( + `not an ISO-8601 date (YYYY-MM-DD) or timestamp: ${JSON.stringify(value)}`, + ); + } + const path = brainConfigPath(vault); + await withFileLock(path, { staleMs: 30_000, retries: 3 }, () => { + if (!existsSync(path)) { + throw new HealthBaselineError("_brain.yaml is missing; run `o2b brain init` to bootstrap it"); + } + const before = readFileSync(path, "utf8"); + // Temp-file + rename, matching how schema-mutate.ts writes _brain.yaml: + // a crash mid-write must never truncate the vault config. + atomicWriteText(path, applyHealthSilenceBeforeToYaml(before, value)); + }); +} + +/** ISO instants contain no YAML-hazardous bytes, so a plain quote round-trips. */ +function formatSilenceBeforeYamlValue(value: string): string { + return `"${value}"`; +} + +/** + * Pure `_brain.yaml` transform: set or clear the `silence_before` line + * inside the `health:` block. A non-null value upserts the line (creating + * the block when absent); `null` removes it (and the block header when it + * leaves the block empty). Other health keys and unrelated blocks are + * preserved byte-for-byte. + */ +export function applyHealthSilenceBeforeToYaml(configText: string, value: string | null): string { + // CRLF `_brain.yaml` files split into lines that keep a trailing `\r` (the + // split is on `\n` only), so untouched lines round-trip byte-for-byte + // through `lines.join("\n")` automatically. Only text this function + // generates - a fresh header, a replaced/inserted `silence_before` line - + // needs to carry that trailing `\r` explicitly to match. + const eol = configText.includes("\r\n") ? "\r\n" : "\n"; + const hadContent = configText.length > 0; + const normalized = !hadContent || configText.endsWith("\n") ? configText : `${configText}${eol}`; + const lines = normalized.split("\n"); + const headerIdx = lines.findIndex((line) => /^health:[ \t]*\r?$/.test(line)); + + if (value !== null) { + const quoted = formatSilenceBeforeYamlValue(value); + if (headerIdx < 0) { + const base = normalized.replace(/[\r\n]+$/, "") + eol; + const separator = hadContent ? eol : ""; + return `${base}${separator}health:${eol} silence_before: ${quoted}${eol}`; + } + const childIdx = findHealthChild(lines, headerIdx, /^[ \t]+silence_before[ \t]*:/); + if (childIdx >= 0) { + // Replace in place: keep the line's own indent and line ending rather + // than forcing two spaces, so an existing wider (or narrower) block + // indent - which parseBrainYaml requires siblings to share - survives. + const existing = lines[childIdx]!; + const trailingCr = existing.endsWith("\r") ? "\r" : ""; + const indent = /^[ \t]+/.exec(existing)?.[0] ?? " "; + lines[childIdx] = `${indent}silence_before: ${quoted}${trailingCr}`; + } else { + // New key in an existing block: match a sibling's indent so the + // inserted line doesn't clash with the block's established width. + // Fall back to two spaces only when the block has no siblings yet. + const indent = detectHealthSiblingIndent(lines, headerIdx) ?? " "; + const trailingCr = eol === "\r\n" ? "\r" : ""; + lines.splice(headerIdx + 1, 0, `${indent}silence_before: ${quoted}${trailingCr}`); + } + return lines.join("\n"); + } + + if (headerIdx < 0) return normalized; + const childIdx = findHealthChild(lines, headerIdx, /^[ \t]+silence_before[ \t]*:/); + if (childIdx < 0) return normalized; + lines.splice(childIdx, 1); + // Drop a now-empty `health:` header (plus the blank lines it left behind) + // so clearing a CLI-created block returns the file to its prior shape. + if (findHealthChild(lines, headerIdx, /^[ \t]+\S/) < 0) { + let removeTo = headerIdx + 1; + while (removeTo < lines.length && lines[removeTo]!.trim() === "") removeTo++; + lines.splice(headerIdx, removeTo - headerIdx); + } + return lines.join("\n"); +} + +/** + * Leading whitespace of the first child line inside the `health:` block + * (headerIdx), or `null` when the block has no children yet (empty or + * about to be populated for the first time). `parseBrainYaml` requires all + * siblings in a block to share one indent, so any inserted key must match + * whatever indent the block already established. + */ +function detectHealthSiblingIndent(lines: ReadonlyArray, headerIdx: number): string | null { + for (let i = headerIdx + 1; i < lines.length; i++) { + const line = lines[i]!; + const trimmed = line.trim(); + // Comment lines are invisible to parseBrainYaml's own indent checks + // (splitLines drops them before that stage), so they must be equally + // invisible here: skip rather than let one set - or end - the scan. + if (trimmed === "" || trimmed.startsWith("#")) continue; + if (!/^[ \t]/.test(line)) break; + return /^[ \t]+/.exec(line)![0]; + } + return null; +} + +/** + * First line inside the `health:` block (headerIdx) matching `re`. Blank + * lines are skipped; the first unindented non-empty line ends the block. + */ +function findHealthChild(lines: ReadonlyArray, headerIdx: number, re: RegExp): number { + for (let i = headerIdx + 1; i < lines.length; i++) { + const line = lines[i]!; + if (line.trim() === "") continue; + if (!/^[ \t]/.test(line)) break; + if (re.test(line)) return i; + } + return -1; +} diff --git a/src/core/brain/health/batch-inflation.ts b/src/core/brain/health/batch-inflation.ts index 1bed2a8a..565396ff 100644 --- a/src/core/brain/health/batch-inflation.ts +++ b/src/core/brain/health/batch-inflation.ts @@ -23,18 +23,10 @@ */ import { BRAIN_PREFERENCE_STATUS, type BrainPreferenceStatus } from "../types.ts"; +import { parseIsoUtc } from "./iso-time.ts"; const HOUR_MS = 60 * 60 * 1000; -/** Date-only ISO form (`YYYY-MM-DD`), which the doctor's checkIso allows. */ -const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; - -/** Mirrors `stale-claim.ts`'s deterministic UTC-midnight expansion. */ -function parseIsoUtc(value: string): number { - const iso = ISO_DATE_ONLY_RE.test(value) ? `${value}T00:00:00Z` : value; - return Date.parse(iso); -} - /** Narrow projection the detector needs; {@link BrainPreference} satisfies it. */ export interface PreferenceForBatchInflation { readonly id: string; diff --git a/src/core/brain/health/concept-gap.ts b/src/core/brain/health/concept-gap.ts index 8538e9dc..978d2bcf 100644 --- a/src/core/brain/health/concept-gap.ts +++ b/src/core/brain/health/concept-gap.ts @@ -15,6 +15,7 @@ import { extractEntities } from "../../search/entities.ts"; import { tokenise } from "../similarity.ts"; +import { parseIsoUtc } from "./iso-time.ts"; export interface ConceptGapFinding { readonly term: string; @@ -65,3 +66,31 @@ export function detectConceptGaps( out.sort((a, b) => b.frequency - a.frequency || a.term.localeCompare(b.term)); return out; } + +/** + * For each entity, the newest authored date (in epoch ms) among the + * corpus entries that mention it. Uses the same entity extraction as + * {@link detectConceptGaps}, so the keys line up with finding terms. + * + * An entry whose date is `null`, missing (a shorter `dates` array), or + * unparseable contributes `Infinity` - deliberately treating an undated + * mention as newer than any watermark, so a gap can only ever be + * suppressed when EVERY entry mentioning it is provably older. Used by + * the reconcile baseline filter, never by detection itself. + */ +export function latestEntryMsByTerm( + principles: ReadonlyArray, + dates: ReadonlyArray | undefined, +): Map { + const out = new Map(); + for (let index = 0; index < principles.length; index++) { + const rawDate = dates?.[index] ?? null; + const parsed = rawDate === null ? Number.NaN : parseIsoUtc(rawDate); + const entryMs = Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY; + for (const entity of extractEntities(principles[index]!)) { + const prior = out.get(entity); + if (prior === undefined || entryMs > prior) out.set(entity, entryMs); + } + } + return out; +} diff --git a/src/core/brain/health/iso-time.ts b/src/core/brain/health/iso-time.ts new file mode 100644 index 00000000..826b9dd3 --- /dev/null +++ b/src/core/brain/health/iso-time.ts @@ -0,0 +1,32 @@ +/** + * Shared ISO-8601 parsing for the semantic-health detectors. + * + * `stale-claim.ts` and `batch-inflation.ts` each grew an identical + * `parseIsoUtc` helper because both compare stored `YYYY-MM-DD` or full + * timestamps against a clock. The acknowledge-before watermark is a + * third consumer (config validation plus the reconcile filter), so the + * logic moves here as one source of truth rather than a fourth copy. + */ + +/** Date-only ISO form (`YYYY-MM-DD`), which the doctor's checkIso allows. */ +export const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Parse an ISO timestamp deterministically. A date-only value is + * expanded to UTC midnight before parsing so the millisecond result is + * identical on every engine and peer, never drifting to a local-midnight + * interpretation. Returns `NaN` for an unparseable value. + */ +export function parseIsoUtc(value: string): number { + const iso = ISO_DATE_ONLY_RE.test(value) ? `${value}T00:00:00Z` : value; + return Date.parse(iso); +} + +/** + * True when `value` is a parseable date-only (`YYYY-MM-DD`) or full ISO + * timestamp. Used to reject an invalid watermark loudly instead of + * silently treating it as absent. + */ +export function isValidIsoInstant(value: string): boolean { + return Number.isFinite(parseIsoUtc(value)); +} diff --git a/src/core/brain/health/reconcile.ts b/src/core/brain/health/reconcile.ts index 7d3532e5..249dfef2 100644 --- a/src/core/brain/health/reconcile.ts +++ b/src/core/brain/health/reconcile.ts @@ -17,7 +17,8 @@ import { type ContradictionFinding, type PreferenceForContradiction, } from "./contradiction.ts"; -import { detectConceptGaps, type ConceptGapFinding } from "./concept-gap.ts"; +import { detectConceptGaps, latestEntryMsByTerm, type ConceptGapFinding } from "./concept-gap.ts"; +import { parseIsoUtc } from "./iso-time.ts"; import { detectStaleClaims, type PreferenceForStaleClaim, @@ -39,6 +40,16 @@ export interface SemanticHealthInput { readonly signSignById: ReadonlyMap; /** Signal + preference principle text the concept-gap detector counts over. */ readonly corpusPrinciples: ReadonlyArray; + /** + * Per-entry authored dates, aligned index-for-index with + * {@link corpusPrinciples}. Only consulted when a `silenceBefore` + * watermark is set, to decide whether every entry mentioning a gap + * term predates the watermark. An entry with no parseable date (a + * `null` slot, a missing slot, or an unparseable string) is treated as + * NEWER than any watermark, so the filter can only ever hide what is + * provably old. Absent entirely: no concept gap is watermark-eligible. + */ + readonly corpusPrincipleDates?: ReadonlyArray; /** Preference topic slugs that already own a concept. */ readonly coveredTopics: ReadonlyArray; } @@ -49,18 +60,40 @@ export interface SemanticHealthConfig { readonly staleClaimMaxAgeDays: number; readonly batchInflationWindowHours?: number; readonly batchInflationMinBurstSize?: number; + /** + * Optional acknowledge-before watermark (`health.silence_before`). When + * set, advisory concept-gap and batch-inflation findings that are + * entirely older than this instant are hidden from the surfaced + * report and the verdict. Absent: behavior is byte-identical to a + * build without the feature. An unparseable value throws rather than + * silently disabling the filter. + */ + readonly silenceBefore?: string; readonly now: Date; } /** Mirrors the doctor's `TrustVerdict` ladder. */ export type SemanticHealthVerdict = "clean" | "watch" | "investigate"; +/** + * Count of advisory findings hidden by the `silenceBefore` watermark, + * with the baseline that hid them. Present only when a watermark is set + * AND it suppressed at least one finding, so an unset watermark keeps the + * report byte-identical and silent hiding never happens. + */ +export interface SemanticHealthSuppression { + readonly conceptGaps: number; + readonly batchInflation: number; + readonly baseline: string; +} + export interface SemanticHealthReport { readonly contradictions: ReadonlyArray; readonly conceptGaps: ReadonlyArray; readonly staleClaims: ReadonlyArray; readonly batchInflation: ReadonlyArray; readonly verdict: SemanticHealthVerdict; + readonly suppressed?: SemanticHealthSuppression; } export function reconcileSemanticHealth( @@ -70,14 +103,14 @@ export function reconcileSemanticHealth( const contradictions = detectContradictions(input.preferences, input.signSignById, { jaccard: config.contradictionJaccard, }); - const conceptGaps = detectConceptGaps(input.corpusPrinciples, input.coveredTopics, { + const allConceptGaps = detectConceptGaps(input.corpusPrinciples, input.coveredTopics, { minFrequency: config.conceptGapMinFrequency, }); const staleClaims = detectStaleClaims(input.preferences, { maxAgeDays: config.staleClaimMaxAgeDays, now: config.now, }); - const batchInflation = detectBatchInflation(input.preferences, { + const allBatchInflation = detectBatchInflation(input.preferences, { ...(config.batchInflationWindowHours !== undefined ? { windowHours: config.batchInflationWindowHours } : {}), @@ -86,14 +119,91 @@ export function reconcileSemanticHealth( : {}), }); + // Acknowledge-before watermark: hide advisory findings the operator has + // already seen and accepted. Detection above is untouched; this is a + // surfacing filter over its output. + const { conceptGaps, batchInflation, suppressed } = applyBaseline( + allConceptGaps, + allBatchInflation, + input, + config.silenceBefore, + ); + // A contradiction between two confirmed preferences is the most // serious finding - two active rules disagree, so an agent will apply // a coin-flip. Gaps, stale claims, and batch-inflation bursts are // quality nudges, not active conflicts, so they only raise a watch. + // The verdict folds the SURFACED findings, so a fully-acknowledged + // burst no longer pins `watch`. let verdict: SemanticHealthVerdict = "clean"; if (contradictions.length > 0) verdict = "investigate"; else if (conceptGaps.length > 0 || staleClaims.length > 0 || batchInflation.length > 0) verdict = "watch"; - return { contradictions, conceptGaps, staleClaims, batchInflation, verdict }; + return { + contradictions, + conceptGaps, + staleClaims, + batchInflation, + verdict, + ...(suppressed !== null ? { suppressed } : {}), + }; +} + +/** + * Partition the advisory findings into what surfaces and what the + * watermark hides. When `silenceBefore` is absent the inputs pass through + * untouched and `suppressed` is `null`, keeping the report byte-identical + * to a build without the feature. + */ +function applyBaseline( + conceptGaps: ReadonlyArray, + batchInflation: ReadonlyArray, + input: SemanticHealthInput, + silenceBefore: string | undefined, +): { + conceptGaps: ReadonlyArray; + batchInflation: ReadonlyArray; + suppressed: SemanticHealthSuppression | null; +} { + if (silenceBefore === undefined) { + return { conceptGaps, batchInflation, suppressed: null }; + } + const watermarkMs = parseIsoUtc(silenceBefore); + if (!Number.isFinite(watermarkMs)) { + throw new Error( + `silenceBefore is not a parseable ISO-8601 date or timestamp: ${JSON.stringify(silenceBefore)}`, + ); + } + + // A burst is hidden when its whole window predates the watermark, i.e. + // its newest member (`windowEnd`) is strictly older than the baseline. + const survivingBatch = batchInflation.filter((b) => { + const endMs = parseIsoUtc(b.windowEnd); + return !(Number.isFinite(endMs) && endMs < watermarkMs); + }); + + // A gap is hidden only when EVERY corpus entry mentioning its term is + // older than the watermark. An entry with no parseable date counts as + // newer than any watermark (Infinity), so it keeps the gap visible. + const latestByTerm = latestEntryMsByTerm(input.corpusPrinciples, input.corpusPrincipleDates); + const survivingGaps = conceptGaps.filter((g) => { + const latest = latestByTerm.get(g.term) ?? Number.POSITIVE_INFINITY; + return latest >= watermarkMs; + }); + + const conceptGapsHidden = conceptGaps.length - survivingGaps.length; + const batchHidden = batchInflation.length - survivingBatch.length; + if (conceptGapsHidden === 0 && batchHidden === 0) { + return { conceptGaps, batchInflation, suppressed: null }; + } + return { + conceptGaps: survivingGaps, + batchInflation: survivingBatch, + suppressed: { + conceptGaps: conceptGapsHidden, + batchInflation: batchHidden, + baseline: silenceBefore, + }, + }; } diff --git a/src/core/brain/health/stale-claim.ts b/src/core/brain/health/stale-claim.ts index 6a347920..9d451e3b 100644 --- a/src/core/brain/health/stale-claim.ts +++ b/src/core/brain/health/stale-claim.ts @@ -11,23 +11,10 @@ */ import { BRAIN_PREFERENCE_STATUS, type BrainPreferenceStatus } from "../types.ts"; +import { parseIsoUtc } from "./iso-time.ts"; const DAY_MS = 24 * 60 * 60 * 1000; -/** Date-only ISO form (`YYYY-MM-DD`), which the doctor's checkIso allows. */ -const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; - -/** - * Parse an ISO timestamp deterministically. A date-only value is - * expanded to UTC midnight before parsing so the millisecond result - - * and therefore `ageDays` - is identical on every engine and peer, - * never drifting to a local-midnight interpretation. - */ -function parseIsoUtc(value: string): number { - const iso = ISO_DATE_ONLY_RE.test(value) ? `${value}T00:00:00Z` : value; - return Date.parse(iso); -} - /** Narrow projection the detector needs; {@link BrainPreference} satisfies it. */ export interface PreferenceForStaleClaim { readonly id: string; diff --git a/src/core/brain/health/thesis.ts b/src/core/brain/health/thesis.ts index 9d38472d..56ee3724 100644 --- a/src/core/brain/health/thesis.ts +++ b/src/core/brain/health/thesis.ts @@ -55,9 +55,9 @@ import { type NoteForContradiction, } from "./contradiction.ts"; import { jaccard, tokenise } from "../similarity.ts"; +import { parseIsoUtc } from "./iso-time.ts"; const DAY_MS = 24 * 60 * 60 * 1000; -const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; /** A thesis is either an active standing position or formally closed. */ export const THESIS_STATUS = { @@ -558,11 +558,6 @@ export interface GraveyardThesisFinding { readonly suggestion: "close"; } -function parseIsoUtc(value: string): number { - const iso = ISO_DATE_ONLY_RE.test(value) ? `${value}T00:00:00Z` : value; - return Date.parse(iso); -} - /** * Flag active theses with no supporting evidence in the last * `maxAgeDays` (the "thesis graveyard" pass). A thesis that never had diff --git a/src/core/brain/policy.ts b/src/core/brain/policy.ts index 6de2f19a..9430337f 100644 --- a/src/core/brain/policy.ts +++ b/src/core/brain/policy.ts @@ -21,6 +21,7 @@ import { existsSync, readFileSync } from "node:fs"; import { parseBrainYaml, type ParsedBlock } from "./yaml-parse.ts"; +import { isValidIsoInstant } from "./health/iso-time.ts"; import type { BrainActiveConfig, @@ -275,6 +276,7 @@ export const BRAIN_HEALTH_DEFAULTS: ResolvedBrainHealthConfig = Object.freeze({ concept_gap_min_frequency: 3, stale_claim_max_age_days: 180, remediation_step_cap: 20, + silence_before: null, }) as ResolvedBrainHealthConfig; /** @@ -291,6 +293,7 @@ export function resolveHealth(cfg: BrainConfig): ResolvedBrainHealthConfig { stale_claim_max_age_days: h.stale_claim_max_age_days ?? BRAIN_HEALTH_DEFAULTS.stale_claim_max_age_days, remediation_step_cap: h.remediation_step_cap ?? BRAIN_HEALTH_DEFAULTS.remediation_step_cap, + silence_before: h.silence_before ?? BRAIN_HEALTH_DEFAULTS.silence_before, }; } @@ -1341,6 +1344,7 @@ export function validateBrainConfigDetailed( // concept_gap_min_frequency: 3 # positive integer // stale_claim_max_age_days: 180 // remediation_step_cap: 20 + // silence_before: "2026-01-01" # ISO date/timestamp (watermark) // Absent block → `cfg.health` undefined; resolveHealth returns the // bit-identical defaults. let health: BrainHealthConfig | undefined; @@ -1371,6 +1375,17 @@ export function validateBrainConfigDetailed( partialH[key] = v; } } + if ("silence_before" in hObj) { + const v = hObj["silence_before"]; + if (typeof v !== "string" || !isValidIsoInstant(v)) { + throw new BrainConfigError( + "must be an ISO-8601 date (YYYY-MM-DD) or timestamp", + "health.silence_before", + source, + ); + } + partialH["silence_before"] = v; + } warnUnknownKeys( hObj, [ @@ -1378,6 +1393,7 @@ export function validateBrainConfigDetailed( "concept_gap_min_frequency", "stale_claim_max_age_days", "remediation_step_cap", + "silence_before", ], "health", source, diff --git a/src/core/brain/types.ts b/src/core/brain/types.ts index 74dff535..a26ffa43 100644 --- a/src/core/brain/types.ts +++ b/src/core/brain/types.ts @@ -1626,6 +1626,13 @@ export interface BrainHealthConfig { readonly stale_claim_max_age_days?: number; /** Maximum auto-safe steps a single remediation run applies. Positive integer. */ readonly remediation_step_cap?: number; + /** + * Acknowledge-before watermark. When set, advisory concept-gap and + * batch-inflation findings entirely older than this instant are hidden + * from the semantic-health report and verdict. Date-only `YYYY-MM-DD` + * or a full ISO-8601 timestamp. Absent: the feature is off. + */ + readonly silence_before?: string; } /** @@ -1637,6 +1644,8 @@ export interface ResolvedBrainHealthConfig { readonly concept_gap_min_frequency: number; readonly stale_claim_max_age_days: number; readonly remediation_step_cap: number; + /** Acknowledge-before watermark, or `null` when the feature is off. */ + readonly silence_before: string | null; } // ----- Doctor / trust-layer shapes ------------------------------------------- diff --git a/src/mcp/brain/health-tools.ts b/src/mcp/brain/health-tools.ts index 11bd8609..ff5172de 100644 --- a/src/mcp/brain/health-tools.ts +++ b/src/mcp/brain/health-tools.ts @@ -131,6 +131,15 @@ async function toolBrainHealth( count: b.count, topics: b.topics, })), + ...(sh?.suppressed + ? { + suppressed: { + concept_gaps: sh.suppressed.conceptGaps, + batch_inflation: sh.suppressed.batchInflation, + baseline: sh.suppressed.baseline, + }, + } + : {}), }; } diff --git a/tests/cli/brain-health-baseline.test.ts b/tests/cli/brain-health-baseline.test.ts new file mode 100644 index 00000000..798060b0 --- /dev/null +++ b/tests/cli/brain-health-baseline.test.ts @@ -0,0 +1,123 @@ +/** + * CLI smoke tests for `o2b brain health-baseline` - the acknowledge-before + * watermark setter over `health.silence_before` in `_brain.yaml`. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { runCli } from "../helpers/run-cli.ts"; +import { loadBrainConfig, resolveHealth } from "../../src/core/brain/policy.ts"; +import { writePreference } from "../../src/core/brain/preference.ts"; +import { BRAIN_PREFERENCE_STATUS } from "../../src/core/brain/types.ts"; + +let tmp: string; +let vault: string; +let configPath: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "o2b-health-baseline-cli-")); + vault = join(tmp, "vault"); + for (const d of ["preferences", "retired", "inbox", "processed", "log"]) { + mkdirSync(join(vault, "Brain", d), { recursive: true }); + } + writeFileSync( + join(vault, "Brain", "_brain.yaml"), + "schema_version: 1\n\nhealth:\n concept_gap_min_frequency: 3\n", + ); + configPath = join(tmp, "config.yaml"); + writeFileSync(configPath, `vault: ${vault}\nagent_name: test\n`); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +const env = () => ({ OPEN_SECOND_BRAIN_CONFIG: configPath }); + +describe("o2b brain health-baseline", () => { + test("get on a fresh vault reports no baseline", async () => { + const r = await runCli(["brain", "health-baseline", "get"], { env: env() }); + expect(r.returncode).toBe(0); + expect(r.stdout).toContain("no health baseline set"); + }); + + test("set records the value and preserves the sibling health key", async () => { + const set = await runCli(["brain", "health-baseline", "set", "2026-01-01"], { env: env() }); + expect(set.returncode).toBe(0); + + const get = await runCli(["brain", "health-baseline", "get"], { env: env() }); + expect(get.stdout).toContain("2026-01-01"); + + // The written config still parses and keeps the neighbouring knob. + const resolved = resolveHealth(loadBrainConfig(vault)); + expect(resolved.silence_before).toBe("2026-01-01"); + expect(resolved.concept_gap_min_frequency).toBe(3); + }); + + test("set now stores a full ISO timestamp", async () => { + const set = await runCli(["brain", "health-baseline", "set", "now"], { env: env() }); + expect(set.returncode).toBe(0); + const resolved = resolveHealth(loadBrainConfig(vault)); + expect(resolved.silence_before).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + test("clear removes the watermark", async () => { + await runCli(["brain", "health-baseline", "set", "2026-01-01"], { env: env() }); + const clear = await runCli(["brain", "health-baseline", "clear"], { env: env() }); + expect(clear.returncode).toBe(0); + expect(resolveHealth(loadBrainConfig(vault)).silence_before).toBeNull(); + // The other health key must survive the clear. + expect(resolveHealth(loadBrainConfig(vault)).concept_gap_min_frequency).toBe(3); + }); + + test("an invalid date is a usage error (exit 2), no write", async () => { + const r = await runCli(["brain", "health-baseline", "set", "2026-13-99"], { env: env() }); + expect(r.returncode).toBe(2); + expect(resolveHealth(loadBrainConfig(vault)).silence_before).toBeNull(); + }); + + test("--json set emits the baseline", async () => { + const r = await runCli(["brain", "health-baseline", "set", "2026-01-01", "--json"], { + env: env(), + }); + expect(r.returncode).toBe(0); + expect(JSON.parse(r.stdout)).toEqual({ ok: true, baseline: "2026-01-01" }); + }); + + test("the watermark suppresses an old batch-inflation burst in brain health", async () => { + for (let i = 0; i < 6; i++) { + writePreference( + vault, + { + slug: `batch-${i}`, + topic: `topic-${i}`, + principle: `rule number ${i} for the batch`, + created_at: "2026-01-01T00:00:00Z", + unconfirmed_until: "2026-01-08T00:00:00Z", + confirmed_at: "2026-01-01T00:00:00Z", + status: BRAIN_PREFERENCE_STATUS.confirmed, + evidenced_by: [], + content_hash: "0".repeat(64), + }, + { overwrite: true }, + ); + } + + const before = await runCli(["brain", "health"], { env: env() }); + expect(before.stdout).toContain("batch-inflation"); + + await runCli(["brain", "health-baseline", "set", "2026-06-01"], { env: env() }); + + const after = await runCli(["brain", "health"], { env: env() }); + expect(after.stdout).not.toContain("[batch-inflation]"); + expect(after.stdout).toContain("suppressed:"); + expect(after.stdout).toContain("baseline 2026-06-01"); + // A file on disk was left untouched by the surfacing filter. + expect(readFileSync(join(vault, "Brain", "preferences", "pref-batch-0.md"), "utf8")).toContain( + "rule number 0", + ); + }); +}); diff --git a/tests/core/brain/health-baseline.test.ts b/tests/core/brain/health-baseline.test.ts new file mode 100644 index 00000000..85357a4d --- /dev/null +++ b/tests/core/brain/health-baseline.test.ts @@ -0,0 +1,229 @@ +/** + * Pure `_brain.yaml` transform behind `o2b brain health-baseline`. The + * writer must upsert or remove ONLY the `silence_before` line, leaving + * every other block and key byte-for-byte intact, and the result must + * always re-parse through the real config loader. + */ + +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + applyHealthSilenceBeforeToYaml, + writeHealthBaseline, +} from "../../../src/core/brain/health-baseline.ts"; +import { loadBrainConfig, resolveHealth } from "../../../src/core/brain/policy.ts"; +import { withFileLock } from "../../../src/core/reliability/lock.ts"; +import { parseBrainYaml } from "../../../src/core/brain/yaml-parse.ts"; + +const BASE = "schema_version: 1\n\nvault:\n ignore_paths:\n - .git\n"; + +function silenceOf(text: string): unknown { + const parsed = parseBrainYaml(text) as Record>; + return parsed["health"]?.["silence_before"]; +} + +describe("applyHealthSilenceBeforeToYaml", () => { + test("appends a health block when none exists", () => { + const out = applyHealthSilenceBeforeToYaml(BASE, "2026-01-01"); + expect(silenceOf(out)).toBe("2026-01-01"); + // The pre-existing vault block is untouched. + expect(out).toContain("vault:\n ignore_paths:\n - .git"); + }); + + test("upserts into an existing health block without disturbing siblings", () => { + const withBlock = `${BASE}\nhealth:\n concept_gap_min_frequency: 5\n`; + const out = applyHealthSilenceBeforeToYaml(withBlock, "2026-02-02"); + expect(silenceOf(out)).toBe("2026-02-02"); + const parsed = parseBrainYaml(out) as Record>; + expect(parsed["health"]?.["concept_gap_min_frequency"]).toBe(5); + }); + + test("replaces an existing silence_before line rather than duplicating it", () => { + const first = applyHealthSilenceBeforeToYaml(BASE, "2026-01-01"); + const second = applyHealthSilenceBeforeToYaml(first, "2026-09-09"); + expect(silenceOf(second)).toBe("2026-09-09"); + expect(second.match(/silence_before/g)?.length).toBe(1); + }); + + test("clearing removes the line and the now-empty header it created", () => { + const set = applyHealthSilenceBeforeToYaml(BASE, "2026-01-01"); + const cleared = applyHealthSilenceBeforeToYaml(set, null); + expect(silenceOf(cleared)).toBeUndefined(); + expect(cleared).not.toContain("health:"); + }); + + test("clearing keeps a health block that still has other keys", () => { + const withBlock = `${BASE}\nhealth:\n concept_gap_min_frequency: 5\n silence_before: "2026-01-01"\n`; + const cleared = applyHealthSilenceBeforeToYaml(withBlock, null); + expect(silenceOf(cleared)).toBeUndefined(); + const parsed = parseBrainYaml(cleared) as Record>; + expect(parsed["health"]?.["concept_gap_min_frequency"]).toBe(5); + }); + + test("clearing an absent watermark is a no-op", () => { + expect(applyHealthSilenceBeforeToYaml(BASE, null)).toBe(BASE); + }); + + test("CRLF file: upserts into an existing health block in place, preserving line endings", () => { + const withBlock = `${BASE}\nhealth:\n concept_gap_min_frequency: 5\n`; + const crlf = withBlock.replace(/\n/g, "\r\n"); + const out = applyHealthSilenceBeforeToYaml(crlf, "2026-02-02"); + expect(out).not.toContain("\r\r\n"); + expect(out.includes("\r\n")).toBe(true); + expect(out.includes("\n")).toBe(true); + // Every line-ending in the file is \r\n (no bare \n survives). + expect(out.replace(/\r\n/g, "")).not.toContain("\n"); + expect(silenceOf(out)).toBe("2026-02-02"); + const parsed = parseBrainYaml(out) as Record>; + expect(parsed["health"]?.["concept_gap_min_frequency"]).toBe(5); + expect(out.match(/silence_before/g)?.length).toBe(1); + + const second = applyHealthSilenceBeforeToYaml(out, "2026-03-03"); + expect(second.replace(/\r\n/g, "")).not.toContain("\n"); + expect(silenceOf(second)).toBe("2026-03-03"); + expect(second.match(/silence_before/g)?.length).toBe(1); + + const cleared = applyHealthSilenceBeforeToYaml(second, null); + expect(cleared.replace(/\r\n/g, "")).not.toContain("\n"); + expect(silenceOf(cleared)).toBeUndefined(); + const clearedParsed = parseBrainYaml(cleared) as Record>; + expect(clearedParsed["health"]?.["concept_gap_min_frequency"]).toBe(5); + }); + + test("CRLF file without a health block: creates one and keeps line endings intact", () => { + const crlfBase = BASE.replace(/\n/g, "\r\n"); + const out = applyHealthSilenceBeforeToYaml(crlfBase, "2026-01-01"); + expect(out.replace(/\r\n/g, "")).not.toContain("\n"); + expect(silenceOf(out)).toBe("2026-01-01"); + expect(out).toContain("vault:\r\n ignore_paths:\r\n - .git"); + + const cleared = applyHealthSilenceBeforeToYaml(out, null); + expect(cleared.replace(/\r\n/g, "")).not.toContain("\n"); + expect(silenceOf(cleared)).toBeUndefined(); + expect(cleared).not.toContain("health:"); + }); + + test("a 4-space-indented health block keeps sibling indent and stays parseable", () => { + const fourSpace = `${BASE}\nhealth:\n concept_gap_min_frequency: 5\n`; + const out = applyHealthSilenceBeforeToYaml(fourSpace, "2026-04-04"); + // Inserted line matches the block's existing 4-space sibling indent. + expect(out).toContain('\n silence_before: "2026-04-04"\n'); + expect(out).not.toContain("\n silence_before:"); + // Must actually parse: parseBrainYaml rejects mismatched sibling indents. + const parsed = parseBrainYaml(out) as Record>; + expect(parsed["health"]?.["silence_before"]).toBe("2026-04-04"); + expect(parsed["health"]?.["concept_gap_min_frequency"]).toBe(5); + + const second = applyHealthSilenceBeforeToYaml(out, "2026-05-05"); + const secondParsed = parseBrainYaml(second) as Record>; + expect(secondParsed["health"]?.["silence_before"]).toBe("2026-05-05"); + }); + + test("an indented comment before the real sibling does not set the inserted indent", () => { + // parseBrainYaml discards comment lines wholesale before checking indent + // consistency, so this 2-space comment followed by a 4-space real key is + // valid input. The inserted `silence_before` must match the 4-space real + // sibling, not the 2-space comment. + const withComment = `${BASE}\nhealth:\n # a comment\n concept_gap_min_frequency: 5\n`; + const out = applyHealthSilenceBeforeToYaml(withComment, "2026-06-06"); + expect(out).toContain('\n silence_before: "2026-06-06"\n'); + expect(out).not.toContain("\n silence_before:"); + const parsed = parseBrainYaml(out) as Record>; + expect(parsed["health"]?.["silence_before"]).toBe("2026-06-06"); + expect(parsed["health"]?.["concept_gap_min_frequency"]).toBe(5); + }); + + test("an unindented comment inside the block does not end the sibling scan early", () => { + // An unindented `#` line is still a comment, invisible to parseBrainYaml's + // indent checks, so the real 4-space sibling below it still governs the + // block's indent - the scan must not stop at the comment and fall back + // to two spaces. + const withUnindentedComment = `${BASE}\nhealth:\n# a comment\n concept_gap_min_frequency: 5\n`; + const out = applyHealthSilenceBeforeToYaml(withUnindentedComment, "2026-07-07"); + expect(out).toContain('\n silence_before: "2026-07-07"\n'); + expect(out).not.toContain("\n silence_before:"); + const parsed = parseBrainYaml(out) as Record>; + expect(parsed["health"]?.["silence_before"]).toBe("2026-07-07"); + expect(parsed["health"]?.["concept_gap_min_frequency"]).toBe(5); + }); +}); + +/** + * `writeHealthBaseline` upserts `_brain.yaml` under the same `withFileLock` + * convention `schema-mutate.ts` uses. Without it, a concurrent writer's + * read-modify-write can interleave with this one and one side's edit is + * silently lost. These tests exercise the real file on disk (not the pure + * transform above) to prove the lock actually serializes the two writers. + */ +describe("writeHealthBaseline locking", () => { + function makeVault(): { tmp: string; vault: string; configPath: string } { + const tmp = mkdtempSync(join(tmpdir(), "o2b-health-baseline-lock-")); + const vault = join(tmp, "vault"); + mkdirSync(join(vault, "Brain"), { recursive: true }); + const configPath = join(vault, "Brain", "_brain.yaml"); + writeFileSync(configPath, "schema_version: 1\n\nhealth:\n concept_gap_min_frequency: 3\n"); + return { tmp, vault, configPath }; + } + + test("serializes against a concurrent _brain.yaml writer instead of losing an update", async () => { + const { tmp, vault, configPath } = makeVault(); + try { + const events: string[] = []; + + // Stand-in for another writer of _brain.yaml (e.g. schema-mutate.ts): + // takes the same lock, holds it briefly, then edits an unrelated line + // via a plain read-modify-write (no atomic rename needed for the test - + // only the locking matters here). + const competitor = withFileLock(configPath, { staleMs: 30_000, retries: 3 }, async () => { + events.push("competitor-start"); + const before = readFileSync(configPath, "utf8"); + await new Promise((resolve) => setTimeout(resolve, 40)); + writeFileSync(configPath, `${before}\nnote: unrelated\n`); + events.push("competitor-end"); + }); + + // Give the competitor a head start so it holds the lock first; without + // the fix, writeHealthBaseline would instead race straight in and one + // of the two edits would be overwritten. + await new Promise((resolve) => setTimeout(resolve, 10)); + const baseline = writeHealthBaseline(vault, "2026-03-03").then(() => { + events.push("baseline-done"); + }); + + await Promise.all([competitor, baseline]); + + expect(events).toEqual(["competitor-start", "competitor-end", "baseline-done"]); + + const finalText = readFileSync(configPath, "utf8"); + expect(finalText).toContain("note: unrelated"); + const resolved = resolveHealth(loadBrainConfig(vault)); + expect(resolved.silence_before).toBe("2026-03-03"); + expect(resolved.concept_gap_min_frequency).toBe(3); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + test("two concurrent writeHealthBaseline calls serialize to a single, uncorrupted line", async () => { + const { tmp, vault, configPath } = makeVault(); + try { + await Promise.all([ + writeHealthBaseline(vault, "2026-04-04"), + writeHealthBaseline(vault, "2026-05-05"), + ]); + + const finalText = readFileSync(configPath, "utf8"); + // Exactly one writer's line survives - never a torn or duplicated one. + expect(finalText.match(/silence_before/g)?.length).toBe(1); + const resolved = resolveHealth(loadBrainConfig(vault)); + expect(resolved.silence_before).not.toBeNull(); + expect(["2026-04-04", "2026-05-05"]).toContain(resolved.silence_before as string); + expect(resolved.concept_gap_min_frequency).toBe(3); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/core/brain/health/concept-gap.test.ts b/tests/core/brain/health/concept-gap.test.ts index 4d755fb7..e6f56167 100644 --- a/tests/core/brain/health/concept-gap.test.ts +++ b/tests/core/brain/health/concept-gap.test.ts @@ -11,7 +11,10 @@ import { describe, expect, test } from "bun:test"; -import { detectConceptGaps } from "../../../../src/core/brain/health/concept-gap.ts"; +import { + detectConceptGaps, + latestEntryMsByTerm, +} from "../../../../src/core/brain/health/concept-gap.ts"; describe("detectConceptGaps", () => { test("flags an entity recurring across enough distinct principles", () => { @@ -86,3 +89,29 @@ describe("detectConceptGaps", () => { expect(gaps).toEqual([{ term: "бэклог", frequency: 3 }]); }); }); + +describe("latestEntryMsByTerm", () => { + test("returns the newest mentioning-entry date per entity", () => { + const map = latestEntryMsByTerm( + ["Kanban slow", "Kanban stuck"], + ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"], + ); + expect(map.get("kanban")).toBe(Date.parse("2026-03-01T00:00:00Z")); + }); + + test("an undated, missing, or unparseable entry counts as newer than any watermark", () => { + for (const dates of [ + ["2026-01-01T00:00:00Z", null], + ["2026-01-01T00:00:00Z"], // shorter array: index 1 is missing + ["2026-01-01T00:00:00Z", "not-a-date"], + ]) { + const map = latestEntryMsByTerm(["Kanban slow", "Kanban stuck"], dates); + expect(map.get("kanban")).toBe(Number.POSITIVE_INFINITY); + } + }); + + test("absent dates array leaves every term undated (Infinity)", () => { + const map = latestEntryMsByTerm(["Kanban slow", "Kanban stuck"], undefined); + expect(map.get("kanban")).toBe(Number.POSITIVE_INFINITY); + }); +}); diff --git a/tests/core/brain/health/reconcile.test.ts b/tests/core/brain/health/reconcile.test.ts index 69edcd2b..f1cf5cca 100644 --- a/tests/core/brain/health/reconcile.test.ts +++ b/tests/core/brain/health/reconcile.test.ts @@ -159,3 +159,214 @@ describe("reconcileSemanticHealth", () => { expect(report.verdict).toBe("clean"); }); }); + +describe("reconcileSemanticHealth acknowledge-before watermark", () => { + test("an unset watermark leaves the report free of a suppressed field", () => { + const report = reconcileSemanticHealth( + { + preferences: [ + pref({ id: "pref-a", confirmed_at: "2026-05-01T00:00:00Z" }), + pref({ id: "pref-b", confirmed_at: "2026-05-01T00:10:00Z" }), + ], + signSignById: signs, + corpusPrinciples: ["Kanban slow", "Kanban stuck", "Kanban grooming"], + coveredTopics: [], + }, + { ...config, batchInflationMinBurstSize: 2 }, + ); + expect(report.batchInflation).toHaveLength(1); + expect(report.conceptGaps.length).toBeGreaterThan(0); + expect(report.verdict).toBe("watch"); + expect("suppressed" in report).toBe(false); + }); + + test("a burst whose windowEnd predates the watermark is suppressed and clears the verdict", () => { + const report = reconcileSemanticHealth( + { + preferences: [ + pref({ id: "pref-a", confirmed_at: "2026-01-01T00:00:00Z" }), + pref({ id: "pref-b", confirmed_at: "2026-01-01T00:10:00Z" }), + ], + signSignById: signs, + corpusPrinciples: [], + coveredTopics: [], + }, + { ...config, batchInflationMinBurstSize: 2, silenceBefore: "2026-03-01" }, + ); + expect(report.batchInflation).toEqual([]); + expect(report.verdict).toBe("clean"); + expect(report.suppressed).toEqual({ + conceptGaps: 0, + batchInflation: 1, + baseline: "2026-03-01", + }); + }); + + test("a burst reaching at or after the watermark still surfaces", () => { + const report = reconcileSemanticHealth( + { + preferences: [ + pref({ id: "pref-a", confirmed_at: "2026-05-01T00:00:00Z" }), + pref({ id: "pref-b", confirmed_at: "2026-05-01T00:10:00Z" }), + ], + signSignById: signs, + corpusPrinciples: [], + coveredTopics: [], + }, + { ...config, batchInflationMinBurstSize: 2, silenceBefore: "2026-03-01" }, + ); + expect(report.batchInflation).toHaveLength(1); + expect(report.verdict).toBe("watch"); + expect("suppressed" in report).toBe(false); + }); + + test("a burst whose windowEnd equals the watermark instant surfaces (strictly-older boundary)", () => { + // windowEnd == watermark: `endMs < watermarkMs` is false, so the burst + // is kept. Only a window entirely BEFORE the watermark is hidden. + const report = reconcileSemanticHealth( + { + preferences: [ + pref({ id: "pref-a", confirmed_at: "2026-03-01T00:00:00Z" }), + pref({ id: "pref-b", confirmed_at: "2026-03-01T00:00:00Z" }), + ], + signSignById: signs, + corpusPrinciples: [], + coveredTopics: [], + }, + { ...config, batchInflationMinBurstSize: 2, silenceBefore: "2026-03-01" }, + ); + expect(report.batchInflation).toHaveLength(1); + expect(report.verdict).toBe("watch"); + expect("suppressed" in report).toBe(false); + }); + + test("a burst straddling the watermark surfaces (only fully-old bursts hide)", () => { + // windowStart before, windowEnd after the watermark (1h apart, inside + // the 24h default window). The burst is not entirely old, so it stays. + const report = reconcileSemanticHealth( + { + preferences: [ + pref({ id: "pref-a", confirmed_at: "2026-02-28T23:30:00Z" }), + pref({ id: "pref-b", confirmed_at: "2026-03-01T00:30:00Z" }), + ], + signSignById: signs, + corpusPrinciples: [], + coveredTopics: [], + }, + { ...config, batchInflationMinBurstSize: 2, silenceBefore: "2026-03-01" }, + ); + expect(report.batchInflation).toHaveLength(1); + expect(report.verdict).toBe("watch"); + expect("suppressed" in report).toBe(false); + }); + + test("a concept gap whose newest mention equals the watermark surfaces (kept on the boundary)", () => { + // latest == watermark: `latest >= watermarkMs` is true, so the gap is + // kept. Suppression needs EVERY mention strictly older. + const report = reconcileSemanticHealth( + { + preferences: [], + signSignById: signs, + corpusPrinciples: ["Kanban slow", "Kanban stuck", "Kanban grooming"], + corpusPrincipleDates: [ + "2026-01-01T00:00:00Z", + "2026-01-02T00:00:00Z", + "2026-03-01T00:00:00Z", + ], + coveredTopics: [], + }, + { ...config, silenceBefore: "2026-03-01" }, + ); + expect(report.conceptGaps.some((g) => g.term === "kanban")).toBe(true); + expect(report.verdict).toBe("watch"); + expect("suppressed" in report).toBe(false); + }); + + test("a concept gap is suppressed when every mentioning entry predates the watermark", () => { + const report = reconcileSemanticHealth( + { + preferences: [], + signSignById: signs, + corpusPrinciples: ["Kanban slow", "Kanban stuck", "Kanban grooming"], + corpusPrincipleDates: [ + "2026-01-01T00:00:00Z", + "2026-01-02T00:00:00Z", + "2026-01-03T00:00:00Z", + ], + coveredTopics: [], + }, + { ...config, silenceBefore: "2026-03-01" }, + ); + expect(report.conceptGaps).toEqual([]); + expect(report.verdict).toBe("clean"); + expect(report.suppressed).toEqual({ + conceptGaps: 1, + batchInflation: 0, + baseline: "2026-03-01", + }); + }); + + test("a concept gap with one fresh mention surfaces at full frequency", () => { + const report = reconcileSemanticHealth( + { + preferences: [], + signSignById: signs, + corpusPrinciples: ["Kanban slow", "Kanban stuck", "Kanban grooming"], + corpusPrincipleDates: [ + "2026-01-01T00:00:00Z", + "2026-01-02T00:00:00Z", + "2026-06-01T00:00:00Z", + ], + coveredTopics: [], + }, + { ...config, silenceBefore: "2026-03-01" }, + ); + const kanban = report.conceptGaps.find((g) => g.term === "kanban"); + expect(kanban?.frequency).toBe(3); + expect(report.verdict).toBe("watch"); + expect("suppressed" in report).toBe(false); + }); + + test("an undated mention keeps the gap visible (undated counts as newer)", () => { + const report = reconcileSemanticHealth( + { + preferences: [], + signSignById: signs, + corpusPrinciples: ["Kanban slow", "Kanban stuck", "Kanban grooming"], + corpusPrincipleDates: ["2026-01-01T00:00:00Z", "2026-01-02T00:00:00Z", null], + coveredTopics: [], + }, + { ...config, silenceBefore: "2026-03-01" }, + ); + expect(report.conceptGaps.some((g) => g.term === "kanban")).toBe(true); + expect("suppressed" in report).toBe(false); + }); + + test("a set watermark that hides nothing keeps the report byte-identical (no suppressed field)", () => { + const report = reconcileSemanticHealth( + { + preferences: [pref({ id: "pref-a", principle: "write tests first" })], + signSignById: signs, + corpusPrinciples: ["write tests first"], + coveredTopics: ["tests-first"], + }, + { ...config, silenceBefore: "2026-03-01" }, + ); + expect(report.verdict).toBe("clean"); + expect("suppressed" in report).toBe(false); + }); + + test("an unparseable watermark throws rather than silently disabling the filter", () => { + expect(() => + reconcileSemanticHealth( + { + preferences: [], + signSignById: signs, + corpusPrinciples: [], + coveredTopics: [], + }, + { ...config, silenceBefore: "not-a-date" }, + ), + ).toThrow(); + }); +}); diff --git a/tests/core/brain/policy-health.test.ts b/tests/core/brain/policy-health.test.ts index 81c853a6..30683f9d 100644 --- a/tests/core/brain/policy-health.test.ts +++ b/tests/core/brain/policy-health.test.ts @@ -21,11 +21,37 @@ function validate(yaml: string) { const HEAD = `schema_version: 1\n`; describe("BRAIN_HEALTH_DEFAULTS", () => { - test("documents the four knob defaults", () => { + test("documents the knob defaults", () => { expect(BRAIN_HEALTH_DEFAULTS.contradiction_jaccard).toBe(0.5); expect(BRAIN_HEALTH_DEFAULTS.concept_gap_min_frequency).toBe(3); expect(BRAIN_HEALTH_DEFAULTS.stale_claim_max_age_days).toBe(180); expect(BRAIN_HEALTH_DEFAULTS.remediation_step_cap).toBe(20); + expect(BRAIN_HEALTH_DEFAULTS.silence_before).toBeNull(); + }); +}); + +describe("health.silence_before", () => { + test("a date-only value resolves as the watermark string", () => { + const { config } = validate(HEAD + `health:\n silence_before: "2026-01-01"\n`); + expect(resolveHealth(config).silence_before).toBe("2026-01-01"); + }); + + test("a full ISO timestamp resolves as the watermark string", () => { + const { config } = validate(HEAD + `health:\n silence_before: "2026-01-01T12:00:00Z"\n`); + expect(resolveHealth(config).silence_before).toBe("2026-01-01T12:00:00Z"); + }); + + test("an unparseable date is rejected loudly, never silently ignored", () => { + expect(() => validate(HEAD + `health:\n silence_before: "2026-13-99"\n`)).toThrow( + BrainConfigError, + ); + expect(() => validate(HEAD + `health:\n silence_before: "not-a-date"\n`)).toThrow( + BrainConfigError, + ); + }); + + test("a non-string value is rejected", () => { + expect(() => validate(HEAD + `health:\n silence_before: 2026\n`)).toThrow(BrainConfigError); }); }); @@ -50,6 +76,7 @@ describe("health config block", () => { concept_gap_min_frequency: 5, stale_claim_max_age_days: 90, remediation_step_cap: 4, + silence_before: null, }); }); diff --git a/tests/mcp/brain-health.test.ts b/tests/mcp/brain-health.test.ts index efeb04e7..54bf06c5 100644 --- a/tests/mcp/brain-health.test.ts +++ b/tests/mcp/brain-health.test.ts @@ -130,4 +130,73 @@ describe("brain_health MCP tool", () => { expect(out["verdict"]).toBe("investigate"); expect((out["contradictions"] as unknown[]).length).toBe(1); }); + + test("silence_before watermark surfaces the suppressed mirror (snake_case)", async () => { + // A burst of confirmed preferences dated well before the watermark + // trips batch-inflation detection; setting `health.silence_before` + // after that date hides the burst and the tool must mirror the hidden + // count back as `suppressed.batch_inflation`. + for (let i = 0; i < 6; i++) { + writePreference( + vault, + { + slug: `batch-${i}`, + topic: `topic-${i}`, + principle: `rule number ${i} for the batch`, + created_at: "2026-01-01T00:00:00Z", + unconfirmed_until: "2026-01-08T00:00:00Z", + confirmed_at: "2026-01-01T00:00:00Z", + status: BRAIN_PREFERENCE_STATUS.confirmed, + evidenced_by: [], + content_hash: "0".repeat(64), + }, + { overwrite: true }, + ); + } + writeFileSync( + join(vault, "Brain", "_brain.yaml"), + 'schema_version: 1\n\nhealth:\n silence_before: "2026-06-01"\n', + ); + + const server = new MCPServer({ vault, configPath }); + await initialize(server); + const out = await callTool(server, "brain_health", {}); + + expect(out["suppressed"]).toEqual({ + concept_gaps: 0, + batch_inflation: 1, + baseline: "2026-06-01", + }); + expect(out["batch_inflation"] as unknown[]).toEqual([]); + }); + + test("no watermark set means no suppressed key at all", async () => { + // Same burst as above, but _brain.yaml (written in beforeEach) carries + // no `health.silence_before` - the finding surfaces normally and the + // tool must not emit a `suppressed` key, not even an empty one. + for (let i = 0; i < 6; i++) { + writePreference( + vault, + { + slug: `batch-${i}`, + topic: `topic-${i}`, + principle: `rule number ${i} for the batch`, + created_at: "2026-01-01T00:00:00Z", + unconfirmed_until: "2026-01-08T00:00:00Z", + confirmed_at: "2026-01-01T00:00:00Z", + status: BRAIN_PREFERENCE_STATUS.confirmed, + evidenced_by: [], + content_hash: "0".repeat(64), + }, + { overwrite: true }, + ); + } + + const server = new MCPServer({ vault, configPath }); + await initialize(server); + const out = await callTool(server, "brain_health", {}); + + expect("suppressed" in out).toBe(false); + expect((out["batch_inflation"] as unknown[]).length).toBe(1); + }); });