From 8269e8b359962a93dbe9ce53f3348e49571c1548 Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 18:43:02 -0400 Subject: [PATCH 1/8] docs: specify professional discrepancy center Signed-off-by: John Siracusa --- specs/contextcake-conflict-resolution/spec.md | 3 + specs/contextcake-discrepancy-center/spec.md | 69 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 specs/contextcake-discrepancy-center/spec.md diff --git a/specs/contextcake-conflict-resolution/spec.md b/specs/contextcake-conflict-resolution/spec.md index b9d26428..0177697f 100644 --- a/specs/contextcake-conflict-resolution/spec.md +++ b/specs/contextcake-conflict-resolution/spec.md @@ -1,5 +1,8 @@ # Conflict Resolution +**Status:** Shipped predecessor. The additive professional workflow and governed +learning contract lives in `specs/contextcake-discrepancy-center/spec.md`. + ContextCake turns surfaced section conflicts into quick, reversible decisions. It resolves only meaning-preserving differences automatically, asks one clear question when judgment is required, and keeps an append-only local record of every applied choice. ## Problem Statement diff --git a/specs/contextcake-discrepancy-center/spec.md b/specs/contextcake-discrepancy-center/spec.md new file mode 100644 index 00000000..e20e244c --- /dev/null +++ b/specs/contextcake-discrepancy-center/spec.md @@ -0,0 +1,69 @@ +# ContextCake Discrepancy Center + +ContextCake turns structural disagreement across a selected source profile into +decision-ready evidence, safe resolution, and user-approved reusable policy. It +extends the shipped conflict-resolution workflow without changing resolver +precedence or inferring semantic contradictions. + +## Problem Statement + +The shipped resolver and Conflicts view expose same-concept, same-section text +differences, but professional teams also need metadata disagreements, broken +knowledge links, decisions that reopen after sources change, inspectable diffs, +and a trustworthy explanation of what an action will write. Repeated human +choices should make the product faster without granting an opaque model the +authority to change knowledge. + +## User Stories + +- As an engineer, I can understand why a discrepancy exists and what currently wins. +- As a technical leader, I can filter actionable discrepancies by kind, owner, source, status, and priority. +- As a reviewer, I can choose an existing answer, compose a reconciled answer, or acknowledge an intentional scoped difference. +- As an auditor, I can inspect every original answer and every superseding decision. +- As a user, I can approve an explainable rule recommendation and separately decide whether it may run automatically. +- As a teammate, I can promote a local recommendation without silently enabling automation for other people. + +## Acceptance Criteria + +- [ ] WHEN contributors define the same section differently THE SYSTEM SHALL emit a `section_content` discrepancy with every contribution and the deterministic winner reason. +- [ ] WHEN contributors define the same authored frontmatter field differently THE SYSTEM SHALL emit a `frontmatter_value` discrepancy, excluding `updated` and `override`. +- [ ] WHEN an outgoing OKF link has no target in a healthy, settled selected profile THE SYSTEM SHALL emit a `broken_link` discrepancy. +- [ ] WHEN a recorded contributor fingerprint changes after a decision THE SYSTEM SHALL reopen it as `changed_after_decision`, even if the authored date did not change. +- [ ] WHEN sources are indexing or unavailable THE SYSTEM SHALL report incomplete coverage and SHALL NOT manufacture broken-link findings. +- [ ] WHEN a discrepancy is resolved THE SYSTEM SHALL support choosing a contribution, composing a reconciled value, or acknowledging a scoped difference. +- [ ] WHEN a scoped difference is acknowledged THE SYSTEM SHALL write no source content and SHALL require a reason code. +- [ ] WHEN any source write or decision-log append fails THE SYSTEM SHALL restore every changed target or explicitly report recovery-required state. +- [ ] WHEN an incomplete prepared transaction is found at startup THE SYSTEM SHALL restore its original files and append a rollback outcome. +- [ ] WHEN a v1 conflict-resolution record is read THE SYSTEM SHALL preserve and display it without rewriting the file. +- [ ] WHEN three distinct discrepancies receive the same structural manual decision THE SYSTEM SHALL offer an evidence-backed local rule suggestion. +- [ ] WHEN a rule is approved THE SYSTEM SHALL default it to recommendation mode; automatic mode requires a separate explicit action. +- [ ] WHEN multiple matching rules disagree THE SYSTEM SHALL perform no automatic action. +- [ ] WHEN a promoted team rule reaches another user THE SYSTEM SHALL remain a recommendation until that user explicitly enables local automation. +- [ ] WHEN the Web Demo performs a decision THE SYSTEM SHALL identify it as a simulation, write no files, run no automatic rules, and state that history resets on reload. +- [ ] WHEN an agent reads an acknowledged discrepancy THE SYSTEM SHALL expose additive disposition metadata without removing the original conflicts. +- [ ] WHEN the primary review workflow is used with a keyboard THE SYSTEM SHALL expose the same evidence, actions, focus state, status, and errors as pointer use. + +## Out of Scope + +- Semantic entity matching, embeddings, or model-inferred contradictions. +- Cross-source incoming links not exposed by a source adapter. +- Hosted policy infrastructure, approval routing, or new shared telemetry fields. +- Treating missing concepts or singly defined fields as discrepancies. +- Learning or replaying free-form reconciled content. + +## Defaults and Boundaries + +- Resolver precedence, per-section conflict retention, and explicit suppression remain unchanged. +- Priority defaults to `unassigned`; ContextCake does not invent business severity. +- Local rules outrank shared recommendations only for the current profile; conflicts between rules disable automation. +- Rules store structural metadata and decision ids only, never source content, notes, prompts, or excerpts. +- Shared rules use the existing live-layer git trust boundary and remain recommendations for other users. + +## Dependencies + +- Existing resolver provenance and conflict output. +- Existing manifest/profile lock and guarded section writers. +- Existing append-only conflict-resolution log. +- Existing live-layer git mutation and offline queue. +- Existing console Review surface and generated demo bundle. + From d2d22ebe31a3fae0bc11c563fe0b71950780c391 Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 19:31:05 -0400 Subject: [PATCH 2/8] feat: detect structural discrepancies Signed-off-by: John Siracusa --- packages/core/src/discrepancies.mjs | 207 +++++++++++++++++++++ packages/core/src/discrepancy-rules.mjs | 149 +++++++++++++++ packages/core/src/resolver.mjs | 35 +++- packages/core/tests/discrepancies.test.mjs | 115 ++++++++++++ 4 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/discrepancies.mjs create mode 100644 packages/core/src/discrepancy-rules.mjs create mode 100644 packages/core/tests/discrepancies.test.mjs diff --git a/packages/core/src/discrepancies.mjs b/packages/core/src/discrepancies.mjs new file mode 100644 index 00000000..425ce03d --- /dev/null +++ b/packages/core/src/discrepancies.mjs @@ -0,0 +1,207 @@ +// Structural discrepancy projection over resolved concepts. This module never +// changes resolver precedence and never performs semantic/model inference. + +import { createHash } from "node:crypto"; + +export const DISCREPANCY_KINDS = new Set([ + "section_content", "frontmatter_value", "broken_link", "changed_after_decision", +]); + +export function fingerprint(value) { + return createHash("sha256").update(stable(value)).digest("hex"); +} + +export function discrepancyRevision(contributions) { + return fingerprint(contributions + .map((item) => [item.source, item.level, item.fingerprint]) + .sort(([a], [b]) => String(a).localeCompare(String(b)))); +} + +export function buildDiscrepancies(concepts, { + decisions = [], coverageComplete = true, sourceHealth = [], priorities = {}, rules = [], +} = {}) { + const out = []; + const ids = new Set(concepts.map((concept) => concept.id)); + const healthBySource = new Map(sourceHealth.map((source) => [source.name, source])); + const decisionsById = decisionMap(decisions); + + for (const concept of concepts) { + const owner = String(concept.frontmatter?.owner ?? "Unassigned"); + const conceptType = String(concept.frontmatter?.type ?? "concept"); + const levels = new Map(concept.contributors.map((item) => [item.layer, item.level])); + + for (const section of concept.sections) { + if (section.conflicts?.length) { + const baseKind = "section_content"; + const baseId = `${baseKind}::${concept.id}::${section.key}`; + const contributions = [ + contribution(section.sourceLayer, levels.get(section.sourceLayer), section.sourceUpdated, section.content, true), + ...section.conflicts.map((item) => contribution(item.layer, levels.get(item.layer), item.updated, item.content, false)), + ]; + out.push(finalize({ + id: baseId, legacyId: `${concept.id}::${section.key}`, kind: baseKind, + conceptId: concept.id, conceptTitle: String(concept.frontmatter?.title ?? concept.id), conceptType, + key: section.key, label: headingText(section.heading) || section.key, owner, + effectiveSource: section.sourceLayer, effectiveValue: section.content, + winnerReason: `${section.sourceLayer} wins by configured layer precedence.`, + contributions, fresherDissent: section.fresherDissent === true, + sourceHealth: contributions.map((item) => healthSummary(healthBySource.get(item.source))), + priority: priorities[baseId] ?? "unassigned", + }, decisionsById, rules)); + } + + if (coverageComplete) { + for (const target of extractLinks(section.content)) { + if (ids.has(target)) continue; + const id = `broken_link::${concept.id}::${section.key}::${target}`; + const contributions = [contribution(section.sourceLayer, levels.get(section.sourceLayer), section.sourceUpdated, target, true)]; + out.push(finalize({ + id, kind: "broken_link", conceptId: concept.id, + conceptTitle: String(concept.frontmatter?.title ?? concept.id), conceptType, + key: section.key, label: `Missing link target: ${target}`, target, owner, + effectiveSource: section.sourceLayer, effectiveValue: target, + winnerReason: "The effective section links to a concept that no settled source provides.", + contributions, fresherDissent: false, + sourceHealth: contributions.map((item) => healthSummary(healthBySource.get(item.source))), + priority: priorities[id] ?? "unassigned", + }, decisionsById, rules)); + } + } + } + + for (const conflict of concept.frontmatterConflicts ?? []) { + const id = `frontmatter_value::${concept.id}::${conflict.key}`; + const contributions = conflict.contributions.map((item) => contribution( + item.layer, item.level, item.updated, item.value, item.layer === conflict.winner.layer, + )); + out.push(finalize({ + id, kind: "frontmatter_value", conceptId: concept.id, + conceptTitle: String(concept.frontmatter?.title ?? concept.id), conceptType, + key: conflict.key, label: conflict.key, owner, + effectiveSource: conflict.winner.layer, effectiveValue: conflict.winner.value, + winnerReason: `${conflict.winner.layer} wins by configured layer precedence.`, + contributions, fresherDissent: false, + sourceHealth: contributions.map((item) => healthSummary(healthBySource.get(item.source))), + priority: priorities[id] ?? "unassigned", + }, decisionsById, rules)); + } + } + // A completed write removes the structural disagreement from the resolver's + // current output. Keep its evidence discoverable from the append-only log so + // "resolved" never means "forgotten". A current finding always wins this + // projection (including a reopened finding after a later source edit). + const currentIds = new Set(out.map((item) => item.id)); + for (const [id, history] of decisionsById) { + if (!id.includes("::") || currentIds.has(id)) continue; + const latest = history.at(-1); + if (latest?.schemaVersion !== 2 || latest.action === "acknowledge") continue; + const contributions = (latest.contributions ?? []).map((item) => contribution( + item.layer, item.level, item.updated, item.content, item.layer === latest.chosen?.layer, + )); + out.push({ + id, legacyId: latest.conflictId, kind: latest.discrepancyKind ?? "section_content", + originalKind: latest.discrepancyKind ?? "section_content", conceptId: latest.conceptId, + conceptTitle: latest.title ?? latest.conceptId, conceptType: latest.conceptType ?? "concept", + key: latest.sectionKey ?? latest.fieldKey ?? latest.linkTarget ?? "unknown", + label: latest.sectionHeading ?? latest.fieldKey ?? latest.linkTarget ?? "Resolved discrepancy", + owner: latest.owner ?? "Unassigned", effectiveSource: latest.chosen?.layer ?? null, + effectiveValue: latest.chosen?.content ?? latest.reconciledContent ?? null, + winnerReason: "This value was established by the recorded decision.", contributions, + revision: latest.revision, fresherDissent: false, sourceHealth: [], priority: latest.priority ?? "unassigned", + freshness: { effectiveUpdated: latest.chosen?.updated ?? null, newestUpdated: newestDate(contributions.map((entry) => entry.updated)), hasNewerDissent: false }, + affectedLinks: [...new Set(contributions.flatMap((entry) => typeof entry.value === "string" ? extractLinks(entry.value) : []))], + status: latest.transactionState === "committed" || latest.transactionState === "not_required" ? "resolved" : "blocked", + history, matchingRules: [], + }); + } + return { discrepancies: out, coverageComplete }; +} + +function finalize(item, decisionsById, rules) { + item.originalKind = item.kind; + item.freshness = { + effectiveUpdated: item.contributions.find((entry) => entry.effective)?.updated ?? null, + newestUpdated: newestDate(item.contributions.map((entry) => entry.updated)), + hasNewerDissent: item.fresherDissent === true, + }; + item.affectedLinks = [...new Set(item.contributions.flatMap((entry) => typeof entry.value === "string" ? extractLinks(entry.value) : []))]; + item.revision = discrepancyRevision(item.contributions); + const history = decisionsById.get(item.id) ?? decisionsById.get(item.legacyId) ?? []; + const latest = history.at(-1) ?? null; + const recorded = latest?.contributorFingerprints ?? latest?.contributions?.map((c) => ({ source: c.layer, fingerprint: fingerprint(c.content) })); + const changed = Boolean(latest && recorded && !sameFingerprints(recorded, item.contributions)); + const ruleMatch = matchRules(item, rules); + const matchingRules = ruleMatch.rules; + item.kind = changed ? "changed_after_decision" : item.kind; + item.status = changed ? "reopened" + : latest?.action === "acknowledge" ? "acknowledged" + : !ruleMatch.conflict && matchingRules.some((rule) => rule.mode === "automatic") ? "auto_ready" + : !ruleMatch.conflict && matchingRules.length ? "recommended" : "needs_review"; + item.history = history; + item.matchingRules = matchingRules.map(publicRule); + item.ruleConflict = ruleMatch.conflict; + return item; +} + +function contribution(source, level, updated, value, effective) { + return { source, level: level ?? 0, updated: updated ?? null, value, fingerprint: fingerprint(value), effective }; +} + +function decisionMap(decisions) { + const map = new Map(); + for (const decision of decisions) { + const keys = [decision.discrepancyId, decision.conflictId].filter(Boolean); + for (const key of keys) { + const rows = map.get(key) ?? []; + rows.push(decision); + map.set(key, rows); + } + } + return map; +} + +function sameFingerprints(recorded, current) { + const a = recorded.map((x) => `${x.source ?? x.layer}:${x.fingerprint}`).sort(); + const b = current.map((x) => `${x.source}:${x.fingerprint}`).sort(); + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function matchRules(item, rules) { + const sources = item.contributions.map((c) => c.source).sort().join("|"); + const matches = rules.filter((rule) => rule.enabled !== false + && rule.match?.kind === item.kind + && rule.match?.conceptType === item.conceptType + && rule.match?.key === item.key + && [...(rule.match?.sources ?? [])].sort().join("|") === sources); + const actions = new Set(matches.map((rule) => stable(rule.action))); + return { rules: matches, conflict: actions.size > 1 }; +} + +function publicRule(rule) { + return { id: rule.id, scope: rule.scope, mode: rule.mode, action: rule.action, evidenceDecisionIds: rule.evidenceDecisionIds ?? [] }; +} + +function extractLinks(text) { + const out = []; + for (const match of String(text).matchAll(/!?\[[^\]]*]\(([^)]+)\)/g)) { + if (!match[0].startsWith("!") && localTarget(match[1])) out.push(cleanTarget(match[1])); + } + for (const match of String(text).matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?]]/g)) { + if (localTarget(match[1])) out.push(cleanTarget(match[1])); + } + return [...new Set(out.filter(Boolean))]; +} + +function localTarget(target) { return !/^[a-z][a-z0-9+.-]*:/i.test(String(target)) && !String(target).startsWith("#"); } +function cleanTarget(target) { return String(target).split("#")[0].replace(/^\.\//, "").replace(/\.md$/i, "").trim(); } +function headingText(value) { return String(value ?? "").replace(/^#+\s*/, "").replace(/\s*\{#.*\}\s*$/, "").trim(); } +function healthSummary(source) { return source ? { source: source.name, status: source.status, error: source.error ?? null } : null; } +function newestDate(values) { + return values.filter(Boolean).sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ?? null; +} +function stable(value) { + if (value === undefined) return "undefined"; + if (Array.isArray(value)) return `[${value.map((item) => stable(item)).join(",")}]`; + if (value && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(",")}}`; + return JSON.stringify(value); +} diff --git a/packages/core/src/discrepancy-rules.mjs b/packages/core/src/discrepancy-rules.mjs new file mode 100644 index 00000000..0ca81d14 --- /dev/null +++ b/packages/core/src/discrepancy-rules.mjs @@ -0,0 +1,149 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const VERSION = 1; + +export function createDiscrepancyRuleStore(manifestPath) { + const dir = path.join(path.dirname(path.resolve(manifestPath)), ".contextcake"); + const file = path.join(dir, "discrepancy-rules.json"); + + async function list() { + try { + const parsed = JSON.parse(await fsp.readFile(file, "utf8")); + return parsed?.version === VERSION && Array.isArray(parsed.rules) ? parsed.rules.map(validateRule) : []; + } catch (error) { + if (error.code === "ENOENT") return []; + throw new Error(`Discrepancy rules are unreadable: ${error.message}`); + } + } + + async function save(rules) { + await fsp.mkdir(dir, { recursive: true, mode: 0o700 }); + const temp = `${file}.${randomUUID()}.tmp`; + await fsp.writeFile(temp, `${JSON.stringify({ version: VERSION, rules }, null, 2)}\n`, { mode: 0o600 }); + await fsp.rename(temp, file); + } + + async function create(input) { + const rules = await list(); + // The API accepts only the structural contract. Content, notes, excerpts, + // prompts, and arbitrary caller metadata must never enter a rule file. + const rule = validateRule({ + id: randomUUID(), scope: "local", mode: "recommend", enabled: true, + createdAt: new Date().toISOString(), + match: input?.match, action: input?.action, + evidenceDecisionIds: input?.evidenceDecisionIds, + }); + rules.push(rule); + await save(rules); + return rule; + } + + async function patch(id, changes) { + const rules = await list(); + const index = rules.findIndex((rule) => rule.id === id); + if (index === -1) throw Object.assign(new Error("Discrepancy rule not found"), { status: 404 }); + const allowed = {}; + if (changes.mode === "recommend" || changes.mode === "automatic") allowed.mode = changes.mode; + if (typeof changes.enabled === "boolean") allowed.enabled = changes.enabled; + rules[index] = validateRule({ ...rules[index], ...allowed, updatedAt: new Date().toISOString() }); + await save(rules); + return rules[index]; + } + + async function setLocalOverride(teamRule, changes) { + const rules = await list(); + const index = rules.findIndex((rule) => rule.id === teamRule.id); + const base = index === -1 ? { ...teamRule, scope: "local", createdAt: new Date().toISOString() } : rules[index]; + const next = validateRule({ + ...base, + ...(changes.mode === "recommend" || changes.mode === "automatic" ? { mode: changes.mode } : {}), + ...(typeof changes.enabled === "boolean" ? { enabled: changes.enabled } : {}), + updatedAt: new Date().toISOString(), + }); + if (index === -1) rules.push(next); else rules[index] = next; + await save(rules); + return next; + } + + return { file, list, create, patch, setLocalOverride }; +} + +export function parseRuleDocument(text) { + const parsed = JSON.parse(text); + if (parsed?.version !== VERSION || !Array.isArray(parsed.rules)) throw new Error("Unsupported discrepancy rule document"); + return parsed.rules.map((rule) => validateRule({ ...rule, scope: "team", mode: "recommend" })); +} + +export function serializeRuleDocument(rules) { + return `${JSON.stringify({ version: VERSION, rules: rules.map((rule) => validateRule({ ...rule, scope: "team", mode: "recommend" })) }, null, 2)}\n`; +} + +export function suggestDiscrepancyRules(decisions, existing = []) { + const latestByDiscrepancy = new Map(); + const contradictory = new Set(); + for (const row of decisions) { + if (row.schemaVersion !== 2 || row.method === "automatic" || !row.learningPattern || !row.ruleAction) continue; + const prior = latestByDiscrepancy.get(row.discrepancyId); + if (prior && JSON.stringify(prior.ruleAction) !== JSON.stringify(row.ruleAction)) contradictory.add(row.discrepancyId); + latestByDiscrepancy.set(row.discrepancyId, row); + } + const groups = new Map(); + for (const row of latestByDiscrepancy.values()) { + if (contradictory.has(row.discrepancyId)) continue; + const key = JSON.stringify(row.learningPattern); + const group = groups.get(key) ?? []; + group.push(row); + groups.set(key, group); + } + const existingPatterns = new Set(existing.map((rule) => JSON.stringify(rule.match))); + const suggestions = []; + for (const [key, rows] of groups) { + const distinct = [...new Map(rows.map((row) => [row.discrepancyId, row])).values()]; + if (distinct.length < 3 || existingPatterns.has(key)) continue; + const actions = new Set(distinct.map((row) => JSON.stringify(row.ruleAction))); + if (actions.size !== 1) continue; + suggestions.push({ + id: `suggestion:${Buffer.from(key).toString("base64url").slice(0, 24)}`, + match: JSON.parse(key), action: distinct[0].ruleAction, + evidenceDecisionIds: distinct.map((row) => row.id), evidenceCount: distinct.length, + }); + } + return suggestions; +} + +function validateRule(rule) { + if (!rule || typeof rule.id !== "string" || !rule.match || !rule.action) throw new Error("Invalid discrepancy rule"); + if (rule.mode !== "recommend" && rule.mode !== "automatic") throw new Error("Invalid discrepancy rule mode"); + const { kind, conceptType, key, sources } = rule.match; + if (!["section_content", "frontmatter_value"].includes(kind) + || typeof conceptType !== "string" || !conceptType + || typeof key !== "string" || !key + || !Array.isArray(sources) || sources.length < 2 + || sources.some((source) => typeof source !== "string" || !source)) { + throw new Error("Invalid discrepancy rule match"); + } + if (rule.action.type === "prefer_source") { + if (typeof rule.action.source !== "string" || !sources.includes(rule.action.source)) throw new Error("Invalid preferred source"); + } else if (rule.action.type === "acknowledge") { + if (!["different_scopes", "temporary_migration", "source_specific_authority", "other"].includes(rule.action.reasonCode)) { + throw new Error("Invalid acknowledgement reason"); + } + } else throw new Error("Invalid discrepancy rule action"); + if (rule.evidenceDecisionIds !== undefined + && (!Array.isArray(rule.evidenceDecisionIds) || rule.evidenceDecisionIds.some((id) => typeof id !== "string"))) { + throw new Error("Invalid discrepancy rule evidence"); + } + return { + id: rule.id, scope: rule.scope === "team" ? "team" : "local", + mode: rule.mode, enabled: rule.enabled !== false, + match: { kind, conceptType, key, sources: [...new Set(sources)].sort() }, + action: rule.action.type === "prefer_source" + ? { type: "prefer_source", source: rule.action.source } + : { type: "acknowledge", reasonCode: rule.action.reasonCode }, + evidenceDecisionIds: [...new Set(rule.evidenceDecisionIds ?? [])], + ...(typeof rule.createdAt === "string" ? { createdAt: rule.createdAt } : {}), + ...(typeof rule.updatedAt === "string" ? { updatedAt: rule.updatedAt } : {}), + }; +} diff --git a/packages/core/src/resolver.mjs b/packages/core/src/resolver.mjs index 68f8aad9..05eb1528 100644 --- a/packages/core/src/resolver.mjs +++ b/packages/core/src/resolver.mjs @@ -92,6 +92,26 @@ export function mergeConcepts(contributors) { } } + // Frontmatter still resolves by precedence, exactly as before. The additive + // conflict list gives inspection surfaces the values that lost without + // changing the effective object or treating a singly-defined field as a + // disagreement. `updated` and `override` are resolver mechanics, not domain + // facts a person should reconcile. + const frontmatterConflicts = []; + const frontmatterKeys = new Set(active.flatMap((c) => Object.keys(c.frontmatter))); + for (const key of frontmatterKeys) { + if (key === "updated" || key === "override") continue; + const definitions = active + .filter((c) => Object.prototype.hasOwnProperty.call(c.frontmatter, key)) + .map((c) => ({ layer: c.layer, level: c.level, updated: c.updated, value: c.frontmatter[key] })); + if (definitions.length < 2) continue; + const signatures = new Set(definitions.map((item) => stableValue(item.value))); + if (signatures.size < 2) continue; + const winnerLayer = frontmatterProvenance[key]; + const winner = definitions.find((item) => item.layer === winnerLayer); + frontmatterConflicts.push({ key, winner, contributions: definitions }); + } + // Per-section winner: highest level wins (vertical precedence). Display order // follows first appearance in precedence order, so a higher layer's section // ordering leads. Dissenters are collected per section for honest-conflict output. @@ -147,7 +167,20 @@ export function mergeConcepts(contributors) { }; }); - return { frontmatter, frontmatterProvenance, sections }; + return { + frontmatter, + frontmatterProvenance, + ...(frontmatterConflicts.length ? { frontmatterConflicts } : {}), + sections, + }; +} + +function stableValue(value) { + if (Array.isArray(value)) return JSON.stringify(value); + if (value && typeof value === "object") { + return JSON.stringify(Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))); + } + return JSON.stringify(value); } // Higher level wins; equal level keeps the first contributor seen. Contributors diff --git a/packages/core/tests/discrepancies.test.mjs b/packages/core/tests/discrepancies.test.mjs new file mode 100644 index 00000000..d3f74014 --- /dev/null +++ b/packages/core/tests/discrepancies.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildDiscrepancies, discrepancyRevision, fingerprint } from "../src/discrepancies.mjs"; +import { serializeRuleDocument, suggestDiscrepancyRules } from "../src/discrepancy-rules.mjs"; +import { mergeConcepts } from "../src/resolver.mjs"; + +const concept = { + id: "decisions/db", + contributors: [ + { layer: "team", level: 2, updated: "2026-08-01" }, + { layer: "company", level: 0, updated: "2026-07-01" }, + ], + frontmatter: { title: "Database", type: "decision", owner: "Platform" }, + frontmatterConflicts: [{ + key: "owner", + winner: { layer: "team", level: 2, updated: "2026-08-01", value: "Platform" }, + contributions: [ + { layer: "team", level: 2, updated: "2026-08-01", value: "Platform" }, + { layer: "company", level: 0, updated: "2026-07-01", value: "Architecture" }, + ], + }], + sections: [{ + key: "choice", heading: "## Choice {#choice}", content: "Use Postgres. See [[runbooks/missing]].", + sourceLayer: "team", sourceUpdated: "2026-08-01", + conflicts: [{ layer: "company", updated: "2026-07-01", content: "Use MySQL." }], + }], +}; + +test("builds section, frontmatter, and settled broken-link discrepancies", () => { + const result = buildDiscrepancies([concept], { coverageComplete: true }); + assert.deepEqual(result.discrepancies.map((item) => item.kind).sort(), ["broken_link", "frontmatter_value", "section_content"]); + const section = result.discrepancies.find((item) => item.originalKind === "section_content"); + assert.equal(section.owner, "Platform"); + assert.equal(section.status, "needs_review"); + assert.equal(section.contributions.length, 2); +}); + +test("does not manufacture broken links with incomplete coverage", () => { + const result = buildDiscrepancies([concept], { coverageComplete: false }); + assert.equal(result.discrepancies.some((item) => item.kind === "broken_link"), false); +}); + +test("an acknowledged discrepancy reopens when a contributor fingerprint changes", () => { + const decisions = [{ + schemaVersion: 2, id: "d1", discrepancyId: "section_content::decisions/db::choice", action: "acknowledge", + contributorFingerprints: [ + { source: "team", fingerprint: fingerprint("old") }, + { source: "company", fingerprint: fingerprint("Use MySQL.") }, + ], + }]; + const result = buildDiscrepancies([concept], { decisions, coverageComplete: false }); + const section = result.discrepancies.find((item) => item.originalKind === "section_content"); + assert.equal(section.kind, "changed_after_decision"); + assert.equal(section.status, "reopened"); +}); + +test("suggestions need three distinct, consistent decisions", () => { + const pattern = { kind: "section_content", conceptType: "decision", key: "choice", sources: ["company", "team"] }; + const rows = ["a", "b", "c"].map((id) => ({ + schemaVersion: 2, id: `decision-${id}`, discrepancyId: `section_content::${id}::choice`, + method: "manual", learningPattern: pattern, ruleAction: { type: "prefer_source", source: "team" }, + })); + assert.equal(suggestDiscrepancyRules(rows.slice(0, 2)).length, 0); + const [suggestion] = suggestDiscrepancyRules(rows); + assert.equal(suggestion.evidenceCount, 3); + assert.equal(suggestion.action.source, "team"); +}); + +test("frontmatter detection excludes resolver mechanics and singly defined fields", () => { + const merged = mergeConcepts([ + { layer: "team", level: 2, updated: "2026-08-02", frontmatter: { owner: "Platform", updated: "2026-08-02", override: "merge", solo: "normal" }, sections: [] }, + { layer: "company", level: 0, updated: "2026-07-01", frontmatter: { owner: "Architecture", updated: "2026-07-01", override: "full" }, sections: [] }, + ]); + assert.deepEqual(merged.frontmatterConflicts.map((item) => item.key), ["owner"]); +}); + +test("revisions are stable across contribution order and change with fingerprints", () => { + const contributions = [ + { source: "team", level: 2, fingerprint: fingerprint("a") }, + { source: "company", level: 0, fingerprint: fingerprint("b") }, + ]; + assert.equal(discrepancyRevision(contributions), discrepancyRevision([...contributions].reverse())); + assert.notEqual(discrepancyRevision(contributions), discrepancyRevision([{ ...contributions[0], fingerprint: fingerprint("changed") }, contributions[1]])); +}); + +test("a reversal disqualifies that discrepancy from learned evidence", () => { + const pattern = { kind: "section_content", conceptType: "decision", key: "choice", sources: ["company", "team"] }; + const rows = ["a", "b", "c"].flatMap((id) => [{ + schemaVersion: 2, id: `${id}-1`, discrepancyId: id, method: "manual", learningPattern: pattern, + ruleAction: { type: "prefer_source", source: "team" }, + }, ...(id === "c" ? [{ + schemaVersion: 2, id: `${id}-2`, discrepancyId: id, method: "manual", learningPattern: pattern, + ruleAction: { type: "prefer_source", source: "company" }, supersedes: `${id}-1`, + }] : [])]); + assert.equal(suggestDiscrepancyRules(rows).length, 0); +}); + +test("conflicting matching rules disable automation and surface the ambiguity", () => { + const match = { kind: "section_content", conceptType: "decision", key: "choice", sources: ["company", "team"] }; + const rules = ["team", "company"].map((source) => ({ id: source, scope: "local", mode: "automatic", enabled: true, match, action: { type: "prefer_source", source } })); + const item = buildDiscrepancies([concept], { rules, coverageComplete: false }).discrepancies.find((row) => row.originalKind === "section_content"); + assert.equal(item.ruleConflict, true); + assert.equal(item.status, "needs_review"); +}); + +test("serialized shared rules contain structural metadata only", () => { + const text = serializeRuleDocument([{ + id: "r1", scope: "local", mode: "automatic", enabled: true, + match: { kind: "section_content", conceptType: "decision", key: "choice", sources: ["company", "team"] }, + action: { type: "prefer_source", source: "team" }, evidenceDecisionIds: ["d1", "d2", "d3"], + note: "secret note", content: "secret source content", prompt: "secret prompt", + }]); + assert.equal(text.includes("secret"), false); + assert.match(text, /"mode": "recommend"/); +}); From 5e9258797e412b7d944c3291315f6385adbaa4ef Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 19:31:09 -0400 Subject: [PATCH 3/8] feat: apply governed discrepancy decisions Signed-off-by: John Siracusa --- package.json | 2 +- packages/core/src/conflict-resolutions.mjs | 83 +++- packages/core/src/discrepancy-priorities.mjs | 33 ++ packages/core/src/layer-files.mjs | 133 +++++- packages/core/src/mcp-server.mjs | 27 ++ packages/core/src/service.mjs | 436 +++++++++++++++++- .../tests/discrepancy-transactions.test.mjs | 96 ++++ packages/core/tests/service-test.sh | 41 ++ 8 files changed, 835 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/discrepancy-priorities.mjs create mode 100644 packages/core/tests/discrepancy-transactions.test.mjs diff --git a/package.json b/package.json index 7826c545..bf51f7ae 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Federated team knowledge with cascading layer precedence — OKF-compatible, MCP-ready.", "type": "module", "scripts": { - "test": "bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && node --test packages/core/tests/tokens.test.mjs && node --test packages/core/tests/git-auth.test.mjs && node --test packages/core/tests/manifest.test.mjs && node --test packages/core/tests/index-keys.test.mjs && node --test packages/core/tests/cache-source.test.mjs && node --test packages/core/tests/search.test.mjs && node --test packages/core/tests/conflict-resolutions.test.mjs && bash packages/core/tests/profile-runtime-test.sh && bash packages/core/tests/pack-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh && bash packages/core/tests/setup-robustness-test.sh && npm run eval && npm run test:metrics && npm run test:release-workflow && bash packages/core/tests/index-stability-test.sh && bash packages/core/tests/index-lifecycle-test.sh && bash packages/core/tests/graph-latency-test.sh", + "test": "bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && node --test packages/core/tests/tokens.test.mjs && node --test packages/core/tests/git-auth.test.mjs && node --test packages/core/tests/manifest.test.mjs && node --test packages/core/tests/index-keys.test.mjs && node --test packages/core/tests/cache-source.test.mjs && node --test packages/core/tests/search.test.mjs && node --test packages/core/tests/conflict-resolutions.test.mjs && node --test packages/core/tests/discrepancies.test.mjs && node --test packages/core/tests/discrepancy-transactions.test.mjs && bash packages/core/tests/profile-runtime-test.sh && bash packages/core/tests/pack-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh && bash packages/core/tests/setup-robustness-test.sh && npm run eval && npm run test:metrics && npm run test:release-workflow && bash packages/core/tests/index-stability-test.sh && bash packages/core/tests/index-lifecycle-test.sh && bash packages/core/tests/graph-latency-test.sh", "eval": "node packages/core/eval/run.mjs", "mcp": "node mcp-server.mjs", "playground": "node apps/playground/server.mjs", diff --git a/packages/core/src/conflict-resolutions.mjs b/packages/core/src/conflict-resolutions.mjs index 9bbbf469..0a3c93f0 100644 --- a/packages/core/src/conflict-resolutions.mjs +++ b/packages/core/src/conflict-resolutions.mjs @@ -9,6 +9,7 @@ import fsp from "node:fs/promises"; import path from "node:path"; const SCHEMA_VERSION = 1; +const SUPPORTED_SCHEMA_VERSIONS = new Set([1, 2]); /** * A deliberately narrow equivalence rule for the magic wand. @@ -56,7 +57,7 @@ export function createConflictResolutionLog(manifestPath) { if (!line.trim()) continue; try { const record = JSON.parse(line); - if (record?.schemaVersion !== SCHEMA_VERSION || typeof record.id !== "string") { + if (!SUPPORTED_SCHEMA_VERSIONS.has(record?.schemaVersion) || typeof record.id !== "string") { throw new Error("unsupported record"); } records.push(record); @@ -69,7 +70,7 @@ export function createConflictResolutionLog(manifestPath) { async function append(record) { await prepare(); - const saved = { schemaVersion: SCHEMA_VERSION, ...record }; + const saved = { schemaVersion: record.schemaVersion ?? SCHEMA_VERSION, ...record }; appendTail = appendTail.then(() => fsp.appendFile(file, `${JSON.stringify(saved)}\n`, { encoding: "utf8", mode: 0o600 })); await appendTail; return saved; @@ -81,3 +82,81 @@ export function createConflictResolutionLog(manifestPath) { return { file, prepare, list, append, find }; } + +export function createDiscrepancyTransactionJournal(manifestPath) { + const dir = path.join(path.dirname(path.resolve(manifestPath)), ".contextcake"); + const file = path.join(dir, "discrepancy-transactions.ndjson"); + let appendTail = Promise.resolve(); + + async function append(record) { + await fsp.mkdir(dir, { recursive: true, mode: 0o700 }); + appendTail = appendTail.then(() => fsp.appendFile(file, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 })); + await appendTail; + return record; + } + + async function list() { + let text; + try { text = await fsp.readFile(file, "utf8"); } + catch (error) { if (error.code === "ENOENT") return []; throw error; } + return text.split("\n").filter(Boolean).map((line, index) => { + try { return JSON.parse(line); } + catch { throw new Error(`Discrepancy transaction journal is unreadable at line ${index + 1}`); } + }); + } + + async function recover(allowedRoots = [], committedTransactionIds = []) { + const records = await list(); + const final = new Set(records.filter((r) => r.state === "committed" || r.state === "rolled_back").map((r) => r.id)); + const committedDecisions = new Set(committedTransactionIds); + const pending = records.filter((r) => r.state === "prepared" && !final.has(r.id)); + const recovered = []; + const failures = []; + for (const tx of pending) { + try { + // A decision is appended only after every replacement succeeds. If the + // process died before the journal's final marker, that durable decision + // proves the write committed; rolling it back would make history lie. + if (committedDecisions.has(tx.id)) { + for (const target of tx.targets ?? []) { + if (!insideAnyRoot(target.path, allowedRoots) + || !insideAnyRoot(target.staged, allowedRoots) + || !insideAnyRoot(target.backup, allowedRoots)) { + throw new Error("journal target is outside the selected source roots"); + } + await fsp.unlink(target.staged).catch(() => {}); + await fsp.unlink(target.backup).catch(() => {}); + } + await append({ id: tx.id, state: "committed", recoveredAt: new Date().toISOString(), reason: "decision log confirmed commit" }); + continue; + } + for (const target of tx.targets ?? []) { + if (!insideAnyRoot(target.path, allowedRoots) || !insideAnyRoot(target.backup, allowedRoots)) { + throw new Error("journal target is outside the selected source roots"); + } + await fsp.copyFile(target.backup, target.path); + await fsp.unlink(target.staged).catch(() => {}); + await fsp.unlink(target.backup).catch(() => {}); + } + await append({ id: tx.id, state: "rolled_back", recoveredAt: new Date().toISOString(), reason: "startup recovery" }); + recovered.push(tx.id); + } catch (error) { + await append({ id: tx.id, state: "recovery_required", failedAt: new Date().toISOString(), error: error.message }); + failures.push(`${tx.id}: ${error.message}`); + } + } + if (failures.length) throw new Error(`Recovery is required for ${failures.join("; ")}`); + return recovered; + } + + return { file, append, list, recover }; +} + +function insideAnyRoot(target, roots) { + const resolved = path.resolve(String(target)); + return roots.some((root) => { + const base = path.resolve(root); + const rel = path.relative(base, resolved); + return rel && !rel.startsWith("..") && !path.isAbsolute(rel); + }); +} diff --git a/packages/core/src/discrepancy-priorities.mjs b/packages/core/src/discrepancy-priorities.mjs new file mode 100644 index 00000000..ad5c1c40 --- /dev/null +++ b/packages/core/src/discrepancy-priorities.mjs @@ -0,0 +1,33 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const ALLOWED = new Set(["unassigned", "high", "medium", "low"]); + +export function createDiscrepancyPriorityStore(manifestPath) { + const dir = path.join(path.dirname(path.resolve(manifestPath)), ".contextcake"); + const file = path.join(dir, "discrepancy-priorities.json"); + + async function list() { + try { + const value = JSON.parse(await fsp.readFile(file, "utf8")); + if (value?.version !== 1 || !value.priorities || Array.isArray(value.priorities)) throw new Error("unsupported document"); + return Object.fromEntries(Object.entries(value.priorities).filter(([, priority]) => ALLOWED.has(priority))); + } catch (error) { + if (error.code === "ENOENT") return {}; + throw new Error(`Discrepancy priorities are unreadable: ${error.message}`); + } + } + + async function set(id, priority) { + if (typeof id !== "string" || !id || !ALLOWED.has(priority)) throw new Error("Invalid discrepancy priority"); + const priorities = await list(); + if (priority === "unassigned") delete priorities[id]; else priorities[id] = priority; + await fsp.mkdir(dir, { recursive: true, mode: 0o700 }); + const temp = `${file}.${randomUUID()}.tmp`; + await fsp.writeFile(temp, `${JSON.stringify({ version: 1, priorities }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await fsp.rename(temp, file); + return priority; + } + return { file, list, set }; +} diff --git a/packages/core/src/layer-files.mjs b/packages/core/src/layer-files.mjs index 8134d536..64eccab1 100644 --- a/packages/core/src/layer-files.mjs +++ b/packages/core/src/layer-files.mjs @@ -205,7 +205,7 @@ export async function writeFileApi(rawBody, roots) { * partial disagreement. Omitting a layer (or the map) skips its check, which is * also the compatibility path for older clients. */ -export async function writeSectionApi(rawBody, roots) { +export async function prepareSectionWrites(rawBody, roots) { const body = parseJson(rawBody); const { conceptId, sectionKey, layers, content } = body; if (typeof conceptId !== "string" || typeof sectionKey !== "string" || typeof content !== "string") { @@ -253,7 +253,7 @@ export async function writeSectionApi(rawBody, roots) { ? replacePlainTextBody(originalText, sectionKey, content) : replaceSection(originalText, sectionKey, content, { refreshUpdatedTo: today }); if (!replaced) { skipped.push({ layer, reason: `section "${sectionKey}" not found` }); continue; } - writes.push({ layer, abs: target.abs, text, currentContent, mtime: stat.mtime.toISOString() }); + writes.push({ layer, abs: target.abs, text, currentContent, mtime: stat.mtime.toISOString(), mode: stat.mode }); } if (body.requireAll === true && skipped.length > 0) { throw httpError(409, `Nothing was changed. ${skipped.map((item) => `${item.layer}: ${item.reason}`).join("; ")}`); @@ -268,6 +268,11 @@ export async function writeSectionApi(rawBody, roots) { throw httpError(409, `${write.layer}/${conceptId}.md changed after this conflict was loaded. Reload it before resolving — nothing was written.`); } } + return { body, writes, skipped }; +} + +export async function writeSectionApi(rawBody, roots) { + const { writes, skipped } = await prepareSectionWrites(rawBody, roots); // A section write into a clone-backed layer dirties .cache/repos, and a later // Sync's `git pull --ff-only` will surface that as a failure. Acceptable — // the user chose to edit their copy; don't guard it here. @@ -275,6 +280,130 @@ export async function writeSectionApi(rawBody, roots) { return { ok: true, written: writes.map((write) => write.layer), skipped }; } +/** + * Stage a recoverable multi-file section transaction. New and original bytes + * live beside each target so rename/copy never crosses filesystems. The caller + * journals `targets` before commit and owns final cleanup. + */ +export async function stageSectionTransaction(rawBody, roots, transactionId, options = {}) { + const { writes, skipped } = await prepareSectionWrites(rawBody, roots); + return stagePreparedWrites(writes, skipped, transactionId, options); +} + +export async function stageFrontmatterTransaction(rawBody, roots, transactionId) { + const body = parseJson(rawBody); + const { conceptId, key, layers, value } = body; + if (typeof conceptId !== "string" || typeof key !== "string" || !Array.isArray(layers) || !layers.length) { + throw httpError(400, "Provide conceptId, key, and layers"); + } + if (key === "updated" || key === "override") throw httpError(400, `Frontmatter field ${key} is resolver-managed`); + const expectedValues = body.expectedValues ?? {}; + const writes = []; + const skipped = []; + for (const layer of layers) { + const root = roots.get(layer); + if (!root) { skipped.push({ layer, reason: "source is not locally writable" }); continue; } + let target = null; + let stat = null; + for (const ext of root.kind === "files" ? FILES_EXTENSIONS.filter((item) => item !== ".txt") : [".md"]) { + const candidate = resolveLayerFile(`${layer}/${conceptId}${ext}`, roots); + try { stat = await fsp.stat(candidate.abs); } catch { stat = null; } + if (stat?.isFile()) { target = candidate; break; } + } + if (!target || !stat) { skipped.push({ layer, reason: "no writable frontmatter document" }); continue; } + const originalText = await fsp.readFile(target.abs, "utf8"); + const currentValue = readFrontmatterValue(originalText, key); + if (stableScalar(currentValue) !== stableScalar(expectedValues[layer])) { + throw httpError(409, `${layer}/${conceptId}${target.ext} changed after this discrepancy loaded. Reload it before resolving — nothing was written.`); + } + const text = replaceFrontmatterValue(originalText, key, value); + writes.push({ layer, abs: target.abs, text, mode: stat.mode }); + } + if (skipped.length) throw httpError(409, `Nothing was changed. ${skipped.map((item) => `${item.layer}: ${item.reason}`).join("; ")}`); + return stagePreparedWrites(writes, skipped, transactionId); +} + +async function stagePreparedWrites(writes, skipped, transactionId, options = {}) { + const targets = []; + try { + for (const [index, write] of writes.entries()) { + const suffix = `.contextcake-${transactionId}-${index}`; + const staged = `${write.abs}${suffix}.new`; + const backup = `${write.abs}${suffix}.bak`; + await fsp.writeFile(staged, write.text, { encoding: "utf8", flag: "wx", mode: write.mode & 0o777 }); + await fsp.copyFile(write.abs, backup, fs.constants.COPYFILE_EXCL); + targets.push({ layer: write.layer, path: write.abs, staged, backup }); + } + } catch (error) { + await cleanupTargets(targets); + throw error; + } + + async function commit() { + const changed = []; + try { + for (const [index, target] of targets.entries()) { + await options.beforeReplace?.(index, target); + await fsp.rename(target.staged, target.path); + changed.push(target); + } + return changed.map((target) => target.layer); + } catch (error) { + try { + for (const target of changed.reverse()) await fsp.copyFile(target.backup, target.path); + } catch (rollbackError) { + const combined = new Error(`${error.message}; rollback failed: ${rollbackError.message}`); + combined.code = "RecoveryRequired"; + throw combined; + } + throw error; + } + } + + async function rollback() { + for (const target of targets) await fsp.copyFile(target.backup, target.path); + } + + async function cleanup() { await cleanupTargets(targets); } + return { targets, skipped, commit, rollback, cleanup }; +} + +async function cleanupTargets(targets) { + await Promise.all(targets.flatMap((target) => [target.staged, target.backup].map((file) => fsp.unlink(file).catch(() => {})))); +} + +function readFrontmatterValue(text, key) { + if (!text.startsWith("---\n")) return undefined; + const end = text.indexOf("\n---", 4); + if (end === -1) return undefined; + for (const line of text.slice(4, end).split(/\r?\n/)) { + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (match?.[1] === key) return parseScalar(match[2].trim()); + } + return undefined; +} + +function replaceFrontmatterValue(text, key, value) { + if (!text.startsWith("---\n")) throw httpError(409, "This document has no writable frontmatter"); + const end = text.indexOf("\n---", 4); + if (end === -1) throw httpError(409, "This document has malformed frontmatter"); + const before = text.slice(4, end).split(/\r?\n/); + const index = before.findIndex((line) => line.startsWith(`${key}:`)); + if (index === -1) throw httpError(409, `Frontmatter field ${key} no longer exists`); + before[index] = `${key}: ${renderScalar(value)}`; + return `---\n${before.join("\n")}\n---${text.slice(end + 4)}`; +} + +function parseScalar(value) { + if (value.startsWith("[") && value.endsWith("]")) return value.slice(1, -1).split(",").map((part) => part.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean); + return value.replace(/^['"]|['"]$/g, ""); +} +function renderScalar(value) { + if (Array.isArray(value)) return `[${value.map((item) => JSON.stringify(String(item))).join(", ")}]`; + return JSON.stringify(String(value)); +} +function stableScalar(value) { return JSON.stringify(value); } + // Replace the body of the section identified by `key`, keeping its heading. // Mirrors the OKF parser's key derivation ({#anchor} or normalized heading). // `refreshUpdatedTo` (YYYY-MM-DD) rewrites an authored `updated=` attr on that diff --git a/packages/core/src/mcp-server.mjs b/packages/core/src/mcp-server.mjs index f5b4880f..dba2887d 100755 --- a/packages/core/src/mcp-server.mjs +++ b/packages/core/src/mcp-server.mjs @@ -12,6 +12,8 @@ import path from "node:path"; import readline from "node:readline"; import { isNewerDay } from "./conflict-policy.mjs"; import { resolveConcept } from "./resolver.mjs"; +import { createConflictResolutionLog } from "./conflict-resolutions.mjs"; +import { fingerprint } from "./discrepancies.mjs"; import { searchConcepts, searchCaptures } from "./search.mjs"; import { buildSources } from "./sources/index.mjs"; import { isTraversal } from "./sources/okf-local.mjs"; @@ -59,6 +61,7 @@ if ((args.capture || args.telemetry) && !liveLayer) { } const layerByName = new Map(layers.map((layer) => [layer.name, layer])); +const discrepancyDecisions = runtime ? createConflictResolutionLog(runtime.manifestPath) : null; const serverInfo = { name: "contextcake", version: "0.5.0" }; const serverInstructions = [ "Consult ContextCake before answering project-specific questions.", @@ -478,10 +481,34 @@ async function readFileTool({ concept_id, layer }) { const resolved = await resolveConcept(id, layers); if (!resolved) throw new Error(`Concept not found in any layer: ${id}`); + await decorateDiscrepancyDisposition(resolved); emitTelemetry({ event: "read", concept: id, layer: resolved.contributors[0]?.layer ?? null }); return { ...resolved, markdown: assembleMarkdown(resolved) }; } +async function decorateDiscrepancyDisposition(resolved) { + const decisions = discrepancyDecisions ? await discrepancyDecisions.list() : []; + for (const section of resolved.sections) { + if (!section.conflicts?.length) continue; + const discrepancyId = `section_content::${resolved.id}::${section.key}`; + const history = decisions.filter((row) => row.discrepancyId === discrepancyId || row.conflictId === `${resolved.id}::${section.key}`); + const latest = history.at(-1); + const contributions = [ + { source: section.sourceLayer, fingerprint: fingerprint(section.content) }, + ...section.conflicts.map((item) => ({ source: item.layer, fingerprint: fingerprint(item.content) })), + ]; + const recorded = latest?.contributorFingerprints ?? []; + const unchanged = recorded.length === contributions.length + && recorded.map((item) => `${item.source}:${item.fingerprint}`).sort().every((value, index) => value === contributions.map((item) => `${item.source}:${item.fingerprint}`).sort()[index]); + section.discrepancy = { + id: discrepancyId, + status: latest?.action === "acknowledge" && unchanged ? "acknowledged" : latest ? "reopened" : "needs_review", + ...(latest?.id ? { decisionId: latest.id } : {}), + ...(latest?.reasonCode ? { reasonCode: latest.reasonCode } : {}), + }; + } +} + async function listConcepts({ type } = {}) { const byId = new Map(); for (const source of layers) { diff --git a/packages/core/src/service.mjs b/packages/core/src/service.mjs index 6c3ba551..c8656cda 100644 --- a/packages/core/src/service.mjs +++ b/packages/core/src/service.mjs @@ -37,8 +37,18 @@ import { } from "./http-util.mjs"; import { layerRootMap, listFilesApi, readFileApi, serveRawApi, writeFileApi, writeSectionApi, + stageSectionTransaction, stageFrontmatterTransaction, } from "./layer-files.mjs"; -import { createConflictResolutionLog, trivialConflictReason } from "./conflict-resolutions.mjs"; +import { + createConflictResolutionLog, createDiscrepancyTransactionJournal, trivialConflictReason, +} from "./conflict-resolutions.mjs"; +import { buildDiscrepancies } from "./discrepancies.mjs"; +import { + createDiscrepancyRuleStore, parseRuleDocument, serializeRuleDocument, suggestDiscrepancyRules, +} from "./discrepancy-rules.mjs"; +import { createDiscrepancyPriorityStore } from "./discrepancy-priorities.mjs"; +import { resolveLiveLayer } from "./sources/git-sync.mjs"; +import { commitPathsWithMutation, push as pushGit } from "./sources/git-core.mjs"; import { indexEntryKeys, layerIdentity } from "./index-keys.mjs"; import { classifyManifest, @@ -317,6 +327,12 @@ export function createEngineService({ // Git-backed sources clone next to the manifest that declares them. const CACHE_DIR = path.join(MANIFEST_DIR, ".cache", "repos"); const conflictResolutionLog = createConflictResolutionLog(MANIFEST); + const discrepancyTransactionJournal = createDiscrepancyTransactionJournal(MANIFEST); + const discrepancyRuleStore = createDiscrepancyRuleStore(MANIFEST); + const discrepancyPriorityStore = createDiscrepancyPriorityStore(MANIFEST); + let discrepancyRecovery = null; + let automaticTimer = null; + let automaticTail = Promise.resolve(); // ---- source lifecycle ------------------------------------------------------ // @@ -495,6 +511,8 @@ export function createEngineService({ function close() { closed = true; + clearTimeout(automaticTimer); + automaticTimer = null; closeWatchers(); const prev = cache; cache = null; @@ -657,6 +675,7 @@ export function createEngineService({ entry.dirty = false; scheduleFollowUp(entry); } + scheduleAutomaticRules(); }); return entry; } @@ -957,6 +976,58 @@ export function createEngineService({ if (p === "/api/status") { json(res, 200, statusApi()); return true; } if (p === "/api/resolve") { json(res, 200, await resolveOne(url.searchParams.get("concept"))); return true; } if (p === "/api/resolve-all") { json(res, 200, await resolveAllApi(waitParam(url))); return true; } + if (p === "/api/discrepancies" && req.method === "GET") { + json(res, 200, await discrepanciesApi(waitParam(url))); + return true; + } + if (p === "/api/discrepancies" && req.method === "PATCH") { + if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; } + const id = url.searchParams.get("id"); + if (!id) throw httpError(400, "Provide ?id="); + const body = parseJson(await readBody(req)); + try { + const priority = await withManifestLockAsync(MANIFEST, () => discrepancyPriorityStore.set(id, body.priority)); + json(res, 200, { id, priority }); + } catch (error) { throw httpError(400, error.message); } + return true; + } + if (p === "/api/discrepancy-decisions" && req.method === "POST") { + if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; } + const rawBody = await readBody(req); + json(res, 200, await withManifestLockAsync(MANIFEST, () => decideDiscrepancyApi(rawBody))); + return true; + } + if (p === "/api/discrepancy-rules") { + if (req.method === "GET") { json(res, 200, await discrepancyRulesApi()); return true; } + if (req.method === "POST") { + if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; } + const body = parseJson(await readBody(req)); + const rule = await withManifestLockAsync(MANIFEST, async () => { + // Re-evaluate evidence inside the same lock used by decisions so a + // reversal cannot race approval after the preview was shown. + const available = await discrepancyRulesApi(); + const suggestion = available.suggestions.find((item) => item.id === body.suggestionId); + if (!suggestion) throw httpError(409, "That rule suggestion is no longer supported by three consistent decisions"); + return discrepancyRuleStore.create(suggestion); + }); + json(res, 200, { rule }); + return true; + } + if (req.method === "PATCH") { + if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; } + const id = url.searchParams.get("id"); + if (!id) throw httpError(400, "Provide ?id="); + const body = parseJson(await readBody(req)); + try { json(res, 200, { rule: await withManifestLockAsync(MANIFEST, () => patchDiscrepancyRule(id, body)) }); } + catch (error) { throw httpError(error.status ?? 400, error.message); } + return true; + } + } + if (p === "/api/discrepancy-rules/promote" && req.method === "POST") { + if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; } + json(res, 200, await promoteDiscrepancyRule(parseJson(await readBody(req)))); + return true; + } if (p === "/api/conflict-resolutions") { if (req.method === "GET") { json(res, 200, { resolutions: await conflictResolutionLog.list() }); return true; } if (req.method === "POST") { @@ -1322,9 +1393,30 @@ export function createEngineService({ const { sources } = openSources(); const resolved = await resolveConcept(conceptId, sources); if (!resolved) throw httpError(404, `Concept not found in any source: ${conceptId}`); + decorateResolvedDispositions(resolved, await conflictResolutionLog.list()); return resolved; } + function decorateResolvedDispositions(resolved, decisions) { + for (const section of resolved.sections) { + if (!section.conflicts?.length) continue; + const id = `section_content::${resolved.id}::${section.key}`; + const latest = decisions.filter((row) => row.discrepancyId === id || row.conflictId === `${resolved.id}::${section.key}`).at(-1); + const current = [ + { source: section.sourceLayer, fingerprint: createHash("sha256").update(section.content).digest("hex") }, + ...section.conflicts.map((item) => ({ source: item.layer, fingerprint: createHash("sha256").update(item.content).digest("hex") })), + ].map((item) => `${item.source}:${item.fingerprint}`).sort(); + const recorded = (latest?.contributorFingerprints ?? []).map((item) => `${item.source}:${item.fingerprint}`).sort(); + const unchanged = recorded.length === current.length && recorded.every((value, index) => value === current[index]); + section.discrepancy = { + id, + status: latest?.action === "acknowledge" && unchanged ? "acknowledged" : latest ? "reopened" : "needs_review", + ...(latest?.id ? { decisionId: latest.id } : {}), + ...(latest?.reasonCode ? { reasonCode: latest.reasonCode } : {}), + }; + } + } + /** * Apply one conflict choice across every contributing local layer, then keep * the original choices in the append-only decision log. The server derives @@ -1347,6 +1439,7 @@ export function createEngineService({ let title; let sectionHeading; let supersedes; + let currentDiscrepancy = null; if (resolutionId !== undefined) { if (typeof resolutionId !== "string" || !resolutionId) throw httpError(400, "resolutionId must be a non-empty string"); @@ -1370,6 +1463,10 @@ export function createEngineService({ expectedContent = Object.fromEntries(contributions.map((item) => [item.layer, item.content])); title = resolved.frontmatter?.title ?? conceptId; sectionHeading = section.heading; + currentDiscrepancy = buildDiscrepancies([resolved], { + decisions: await conflictResolutionLog.list(), rules: await effectiveDiscrepancyRules(), + coverageComplete: false, sourceHealth: statusApi().sources, + }).discrepancies.find((item) => item.id === `section_content::${conceptId}::${sectionKey}`) ?? null; } const chosen = contributions.find((item) => item.layer === selectedLayer); @@ -1383,19 +1480,35 @@ export function createEngineService({ } } - // Prove the log location is writable before source files are touched. - await conflictResolutionLog.prepare(); - const write = await writeSectionApi(JSON.stringify({ + // New open-conflict requests are a compatibility adapter over the v2 + // discrepancy engine. This preserves the route and response envelope while + // gaining revision verification, recoverable writes, and schema-v2 history. + if (resolutionId === undefined) { + const discrepancyId = `section_content::${conceptId}::${sectionKey}`; + const discrepancy = currentDiscrepancy; + if (!discrepancy) throw httpError(409, "This conflict changed while it was being resolved. Reload before trying again."); + const result = await applyDiscrepancyDecision(discrepancy, { + discrepancyId, revision: discrepancy.revision, action: "choose_contribution", selectedSource: selectedLayer, + }, { methodOverride: method, reasonOverride: method === "automatic" ? safeReason : undefined }); + return { ok: true, resolution: result.decision, written: result.written }; + } + + const transactionId = randomUUID(); + const staged = await stageSectionTransaction(JSON.stringify({ conceptId, sectionKey, layers: contributions.map((item) => item.layer), content: chosen.content, expectedContent, requireAll: true, - }), fileRoots()); - - const record = await conflictResolutionLog.append({ - id: randomUUID(), + }), fileRoots(), transactionId); + await conflictResolutionLog.prepare(); + await discrepancyTransactionJournal.append({ + id: transactionId, state: "prepared", preparedAt: new Date().toISOString(), + targets: staged.targets.map((target) => ({ path: target.path, staged: target.staged, backup: target.backup })), + }); + const record = { + schemaVersion: 2, id: randomUUID(), discrepancyId: `section_content::${conceptId}::${sectionKey}`, conflictId, conceptId, title: String(title), @@ -1407,10 +1520,32 @@ export function createEngineService({ reason: method === "automatic" ? safeReason : `You chose the ${selectedLayer} answer.`, actor: "local-user", decidedAt: new Date().toISOString(), + discrepancyKind: "section_content", revision: `legacy-change:${supersedes}`, + action: "choose_contribution", transactionId, transactionState: "committed", + contributorFingerprints: contributions.map((item) => ({ source: item.layer, fingerprint: createHash("sha256").update(item.content).digest("hex") })), + writtenTargets: staged.targets.map((target) => ({ layer: target.layer, path: target.path })), + learningPattern: null, ruleAction: null, ...(supersedes ? { supersedes } : {}), - }); - invalidateIndex(); - return { ok: true, resolution: record, written: write.written }; + }; + try { + const written = await staged.commit(); + const saved = await conflictResolutionLog.append(record); + await discrepancyTransactionJournal.append({ id: transactionId, state: "committed", committedAt: new Date().toISOString() }); + await staged.cleanup(); + invalidateIndex(); + return { ok: true, resolution: saved, written }; + } catch (error) { + try { + await staged.rollback(); + await discrepancyTransactionJournal.append({ id: transactionId, state: "rolled_back", rolledBackAt: new Date().toISOString(), error: error.message }); + await staged.cleanup(); + throw httpError(409, `Nothing was changed. ${error.message}`); + } catch (rollbackError) { + if (rollbackError.status === 409) throw rollbackError; + await discrepancyTransactionJournal.append({ id: transactionId, state: "recovery_required", failedAt: new Date().toISOString(), error: `${error.message}; rollback failed: ${rollbackError.message}` }); + throw httpError(500, `A write could not be rolled back automatically. Recovery is required: ${rollbackError.message}`); + } + } } // Resolve every indexed concept in one pass. The console's initial load calls @@ -1439,10 +1574,282 @@ export function createEngineService({ errors.push({ concept: id, error: err.message }); } } + const decisions = await conflictResolutionLog.list(); + for (const concept of concepts) decorateResolvedDispositions(concept, decisions); const pending = pinned.filter((p) => p.progress.status === "indexing").map((p) => p.source.name); return { concepts, errors, indexing: pending.length > 0, indexingSources: pending }; } + async function discrepanciesApi(waitMs = 0) { + const resolved = await resolveAllApi(waitMs); + const status = statusApi(); + const decisions = await conflictResolutionLog.list(); + const [rules, priorities] = await Promise.all([effectiveDiscrepancyRules(), discrepancyPriorityStore.list()]); + const coverageComplete = !resolved.indexing + && status.sources.every((source) => source.status !== "error" && source.status !== "degraded" && source.status !== "indexing"); + return { + ...buildDiscrepancies(resolved.concepts, { + decisions, rules, priorities, coverageComplete, sourceHealth: status.sources, + }), + indexing: resolved.indexing, + indexingSources: resolved.indexingSources, + errors: resolved.errors, + generation: status.generation, + }; + } + + async function discrepancyRulesApi() { + const rules = await effectiveDiscrepancyRules(); + const decisions = await conflictResolutionLog.list(); + return { rules, suggestions: suggestDiscrepancyRules(decisions, rules) }; + } + + function liveRuleFile() { + const selected = openSources().manifest.layers ?? []; + const live = resolveLiveLayer(selected, MANIFEST_DIR); + return live ? { ...live, relative: ".contextcake/discrepancy-rules.json", file: path.join(live.root, ".contextcake", "discrepancy-rules.json") } : null; + } + + async function teamDiscrepancyRules() { + const live = liveRuleFile(); + if (!live) return []; + try { return parseRuleDocument(await fsp.readFile(live.file, "utf8")); } + catch (error) { if (error.code === "ENOENT") return []; throw httpError(409, `Team discrepancy rules are unreadable: ${error.message}`); } + } + + async function effectiveDiscrepancyRules() { + const [local, team] = await Promise.all([discrepancyRuleStore.list(), teamDiscrepancyRules()]); + const localById = new Map(local.map((rule) => [rule.id, rule])); + return [ + ...team.map((rule) => localById.get(rule.id) ?? rule), + ...local.filter((rule) => !team.some((shared) => shared.id === rule.id)), + ]; + } + + async function patchDiscrepancyRule(id, body) { + try { return await discrepancyRuleStore.patch(id, body); } + catch (error) { + if (error.status !== 404) throw error; + const team = (await teamDiscrepancyRules()).find((rule) => rule.id === id); + if (!team) throw error; + // Enabling a promoted rule automatically is deliberately a per-profile, + // local decision. The shared file itself remains recommendation-only. + return discrepancyRuleStore.setLocalOverride(team, body); + } + } + + async function promoteDiscrepancyRule(body) { + const rule = (await discrepancyRuleStore.list()).find((item) => item.id === body.id); + if (!rule) throw httpError(404, "Local discrepancy rule not found"); + const live = liveRuleFile(); + if (!live) throw httpError(409, "This profile has no writable live team layer"); + const preview = { + id: rule.id, scope: "team", mode: "recommend", enabled: true, + match: rule.match, action: rule.action, evidenceDecisionIds: rule.evidenceDecisionIds, + createdAt: rule.createdAt, promotedAt: new Date().toISOString(), + }; + if (body.confirm !== true) return { requiresConfirmation: true, preview, target: `${live.name}/${live.relative}` }; + let previous = null; + try { previous = await fsp.readFile(live.file); } catch (error) { if (error.code !== "ENOENT") throw error; } + const current = previous ? parseRuleDocument(previous.toString("utf8")) : []; + const next = [...current.filter((item) => item.id !== preview.id), preview]; + const nextText = serializeRuleDocument(next); + await commitPathsWithMutation(live.root, [live.relative], `chore: promote discrepancy rule ${rule.id}`, { + mutate: async () => { + await fsp.mkdir(path.dirname(live.file), { recursive: true }); + await fsp.writeFile(live.file, nextText, { encoding: "utf8", mode: 0o600 }); + }, + rollback: async () => { + if (previous) await fsp.writeFile(live.file, previous); + else await fsp.unlink(live.file).catch(() => {}); + }, + author: live.profileName, + }); + const pushed = await pushGit(live.root); + return { promoted: true, rule: preview, pushed: pushed.pushed === true, queued: pushed.queued === true }; + } + + function scheduleAutomaticRules() { + if (!allowMutations || closed || automaticTimer) return; + automaticTimer = setTimeout(() => { + automaticTimer = null; + automaticTail = automaticTail.then(runAutomaticRules).catch((error) => { + console.error(`contextcake: automatic discrepancy rules failed: ${error.message}`); + }); + }, 50); + automaticTimer.unref?.(); + } + + async function runAutomaticRules() { + const { entries } = ensureIndexes(); + if (entries.some(({ entry }) => entry.running || entry.followUp || entry.status !== "ready")) return; + const payload = await discrepanciesApi(0); + if (!payload.coverageComplete) return; + const decisions = await conflictResolutionLog.list(); + for (const discrepancy of payload.discrepancies) { + if (discrepancy.status !== "auto_ready") continue; + const matches = discrepancy.matchingRules.filter((rule) => rule.mode === "automatic"); + if (matches.length !== 1) continue; + const rule = matches[0]; + if (decisions.some((row) => row.discrepancyId === discrepancy.id && row.revision === discrepancy.revision + && row.method === "automatic" && ["committed", "blocked", "not_required"].includes(row.transactionState))) continue; + const sourceHealthy = discrepancy.sourceHealth.every((health) => health && health.status === "ok"); + const allWritable = discrepancy.contributions.every((item) => fileRoots().has(item.source)); + if (!sourceHealthy || (rule.action.type === "prefer_source" && !allWritable)) continue; + const request = rule.action.type === "prefer_source" + ? { discrepancyId: discrepancy.id, revision: discrepancy.revision, action: "choose_contribution", selectedSource: rule.action.source, ruleId: rule.id } + : { discrepancyId: discrepancy.id, revision: discrepancy.revision, action: "acknowledge", reasonCode: rule.action.reasonCode, ruleId: rule.id }; + try { + await withManifestLockAsync(MANIFEST, () => decideDiscrepancyApi(JSON.stringify(request))); + return; + } catch (error) { + await conflictResolutionLog.append({ + schemaVersion: 2, id: randomUUID(), discrepancyId: discrepancy.id, + discrepancyKind: discrepancy.originalKind ?? discrepancy.kind, revision: discrepancy.revision, + action: request.action, method: "automatic", actor: "local-user", ruleId: rule.id, + transactionState: "blocked", reason: error.message, decidedAt: new Date().toISOString(), + contributorFingerprints: discrepancy.contributions.map((item) => ({ source: item.source, fingerprint: item.fingerprint })), + contributions: discrepancy.contributions.map((item) => ({ layer: item.source, level: item.level, content: item.value, updated: item.updated })), + }); + return; + } + } + } + + async function ensureDiscrepancyRecovery() { + if (!discrepancyRecovery) { + const roots = [...fileRoots().values()].map((entry) => entry.root); + discrepancyRecovery = conflictResolutionLog.list().then((decisions) => discrepancyTransactionJournal.recover( + roots, + decisions.filter((row) => row.transactionState === "committed").map((row) => row.transactionId).filter(Boolean), + )).catch((error) => { + discrepancyRecovery = null; + throw error; + }); + } + return discrepancyRecovery; + } + + async function decideDiscrepancyApi(rawBody, { methodOverride = null, reasonOverride = null } = {}) { + await ensureDiscrepancyRecovery(); + const body = parseJson(rawBody); + const { discrepancyId, action, selectedSource, content, reasonCode, note, ruleId } = body; + if (typeof discrepancyId !== "string" || !discrepancyId) throw httpError(400, "Provide discrepancyId"); + if (!["choose_contribution", "compose", "acknowledge"].includes(action)) throw httpError(400, "Unsupported discrepancy action"); + // A file watcher may have invalidated the index between the review GET and + // this mutation. Decisions must re-resolve against a settled generation; + // treating an in-flight empty snapshot as "no longer open" is both + // misleading and can make a valid current-revision decision impossible. + const payload = await discrepanciesApi(15_000); + if (!payload.coverageComplete || payload.indexing) { + throw httpError(409, "Sources are still indexing. Wait for settled coverage before deciding."); + } + const discrepancy = payload.discrepancies.find((item) => item.id === discrepancyId); + if (!discrepancy) throw httpError(409, "This discrepancy is no longer open. Reload before deciding it."); + if (body.revision !== undefined && body.revision !== discrepancy.revision) { + throw httpError(409, "This discrepancy changed after you opened it. Reload before deciding it."); + } + return applyDiscrepancyDecision(discrepancy, body, { methodOverride, reasonOverride }); + } + + async function applyDiscrepancyDecision(discrepancy, body, { methodOverride = null, reasonOverride = null } = {}) { + await ensureDiscrepancyRecovery(); + const { action, selectedSource, content, reasonCode, note, ruleId } = body; + if (!discrepancy || body.revision !== discrepancy.revision) { + throw httpError(409, "This discrepancy changed after you opened it. Reload before deciding it."); + } + const allowedReasons = new Set(["different_scopes", "temporary_migration", "source_specific_authority", "other"]); + if (action === "acknowledge" && !allowedReasons.has(reasonCode)) throw httpError(400, "Choose why this scoped difference should remain"); + const chosen = action === "choose_contribution" + ? discrepancy.contributions.find((item) => item.source === selectedSource) + : null; + if (action === "choose_contribution" && !chosen) throw httpError(400, "Choose one of this discrepancy's contributing sources"); + if (action === "compose" && typeof content !== "string") throw httpError(400, "Provide reconciled content"); + + const transactionId = randomUUID(); + const now = new Date().toISOString(); + const originalKind = discrepancy.originalKind ?? discrepancy.kind; + const previousDecision = discrepancy.history?.at(-1) ?? null; + const decision = { + schemaVersion: 2, + id: randomUUID(), discrepancyId: discrepancy.id, + ...(discrepancy.legacyId ? { conflictId: discrepancy.legacyId } : {}), + conceptId: discrepancy.conceptId, title: discrepancy.conceptTitle, + discrepancyKind: originalKind, sectionKey: originalKind === "section_content" ? discrepancy.key : undefined, + sectionHeading: discrepancy.label, fieldKey: originalKind === "frontmatter_value" ? discrepancy.key : undefined, + revision: discrepancy.revision, action, + conceptType: discrepancy.conceptType, owner: discrepancy.owner, priority: discrepancy.priority, + contributions: discrepancy.contributions.map((item) => ({ layer: item.source, level: item.level, content: item.value, updated: item.updated })), + contributorFingerprints: discrepancy.contributions.map((item) => ({ source: item.source, fingerprint: item.fingerprint })), + chosen: chosen ? { layer: chosen.source, level: chosen.level, content: chosen.value, updated: chosen.updated } : null, + method: methodOverride ?? (ruleId ? "automatic" : "manual"), actor: "local-user", decidedAt: now, + reason: reasonOverride ?? (action === "acknowledge" ? reasonCode : action === "compose" ? "You wrote a reconciled answer." : `You chose the ${selectedSource} answer.`), + ...(reasonCode ? { reasonCode } : {}), ...(typeof note === "string" && note.trim() ? { note: note.trim() } : {}), + ...(ruleId ? { ruleId } : {}), transactionId, + ...(previousDecision ? { supersedes: previousDecision.id, supersededDecisionId: previousDecision.id } : {}), + ...(action === "compose" ? { reconciledContent: content } : {}), + learningPattern: { + kind: originalKind, conceptType: discrepancy.conceptType, key: discrepancy.key, + sources: discrepancy.contributions.map((item) => item.source).sort(), + }, + ruleAction: action === "choose_contribution" + ? { type: "prefer_source", source: selectedSource } + : action === "acknowledge" ? { type: "acknowledge", reasonCode } : null, + }; + + if (action === "acknowledge") { + decision.transactionState = "not_required"; + decision.writtenTargets = []; + return { ok: true, decision: await conflictResolutionLog.append(decision), written: [] }; + } + if (originalKind === "broken_link") throw httpError(409, "Open the source file to repair this link, or acknowledge the scoped difference."); + + const value = action === "compose" ? content : chosen.value; + const writableSources = discrepancy.contributions.map((item) => item.source).filter((source) => fileRoots().has(source)); + if (writableSources.length === 0) throw httpError(409, "None of this discrepancy's contributors is locally writable. Open the source files to resolve it."); + let staged; + if (originalKind === "frontmatter_value") { + staged = await stageFrontmatterTransaction(JSON.stringify({ + conceptId: discrepancy.conceptId, key: discrepancy.key, + layers: writableSources, value, + expectedValues: Object.fromEntries(discrepancy.contributions.map((item) => [item.source, item.value])), + }), fileRoots(), transactionId); + } else { + staged = await stageSectionTransaction(JSON.stringify({ + conceptId: discrepancy.conceptId, sectionKey: discrepancy.key, + layers: writableSources, content: value, + expectedContent: Object.fromEntries(discrepancy.contributions.map((item) => [item.source, item.value])), requireAll: true, + }), fileRoots(), transactionId); + } + const journalTargets = staged.targets.map((target) => ({ path: target.path, staged: target.staged, backup: target.backup })); + await discrepancyTransactionJournal.append({ id: transactionId, state: "prepared", preparedAt: now, targets: journalTargets }); + try { + const written = await staged.commit(); + decision.transactionState = "committed"; + decision.writtenTargets = staged.targets.map((target) => ({ layer: target.layer, path: target.path })); + const saved = await conflictResolutionLog.append(decision); + await discrepancyTransactionJournal.append({ id: transactionId, state: "committed", committedAt: new Date().toISOString() }); + await staged.cleanup(); + invalidateIndex(); + return { ok: true, decision: saved, written }; + } catch (error) { + if (error.code !== "RecoveryRequired") { + try { + await staged.rollback(); + await discrepancyTransactionJournal.append({ id: transactionId, state: "rolled_back", rolledBackAt: new Date().toISOString(), error: error.message }); + await staged.cleanup(); + throw httpError(409, `Nothing was changed. ${error.message}`); + } catch (rollbackError) { + if (rollbackError.status === 409) throw rollbackError; + await discrepancyTransactionJournal.append({ id: transactionId, state: "recovery_required", failedAt: new Date().toISOString(), error: `${error.message}; rollback failed: ${rollbackError.message}` }); + throw httpError(500, `A write could not be rolled back automatically. Recovery is required: ${rollbackError.message}`); + } + } + await discrepancyTransactionJournal.append({ id: transactionId, state: "recovery_required", failedAt: new Date().toISOString(), error: error.message }); + throw httpError(500, `A write could not be rolled back automatically. Recovery is required: ${error.message}`); + } + } + // ---- settings --------------------------------------------------------------- function getSettingsApi() { @@ -2018,5 +2425,12 @@ export function createEngineService({ }); } + // Recovery is a startup responsibility, not something deferred until a user + // happens to open the Discrepancy Center. Failure remains visible through + // the journal and is retried before any later decision. + queueMicrotask(() => ensureDiscrepancyRecovery().catch((error) => { + console.error(`contextcake: discrepancy transaction recovery requires attention: ${error.message}`); + })); + return { handleRequest, close, getSources, reload, setTokens }; } diff --git a/packages/core/tests/discrepancy-transactions.test.mjs b/packages/core/tests/discrepancy-transactions.test.mjs new file mode 100644 index 00000000..1664383b --- /dev/null +++ b/packages/core/tests/discrepancy-transactions.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { stageSectionTransaction } from "../src/layer-files.mjs"; +import { createDiscrepancyTransactionJournal } from "../src/conflict-resolutions.mjs"; + +const document = (value) => `---\ntype: decision\ntitle: Database\n---\n\n# Database\n\n## Choice {#choice}\n\n${value}\n`; + +async function fixture() { + const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "cc-discrepancy-tx-")); + const roots = new Map(); + for (const [name, value] of [["team", "Postgres"], ["company", "MySQL"]]) { + const root = path.join(dir, name); + await fsp.mkdir(root); + await fsp.writeFile(path.join(root, "database.md"), document(value), { mode: 0o644 }); + roots.set(name, { root, kind: "okf-local" }); + } + return { dir, roots }; +} + +test("a failure at every replacement position restores the full original write set", async (t) => { + for (const failAt of [0, 1]) { + await t.test(`replacement ${failAt + 1}`, async () => { + const { dir, roots } = await fixture(); + try { + const original = await Promise.all([...roots.values()].map(({ root }) => fsp.readFile(path.join(root, "database.md"), "utf8"))); + const staged = await stageSectionTransaction(JSON.stringify({ + conceptId: "database", sectionKey: "choice", layers: ["team", "company"], + content: "SQLite", expectedContent: { team: "Postgres", company: "MySQL" }, requireAll: true, + }), roots, `failure-${failAt}`, { beforeReplace(index) { if (index === failAt) throw new Error("injected replacement failure"); } }); + await assert.rejects(staged.commit(), /injected replacement failure/); + const after = await Promise.all([...roots.values()].map(({ root }) => fsp.readFile(path.join(root, "database.md"), "utf8"))); + assert.deepEqual(after, original); + await staged.cleanup(); + } finally { await fsp.rm(dir, { recursive: true, force: true }); } + }); + } +}); + +test("startup recovery restores prepared targets and records rolled_back", async () => { + const { dir, roots } = await fixture(); + try { + const journal = createDiscrepancyTransactionJournal(path.join(dir, "manifest.json")); + const target = path.join(dir, "team", "database.md"); + const backup = `${target}.bak`; + const staged = `${target}.new`; + const original = await fsp.readFile(target, "utf8"); + await fsp.writeFile(backup, original); + await fsp.writeFile(staged, document("new")); + await fsp.writeFile(target, document("partially replaced")); + await journal.append({ id: "tx-incomplete", state: "prepared", targets: [{ path: target, backup, staged }] }); + assert.deepEqual(await journal.recover([...roots.values()].map((entry) => entry.root)), ["tx-incomplete"]); + assert.equal(await fsp.readFile(target, "utf8"), original); + assert.equal((await journal.list()).at(-1).state, "rolled_back"); + } finally { await fsp.rm(dir, { recursive: true, force: true }); } +}); + +test("startup recovery preserves a write confirmed by the committed decision log", async () => { + const { dir, roots } = await fixture(); + try { + const journal = createDiscrepancyTransactionJournal(path.join(dir, "manifest.json")); + const target = path.join(dir, "team", "database.md"); + const backup = `${target}.bak`; + const staged = `${target}.new`; + await fsp.writeFile(backup, document("original")); + await fsp.writeFile(staged, document("stale staged bytes")); + await fsp.writeFile(target, document("committed")); + await journal.append({ id: "tx-confirmed", state: "prepared", targets: [{ path: target, backup, staged }] }); + + assert.deepEqual(await journal.recover([...roots.values()].map((entry) => entry.root), ["tx-confirmed"]), []); + assert.equal(await fsp.readFile(target, "utf8"), document("committed")); + await assert.rejects(fsp.stat(staged), { code: "ENOENT" }); + await assert.rejects(fsp.stat(backup), { code: "ENOENT" }); + assert.equal((await journal.list()).at(-1).state, "committed"); + } finally { await fsp.rm(dir, { recursive: true, force: true }); } +}); + +test("failed startup recovery records recovery_required and rejects", async () => { + const { dir, roots } = await fixture(); + try { + const journal = createDiscrepancyTransactionJournal(path.join(dir, "manifest.json")); + const target = path.join(dir, "team", "database.md"); + await journal.append({ + id: "tx-broken", state: "prepared", + targets: [{ path: target, backup: `${target}.missing-backup`, staged: `${target}.missing-stage` }], + }); + + await assert.rejects( + journal.recover([...roots.values()].map((entry) => entry.root)), + /Recovery is required for tx-broken/, + ); + assert.equal((await journal.list()).at(-1).state, "recovery_required"); + } finally { await fsp.rm(dir, { recursive: true, force: true }); } +}); diff --git a/packages/core/tests/service-test.sh b/packages/core/tests/service-test.sh index ea1b539a..daea4531 100755 --- a/packages/core/tests/service-test.sh +++ b/packages/core/tests/service-test.sh @@ -201,6 +201,47 @@ grep -q 'AGREED.' "$TMP/m2/merge-me.md" && pass "changed decision reused the sav [ "$(curl -s "${AUTH[@]}" "$BASE/api/conflict-resolutions" | JQ 'String(d.resolutions.length)')" = "2" ] && pass "changed decision appends instead of rewriting history" || fail "changed decision did not append" code 405 "$(C -X POST -H 'content-type: application/json' -d '{}' "$BASE2/api/conflict-resolutions")" "resolution respects the service mutation gate" +echo "professional discrepancy API: revision guard, acknowledgement, and transactional choice" +printf -- '---\ntype: decision\ntitle: Governed\nowner: Platform\n---\n\n# Governed\n\n## Pick {#pick}\n\nteam answer\n' > "$TMP/bundle/governed.md" +printf -- '---\ntype: decision\ntitle: Governed\nowner: Architecture\n---\n\n# Governed\n\n## Pick {#pick}\n\ncompany answer\n' > "$TMP/m2/governed.md" +DID="" +for _ in $(seq 1 60); do + DSET="$(curl -s "${AUTH[@]}" "$BASE/api/discrepancies?wait=15000")" + DID="$(JQ 'd.discrepancies.find((x) => x.originalKind === "section_content" && x.conceptId === "governed")?.id ?? ""' <<<"$DSET")" + [ -n "$DID" ] && break + sleep 0.1 +done +DREV="$(JQ 'd.discrepancies.find((x) => x.originalKind === "section_content" && x.conceptId === "governed")?.revision ?? ""' <<<"$DSET")" +[ -n "$DID" ] && [ -n "$DREV" ] && pass "unified API detects the section discrepancy" || fail "section discrepancy missing ($DSET)" +code 409 "$(C -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"discrepancyId\":\"$DID\",\"revision\":\"stale\",\"action\":\"choose_contribution\",\"selectedSource\":\"t\"}" "$BASE/api/discrepancy-decisions")" "stale discrepancy revision is refused" +DDEC="$(curl -s -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"discrepancyId\":\"$DID\",\"revision\":\"$DREV\",\"action\":\"choose_contribution\",\"selectedSource\":\"t\"}" "$BASE/api/discrepancy-decisions")" +[ "$(JQ '`${d.decision.schemaVersion}:${d.decision.transactionState}`' <<<"$DDEC")" = "2:committed" ] && pass "new endpoint records a committed schema-v2 decision" || fail "v2 decision failed ($DDEC)" +grep -q 'team answer' "$TMP/m2/governed.md" && pass "transactional choice reached every writable contributor" || fail "transactional choice missed a contributor" +code 405 "$(C -X POST -H 'content-type: application/json' -d '{}' "$BASE2/api/discrepancy-decisions")" "discrepancy decisions respect the mutation gate" + +printf -- '# Scoped\n\n## Pick {#pick}\n\nlocal scope\n' > "$TMP/bundle/scoped.md" +printf -- '# Scoped\n\n## Pick {#pick}\n\nteam scope\n' > "$TMP/m2/scoped.md" +SID="" +for _ in $(seq 1 60); do + SSET="$(curl -s "${AUTH[@]}" "$BASE/api/discrepancies?wait=15000")" + SID="$(JQ 'd.discrepancies.find((x) => x.originalKind === "section_content" && x.conceptId === "scoped")?.id ?? ""' <<<"$SSET")" + [ -n "$SID" ] && break + sleep 0.1 +done +SREV="$(JQ 'd.discrepancies.find((x) => x.originalKind === "section_content" && x.conceptId === "scoped")?.revision ?? ""' <<<"$SSET")" +SACK="$(curl -s -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"discrepancyId\":\"$SID\",\"revision\":\"$SREV\",\"action\":\"acknowledge\",\"reasonCode\":\"different_scopes\"}" "$BASE/api/discrepancy-decisions")" +[ "$(JQ 'd.decision.transactionState' <<<"$SACK")" = "not_required" ] && pass "acknowledgement records a reason without a file transaction" || fail "acknowledgement failed ($SACK)" +grep -q 'team scope' "$TMP/m2/scoped.md" && pass "acknowledgement leaves source content unchanged" || fail "acknowledgement mutated source content" +printf -- '# Scoped\n\n## Pick {#pick}\n\nteam scope changed\n' > "$TMP/m2/scoped.md" +SSTATUS="" +for _ in $(seq 1 60); do + SOPEN="$(curl -s "${AUTH[@]}" "$BASE/api/discrepancies?wait=15000")" + SSTATUS="$(JQ 'd.discrepancies.find((x) => x.conceptId === "scoped")?.status ?? ""' <<<"$SOPEN")" + [ "$SSTATUS" = "reopened" ] && break + sleep 0.1 +done +[ "$SSTATUS" = "reopened" ] && pass "acknowledged discrepancy reopens when a fingerprint changes" || fail "acknowledgement did not reopen ($SOPEN)" + printf -- '# Format only\n\n## Pick {#pick}\n\nUse **Postgres** for writes.\n' > "$TMP/bundle/format-only.md" printf -- '# Format only\n\n## Pick {#pick}\n\nUse postgres for writes\n' > "$TMP/m2/format-only.mdx" AUTO="$(curl -s -X POST "${AUTH[@]}" -H 'content-type: application/json' -d '{"conceptId":"format-only","sectionKey":"pick","selectedLayer":"t","method":"automatic"}' "$BASE/api/conflict-resolutions")" From d3e32dc2871e0cafc681deb685ff61ff09a597fa Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Fri, 7 Aug 2026 19:31:14 -0400 Subject: [PATCH 4/8] feat: add professional discrepancy center Signed-off-by: John Siracusa --- apps/console/src/App.test.tsx | 4 +- apps/console/src/App.tsx | 8 +- apps/console/src/api.ts | 132 +++++- apps/console/src/components/Header.tsx | 8 +- apps/console/src/components/Sidebar.tsx | 2 +- apps/console/src/data.ts | 15 +- apps/console/src/store.tsx | 77 +++- apps/console/src/styles.css | 115 +++++ apps/console/src/types.ts | 95 +++- apps/console/src/views/Conflicts.test.tsx | 46 +- apps/console/src/views/Conflicts.tsx | 503 +++++++++------------- apps/console/src/views/Overview.test.tsx | 2 +- apps/console/src/views/Overview.tsx | 6 +- 13 files changed, 675 insertions(+), 338 deletions(-) diff --git a/apps/console/src/App.test.tsx b/apps/console/src/App.test.tsx index fb4b3667..5598568c 100644 --- a/apps/console/src/App.test.tsx +++ b/apps/console/src/App.test.tsx @@ -45,7 +45,9 @@ describe('Mac-first application shell', () => { await act(async () => window.dispatchEvent(new KeyboardEvent('keydown', { key: '5', metaKey: true, bubbles: true }))) expect(container.querySelector('[data-destination="review"]')?.getAttribute('aria-current')).toBe('page') expect(button('Queue 3')).toBeTruthy() - expect(button('Conflicts 3')).toBeTruthy() + expect(button('Discrepancies 3')).toBeTruthy() + expect(container.textContent).toContain('Simulation—no files will change.') + expect(container.textContent).toContain('Automatic rules never run.') expect(container.querySelector('[aria-live="polite"]')?.textContent).toBe('') }) diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 57e81299..1e3e4c23 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -182,7 +182,7 @@ export function App() { { id: 'files', label: 'Go to Knowledge: Files', keywords: 'markdown documents', shortcut: '⇧⌘F', run: () => setView('files') }, { id: 'sources', label: 'Go to Sources', shortcut: '⌘4', run: () => setView('sources') }, { id: 'queue', label: 'Go to Review: Queue', keywords: 'triage', run: () => setView('triage') }, - { id: 'conflicts', label: 'Go to Review: Conflicts', keywords: 'resolve', run: () => setView('conflicts') }, + { id: 'conflicts', label: 'Go to Review: Discrepancies', keywords: 'resolve align', run: () => setView('conflicts') }, // One per source: the palette is the keyboard route into the navigator, // matching the Sources panel's "Browse files" button — including in the // demo, where that button is offered too. Browsing is a read. @@ -378,6 +378,12 @@ export function App() { onAddSource={mode === 'live' ? reopenWizard : undefined} onConnectAgent={isDesktop && !needsSetup ? openConnect : undefined} /> + {mode === 'demo' && ( +
+ Simulation—no files will change. + Actions and history reset on reload. Automatic rules never run. +
+ )}
{backgroundAnnouncement}
diff --git a/apps/console/src/api.ts b/apps/console/src/api.ts index bd7d67b8..51bfc2c1 100644 --- a/apps/console/src/api.ts +++ b/apps/console/src/api.ts @@ -13,7 +13,8 @@ import demoBundleRaw from './generated/demo-cascade.json' import type { - ConflictResolutionRecord, DemoBundle, GraphSummary, GraphSource, ResolveConflictRequest, + ConflictResolutionRecord, DemoBundle, DiscrepanciesResponse, DiscrepancyDecisionRequest, DiscrepancyRecord, + DiscrepancyRule, DiscrepancyRuleSuggestion, GraphSummary, GraphSource, ResolveConflictRequest, ResolvedConcept, ResolvedSection, SourceStatus, StatusSummary, } from './types' import type { Concept, ConceptSection, Conflict, Dissent, Source } from './data' @@ -60,6 +61,13 @@ export interface DataSource { status(): Promise conflictResolutions(): Promise resolveConflict(request: ResolveConflictRequest): Promise + discrepancies(): Promise + decideDiscrepancy(request: DiscrepancyDecisionRequest): Promise + discrepancyRules(): Promise<{ rules: DiscrepancyRule[]; suggestions: DiscrepancyRuleSuggestion[] }> + createDiscrepancyRule(suggestionId: string): Promise + patchDiscrepancyRule(id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }): Promise + promoteDiscrepancyRule(id: string, confirm: boolean): Promise> + setDiscrepancyPriority(id: string, priority: string): Promise } // ---- Transport -------------------------------------------------------------- @@ -188,6 +196,42 @@ class DemoSource implements DataSource { } } async conflictResolutions(): Promise { return this.resolutions } + async discrepancies(): Promise { + const conflicts = adaptConflicts(this.bundle.concepts, this.resolutions) + return { + discrepancies: conflicts.map((conflict) => legacyConflictRecord(conflict)), + coverageComplete: true, indexing: false, indexingSources: [], errors: [], generation: 1, + } + } + async decideDiscrepancy(request: DiscrepancyDecisionRequest): Promise { + if (request.action !== 'choose_contribution' || !request.selectedSource) { + const current = (await this.discrepancies()).discrepancies.find((item) => item.id === request.discrepancyId) + if (!current) throw new LiveDataError('bad-status', 'This discrepancy is no longer open.', 409) + const chosen = request.action === 'compose' + ? { layer: current.effectiveSource ?? current.contributions[0].source, content: request.content ?? '', updated: new Date().toISOString() } + : null + const record: ConflictResolutionRecord = { + schemaVersion: 2, id: `demo-${Date.now()}-${this.resolutions.length + 1}`, + conflictId: current.legacyId ?? current.id, discrepancyId: current.id, + conceptId: current.conceptId, title: current.conceptTitle, sectionKey: current.key, + sectionHeading: current.label, + contributions: current.contributions.map((item) => ({ layer: item.source, level: item.level, content: String(item.value), updated: item.updated })), + chosen, method: 'manual', actor: 'local-user', decidedAt: new Date().toISOString(), + action: request.action, reason: request.action === 'acknowledge' ? 'You kept this scoped difference.' : 'You wrote a reconciled answer.', + reasonCode: request.reasonCode, note: request.note, + transactionState: request.action === 'acknowledge' ? 'not_required' : 'committed', writtenTargets: [], + } + this.resolutions.push(record) + return record + } + const [, conceptId, sectionKey] = request.discrepancyId.split('::') + return this.resolveConflict({ conceptId, sectionKey, selectedLayer: request.selectedSource, method: 'manual' }) + } + async discrepancyRules() { return { rules: [], suggestions: [] } } + async createDiscrepancyRule(): Promise { throw new LiveDataError('bad-status', 'Simulation rules reset on reload.', 405) } + async patchDiscrepancyRule(): Promise { throw new LiveDataError('bad-status', 'Automatic rules never run in simulation.', 405) } + async promoteDiscrepancyRule(): Promise> { throw new LiveDataError('bad-status', 'Simulation cannot promote team rules.', 405) } + async setDiscrepancyPriority(): Promise { /* simulation-only local state is owned by the store */ } async resolveConflict(request: ResolveConflictRequest): Promise { const prior = request.resolutionId ? this.resolutions.find((item) => item.id === request.resolutionId) @@ -301,6 +345,41 @@ class LiveSource implements DataSource { }) return response.resolution } + async discrepancies(): Promise { + try { return await this.get('/api/discrepancies') } + catch (error) { + if (error instanceof LiveDataError && error.kind === 'bad-status' && error.status === 404) return null + throw error + } + } + async decideDiscrepancy(request: DiscrepancyDecisionRequest): Promise { + return (await this.request<{ decision: ConflictResolutionRecord }>('/api/discrepancy-decisions', { + method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(request), + })).decision + } + async discrepancyRules(): Promise<{ rules: DiscrepancyRule[]; suggestions: DiscrepancyRuleSuggestion[] }> { + return this.get('/api/discrepancy-rules') + } + async createDiscrepancyRule(suggestionId: string): Promise { + return (await this.request<{ rule: DiscrepancyRule }>('/api/discrepancy-rules', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ suggestionId }), + })).rule + } + async patchDiscrepancyRule(id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }): Promise { + return (await this.request<{ rule: DiscrepancyRule }>(`/api/discrepancy-rules?id=${encodeURIComponent(id)}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changes), + })).rule + } + async promoteDiscrepancyRule(id: string, confirm: boolean): Promise> { + return this.request('/api/discrepancy-rules/promote', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, confirm }), + }) + } + async setDiscrepancyPriority(id: string, priority: string): Promise { + await this.request(`/api/discrepancies?id=${encodeURIComponent(id)}`, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ priority }), + }) + } private async get(path: string): Promise { return this.request(path, { headers: { accept: 'application/json' } }) } @@ -697,7 +776,7 @@ export function adaptConflicts(concepts: ResolvedConcept[], resolutions: Conflic section: headingText(latest.sectionHeading), title: `${headingText(latest.sectionHeading)} — ${latest.title}`, status: 'resolved', - winner: layerOf(latest.chosen.layer, latest.chosen.level ?? (latest.chosen.layer === 'personal' ? 3 : latest.chosen.layer === 'team' ? 2 : 0)), + winner: layerOf(latest.chosen?.layer ?? latest.contributions[0]?.layer ?? '', latest.chosen?.level ?? latest.contributions[0]?.level ?? 0), contributions, safe: false, history, @@ -705,3 +784,52 @@ export function adaptConflicts(concepts: ResolvedConcept[], resolutions: Conflic } return out } + +function legacyConflictRecord(conflict: Conflict): DiscrepancyRecord { + return { + id: `section_content::${conflict.concept}::${conflict.sectionKey}`, + legacyId: conflict.id, + kind: 'section_content', originalKind: 'section_content', + conceptId: conflict.concept, conceptTitle: conflict.title, conceptType: 'concept', + key: conflict.sectionKey, label: conflict.section, + revision: `${conflict.id}:${conflict.history.length}`, + status: conflict.status === 'resolved' ? 'resolved' : 'needs_review', + contributions: conflict.contributions.map((item, index) => ({ + source: item.sourceLayer, level: item.layer === 'personal' ? 3 : item.layer === 'team' ? 2 : 0, + updated: item.updated || null, value: item.value, fingerprint: `${conflict.id}:${index}`, effective: index === 0, + })), + effectiveSource: conflict.contributions[0]?.sourceLayer ?? null, + effectiveValue: conflict.contributions[0]?.value ?? '', + winnerReason: `${conflict.contributions[0]?.sourceLayer ?? 'The selected source'} wins by configured layer precedence.`, + owner: 'Unassigned', priority: 'unassigned', fresherDissent: conflict.contributions.some((item) => item.fresherDissent), + freshness: { effectiveUpdated: conflict.contributions[0]?.updated ?? null, newestUpdated: conflict.contributions[0]?.updated ?? null, hasNewerDissent: conflict.contributions.some((item) => item.fresherDissent) }, + affectedLinks: [], + sourceHealth: conflict.contributions.map((item) => ({ source: item.sourceLayer, status: 'ok', error: null })), + history: conflict.history, matchingRules: [], + } +} + +/** Raw professional discrepancy records → the existing navigator view model. */ +export function adaptDiscrepancies(records: DiscrepancyRecord[], coverageComplete = true): Conflict[] { + return records.map((record) => { + const contributions = record.contributions.map((item) => ({ + layer: layerOf(item.source, item.level), sourceLayer: item.source, + value: typeof item.value === 'string' ? item.value : JSON.stringify(item.value, null, 2), + updated: item.updated ?? '', + ...(record.fresherDissent && !item.effective ? { fresherDissent: true } : {}), + })) + const effective = record.contributions.find((item) => item.effective) ?? record.contributions[0] + return { + id: record.id, concept: record.conceptId, sectionKey: record.key, + section: record.label, title: `${record.label} — ${record.conceptTitle}`, + status: record.status === 'resolved' ? 'resolved' : 'open', + winner: layerOf(effective?.source ?? '', effective?.level ?? 0), + contributions, safe: false, history: record.history, + kind: record.kind, discrepancyStatus: record.status, revision: record.revision, + owner: record.owner, priority: record.priority, winnerReason: record.winnerReason, + effectiveSource: record.effectiveSource, coverageComplete, sourceHealth: record.sourceHealth, + matchingRules: record.matchingRules, ruleConflict: record.ruleConflict, target: record.target, + affectedLinks: record.affectedLinks, + } + }) +} diff --git a/apps/console/src/components/Header.tsx b/apps/console/src/components/Header.tsx index 8900a17a..5f3e2adc 100644 --- a/apps/console/src/components/Header.tsx +++ b/apps/console/src/components/Header.tsx @@ -25,7 +25,7 @@ function HeaderInner({ const destination = destinationForView(view) const searchable = SEARCHABLE_VIEWS.has(view) const queueCount = signals.filter((signal) => signal.route === 'review_required').length - const conflictCount = conflicts.filter((conflict) => conflict.status === 'open').length + const conflictCount = conflicts.filter((conflict) => ['needs_review', 'reopened', 'recommended', 'auto_ready', 'blocked'].includes(conflict.discrepancyStatus ?? (conflict.status === 'open' ? 'needs_review' : 'resolved'))).length useEffect(() => { const focus = () => search.current?.focus() @@ -44,7 +44,7 @@ function HeaderInner({ { value: 'concepts', label: 'Concepts' }, { value: 'files', label: 'Files' }, ]} />} {destination === 'review' && }
@@ -57,8 +57,8 @@ function HeaderInner({ setQuery('') } }} - label={`Search ${view === 'triage' ? 'queue' : view}`} - placeholder={`Search ${view === 'triage' ? 'queue' : view}`} + label={`Search ${view === 'triage' ? 'queue' : view === 'conflicts' ? 'discrepancies' : view}`} + placeholder={`Search ${view === 'triage' ? 'queue' : view === 'conflicts' ? 'discrepancies' : view}`} />} {/* Background work and its health, from every destination — a count with no progress and no detail was the badge this replaces. */} diff --git a/apps/console/src/components/Sidebar.tsx b/apps/console/src/components/Sidebar.tsx index 6b005d41..3cc7773e 100644 --- a/apps/console/src/components/Sidebar.tsx +++ b/apps/console/src/components/Sidebar.tsx @@ -51,7 +51,7 @@ function SidebarInner({ onOpenSettings, onNavigate }: { onOpenSettings?: () => v if (view === 'triage' || view === 'conflicts') reviewView.current = view const reviewCount = signals.filter((signal) => signal.route === 'review_required').length - + conflicts.filter((conflict) => conflict.status === 'open').length + + conflicts.filter((conflict) => ['needs_review', 'reopened', 'recommended', 'auto_ready', 'blocked'].includes(conflict.discrepancyStatus ?? (conflict.status === 'open' ? 'needs_review' : 'resolved'))).length const sourceErrors = sources.filter((source) => source.status === 'degraded' || source.status === 'error').length const go = (destination: ShellDestination) => { diff --git a/apps/console/src/data.ts b/apps/console/src/data.ts index c38293bf..dcac85b6 100644 --- a/apps/console/src/data.ts +++ b/apps/console/src/data.ts @@ -1,4 +1,4 @@ -import type { ConflictResolutionRecord } from './types' +import type { ConflictResolutionRecord, DiscrepancyKind, DiscrepancyStatus, DiscrepancyRule } from './types' import type { LayerId, RouteId } from './theme' export interface Layer { @@ -66,6 +66,19 @@ export interface Conflict { status: 'open' | 'resolved'; contributions: Contribution[]; winner: LayerId safe: boolean history: ConflictResolutionRecord[] + kind?: DiscrepancyKind + discrepancyStatus?: DiscrepancyStatus + revision?: string + owner?: string + priority?: string + winnerReason?: string + effectiveSource?: string | null + coverageComplete?: boolean + sourceHealth?: ({ source: string; status: string; error: string | null } | null)[] + matchingRules?: Pick[] + ruleConflict?: boolean + target?: string + affectedLinks?: string[] } /** `sourceLayer` is the source's real name; `layer` is the lane it renders in. */ diff --git a/apps/console/src/store.tsx b/apps/console/src/store.tsx index f8cf0955..0b7edb18 100644 --- a/apps/console/src/store.tsx +++ b/apps/console/src/store.tsx @@ -6,10 +6,13 @@ import { type Activity, type Concept, type Conflict, type Signal, type Source, } from './data' import { - adaptConcept, adaptConflicts, adaptSources, createDataSource, LiveDataError, mergeSourceStatus, + adaptConcept, adaptConflicts, adaptDiscrepancies, adaptSources, createDataSource, LiveDataError, mergeSourceStatus, type Mode, } from './api' -import type { GraphSummary, SourceStatus } from './types' +import type { + DiscrepancyDecisionRequest, DiscrepancyRule, DiscrepancyRuleSuggestion, + GraphSummary, SourceStatus, +} from './types' import type { LayerId, RouteId } from './theme' import { dispatchNavigationGuard, filesHash, isViewId, parseHash, type ViewId } from './shell-navigation' @@ -209,6 +212,8 @@ export interface StoreData { loadErrors: { concept: string; error: string }[] resolvingConflict: string | null resolutionError: { message: string; partial: boolean } | null + discrepancyRules: DiscrepancyRule[] + discrepancyRuleSuggestions: DiscrepancyRuleSuggestion[] setView: (v: ViewId) => void setTriageTab: (t: TriageTab) => void @@ -236,6 +241,11 @@ export interface StoreData { route: (target: RouteId) => void resolveConflict: (conflictId: string, sourceLayer: string, method: 'automatic' | 'manual') => Promise resolveSafeConflicts: () => Promise + decideDiscrepancy: (request: DiscrepancyDecisionRequest) => Promise + approveRuleSuggestion: (id: string) => Promise + updateDiscrepancyRule: (id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }) => Promise + promoteDiscrepancyRule: (id: string, confirm: boolean) => Promise> + setDiscrepancyPriority: (id: string, priority: string) => Promise send: (text?: string) => void reload: () => void /** @@ -306,6 +316,8 @@ export function StoreProvider({ children }: { children: ReactNode }) { const [loadErrors, setLoadErrors] = useState<{ concept: string; error: string }[]>([]) const [resolvingConflict, setResolvingConflict] = useState(null) const [resolutionError, setResolutionError] = useState<{ message: string; partial: boolean } | null>(null) + const [discrepancyRules, setDiscrepancyRules] = useState([]) + const [discrepancyRuleSuggestions, setDiscrepancyRuleSuggestions] = useState([]) // Triage signals and the activity feed have no resolver equivalent — demo-only // fixtures (D6: live-mode triage is read-only, and there is no signal API). const [signals, setSignals] = useState(mode === 'demo' ? initialSignals : []) @@ -441,9 +453,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { shellReadyRef.current = true setLoading(false) // the shell can render now — everything else streams in - const [{ concepts: raw, errors, indexing, indexingSources: resolvingSources }, resolutionHistory] = await Promise.all([ + const [{ concepts: raw, errors, indexing, indexingSources: resolvingSources }, resolutionHistory, discrepancyPayload, rulePayload] = await Promise.all([ source.resolveAll(), source.conflictResolutions(), + source.discrepancies ? source.discrepancies() : Promise.resolve(null), + source.discrepancyRules ? source.discrepancyRules().catch(() => ({ rules: [], suggestions: [] })) : Promise.resolve({ rules: [], suggestions: [] }), ]) if (cancelled) return false // Only fail the whole page when nothing resolved AND nothing is still @@ -457,8 +471,12 @@ export function StoreProvider({ children }: { children: ReactNode }) { // leaves the banner running after the work has landed. applyIndexing(indexing ? (resolvingSources ?? g.indexingSources ?? []) : []) setConcepts(raw.map(adaptConcept)) - const derivedConflicts = adaptConflicts(raw, resolutionHistory) + const derivedConflicts = discrepancyPayload + ? adaptDiscrepancies(discrepancyPayload.discrepancies, discrepancyPayload.coverageComplete) + : adaptConflicts(raw, resolutionHistory) setConflicts(derivedConflicts) + setDiscrepancyRules(rulePayload.rules) + setDiscrepancyRuleSuggestions(rulePayload.suggestions) // Honor a deep-linked concept from the URL hash; else default to the // first. Only claim the deep link once it actually resolved. const pendingId = pendingConceptRef.current @@ -841,6 +859,45 @@ export function StoreProvider({ children }: { children: ReactNode }) { } }, [applyResolution]) + const decideDiscrepancy = useCallback(async (request: DiscrepancyDecisionRequest) => { + if (resolvingConflictRef.current) return + resolvingConflictRef.current = request.discrepancyId + setResolvingConflict(request.discrepancyId) + setResolutionError(null) + try { + const record = await source.decideDiscrepancy(request) + setConflicts((previous) => previous.map((item) => item.id === request.discrepancyId + ? { ...item, status: request.action === 'acknowledge' ? 'open' : 'resolved', discrepancyStatus: request.action === 'acknowledge' ? 'acknowledged' : 'resolved', history: [...item.history, record] } + : item)) + window.setTimeout(() => setReloadKey((key) => key + 1), 300) + } catch (error) { + setResolutionError({ message: error instanceof Error ? error.message : String(error), partial: false }) + throw error + } finally { + resolvingConflictRef.current = null + setResolvingConflict(null) + } + }, [source]) + + const approveRuleSuggestion = useCallback(async (id: string) => { + const rule = await source.createDiscrepancyRule(id) + setDiscrepancyRules((items) => [...items, rule]) + setDiscrepancyRuleSuggestions((items) => items.filter((item) => item.id !== id)) + setReloadKey((key) => key + 1) + }, [source]) + + const updateDiscrepancyRule = useCallback(async (id: string, changes: { mode?: 'recommend' | 'automatic'; enabled?: boolean }) => { + const rule = await source.patchDiscrepancyRule(id, changes) + setDiscrepancyRules((items) => items.map((item) => item.id === id ? rule : item)) + setReloadKey((key) => key + 1) + }, [source]) + + const promoteDiscrepancyRule = useCallback((id: string, confirm: boolean) => source.promoteDiscrepancyRule(id, confirm), [source]) + const setDiscrepancyPriority = useCallback(async (id: string, priority: string) => { + await source.setDiscrepancyPriority(id, priority) + setConflicts((items) => items.map((item) => item.id === id ? { ...item, priority } : item)) + }, [source]) + const send = useCallback((text?: string) => { const q = (text != null ? text : chatInputRef.current).trim() if (!q || chatBusyRef.current) return @@ -887,11 +944,19 @@ export function StoreProvider({ children }: { children: ReactNode }) { const data = useMemo(() => ({ mode, loading, load, error, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, + discrepancyRules, discrepancyRuleSuggestions, setView, setTriageTab, setSelSignal, setSelConflict, setSelConcept, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat, setChatInput, - retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, - }), [mode, loading, load, error, concepts, sources, signals, conflicts, activity, loadErrors, resolvingConflict, resolutionError, retryNow, route, resolveConflict, resolveSafeConflicts, send, reload, reloadKey, setView, setSelConcept, setQuery, setFilesScope, setFilesPath, openFilesScope, openConcept, openChat, closeChat]) + retryNow, route, resolveConflict, resolveSafeConflicts, decideDiscrepancy, + approveRuleSuggestion, updateDiscrepancyRule, promoteDiscrepancyRule, setDiscrepancyPriority, + send, reload, reloadKey, + }), [mode, loading, load, error, concepts, sources, signals, conflicts, activity, loadErrors, + resolvingConflict, resolutionError, discrepancyRules, discrepancyRuleSuggestions, + retryNow, route, resolveConflict, resolveSafeConflicts, decideDiscrepancy, + approveRuleSuggestion, updateDiscrepancyRule, promoteDiscrepancyRule, setDiscrepancyPriority, + send, reload, reloadKey, setView, setSelConcept, setQuery, setFilesScope, setFilesPath, + openFilesScope, openConcept, openChat, closeChat]) const nav = useMemo( () => ({ view, triageTab, selSignal, selConflict, selConcept, filesScope, filesPath, chatOpen }), diff --git a/apps/console/src/styles.css b/apps/console/src/styles.css index ac52d2d5..03eb0284 100644 --- a/apps/console/src/styles.css +++ b/apps/console/src/styles.css @@ -1775,6 +1775,121 @@ textarea:focus-visible, .cc-src-cov { display: none; } /* the coverage bar drops on small screens */ } +/* Professional Discrepancy Center ----------------------------------------- */ +.cc-discrepancy-center { gap: 16px; padding-bottom: 42px; } +.cc-simulation-notice, .cc-coverage-warning { + padding: 11px 14px; border: 1px solid var(--cc-blue-stroke); border-radius: 10px; + background: var(--cc-blue-fill); color: var(--cc-blue-text); font-size: 12px; line-height: 1.45; +} +.cc-global-simulation { min-height: 34px; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 6px 14px; border-bottom: 1px solid var(--cc-blue-stroke); background: var(--cc-blue-fill); color: var(--cc-blue-text); font-size: 11px; } +.cc-global-simulation span { color: var(--cc-body); } +.cc-simulation-notice { position: sticky; top: 0; z-index: 5; box-shadow: 0 4px 16px var(--cc-shadow); } +.cc-discrepancy-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 4px 2px 0; } +.cc-eyebrow { color: var(--cc-teal-text); font-size: 10px; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; } +.cc-discrepancy-header h2 { margin: 5px 0 4px; color: var(--cc-ink); font-size: clamp(22px, 2.2vw, 30px); letter-spacing: -.035em; } +.cc-discrepancy-header p { margin: 0; color: var(--cc-caption); font-size: 12.5px; } +.cc-actionable-count { min-width: 48px; min-height: 48px; display: grid; place-items: center; border: 1px solid var(--cc-amber-stroke); border-radius: 14px; background: var(--cc-amber-fill); color: var(--cc-amber-text); font-size: 19px; font-weight: 750; } +.cc-status-tabs { display: flex; gap: 4px; overflow-x: auto; padding: 4px; border: 1px solid var(--cc-line); border-radius: 11px; background: var(--cc-neutral-fill); } +.cc-status-tabs button { min-height: 44px; flex: 1 0 auto; padding: 0 13px; border: 0; border-radius: 8px; background: transparent; color: var(--cc-caption); font: inherit; font-size: 12px; font-weight: 650; cursor: pointer; } +.cc-status-tabs button[data-active="true"] { background: var(--cc-raised); color: var(--cc-ink); box-shadow: 0 1px 4px var(--cc-shadow); } +.cc-discrepancy-filters { display: grid; grid-template-columns: repeat(4, minmax(120px, 1fr)) auto; gap: 8px; } +.cc-discrepancy-filters select, .cc-filter-check { min-height: 44px; border: 1px solid var(--cc-line); border-radius: 9px; background: var(--cc-surface); color: var(--cc-body); font: inherit; font-size: 12px; } +.cc-discrepancy-filters select { padding: 0 30px 0 11px; } +.cc-filter-check { display: flex; align-items: center; gap: 7px; padding: 0 12px; white-space: nowrap; } +.cc-filter-check input { width: 16px; height: 16px; accent-color: var(--cc-teal-stroke-e); } +.cc-discrepancy-center .cc-conflict-layout { min-height: 560px; grid-template-columns: minmax(250px, .72fr) minmax(0, 1.7fr); } +.cc-discrepancy-center .cc-conflict-list { max-height: 760px; overflow-y: auto; padding-right: 3px; } +.cc-discrepancy-center .cc-conflict-row { min-height: 126px; } +.cc-kind-pill { display: inline-flex; width: max-content; align-items: center; min-height: 23px; padding: 0 8px; border-radius: 999px; background: var(--cc-neutral-fill); color: var(--cc-caption); font-size: 9.5px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase; } +.cc-discrepancy-center .cc-conflict-row > code { color: var(--cc-caption); font-size: 10.5px; overflow: hidden; text-overflow: ellipsis; } +.cc-discrepancy-center .cc-conflict-row-foot { color: var(--cc-caption); font-size: 10.5px; } +.cc-discrepancy-center .cc-conflict-detail { max-height: 760px; overflow-y: auto; padding: 24px; } +.cc-discrepancy-center .cc-detail-close { min-width: 44px; min-height: 44px; } +.cc-discrepancy-path { display: flex; gap: 8px; align-items: center; color: var(--cc-caption); font-size: 11px; } +.cc-discrepancy-path code { padding: 4px 8px; border-radius: 6px; background: var(--cc-blue-fill); color: var(--cc-blue-text); } +.cc-discrepancy-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-top: 18px; } +.cc-discrepancy-title h2 { margin: 7px 0 0; color: var(--cc-ink); font-size: 22px; letter-spacing: -.025em; } +.cc-status-large { padding: 6px 9px; border-radius: 7px; background: var(--cc-amber-fill); color: var(--cc-amber-text); font-size: 10.5px; font-weight: 700; } +.cc-discrepancy-explanation { max-width: 76ch; margin: 10px 0 18px; color: var(--cc-body); font-size: 13px; line-height: 1.55; } +.cc-evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; margin-bottom: 20px; border: 1px solid var(--cc-line); border-radius: 10px; background: var(--cc-line); } +.cc-evidence-grid > div { min-height: 66px; padding: 11px 12px; background: var(--cc-surface); } +.cc-evidence-grid span, .cc-evidence-grid strong { display: block; } +.cc-evidence-grid span { color: var(--cc-caption); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; } +.cc-evidence-grid strong { margin-top: 5px; color: var(--cc-body); font-size: 12px; line-height: 1.4; } +.cc-conflict-detail section > h3, .cc-rules h3 { margin: 22px 0 10px; color: var(--cc-ink); font-size: 13px; } +.cc-answer-stack { display: grid; gap: 9px; } +.cc-discrepancy-answer { padding: 13px; border: 1px solid var(--cc-line); border-radius: 10px; background: var(--cc-surface); } +.cc-discrepancy-answer[data-effective="true"] { border-color: var(--cc-teal-stroke-e); background: var(--cc-teal-fill); } +.cc-discrepancy-answer header { display: flex; justify-content: space-between; gap: 12px; } +.cc-discrepancy-answer header strong { color: var(--cc-ink); font-size: 12.5px; } +.cc-discrepancy-answer header span { color: var(--cc-caption); font-size: 10.5px; } +.cc-discrepancy-meta { margin-top: 3px; color: var(--cc-caption); font-size: 10px; } +.cc-discrepancy-answer details, .cc-discrepancy-history details { margin-top: 10px; } +.cc-discrepancy-answer summary, .cc-discrepancy-history summary { color: var(--cc-teal-text); font-size: 11px; cursor: pointer; } +.cc-discrepancy-original { margin-top: 10px; padding: 12px; border-radius: 8px; background: var(--cc-raised); color: var(--cc-body); font-size: 12px; overflow-wrap: anywhere; } +.cc-word-diff { margin: 11px 0 0; color: var(--cc-body); font-size: 12px; line-height: 1.55; } +.cc-word-diff mark { padding: 1px 2px; border-radius: 3px; background: var(--cc-amber-fill2); color: var(--cc-ink); } +.cc-line-diff { margin: 11px 0 0; overflow-x: auto; padding: 10px; border-radius: 8px; background: var(--cc-code-bg); color: var(--cc-code-fg); font-size: 11px; line-height: 1.55; } +.cc-line-diff [data-change="added"] { background: var(--cc-teal-fill); color: var(--cc-teal-text); } +.cc-diff-same, .cc-muted { color: var(--cc-caption); font-size: 11.5px; } +.cc-decision-panel { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--cc-line); } +.cc-decision-panel fieldset { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; } +.cc-decision-panel legend { margin-bottom: 10px; color: var(--cc-ink); font-size: 13px; font-weight: 700; } +.cc-decision-panel fieldset > label { min-height: 54px; display: flex; gap: 10px; align-items: flex-start; padding: 10px 11px; border: 1px solid var(--cc-line); border-radius: 9px; cursor: pointer; } +.cc-decision-panel fieldset > label:has(input:checked) { border-color: var(--cc-teal-stroke-e); background: var(--cc-teal-fill); } +.cc-decision-panel fieldset > label input { width: 17px; height: 17px; margin-top: 2px; accent-color: var(--cc-teal-stroke-e); } +.cc-decision-panel label span strong, .cc-decision-panel label span small { display: block; } +.cc-decision-panel label span strong { color: var(--cc-body); font-size: 12px; } +.cc-decision-panel label span small { margin-top: 2px; color: var(--cc-caption); font-size: 10.5px; line-height: 1.4; } +.cc-decision-panel select, .cc-decision-panel textarea { width: 100%; border: 1px solid var(--cc-line); border-radius: 8px; background: var(--cc-raised); color: var(--cc-ink); font: inherit; font-size: 12px; } +.cc-decision-panel select { min-height: 44px; padding: 0 10px; } +.cc-decision-panel textarea { min-height: 120px; padding: 10px; line-height: 1.5; resize: vertical; } +.cc-compose, .cc-acknowledge { display: grid; gap: 8px; margin: 0 0 6px 28px; } +.cc-compose > button { justify-self: start; min-height: 36px; } +.cc-compose-preview { padding: 12px; border: 1px solid var(--cc-line); border-radius: 8px; background: var(--cc-surface); } +.cc-callout, .cc-rule-match { padding: 10px 12px; border-radius: 8px; background: var(--cc-neutral-fill); color: var(--cc-body); font-size: 11.5px; line-height: 1.45; } +.cc-priority-assign { display: flex; align-items: center; gap: 10px; margin-top: 12px; color: var(--cc-caption); font-size: 11px; } +.cc-priority-assign select { width: auto; min-width: 130px; } +.cc-decision-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; } +.cc-decision-actions button, .cc-rules button, .cc-rule-preview button, .cc-compose > button { min-height: 44px; padding: 0 13px; border: 1px solid var(--cc-line-strong); border-radius: 8px; background: var(--cc-raised); color: var(--cc-body); font: inherit; font-size: 11.5px; font-weight: 650; cursor: pointer; } +.cc-decision-actions button:disabled, .cc-rules button:disabled { opacity: .5; cursor: not-allowed; } +.cc-decision-actions .cc-button-primary { border-color: var(--cc-teal-stroke-e); background: var(--cc-teal-text); color: var(--cc-raised); } +.cc-discrepancy-history { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; } +.cc-discrepancy-history > li { padding: 12px; border: 1px solid var(--cc-line); border-radius: 9px; background: var(--cc-surface); } +.cc-history-head { display: flex; justify-content: space-between; gap: 10px; } +.cc-history-head strong { color: var(--cc-body); font-size: 12px; } +.cc-history-head span, .cc-history-facts { color: var(--cc-caption); font-size: 10.5px; } +.cc-discrepancy-history p { margin: 7px 0; color: var(--cc-body); font-size: 11.5px; } +.cc-history-facts { display: flex; flex-wrap: wrap; gap: 6px 12px; } +.cc-history-original pre { overflow-x: auto; padding: 8px; border-radius: 6px; background: var(--cc-code-bg); color: var(--cc-code-fg); white-space: pre-wrap; } +.cc-rules { padding: 18px; border: 1px solid var(--cc-line); border-radius: 12px; background: var(--cc-surface); } +.cc-rules > div:first-child { display: flex; justify-content: space-between; gap: 20px; } +.cc-rules h3 { margin: 0; } +.cc-rules > div:first-child p { margin: 0; color: var(--cc-caption); font-size: 11.5px; } +.cc-rules > article { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 16px; margin-top: 11px; padding: 12px; border: 1px solid var(--cc-line); border-radius: 9px; } +.cc-rules article > strong { color: var(--cc-body); font-size: 12px; } +.cc-rules article > span { color: var(--cc-caption); font-size: 11px; } +.cc-rules article > details, .cc-rules article > div { grid-column: 1 / -1; } +.cc-rules article > button { grid-column: 2; grid-row: 1 / span 2; } +.cc-rule-preview { margin-top: 12px; padding: 14px; border: 1px solid var(--cc-blue-stroke); border-radius: 9px; background: var(--cc-blue-fill); } +.cc-rule-preview pre { max-height: 240px; overflow: auto; font-size: 10px; } + +.cc-discrepancy-center button:focus-visible, .cc-discrepancy-center select:focus-visible, .cc-discrepancy-center textarea:focus-visible, .cc-discrepancy-center input:focus-visible, .cc-discrepancy-center summary:focus-visible { outline: 3px solid var(--cc-blue-soft); outline-offset: 2px; } + +@media (max-width: 980px) { + .cc-discrepancy-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .cc-discrepancy-center .cc-conflict-layout { grid-template-columns: 1fr; } + .cc-discrepancy-center .cc-conflict-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); max-height: none; } +} +@media (max-width: 700px) { + .cc-discrepancy-filters, .cc-evidence-grid { grid-template-columns: 1fr; } + .cc-discrepancy-center .cc-conflict-list { display: flex; } + .cc-discrepancy-center .cc-conflict-detail { padding: 17px; } + .cc-discrepancy-title, .cc-rules > div:first-child { flex-direction: column; } + .cc-decision-actions { flex-direction: column; } + .cc-decision-actions button { width: 100%; } +} + /* Container behavior must win over the legacy window-width triage fallback. */ @container (max-width: 839px) { .cc-triage-grid { position: relative; display: block; min-height: 100%; } diff --git a/apps/console/src/types.ts b/apps/console/src/types.ts index 12c3d91e..57399359 100644 --- a/apps/console/src/types.ts +++ b/apps/console/src/types.ts @@ -226,7 +226,7 @@ export interface ConflictResolutionContribution { /** One append-only decision returned by GET /api/conflict-resolutions. */ export interface ConflictResolutionRecord { - schemaVersion: 1 + schemaVersion: 1 | 2 id: string conflictId: string conceptId: string @@ -234,12 +234,103 @@ export interface ConflictResolutionRecord { sectionKey: string sectionHeading: string contributions: ConflictResolutionContribution[] - chosen: ConflictResolutionContribution + chosen: ConflictResolutionContribution | null method: 'automatic' | 'manual' reason: string actor: 'local-user' decidedAt: string supersedes?: string + discrepancyId?: string + discrepancyKind?: DiscrepancyKind + revision?: string + action?: DiscrepancyAction + reasonCode?: AcknowledgementReason + note?: string + ruleId?: string + transactionId?: string + transactionState?: 'committed' | 'rolled_back' | 'recovery_required' | 'not_required' | 'blocked' + writtenTargets?: { layer: string; path: string }[] + contributorFingerprints?: { source: string; fingerprint: string }[] + supersededDecisionId?: string +} + +export type DiscrepancyKind = 'section_content' | 'frontmatter_value' | 'broken_link' | 'changed_after_decision' +export type DiscrepancyStatus = 'needs_review' | 'recommended' | 'auto_ready' | 'acknowledged' | 'resolved' | 'reopened' | 'blocked' +export type DiscrepancyAction = 'choose_contribution' | 'compose' | 'acknowledge' +export type AcknowledgementReason = 'different_scopes' | 'temporary_migration' | 'source_specific_authority' | 'other' + +export interface DiscrepancyContribution { + source: string + level: number + updated: string | null + value: unknown + fingerprint: string + effective: boolean +} + +export interface DiscrepancyRule { + id: string + scope: 'local' | 'team' + mode: 'recommend' | 'automatic' + enabled: boolean + match: { kind: DiscrepancyKind; conceptType: string; key: string; sources: string[] } + action: { type: 'prefer_source'; source: string } | { type: 'acknowledge'; reasonCode: AcknowledgementReason } + evidenceDecisionIds: string[] +} + +export interface DiscrepancyRuleSuggestion { + id: string + match: DiscrepancyRule['match'] + action: DiscrepancyRule['action'] + evidenceDecisionIds: string[] + evidenceCount: number +} + +export interface DiscrepancyRecord { + id: string + legacyId?: string + kind: DiscrepancyKind + originalKind: DiscrepancyKind + conceptId: string + conceptTitle: string + conceptType: string + key: string + label: string + target?: string + revision: string + status: DiscrepancyStatus + contributions: DiscrepancyContribution[] + effectiveSource: string | null + effectiveValue: unknown + winnerReason: string + owner: string + priority: string + fresherDissent: boolean + freshness: { effectiveUpdated: string | null; newestUpdated: string | null; hasNewerDissent: boolean } + affectedLinks: string[] + sourceHealth: ({ source: string; status: string; error: string | null } | null)[] + history: ConflictResolutionRecord[] + matchingRules: Pick[] + ruleConflict?: boolean +} + +export interface DiscrepanciesResponse { + discrepancies: DiscrepancyRecord[] + coverageComplete: boolean + indexing: boolean + indexingSources: string[] + errors: { concept: string; error: string }[] + generation: number +} + +export interface DiscrepancyDecisionRequest { + discrepancyId: string + revision: string + action: DiscrepancyAction + selectedSource?: string + content?: string + reasonCode?: AcknowledgementReason + note?: string } export interface ResolveConflictRequest { diff --git a/apps/console/src/views/Conflicts.test.tsx b/apps/console/src/views/Conflicts.test.tsx index c91ba59e..6ada1f1e 100644 --- a/apps/console/src/views/Conflicts.test.tsx +++ b/apps/console/src/views/Conflicts.test.tsx @@ -1,7 +1,5 @@ // @vitest-environment jsdom -// The "dissent is newer" badge (contract C-b) renders in the Conflicts view -// only, on the dissenting card whose date beats the effective value. Fixture -// driven — no engine required. +// Professional discrepancy presentation and governed decision affordances. import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,6 +17,7 @@ let root: Root function storeWith(conflicts: Conflict[], selConflict: string) { return { + mode: 'demo', query: '', conflicts, selConflict, setSelConflict: vi.fn(), @@ -26,6 +25,10 @@ function storeWith(conflicts: Conflict[], selConflict: string) { resolveSafeConflicts: vi.fn(), resolvingConflict: null, resolutionError: null, + discrepancyRules: [], discrepancyRuleSuggestions: [], + decideDiscrepancy: vi.fn(), setDiscrepancyPriority: vi.fn(), + approveRuleSuggestion: vi.fn(), updateDiscrepancyRule: vi.fn(), promoteDiscrepancyRule: vi.fn(), + openFilesScope: vi.fn(), } } @@ -39,6 +42,10 @@ const freshConflict: Conflict = { winner: 'personal', safe: false, history: [], + kind: 'section_content', discrepancyStatus: 'needs_review', revision: 'rev-1', + effectiveSource: 'personal', winnerReason: 'personal wins by configured layer precedence.', + owner: 'Platform', priority: 'unassigned', coverageComplete: true, + sourceHealth: [{ source: 'personal', status: 'ok', error: null }, { source: 'team', status: 'ok', error: null }], contributions: [ { layer: 'personal', sourceLayer: 'personal', value: 'SingleStore.', updated: '2026-05-12' }, { layer: 'team', sourceLayer: 'team', value: 'Postgres.', updated: '2026-06-01', fresherDissent: true }, @@ -83,41 +90,42 @@ afterEach(async () => { container.remove() }) -describe('Conflicts fresherDissent badge', () => { +describe('Discrepancy Center', () => { it('badges the flagged dissent card as newer than the effective value', async () => { mocks.useStore.mockReturnValue(storeWith([freshConflict, staleConflict], freshConflict.id)) await act(async () => root.render()) - expect(container.textContent).toContain('Newer') - expect(container.textContent).toContain('Used now') - expect(container.textContent).toContain('Which answer should ContextCake use?') + expect(container.textContent).toContain('Newer dissent') + expect(container.textContent).toContain('Effective now') + expect(container.textContent).toContain('Choose a safe disposition') }) it('shows no freshness badge when no dissent is flagged', async () => { mocks.useStore.mockReturnValue(storeWith([staleConflict], staleConflict.id)) await act(async () => root.render()) - expect(container.textContent).not.toContain('Newer') + expect(Array.from(container.querySelectorAll('.cc-discrepancy-answer')).some((answer) => answer.textContent?.includes('Newer dissent'))).toBe(false) }) - it('offers one batch wand action for safe conflicts', async () => { + it('labels every demo action as a simulation and never offers automatic execution', async () => { const store = storeWith([safeConflict], safeConflict.id) mocks.useStore.mockReturnValue(store) await act(async () => root.render()) - const wand = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('Resolve 1 safe conflict')) - expect(wand).toBeTruthy() - await act(async () => wand?.click()) - expect(store.resolveSafeConflicts).toHaveBeenCalledOnce() - expect(container.textContent).toContain('Which answer should ContextCake use?') + expect(container.textContent).toContain('Simulate using') + expect(container.textContent).toContain('Simulation history resets on reload.') }) - it('does not claim nothing changed when a batch stopped after earlier resolutions', async () => { - const store = { ...storeWith([safeConflict], safeConflict.id), resolutionError: { message: 'The last source changed.', partial: true } } + it('requires a reason before an acknowledgement can be submitted', async () => { + const store = storeWith([safeConflict], safeConflict.id) mocks.useStore.mockReturnValue(store) await act(async () => root.render()) - - expect(container.textContent).toContain('Some safe conflicts were resolved.') - expect(container.textContent).not.toContain('Nothing was changed.') + const radio = Array.from(container.querySelectorAll('input[type="radio"]')).find((input) => input.parentElement?.textContent?.includes('Keep the scoped difference'))! + await act(async () => radio.click()) + const submit = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('Simulate acknowledgement'))! + expect(submit.disabled).toBe(true) + const reason = container.querySelector('[aria-label="Acknowledgement reason"]')! + await act(async () => { reason.value = 'different_scopes'; reason.dispatchEvent(new Event('change', { bubbles: true })) }) + expect(submit.disabled).toBe(false) }) }) diff --git a/apps/console/src/views/Conflicts.tsx b/apps/console/src/views/Conflicts.tsx index abe177b2..c5ce53c9 100644 --- a/apps/console/src/views/Conflicts.tsx +++ b/apps/console/src/views/Conflicts.tsx @@ -1,341 +1,250 @@ -import { memo, useEffect, useMemo, useRef, useState } from 'react' -import { C, css, lc, MONO } from '../theme' -import { layerLevel, layerName } from '../data' +import { memo, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import type { Conflict, Contribution } from '../data' -import { LayerChip } from '../components/LayerChip' +import type { AcknowledgementReason, DiscrepancyStatus } from '../types' import { Markdown } from '../components/Markdown' import { useStoreData, useStoreInput, useStoreNav } from '../store' import { useDetailSurface } from '../components/useDetailSurface' -function WandIcon() { +const STATUS_LABEL: Record = { + needs_review: 'Needs review', reopened: 'Needs review', recommended: 'Recommendations', + auto_ready: 'Automated', acknowledged: 'Acknowledged', resolved: 'Resolved', blocked: 'Automated', +} +const KIND_LABEL: Record = { + section_content: 'Section content', frontmatter_value: 'Frontmatter value', + broken_link: 'Broken link', changed_after_decision: 'Changed after decision', +} +const REASONS: { value: AcknowledgementReason; label: string }[] = [ + { value: 'different_scopes', label: 'Different scopes' }, + { value: 'temporary_migration', label: 'Temporary migration' }, + { value: 'source_specific_authority', label: 'Source-specific authority' }, + { value: 'other', label: 'Other' }, +] + +function formatDate(value?: string | null) { + if (!value) return 'Date not recorded' + const parsed = new Date(value.includes('T') ? value : `${value}T12:00:00`) + return Number.isNaN(parsed.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: value.includes('T') ? 'short' : undefined }).format(parsed) +} + +function valueKind(value: string) { + return /```|^\s*[{[]|\n\s*[-+]?\s*["'][^\n]+:|\n.*[;{}]$/m.test(value) ? 'structured' : 'prose' +} + +function wordDiff(base: string, alternative: string) { + const left = base.split(/(\s+)/) + const right = alternative.split(/(\s+)/) + const common = new Set(left.filter((token) => token.trim() && right.includes(token))) + return right.map((token, index) => common.has(token) || !token.trim() + ? {token} + : {token}) +} + +function lineDiff(base: string, alternative: string) { + const current = new Set(base.split('\n')) + return alternative.split('\n').map((line, index) => ( +
+ {line || ' '} +
+ )) +} + +function Diff({ effective, value }: { effective: string; value: string }) { + if (effective === value) return

Matches the effective answer.

+ return valueKind(value) === 'structured' + ?
{lineDiff(effective, value)}
+ :

{wordDiff(effective, value)}

+} + +function SourceAnswer({ choice, effective, isEffective }: { choice: Contribution; effective: string; isEffective: boolean }) { return ( - +
+
+ {choice.sourceLayer} + {isEffective ? 'Effective now' : choice.fresherDissent ? 'Newer dissent' : 'Alternative'} +
+
Updated {formatDate(choice.updated)}
+ {!isEffective && } +
+ Inspect full original value + +
+
) } -function formatDate(value: string) { - if (!value) return 'date not recorded' - const parsed = new Date(value.includes('T') ? value : `${value}T12:00:00`) - if (Number.isNaN(parsed.getTime())) return value - return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: value.includes('T') ? 'short' : undefined }).format(parsed) +function History({ conflict }: { conflict: Conflict }) { + if (!conflict.history.length) return

No previous decisions.

+ return ( +
    + {[...conflict.history].reverse().map((record) => ( +
  1. +
    + {record.action === 'acknowledge' ? 'Kept scoped difference' : record.action === 'compose' ? 'Wrote reconciled answer' : 'Used an existing answer'} + {formatDate(record.decidedAt)} +
    +

    {record.reason}

    +
    + Actor: {record.actor} + Result: {record.transactionState ?? 'committed'} + {record.ruleId && Rule: {record.ruleId}} + {record.supersedes && Superseded: {record.supersedes}} +
    + {record.writtenTargets?.length ? ( +
    {record.writtenTargets.length} affected files
      {record.writtenTargets.map((target) =>
    • {target.path}
    • )}
    + ) : null} +
    + Original answers and decision evidence + {(record.contributions ?? []).map((item) =>
    {item.layer}
    {item.content}
    )} +
    +
  2. + ))} +
+ ) } -function choiceName(choice: Contribution) { - const familiar = layerName(choice.layer) - return choice.sourceLayer === choice.layer ? familiar : `${familiar} · ${choice.sourceLayer}` -} +function DecisionPanel({ conflict, onClose }: { conflict: Conflict; onClose: () => void }) { + const { mode, decideDiscrepancy, setDiscrepancyPriority, resolvingConflict, resolutionError, openFilesScope } = useStoreData() + const [action, setAction] = useState<'choose_contribution' | 'compose' | 'acknowledge'>('choose_contribution') + const [selectedSource, setSelectedSource] = useState(conflict.effectiveSource ?? conflict.contributions[0]?.sourceLayer ?? '') + const [content, setContent] = useState(conflict.contributions[0]?.value ?? '') + const [reasonCode, setReasonCode] = useState('') + const [note, setNote] = useState('') + const [preview, setPreview] = useState(false) + const busy = resolvingConflict === conflict.id + const cannotWrite = conflict.kind === 'broken_link' + + useEffect(() => { + setAction('choose_contribution') + setSelectedSource(conflict.effectiveSource ?? conflict.contributions[0]?.sourceLayer ?? '') + setContent(conflict.contributions[0]?.value ?? '') + setReasonCode('') + setNote('') + setPreview(false) + }, [conflict.id]) + + const submit = async () => { + if (!conflict.revision) return + await decideDiscrepancy({ + discrepancyId: conflict.id, revision: conflict.revision, action, + ...(action === 'choose_contribution' ? { selectedSource } : {}), + ...(action === 'compose' ? { content } : {}), + ...(action === 'acknowledge' && reasonCode ? { reasonCode, note } : {}), + }) + } -function Choice({ - choice, - conflict, - checked, - disabled, - onChange, -}: { - choice: Contribution - conflict: Conflict - checked: boolean - disabled: boolean - onChange: () => void -}) { - const isEffective = choice.sourceLayer === conflict.contributions[0]?.sourceLayer && conflict.status === 'open' - const col = lc(choice.layer) return ( -