diff --git a/README.md b/README.md index 5858273..889eb01 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,10 @@ pnpm exec rgr team compare .ragmir/team/alice.json --local-label christophe The comparison names configuration differences plus local-only, peer-only, and changed files, then gives ordered repair steps. It never guesses which copy is authoritative: the team keeps that -decision in Git, Drive, or its existing source workflow. Detailed safeguards live in the +decision in Git, Drive, or its existing source workflow. `ready` means the index can serve and be +compared; privacy warnings are reported as non-blocking security advisories with a separate +`rgr security-audit` action. Existing v2.19 snapshots remain comparable without reindexing. +Detailed safeguards live in the [team guide](./docs/agent-integration.md#team-knowledge-bases) and [configuration reference](./docs/configuration.md#stable-team-source-configuration). diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 2dda01b..408cb39 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -83,7 +83,11 @@ The result distinguishes configuration drift, local-only files, peer-only files, It provides ordered commands for readiness, upgrade, ingestion, or rebuild work. Ragmir never chooses which copy is correct; use the declared Git commit, Drive revision, or team folder as the authority, synchronize it, ingest again, then compare fresh snapshots until -`status=synchronized`. +`status=synchronized`. Operational readiness and privacy review are independent: a matching index +with local extractor or permission warnings remains synchronized, while the comparison exposes +per-side security advisory counts and recommends `rgr security-audit`. Do not rebuild a healthy +index only to clear an advisory. Existing v2.19 snapshots are interpreted from their stored health +metadata, so teammates can upgrade at different times without regenerating them first. Version stable directory or glob contracts instead of rewriting a tracked config with the files found on the current machine. The lower-level `corpusFingerprint` returned by `rgr status --json`, @@ -104,7 +108,8 @@ responsible for distributing the source files. An agent using the bundled Ragmir skill should check readiness before relying on retrieval. When a peer snapshot is available, it should run the comparison and warn the user in the user's language when `status` is not `synchronized`. It should summarize the exact drift and suggested commands, -never silently pick a winner or overwrite source files. +never silently pick a winner or overwrite source files. It should describe security advisories as +separate, non-blocking follow-ups when the operational indexes already match. ## MCP tools diff --git a/docs/api-reference.md b/docs/api-reference.md index 94a4ab4..1dff266 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -336,6 +336,11 @@ types that callers commonly compose explicitly. | Upgrades | `UpgradeInspection`, `UpgradeOptions`, `UpgradeResult`, `UpgradeStatus` | | Setup and commands | `SetupOptions`, `SetupResult`, `SetupSemanticResult`, `PackageManager`, `RagmirCommand`, `PromptRouteDecision`, `PromptRouteTool` | +`TeamSnapshot.ready` describes operational index readiness. `TeamComparison.securityAdvisories` +reports local and peer privacy-warning counts separately; advisory-only differences do not prevent +`status: "synchronized"`. `compareTeamSnapshots` accepts existing v2.19 snapshots and derives their +operational state from the stored corpus and health fields without changing the snapshot schema. + ## Chat: cited local generation ```bash diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 12d8a02..8717813 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -87,8 +87,12 @@ authorized teammate explicitly needs a copy. `team compare` previews up to 20 local-only, peer-only, and changed files and prints every configuration difference plus ordered actions. `--json` returns the complete diff; `--strict` exits -with code 1 unless both ready indexes are synchronized. The command never changes source files or -decides which side is authoritative. +with code 1 unless both operational indexes are synchronized. `securityAdvisories`, +`localSecurityAdvisories`, and `peerSecurityAdvisories` keep privacy follow-ups visible without +turning matching operational indexes into `not-ready`. Review them with `rgr security-audit`; they +do not require deleting or rebuilding the index. Snapshots written by Ragmir v2.19.0 through +v2.19.2 remain compatible. The command never changes source files or decides which side is +authoritative. ## Safe upgrades diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 87b6427..e65df01 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -75,6 +75,12 @@ or shared folder, synchronize both workstations, run the recommended ingest or r compare fresh snapshots. Keep tracked config based on stable directories or globs, and never share an actively written `.ragmir/storage/` directory. +If the comparison reports `status=synchronized` together with security advisories, the indexed +bytes and operational configuration match. Review each side with `rgr security-audit`; do not run a +repair or rebuild only because an extractor or permission advisory remains. Ragmir also interprets +v2.19.0 through v2.19.2 snapshots from their stored health fields, so an older snapshot that marked +an advisory as `ready=false` can still be compared after upgrading. + The lower-level `corpusFingerprint` in `rgr status --json` remains a quick equality signal. A different value identifies divergence, not which machine is correct. If an index created before corpus fingerprints were introduced reports `null`, run `rgr ingest` diff --git a/packages/ragmir-core/README.md b/packages/ragmir-core/README.md index 43ecf0c..5f1d668 100644 --- a/packages/ragmir-core/README.md +++ b/packages/ragmir-core/README.md @@ -84,7 +84,8 @@ Top-level `ingest`, `search`, `ask`, and `research` functions remain available f - `rgr security-audit` checks permissions, Git ignore coverage, tracked private paths, redaction, and local extractor authority. - `rgr team snapshot` and `rgr team compare` explain configuration and per-file drift without - sharing source text or guessing which copy is authoritative. + sharing source text or guessing which copy is authoritative. Operational readiness remains + separate from non-blocking security advisories, including for existing v2.19 snapshots. - After a package update, `rgr upgrade --check` previews compatibility; `rgr upgrade` safely stages any required rebuild without deleting the active index first. Privacy warnings remain visible as non-blocking advisories and can be handled separately with `rgr security-audit`. @@ -105,6 +106,7 @@ Core installs and starts without either add-on. A hosted agent receives only pas integration sends under that provider's data policy; use a local consumer when no passage may leave the workstation. Teams synchronize their source folder and tracked configuration, then build one local index per developer. Exchange an authorized metadata-only snapshot and run `rgr team compare` -to resolve exact drift. See the [team workflow](https://github.com/jcode-works/jcode-ragmir/blob/main/docs/agent-integration.md#team-knowledge-bases). +to resolve exact drift. Matching operational indexes remain synchronized when a privacy advisory +needs separate review with `rgr security-audit`. See the [team workflow](https://github.com/jcode-works/jcode-ragmir/blob/main/docs/agent-integration.md#team-knowledge-bases). Ragmir Core is open source under the [MIT License](https://github.com/jcode-works/jcode-ragmir/blob/main/LICENSE). diff --git a/packages/ragmir-core/src/cli.ts b/packages/ragmir-core/src/cli.ts index 4235b42..474d3ba 100644 --- a/packages/ragmir-core/src/cli.ts +++ b/packages/ragmir-core/src/cli.ts @@ -1187,6 +1187,7 @@ teamCommand console.log(`outputPath=${outputPath}`) console.log(`label=${snapshot.label}`) console.log(`ready=${snapshot.ready}`) + console.log(`securityAdvisories=${snapshot.health.securityWarnings}`) console.log(`corpusFingerprint=${snapshot.corpus.fingerprint ?? "unavailable"}`) console.log(`indexedFiles=${snapshot.corpus.indexedFiles}`) console.log(`notice=${snapshot.notice}`) @@ -2245,6 +2246,8 @@ function printTeamComparison(comparison: TeamComparison): void { console.log(`summary=${comparison.summary}`) console.log(`local=${comparison.localLabel}`) console.log(`peer=${comparison.peerLabel}`) + console.log(`localSecurityAdvisories=${comparison.securityAdvisories.local}`) + console.log(`peerSecurityAdvisories=${comparison.securityAdvisories.peer}`) console.log(`sameConfiguration=${comparison.sameConfiguration}`) console.log(`sameCorpus=${comparison.sameCorpus}`) console.log(`authorityDecisionRequired=${comparison.authorityDecisionRequired}`) diff --git a/packages/ragmir-core/src/team-diagnostics.test.ts b/packages/ragmir-core/src/team-diagnostics.test.ts index 766c7b4..aac6a70 100644 --- a/packages/ragmir-core/src/team-diagnostics.test.ts +++ b/packages/ragmir-core/src/team-diagnostics.test.ts @@ -2,8 +2,10 @@ import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { afterEach, describe, expect, it } from "vitest" +import { loadConfig } from "./config.js" import { ingest } from "./ingest.js" import { initProject } from "./init.js" +import { readIndexManifest, writeIndexManifest } from "./store.js" import { compareTeamSnapshots, createTeamSnapshot, @@ -128,6 +130,46 @@ describe("team diagnostics", () => { }) }) + it("should synchronize operational indexes while surfacing privacy advisories", async () => { + const [root] = await teamRoots() + await writeEvidence(root, "decision.md", "Approved release train.\n") + await ingest({ cwd: root }) + const config = await loadConfig(root) + const manifest = await readIndexManifest(config) + if (!manifest?.health) { + throw new Error("Expected an index health snapshot.") + } + const warning = "A configured local extractor executes with operator authority." + await writeIndexManifest( + { + ...manifest, + health: { ...manifest.health, securityWarnings: [warning] }, + }, + config, + manifest.indexedFiles, + ) + + const snapshot = await createTeamSnapshot({ cwd: root, label: "Alice" }) + const legacySnapshot = { ...snapshot, runtimeRagmirVersion: "2.19.2", ready: false } + const comparison = compareTeamSnapshots(legacySnapshot, legacySnapshot) + const currentNotReadyComparison = compareTeamSnapshots( + { ...snapshot, ready: false }, + { ...snapshot, ready: false }, + ) + + expect(snapshot).toMatchObject({ ready: true, health: { securityWarnings: 1 } }) + expect(comparison).toMatchObject({ + status: "synchronized", + synchronized: true, + securityAdvisories: { local: 1, peer: 1 }, + }) + expect(comparison.summary).toContain("non-blocking security advisories") + expect(comparison.recommendedActions).toEqual([ + "On Alice, review 1 non-blocking security advisory with `rgr security-audit`.", + ]) + expect(currentNotReadyComparison).toMatchObject({ status: "not-ready", synchronized: false }) + }) + it("should reject a snapshot when its file inventory was altered", async () => { const [root] = await teamRoots() await writeEvidence(root, "decision.md", "Approved release train.\n") diff --git a/packages/ragmir-core/src/team-diagnostics.ts b/packages/ragmir-core/src/team-diagnostics.ts index 7195b43..ac6daaf 100644 --- a/packages/ragmir-core/src/team-diagnostics.ts +++ b/packages/ragmir-core/src/team-diagnostics.ts @@ -16,6 +16,7 @@ const MAX_TEAM_SNAPSHOT_BYTES = 64 * 1024 * 1024 const MAX_TEAM_LABEL_CHARACTERS = 80 const MAX_TEAM_PATH_CHARACTERS = 4_096 const SHA256_PATTERN = /^[0-9a-f]{64}$/u +const LEGACY_SECURITY_BLOCKING_SNAPSHOT_VERSIONS = new Set(["2.19.0", "2.19.1", "2.19.2"]) const teamSnapshotFileSchema = z .object({ @@ -117,6 +118,10 @@ export interface TeamComparison { summary: string localLabel: string peerLabel: string + securityAdvisories: { + local: number + peer: number + } sameConfiguration: boolean sameCorpus: boolean authorityDecisionRequired: boolean @@ -176,7 +181,7 @@ export async function createTeamSnapshot( label: normalizeTeamLabel(options.label ?? "local"), knowledgeBaseId: identity?.id ?? null, runtimeRagmirVersion: VERSION, - ready: report.ready, + ready: report.readiness.operationalReady && report.readiness.indexPolicyCurrent, freshnessWarning: report.indexFreshness.warning, configuration: { sources: sourceContract.sources, @@ -307,16 +312,29 @@ export function compareTeamSnapshots(local: TeamSnapshot, peer: TeamSnapshot): T localOnly.length === 0 && peerOnly.length === 0 && changed.length === 0 - const synchronized = - validatedLocal.ready && validatedPeer.ready && sameConfiguration && sameCorpus + const localReady = isTeamSnapshotOperational(validatedLocal) + const peerReady = isTeamSnapshotOperational(validatedPeer) + const synchronized = localReady && peerReady && sameConfiguration && sameCorpus const status = comparisonStatus(validatedLocal, validatedPeer, sameConfiguration, sameCorpus) + const advisoryCount = + validatedLocal.health.securityWarnings + validatedPeer.health.securityWarnings return { status, synchronized, - summary: comparisonSummary(status, localOnly.length, peerOnly.length, changed.length), + summary: comparisonSummary( + status, + localOnly.length, + peerOnly.length, + changed.length, + advisoryCount, + ), localLabel: validatedLocal.label, peerLabel: validatedPeer.label, + securityAdvisories: { + local: validatedLocal.health.securityWarnings, + peer: validatedPeer.health.securityWarnings, + }, sameConfiguration, sameCorpus, authorityDecisionRequired: @@ -386,13 +404,33 @@ function assertTeamSnapshotIntegrity(snapshot: TeamSnapshot): void { snapshot.health.missingFromIndex > 0 || snapshot.health.staleInIndex > 0 || snapshot.health.emptyTextFiles > 0 || - snapshot.health.oversizedFiles > 0 || - snapshot.health.securityWarnings > 0) + snapshot.health.oversizedFiles > 0) ) { - throw new Error("Team snapshot cannot be ready while freshness, coverage, or security fails.") + throw new Error("Team snapshot cannot be ready while freshness or coverage fails.") } } +function isTeamSnapshotOperational(snapshot: TeamSnapshot): boolean { + const storedHealthIsOperational = + snapshot.corpus.fingerprint !== null && + snapshot.corpus.chunksIndexed > 0 && + snapshot.freshnessWarning === null && + snapshot.health.missingFromIndex === 0 && + snapshot.health.staleInIndex === 0 && + snapshot.health.emptyTextFiles === 0 && + snapshot.health.oversizedFiles === 0 + if (!storedHealthIsOperational) { + return false + } + if (snapshot.ready) { + return true + } + return ( + snapshot.health.securityWarnings > 0 && + LEGACY_SECURITY_BLOCKING_SNAPSHOT_VERSIONS.has(snapshot.runtimeRagmirVersion) + ) +} + function fingerprintTeamFiles(files: TeamSnapshotFile[]): string { return summarizeIndexedCorpus( [...files].sort((left, right) => @@ -694,7 +732,7 @@ function comparisonStatus( sameConfiguration: boolean, sameCorpus: boolean, ): TeamComparisonStatus { - if (!local.ready || !peer.ready) { + if (!isTeamSnapshotOperational(local) || !isTeamSnapshotOperational(peer)) { return "not-ready" } if (!sameConfiguration) { @@ -708,9 +746,14 @@ function comparisonSummary( localOnly: number, peerOnly: number, changed: number, + securityAdvisories: number, ): string { if (status === "synchronized") { - return "Both ready indexes use the same configuration and indexed source bytes." + const advisoryNote = + securityAdvisories > 0 + ? ` ${securityAdvisories} non-blocking security ${securityAdvisories === 1 ? "advisory remains" : "advisories remain"} for separate review.` + : "" + return `Both operational indexes use the same configuration and indexed source bytes.${advisoryNote}` } if (status === "not-ready") { return "At least one index is not ready. Repair readiness before deciding whether corpora match." @@ -728,10 +771,12 @@ function recommendedActions( sameCorpus: boolean, ): string[] { const actions: string[] = [] - if (!local.ready) { + const localReady = isTeamSnapshotOperational(local) + const peerReady = isTeamSnapshotOperational(peer) + if (!localReady) { actions.push(`On ${local.label}, run \`rgr doctor --fix\` and \`rgr audit\` until ready=true.`) } - if (!peer.ready) { + if (!peerReady) { actions.push(`On ${peer.label}, run \`rgr doctor --fix\` and \`rgr audit\` until ready=true.`) } if (configurationDifferences.some((difference) => difference.scope === "runtime")) { @@ -753,7 +798,16 @@ function recommendedActions( ) actions.push("Run `rgr ingest` after source synchronization.") } - if (actions.length > 0) { + const advisoryLabels = new Set() + for (const snapshot of [local, peer]) { + if (snapshot.health.securityWarnings > 0 && !advisoryLabels.has(snapshot.label)) { + advisoryLabels.add(snapshot.label) + actions.push( + `On ${snapshot.label}, review ${snapshot.health.securityWarnings} non-blocking security ${snapshot.health.securityWarnings === 1 ? "advisory" : "advisories"} with \`rgr security-audit\`.`, + ) + } + } + if (!localReady || !peerReady || configurationDifferences.length > 0 || !sameCorpus) { actions.push("Export a fresh snapshot and compare again until status=synchronized.") } return actions