From 788b15afabdf322b2ffac714447958de4a066fa1 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 02:44:00 +0000 Subject: [PATCH 01/21] fix: scope upsertDoc delete to protect branch overlays and main --- src/docs/write.ts | 11 ++++++++--- tests/shared/docs.test.ts | 30 ++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/docs/write.ts b/src/docs/write.ts index d4d710d6..968a0a35 100644 --- a/src/docs/write.ts +++ b/src/docs/write.ts @@ -266,11 +266,16 @@ export async function upsertDoc( // Clear any prior/partial row for this id first, then write exactly one. // The legacy bare-doc_id id is deleted too so pre-scope tables converge // to the namespaced id instead of accumulating a duplicate doc_id row — - // but ONLY within this project: in a shared org table another project - // can legitimately own a legacy row with the same bare doc_id. + // but ONLY within this project AND this scope: in a shared org table + // another project can legitimately own a legacy row with the same bare + // doc_id, and — critically for branch overlays — a SIBLING scope (e.g. + // the canonical `main` row, or another user's branch overlay) for the + // same (project, doc_id) must NOT be deleted when we write our scope. + // Without the scope guard, writing a branch overlay would wipe main. await query( `DELETE FROM "${safe}" WHERE id = '${sqlStr(id)}' ` + - `OR (doc_id = '${sqlStr(input.doc_id)}' AND project = '${sqlStr(input.project ?? "")}')`, + `OR (doc_id = '${sqlStr(input.doc_id)}' AND project = '${sqlStr(input.project ?? "")}' ` + + `AND scope = '${sqlStr(scope)}')`, ); const sql = `INSERT INTO "${safe}" ` + diff --git a/tests/shared/docs.test.ts b/tests/shared/docs.test.ts index 8e67a84f..dbf2cdc7 100644 --- a/tests/shared/docs.test.ts +++ b/tests/shared/docs.test.ts @@ -251,13 +251,11 @@ describe("upsertDoc", () => { expect(calls).toHaveLength(2); // The DELETE clears the namespaced id AND the legacy bare-doc_id row, so // pre-scope tables converge instead of accumulating a duplicate doc_id. - // The legacy clause is constrained to THIS project — another project's - // legacy row with the same bare doc_id must survive in a shared table. - // The second clause sweeps by doc_id+project: it converges BOTH legacy - // bare-id rows AND setDoc's random-UUID rows onto the composite id, - // without ever touching another project's rows. + // The legacy clause is constrained to THIS project AND THIS scope — another + // project's legacy row, or a SIBLING scope (a branch overlay / the canonical + // main row) with the same bare doc_id, must survive in a shared table. expect(calls[0]).toBe( - `DELETE FROM "hivemind_docs" WHERE id = 'p|main|src/a.ts' OR (doc_id = 'src/a.ts' AND project = 'p')`, + `DELETE FROM "hivemind_docs" WHERE id = 'p|main|src/a.ts' OR (doc_id = 'src/a.ts' AND project = 'p' AND scope = 'main')`, ); expect(calls[1]).toMatch(/^INSERT INTO "hivemind_docs"/); // id column is the deterministic composite, NOT a random uuid @@ -265,6 +263,26 @@ describe("upsertDoc", () => { expect(calls[1]).toContain(`'p', 'main', 1, `); // project, scope, always version 1 }); + it("writing a branch overlay scopes the DELETE — never touches the main row", async () => { + // The regression that motivated the scope guard: a branch/user overlay + // (scope = u:|b:) shares (project, doc_id) with the canonical + // main row. Without the scope guard, the legacy convergence clause would + // delete main when writing the overlay, silently destroying the shared doc. + const { calls, query } = mockQuery([() => [], () => []]); + await upsertDoc(query, TBL, { + doc_id: "src/a.ts", path: "/docs/p/a.ts.md", content: "x", project: "p", + scope: "u:alice|b:feature", + }); + // DELETE targets ONLY this overlay's id + same-scope duplicates. The main + // row (scope='main') is not in the predicate, so it survives. + expect(calls[0]).toBe( + `DELETE FROM "hivemind_docs" WHERE id = 'p|u:alice|b:feature|src/a.ts' ` + + `OR (doc_id = 'src/a.ts' AND project = 'p' AND scope = 'u:alice|b:feature')`, + ); + expect(calls[0]).not.toContain("scope = 'main'"); + expect(calls[1]).toContain(`'p', 'u:alice|b:feature', 1, `); // project, overlay scope + }); + it("retry after a timeout re-runs DELETE+INSERT — never forks a second row", async () => { // 1st DELETE ok, INSERT times out; retry: DELETE ok, INSERT ok. const { calls, query } = mockQuery([() => [], timeout, () => [], () => []]); From 32d2a559f90c0809fa5dce0b8b1889366e047912 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 02:46:57 +0000 Subject: [PATCH 02/21] feat: thread scope selector through doc write resolution --- src/docs/read.ts | 25 ++++++++++++++++--------- src/docs/write.ts | 24 +++++++++++++----------- tests/shared/docs.test.ts | 9 +++++++++ 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/docs/read.ts b/src/docs/read.ts index 39bf9de0..483f75b7 100644 --- a/src/docs/read.ts +++ b/src/docs/read.ts @@ -62,17 +62,24 @@ export interface ListDocsOpts { } /** - * Project selector SQL. `project` = STRICT (write paths: never touch another + * Row selector SQL. `project` = STRICT (write paths: never touch another * project's row). `projectOrLegacy` = read paths on shared tables: scope to * one project but keep legacy rows (written before stamping, project='') - * visible everywhere. + * visible everywhere. `scope` = the identity dimension (`main` = canonical, + * `u:|b:` = overlay): when set, resolution is confined to that + * scope so a write/read never crosses into a sibling branch overlay or main. */ -function buildProjectFilter(opts: { project?: string; projectOrLegacy?: string }): string { - if (opts.project !== undefined) return ` AND project = '${sqlStr(opts.project)}'`; - if (opts.projectOrLegacy !== undefined) { - return ` AND (project = '${sqlStr(opts.projectOrLegacy)}' OR project = '')`; +function buildProjectFilter(opts: { project?: string; projectOrLegacy?: string; scope?: string }): string { + const clauses: string[] = []; + if (opts.project !== undefined) { + clauses.push(`project = '${sqlStr(opts.project)}'`); + } else if (opts.projectOrLegacy !== undefined) { + clauses.push(`(project = '${sqlStr(opts.projectOrLegacy)}' OR project = '')`); + } + if (opts.scope !== undefined) { + clauses.push(`scope = '${sqlStr(opts.scope)}'`); } - return ""; + return clauses.length ? ` AND ${clauses.join(" AND ")}` : ""; } const SELECT_COLS = @@ -216,7 +223,7 @@ export async function listDocsByIds( query: QueryFn, tableName: string, docIds: string[], - opts: { project?: string; projectOrLegacy?: string } = {}, + opts: { project?: string; projectOrLegacy?: string; scope?: string } = {}, ): Promise { const ids = [...new Set(docIds.filter((d) => d !== ""))]; if (ids.length === 0) return []; @@ -251,7 +258,7 @@ export async function getDocLatest( query: QueryFn, tableName: string, docId: string, - opts: { project?: string; projectOrLegacy?: string } = {}, + opts: { project?: string; projectOrLegacy?: string; scope?: string } = {}, ): Promise { const safe = sqlIdent(tableName); // Read ALL version rows for this doc through the stability gate, then pick diff --git a/src/docs/write.ts b/src/docs/write.ts index 968a0a35..b94c4bdc 100644 --- a/src/docs/write.ts +++ b/src/docs/write.ts @@ -219,7 +219,7 @@ export async function insertDocResilient( // EVERY attempt including the last — otherwise a final-attempt timeout // whose write landed reports failure and invites a duplicating retry. await sleep(backoff[Math.min(attempt, backoff.length - 1)]); - const landed = await getDocLatest(query, tableName, input.doc_id, { project: input.project }).catch(() => null); + const landed = await getDocLatest(query, tableName, input.doc_id, { project: input.project, scope: input.scope }).catch(() => null); if (landed) return { doc_id: landed.doc_id, version: landed.version }; if (attempt === retries) break; } @@ -311,12 +311,13 @@ export async function editDoc( query: QueryFn, tableName: string, input: EditDocInput, - opts: { project?: string } = {}, + opts: { project?: string; scope?: string } = {}, ): Promise { - // Optional project SELECTOR (distinct from input.project, the value to - // write) — in a shared org table an unscoped read can resolve the same - // doc_id to another project's row. - const previous = await getDocLatest(query, tableName, input.doc_id, { project: opts.project }); + // Optional project + scope SELECTOR (distinct from input.project, the value + // to write) — in a shared org table an unscoped read can resolve the same + // doc_id to another project's row, or (with branch overlays) to a sibling + // scope's row. Passing scope confines the edit to one identity. + const previous = await getDocLatest(query, tableName, input.doc_id, { project: opts.project, scope: opts.scope }); if (!previous) { throw new Error(`Doc not found: ${input.doc_id}`); } @@ -337,11 +338,12 @@ export async function setDoc( query: QueryFn, tableName: string, input: SetDocInput, - opts: { project?: string } = {}, + opts: { project?: string; scope?: string } = {}, ): Promise { - // Project SELECTOR (shared-table safety): without it the bare doc_id can - // resolve to another project's row and this write would version-bump THAT. - const previous = await getDocLatest(query, tableName, input.doc_id, { project: opts.project }); + // Project + scope SELECTOR (shared-table safety): without it the bare doc_id + // can resolve to another project's row — or a sibling branch overlay — and + // this write would version-bump THAT. + const previous = await getDocLatest(query, tableName, input.doc_id, { project: opts.project, scope: opts.scope }); if (!previous) { return insertDoc(query, tableName, { doc_id: input.doc_id, @@ -379,7 +381,7 @@ export async function archiveDoc( query: QueryFn, tableName: string, input: { doc_id: string; agent?: string; plugin_version?: string }, - opts: { project?: string } = {}, + opts: { project?: string; scope?: string } = {}, ): Promise { return editDoc(query, tableName, { doc_id: input.doc_id, diff --git a/tests/shared/docs.test.ts b/tests/shared/docs.test.ts index dbf2cdc7..efc8f4d7 100644 --- a/tests/shared/docs.test.ts +++ b/tests/shared/docs.test.ts @@ -625,6 +625,15 @@ describe("getDocLatest", () => { await getDocLatest(query, TBL, "X", { project: "p2" }); expect(calls[0]).toContain(`doc_id = 'X' AND project = 'p2'`); }); + + it("optional scope selector confines resolution to one identity (branch overlay safety)", async () => { + // With branch overlays, the same (project, doc_id) exists at scope 'main' + // AND at 'u:|b:'. A write-resolution read must pin the scope + // so editing an overlay never resolves (and then version-bumps) the main row. + const { calls, query } = mockQuery([() => []]); + await getDocLatest(query, TBL, "X", { project: "p2", scope: "u:alice|b:feat" }); + expect(calls[0]).toContain(`doc_id = 'X' AND project = 'p2' AND scope = 'u:alice|b:feat'`); + }); }); // ── listDocMeta (light index read — no content) ────────────────────────────── From d2d019f1e618657fd51f4d2615179d955a5c6bb9 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 02:48:55 +0000 Subject: [PATCH 03/21] feat: derive doc branch scope from git checkout --- src/docs/branch-scope.ts | 85 +++++++++++++++++++++++ tests/shared/docs-branch-scope.test.ts | 95 ++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/docs/branch-scope.ts create mode 100644 tests/shared/docs-branch-scope.test.ts diff --git a/src/docs/branch-scope.ts b/src/docs/branch-scope.ts new file mode 100644 index 00000000..9f6c1d22 --- /dev/null +++ b/src/docs/branch-scope.ts @@ -0,0 +1,85 @@ +/** + * Branch identity for doc rows — the value of the `scope` column. + * + * A doc row's `scope` encodes WHICH branch's view it belongs to: + * - `main` — the canonical, public corpus (everyone sees it). + * - `b:` — a shared overlay for a pushed feature branch (everyone + * who is on that branch sees it; others fall back to main). + * + * There is deliberately NO per-user dimension in the cloud scope. Private docs + * (generated from code that is only in a local, unpushed commit) never enter + * the shared table at all — they live in a local store. So the only scopes that + * reach Deeplake are `main` and `b:`, and a pushed branch doc is shared + * by the branch, not owned by a user (two teammates on the same branch push to + * the same origin/, so the doc converges — last write wins). + * + * NB: this `scope` (a branch view) is unrelated to the generation `--scope` + * (`file` | `symbol`), which is a granularity, not an identity. + * + * All git access goes through an injected runner so the logic is pure and unit + * testable — same seam as wiki-refresh (`args.git([...]) => string | null`). + */ + +/** Injected git runner: returns stdout (trimmed by callers) or null on failure. */ +export type GitRunner = (args: string[]) => string | null; + +/** The canonical, public corpus scope. */ +export const MAIN_SCOPE = "main"; + +/** Prefix marking a branch-overlay scope. */ +const BRANCH_PREFIX = "b:"; + +/** The `scope` value for a feature branch overlay. */ +export function branchScope(branch: string): string { + return `${BRANCH_PREFIX}${branch}`; +} + +export type ParsedScope = + | { kind: "main" } + | { kind: "branch"; branch: string }; + +/** Parse a stored `scope` value back into its identity. Unknown -> main. */ +export function parseScope(scope: string | undefined): ParsedScope { + if (scope && scope.startsWith(BRANCH_PREFIX)) { + return { kind: "branch", branch: scope.slice(BRANCH_PREFIX.length) }; + } + return { kind: "main" }; +} + +/** + * Current branch name, or null when git can't answer or HEAD is detached. + * A detached HEAD has no branch identity, so it resolves to the trunk (main). + */ +export function currentBranch(git: GitRunner): string | null { + const out = git(["rev-parse", "--abbrev-ref", "HEAD"])?.trim(); + if (!out || out === "HEAD") return null; // detached / no branch + return out; +} + +/** + * The default branch (trunk) of the repo, from `origin/HEAD`. Falls back to + * `main` when there is no origin or the symbolic ref is unset — the safe + * default (a repo with no remote treats its work as the canonical corpus). + */ +export function trunkBranch(git: GitRunner): string { + // `refs/remotes/origin/HEAD -> refs/remotes/origin/` + const ref = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])?.trim(); + if (ref) { + const slash = ref.lastIndexOf("/"); + const name = slash >= 0 ? ref.slice(slash + 1) : ref; + if (name) return name; + } + return "main"; +} + +/** + * The `scope` value for the current git checkout: `main` when on the trunk (or + * detached / non-git), else `b:`. This is the single place that maps + * "where git HEAD is" onto a doc identity. + */ +export function currentScope(git: GitRunner, trunk?: string): string { + const branch = currentBranch(git); + if (branch === null) return MAIN_SCOPE; + const trunkName = trunk ?? trunkBranch(git); + return branch === trunkName ? MAIN_SCOPE : branchScope(branch); +} diff --git a/tests/shared/docs-branch-scope.test.ts b/tests/shared/docs-branch-scope.test.ts new file mode 100644 index 00000000..f4240c06 --- /dev/null +++ b/tests/shared/docs-branch-scope.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + MAIN_SCOPE, + branchScope, + parseScope, + currentBranch, + trunkBranch, + currentScope, + type GitRunner, +} from "../../src/docs/branch-scope.js"; + +/** Build a git runner from a map of joined-args -> stdout (or null). */ +function fakeGit(responses: Record): GitRunner { + return (args: string[]) => { + const key = args.join(" "); + return key in responses ? responses[key] : null; + }; +} + +describe("branchScope / parseScope", () => { + it("encodes and round-trips a branch overlay scope", () => { + expect(branchScope("feature-x")).toBe("b:feature-x"); + expect(parseScope("b:feature-x")).toEqual({ kind: "branch", branch: "feature-x" }); + }); + + it("parses main and unknown/empty as main", () => { + expect(parseScope("main")).toEqual({ kind: "main" }); + expect(parseScope("")).toEqual({ kind: "main" }); + expect(parseScope(undefined)).toEqual({ kind: "main" }); + }); + + it("preserves branch names that contain slashes", () => { + expect(parseScope(branchScope("feat/auth/login"))).toEqual({ kind: "branch", branch: "feat/auth/login" }); + }); +}); + +describe("currentBranch", () => { + it("returns the branch name on a normal checkout", () => { + expect(currentBranch(fakeGit({ "rev-parse --abbrev-ref HEAD": "feature-x\n" }))).toBe("feature-x"); + }); + + it("returns null on a detached HEAD (no branch identity)", () => { + expect(currentBranch(fakeGit({ "rev-parse --abbrev-ref HEAD": "HEAD" }))).toBeNull(); + }); + + it("returns null when git fails (non-git dir)", () => { + expect(currentBranch(fakeGit({}))).toBeNull(); + }); +}); + +describe("trunkBranch", () => { + it("reads the default branch from origin/HEAD", () => { + expect(trunkBranch(fakeGit({ "symbolic-ref --short refs/remotes/origin/HEAD": "origin/master\n" }))).toBe("master"); + }); + + it("falls back to main when origin/HEAD is unset (no remote)", () => { + expect(trunkBranch(fakeGit({}))).toBe("main"); + }); +}); + +describe("currentScope", () => { + it("is main on the trunk branch", () => { + const git = fakeGit({ + "rev-parse --abbrev-ref HEAD": "main", + "symbolic-ref --short refs/remotes/origin/HEAD": "origin/main", + }); + expect(currentScope(git)).toBe(MAIN_SCOPE); + }); + + it("is a branch overlay off the trunk", () => { + const git = fakeGit({ + "rev-parse --abbrev-ref HEAD": "feature-x", + "symbolic-ref --short refs/remotes/origin/HEAD": "origin/main", + }); + expect(currentScope(git)).toBe("b:feature-x"); + }); + + it("treats the repo's real trunk (master) as main even when named differently", () => { + const git = fakeGit({ + "rev-parse --abbrev-ref HEAD": "master", + "symbolic-ref --short refs/remotes/origin/HEAD": "origin/master", + }); + expect(currentScope(git)).toBe(MAIN_SCOPE); + }); + + it("is main on a detached HEAD", () => { + expect(currentScope(fakeGit({ "rev-parse --abbrev-ref HEAD": "HEAD" }))).toBe(MAIN_SCOPE); + }); + + it("accepts an explicit trunk to avoid a second git call", () => { + const git = fakeGit({ "rev-parse --abbrev-ref HEAD": "dev" }); + expect(currentScope(git, "dev")).toBe(MAIN_SCOPE); + expect(currentScope(git, "main")).toBe("b:dev"); + }); +}); From 564252fb5a2fbb933d589a75415a0a0de9ebba90 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 02:54:25 +0000 Subject: [PATCH 04/21] feat: add scope-precedence resolver for branch-aware reads --- src/docs/branch-scope.ts | 31 +++++++++++++++++++ tests/shared/docs-branch-scope.test.ts | 42 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/docs/branch-scope.ts b/src/docs/branch-scope.ts index 9f6c1d22..817fbc03 100644 --- a/src/docs/branch-scope.ts +++ b/src/docs/branch-scope.ts @@ -83,3 +83,34 @@ export function currentScope(git: GitRunner, trunk?: string): string { const trunkName = trunk ?? trunkBranch(git); return branch === trunkName ? MAIN_SCOPE : branchScope(branch); } + +/** + * Pick the row a reader on `readerScope` should see, from all candidate rows + * for ONE doc_id across scopes. Precedence: + * reader's own branch overlay > main > (any other branch: ignored) + * Within the winning scope, the highest `version` wins. + * + * This is the read-side isolation guarantee: a reader on `b:feature` sees the + * feature overlay if it exists, else falls back to main, and NEVER another + * branch's overlay; a reader on `main` sees only `main` rows. Rows missing a + * `scope` (legacy / un-stamped) count as `main` — so pre-branch corpora keep + * resolving unchanged. + */ +export function pickByScopePrecedence( + rows: readonly T[], + readerScope: string, +): T | null { + let best: T | null = null; + let bestRank = -1; + for (const r of rows) { + const s = r.scope ?? MAIN_SCOPE; + // 2 = the reader's own scope, 1 = main fallback, 0 = a foreign branch. + const rank = s === readerScope ? 2 : s === MAIN_SCOPE ? 1 : 0; + if (rank === 0) continue; // never surface another branch's overlay + if (best === null || rank > bestRank || (rank === bestRank && r.version > best.version)) { + best = r; + bestRank = rank; + } + } + return best; +} diff --git a/tests/shared/docs-branch-scope.test.ts b/tests/shared/docs-branch-scope.test.ts index f4240c06..9f777291 100644 --- a/tests/shared/docs-branch-scope.test.ts +++ b/tests/shared/docs-branch-scope.test.ts @@ -6,6 +6,7 @@ import { currentBranch, trunkBranch, currentScope, + pickByScopePrecedence, type GitRunner, } from "../../src/docs/branch-scope.js"; @@ -93,3 +94,44 @@ describe("currentScope", () => { expect(currentScope(git, "main")).toBe("b:dev"); }); }); + +describe("pickByScopePrecedence", () => { + const row = (scope: string | undefined, version: number, tag: string) => ({ scope, version, tag }); + + it("prefers the reader's branch overlay over main", () => { + const rows = [row("main", 5, "main"), row("b:feat", 2, "overlay")]; + expect(pickByScopePrecedence(rows, "b:feat")?.tag).toBe("overlay"); + }); + + it("falls back to main when the reader's branch has no overlay", () => { + const rows = [row("main", 5, "main"), row("b:other", 9, "other")]; + expect(pickByScopePrecedence(rows, "b:feat")?.tag).toBe("main"); + }); + + it("never surfaces another branch's overlay to a main reader", () => { + const rows = [row("main", 3, "main"), row("b:feat", 99, "overlay")]; + expect(pickByScopePrecedence(rows, "main")?.tag).toBe("main"); + }); + + it("never surfaces a foreign branch overlay to a different branch reader", () => { + const rows = [row("b:alpha", 4, "alpha"), row("b:beta", 7, "beta")]; + // reader on beta sees beta; reader on gamma sees nothing (no main, no gamma) + expect(pickByScopePrecedence(rows, "b:beta")?.tag).toBe("beta"); + expect(pickByScopePrecedence(rows, "b:gamma")).toBeNull(); + }); + + it("within the winning scope, the highest version wins", () => { + const rows = [row("b:feat", 2, "old"), row("b:feat", 4, "new"), row("main", 100, "main")]; + expect(pickByScopePrecedence(rows, "b:feat")?.tag).toBe("new"); + }); + + it("treats a missing scope as main (legacy rows resolve unchanged)", () => { + const rows = [row(undefined, 1, "legacy")]; + expect(pickByScopePrecedence(rows, "main")?.tag).toBe("legacy"); + expect(pickByScopePrecedence(rows, "b:feat")?.tag).toBe("legacy"); // falls back to main + }); + + it("returns null on empty input", () => { + expect(pickByScopePrecedence([], "main")).toBeNull(); + }); +}); From 1093499462477be308e3adf5f26690df0f405ab0 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 02:58:17 +0000 Subject: [PATCH 05/21] feat: resolve docs by reader branch scope on the read path --- src/docs/candidates.ts | 2 +- src/docs/read.ts | 25 ++++++++++++++++++++- src/docs/vfs-handler.ts | 9 +++++++- src/hooks/pre-tool-use.ts | 5 ++++- tests/shared/docs.test.ts | 47 +++++++++++++++++++++++++++++++++++---- 5 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/docs/candidates.ts b/src/docs/candidates.ts index cd58a107..b8349bd8 100644 --- a/src/docs/candidates.ts +++ b/src/docs/candidates.ts @@ -21,7 +21,7 @@ import type { GraphSnapshot } from "../graph/types.js"; /** Run `git ` in `cwd`; returns stdout, or null on any failure. */ export type GitRunner = (args: string[]) => string | null; -function defaultGit(cwd: string): GitRunner { +export function defaultGit(cwd: string): GitRunner { return (args) => { try { return execFileSync("git", ["-C", cwd, ...args], { diff --git a/src/docs/read.ts b/src/docs/read.ts index 483f75b7..458865a6 100644 --- a/src/docs/read.ts +++ b/src/docs/read.ts @@ -15,6 +15,7 @@ import { sqlIdent, sqlLike, sqlStr } from "../utils/sql.js"; import { stableUnionRows } from "./stable-read.js"; +import { pickByScopePrecedence } from "./branch-scope.js"; export type QueryFn = (sql: string) => Promise>>; @@ -258,7 +259,7 @@ export async function getDocLatest( query: QueryFn, tableName: string, docId: string, - opts: { project?: string; projectOrLegacy?: string; scope?: string } = {}, + opts: { project?: string; projectOrLegacy?: string; scope?: string; readerScope?: string } = {}, ): Promise { const safe = sqlIdent(tableName); // Read ALL version rows for this doc through the stability gate, then pick @@ -268,6 +269,28 @@ export async function getDocLatest( // the max guarantees we never resolve to a stale version or miss the doc. // Optional project selector — see listDocsByIds. const projFilter = buildProjectFilter(opts); + + // Read-side branch resolution: when a `readerScope` is given, DON'T filter by + // scope in SQL — fetch every scope's rows for this doc_id and let + // pickByScopePrecedence choose (reader's overlay > main > foreign: hidden). + // The `scope` column is selected only in this mode (generic reads stay + // schema-heal-safe); a table missing the column degrades gracefully — the + // catch retries without it, so every row reads as `main`. + if (opts.readerScope !== undefined) { + const base = `SELECT ${SELECT_COLS}, scope FROM "${safe}" WHERE doc_id = '${sqlStr(docId)}'${projFilter}`; + let raw: Array>; + try { + raw = await stableUnionRows(query, base); + } catch { + raw = await stableUnionRows( + query, + `SELECT ${SELECT_COLS} FROM "${safe}" WHERE doc_id = '${sqlStr(docId)}'${projFilter}`, + ); + } + const rows = raw.map(normalize).filter((r): r is DocRow => r !== null); + return pickByScopePrecedence(rows, opts.readerScope); + } + const raw = await stableUnionRows( query, `SELECT ${SELECT_COLS} FROM "${safe}" WHERE doc_id = '${sqlStr(docId)}'${projFilter}`, diff --git a/src/docs/vfs-handler.ts b/src/docs/vfs-handler.ts index 19c074c1..de1b2182 100644 --- a/src/docs/vfs-handler.ts +++ b/src/docs/vfs-handler.ts @@ -34,6 +34,13 @@ export interface DocsVfsOptions { embedQuery?: DocEmbedder; /** Project scope for shared org tables (legacy '' rows always included). */ project?: string; + /** + * The reader's branch view (`main` or `b:`), from their git checkout. + * When set, a leaf doc resolves with branch precedence: the reader's own + * overlay if present, else main, never another branch's overlay. Absent → + * legacy behavior (latest version, scope-agnostic). + */ + readerScope?: string; } /** Resolve a `/docs/` subpath to rendered text from the docs table. */ @@ -101,7 +108,7 @@ export async function handleDocsVfs( // Leaf: ".md" → doc for that file. const docId = path.slice(0, -".md".length); - const row = await getDocLatest(query, tableName, docId, { projectOrLegacy: opts.project }); + const row = await getDocLatest(query, tableName, docId, { projectOrLegacy: opts.project, readerScope: opts.readerScope }); if (!row) return { kind: "not-found", message: `${subpath}: No such file or directory` }; const header = `# ${row.doc_id}\n` + diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index 71d912e8..a8653a34 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -16,6 +16,8 @@ import { type GrepParams, parseBashGrep, handleGrepDirect } from "./grep-direct. import { handleGraphVfs } from "../graph/vfs-handler.js"; import { handleDocsVfs } from "../docs/vfs-handler.js"; import { makeQueryEmbedder } from "../docs/embed.js"; +import { defaultGit } from "../docs/candidates.js"; +import { currentScope } from "../docs/branch-scope.js"; import { executeCompiledBashCommand } from "./bash-command-compiler.js"; import { findVirtualPaths, @@ -460,7 +462,8 @@ export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreT if (virtualPath && (virtualPath === "/docs" || virtualPath.startsWith("/docs/")) && !virtualPath.endsWith("/")) { const subpath = virtualPath === "/docs" ? "" : virtualPath.slice("/docs/".length); logFn(`docs vfs: ${subpath || "(root)"}`); - const result = await handleDocsVfsFn(subpath, (sql) => api.query(sql), config.docsTableName, { embedQuery: makeQueryEmbedder(), project: deriveProjectKey(input.cwd ?? process.cwd()).key }); + const docsCwd = input.cwd ?? process.cwd(); + const result = await handleDocsVfsFn(subpath, (sql) => api.query(sql), config.docsTableName, { embedQuery: makeQueryEmbedder(), project: deriveProjectKey(docsCwd).key, readerScope: currentScope(defaultGit(docsCwd)) }); const body = result.kind === "ok" ? result.body : `(${result.kind}) ${result.message}`; if (input.tool_name === "Read") { const file_path = writeReadCacheFileFn(input.session_id, virtualPath, body); diff --git a/tests/shared/docs.test.ts b/tests/shared/docs.test.ts index efc8f4d7..b4d9078d 100644 --- a/tests/shared/docs.test.ts +++ b/tests/shared/docs.test.ts @@ -628,11 +628,50 @@ describe("getDocLatest", () => { it("optional scope selector confines resolution to one identity (branch overlay safety)", async () => { // With branch overlays, the same (project, doc_id) exists at scope 'main' - // AND at 'u:|b:'. A write-resolution read must pin the scope - // so editing an overlay never resolves (and then version-bumps) the main row. + // AND at 'b:'. A write-resolution read must pin the scope so editing + // an overlay never resolves (and then version-bumps) the main row. const { calls, query } = mockQuery([() => []]); - await getDocLatest(query, TBL, "X", { project: "p2", scope: "u:alice|b:feat" }); - expect(calls[0]).toContain(`doc_id = 'X' AND project = 'p2' AND scope = 'u:alice|b:feat'`); + await getDocLatest(query, TBL, "X", { project: "p2", scope: "b:feat" }); + expect(calls[0]).toContain(`doc_id = 'X' AND project = 'p2' AND scope = 'b:feat'`); + }); + + it("readerScope selects the scope column and returns the reader's overlay over main", async () => { + // Read-side precedence: the SELECT must include `scope` (not filter by it — + // all candidates are fetched), and the reader on b:feat gets the overlay + // even though main has a higher version. + const { calls, query } = mockQuery([() => [ + fakeRow({ id: "m", doc_id: "X", version: 7, content: "MAIN", scope: "main" }), + fakeRow({ id: "o", doc_id: "X", version: 1, content: "OVERLAY", scope: "b:feat" }), + ]]); + const row = await getDocLatest(query, TBL, "X", { projectOrLegacy: "p", readerScope: "b:feat" }); + expect(calls[0]).toContain(", scope FROM"); // scope column is selected + expect(calls[0]).not.toContain("AND scope ="); // NOT filtered — all scopes fetched + expect(row?.content).toBe("OVERLAY"); + }); + + it("readerScope falls back to main when the reader's branch has no overlay", async () => { + const { query } = mockQuery([() => [ + fakeRow({ id: "m", doc_id: "X", version: 3, content: "MAIN", scope: "main" }), + fakeRow({ id: "o", doc_id: "X", version: 9, content: "OTHER", scope: "b:other" }), + ]]); + const row = await getDocLatest(query, TBL, "X", { projectOrLegacy: "p", readerScope: "b:feat" }); + expect(row?.content).toBe("MAIN"); // never another branch's overlay + }); + + it("readerScope degrades to a scope-less read when the column is missing", async () => { + // First SELECT (with scope) throws 'column does not exist'; the catch retries + // the scope-less SELECT, and every row reads as main. + let call = 0; + const calls: string[] = []; + const query = vi.fn(async (sql: string) => { + calls.push(sql); + if (call++ === 0 && sql.includes(", scope FROM")) throw new Error(`column "scope" does not exist`); + return [fakeRow({ doc_id: "X", version: 2, content: "LEGACY" })]; + }); + const row = await getDocLatest(query, TBL, "X", { projectOrLegacy: "p", readerScope: "b:feat" }); + expect(row?.content).toBe("LEGACY"); + expect(calls[0]).toContain(", scope FROM"); + expect(calls[1]).not.toContain(", scope FROM"); }); }); From 49f03b74ff34ce81ad175cd3ba26e0e29e1fde15 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 03:01:03 +0000 Subject: [PATCH 06/21] feat: stamp doc writes with the current git branch scope --- src/commands/docs.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/commands/docs.ts b/src/commands/docs.ts index 909c98d4..44865235 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -62,6 +62,8 @@ import { repoDir } from "../graph/snapshot.js"; import { execFileSync } from "node:child_process"; import { hostname, userInfo } from "node:os"; import type { GitRunner } from "../docs/candidates.js"; +import { defaultGit } from "../docs/candidates.js"; +import { currentScope } from "../docs/branch-scope.js"; import { deriveProjectKey } from "../utils/repo-identity.js"; import { makeDocEmbedder } from "../docs/embed.js"; import { backfillDocEmbeddings } from "../docs/backfill.js"; @@ -668,6 +670,10 @@ export async function runDocsCommand(args: string[]): Promise { if (!process.env.HIVEMIND_QUERY_TIMEOUT_MS) process.env.HIVEMIND_QUERY_TIMEOUT_MS = "30000"; const report = await runWikiRefreshCycle({ query, tableName, snap, repoRoot: cwd, project, + // Branch identity: on the trunk this is `main` (canonical corpus); on a + // feature branch it is `b:`, so the refresh reads/writes/leases + // its own overlay and never touches main. + scope: currentScope(git), run: makeHostRunPrompt(), runPage: makeHostPageRunPrompt(), git, owner: `${userInfo().username}@${hostname()}:${process.pid}`, force, @@ -858,6 +864,9 @@ export async function runDocsCommand(args: string[]): Promise { if (!process.env.HIVEMIND_QUERY_TIMEOUT_MS) process.env.HIVEMIND_QUERY_TIMEOUT_MS = "30000"; const report = await generateWikiPages({ query, tableName, snap, repoRoot: cwd, project, + // Branch identity for the written rows: `main` on the trunk, `b:` + // on a feature branch (a branch-scoped overlay, invisible on main). + scope: currentScope(defaultGit(cwd)), include, exclude, existing, force, limit, concurrency, run: makeHostRunPrompt(), runPage: makeHostPageRunPrompt(), From d51e457aea505e6810440bbb635719e581aad05b Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 03:57:36 +0000 Subject: [PATCH 07/21] feat: create branch overlays copy-on-write on wiki refresh --- src/docs/read.ts | Bin 14709 -> 16213 bytes src/docs/wiki-refresh.ts | 6 +++- src/docs/wiki-update.ts | 47 +++++++++++++++++++++----- tests/shared/docs-wiki-update.test.ts | 21 ++++++++++++ tests/shared/docs.test.ts | 20 +++++++++++ 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/docs/read.ts b/src/docs/read.ts index 458865a6e52f20c6bd6107f6f7f31655d4ee2906..d4d4542ced411c852fdc9939d7258654d95c91ce 100644 GIT binary patch delta 1240 zcmZ8hO-~d-5S5S^&~P9GqX)=CiO%5caxor2F-k}z!Ki`A0SMDOQ!p*V^f*1RY{-fy ze}H4+uka(;cr@`Zc<>MKAG{i?yLT6454%Zq)vH&pUhSXa_uFsY@W~YbGgwOq1&Nr7 zI~t8bWKn`cNlkd+zK~f6RUmB4J#bG8p zmlqUKOj$(m`mI#86`;#OX1ZhwP&>#|Zi-Ztq|4eN6ztCC}Utny^k>({j0#YO3RLLsM4b^!*2< zv@4=6oQ_mZHL;s1UCQVpzXm1ZP^zMCd(U1$_0k{jObrhX1+%jVZE*;xS+ta*l6cZA zZGp)+IciSa>TB?dwHKCOB+4i;fRSQ#U@>AmM72?yd^X>YM=I4E?LI$3IDNdjw6e6g zw!Qd#Wwjl+mk@I}7dGW7l_*qG&!PG}I5}cMWzoVh|Dy6oZ%qJi4p^MP1o#uWdQ2ai zmQ^~sbRY0dSsRusJSqy`0YL7+n{uRl9)}x&Urn|Jzj_Y58o51FwMXv__24?4(Z2(m zEZp)1eXy@$3a~49e+BI7+<5FQ-f@hl^|bho33Jtj?xu6~T&xV*8o;Q8~N zFJr$3PK+KsIjqDigeywfoxH%a+WIb)9#3JlHOiY>ljXyF*Yg9Ncswo>H%rFPvdt7P8|6E#CG`LJ;!Y$ur0UDVV)7}?YK9AlYwW* zWwiICwQ5e~MgQb2^#Cd_b=|q$CQKQ2PtACCK8L{*+QYA#LDPOsP PPCE>`JLr7B_VeaHGue|# delta 50 zcmV-20L}l^e)V#&C<(K<3j7C?&m%jN`70ih6*K}VJF`3${sFTr7|sEc$1E7LqZ%s# Iv!WccA}iw*Gynhq diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index 9091d83a..c158103b 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -280,7 +280,10 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise(); - for (const d of await listDocs(args.query, args.tableName, { project: args.project, status: "active", limit: 100000 })) { + // Read the branch's VIEW: on a feature branch `readerScope` resolves each page + // to its own overlay where one exists, and to main as the base everywhere + // else — so a first patch on the branch copies-on-write from main. + for (const d of await listDocs(args.query, args.tableName, { project: args.project, status: "active", limit: 100000, readerScope: scope })) { if (d.doc_id.startsWith(WIKI_DOC_PREFIX)) pages.set(d.doc_id, d); } @@ -329,6 +332,7 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise`). When it differs + * from the resolved `page`'s scope, the patched content is a copy-on-write + * overlay for this branch: it is CREATED at the target scope from the (main) + * base, never overwriting main. When it matches, the row is patched in place. + * Default `main` — legacy in-place behavior. + */ + scope?: string; } export type WikiUpdateOutcome = @@ -177,19 +185,42 @@ export async function updateWikiPage(args: WikiUpdateArgs): Promise { expect(calls.filter((c) => /^UPDATE/i.test(c))).toHaveLength(1); }); + it("on a branch, patches a main-based page as a copy-on-write overlay (never UPDATEs main)", async () => { + // page resolved to scope 'main' (the base), but we write to 'b:feat'. The + // write must CREATE the overlay via upsert (DELETE+INSERT at the overlay id), + // and must not UPDATE the main row. + const p = freshPage("## Purpose\nfoo returns 0."); + const { calls, query } = mockQuery([]); + const out = await updateWikiPage({ + query, tableName: "hivemind_docs", page: p, scope: "b:feat", pageKey: "pkg/core", + files: FILES, snap: SNAP(), repoRoot: dir, diff: "- return 0\n+ return 1", + run: async () => "## Purpose\nfoo returns 1.", escalation: noEscalation, + }); + expect(out.action).toBe("patched"); + expect(calls.some((c) => /^UPDATE/i.test(c))).toBe(false); // main untouched + const insert = calls.find((c) => /^INSERT/i.test(c))!; + expect(insert).toContain("'p|b:feat|wiki/pkg/core'"); // overlay row id + expect(insert).toContain("foo returns 1."); + const del = calls.find((c) => /^DELETE/i.test(c))!; + expect(del).toContain("scope = 'b:feat'"); // delete confined to the overlay scope + expect(del).not.toContain("scope = 'main'"); // main row is safe + }); + it("strips a chatty preamble before the first heading — caught live in the tick e2e", async () => { const p = freshPage("## Purpose\nfoo returns 0."); const { calls, query } = mockQuery([[rowFor(p)]]); diff --git a/tests/shared/docs.test.ts b/tests/shared/docs.test.ts index b4d9078d..33a3441c 100644 --- a/tests/shared/docs.test.ts +++ b/tests/shared/docs.test.ts @@ -514,6 +514,26 @@ describe("listDocs", () => { expect(rows.map(r => r.doc_id).sort()).toEqual(["A", "B"]); }); + it("readerScope resolves each doc to the reader's overlay, else main (branch view)", async () => { + const { calls, query } = mockQuery([ + () => [ + // page A: main + a b:feat overlay -> reader on b:feat sees the overlay + fakeRow({ id: "am", doc_id: "A", version: 9, content: "A main", scope: "main" }), + fakeRow({ id: "ao", doc_id: "A", version: 1, content: "A overlay", scope: "b:feat" }), + // page B: only main -> falls back to main + fakeRow({ id: "bm", doc_id: "B", version: 1, content: "B main", scope: "main" }), + // page C: only a FOREIGN branch overlay -> hidden (no row surfaces) + fakeRow({ id: "co", doc_id: "C", version: 1, content: "C other", scope: "b:other" }), + ], + ]); + const rows = await listDocs(query, TBL, { readerScope: "b:feat" }); + const byId = new Map(rows.map(r => [r.doc_id, r.content])); + expect(byId.get("A")).toBe("A overlay"); + expect(byId.get("B")).toBe("B main"); + expect(byId.has("C")).toBe(false); // foreign overlay never surfaces + expect(calls[0]).toContain(", scope FROM"); // scope column selected in this mode + }); + it("union order cannot resurrect a stale version: v1 seen FIRST, v2 still wins", async () => { // stableUnionRows returns first-seen order across re-reads — the SQL // ORDER BY does not survive it. The latest pick must be by comparison. From 32a10b044cec1155934e46565280df2880c65d31 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 05:22:39 +0000 Subject: [PATCH 08/21] feat: seed a branch refresh window from the trunk merge-base --- src/docs/wiki-refresh.ts | 27 ++++++++++++++----- tests/shared/docs-wiki-refresh.test.ts | 36 ++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index c158103b..adfe2431 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -28,6 +28,7 @@ import { join } from "node:path"; import { commitRefresh, readRefreshMeta, releaseClaim, tryClaimTurn } from "./meta.js"; import { gateDocEdit } from "./gate.js"; import { buildUpdatePrompt, updateWikiPage, DEFAULT_WIKI_MAX_CHANGED_LINES, NO_CHANGE } from "./wiki-update.js"; +import { parseScope, trunkBranch } from "./branch-scope.js"; import { appendFilesIndex, generateWikiPages, @@ -251,15 +252,29 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise | null = null; - if (lastSha !== "") { - const out = args.git(["diff", "--name-only", `${lastSha}..HEAD`]); + if (baseSha !== "") { + const out = args.git(["diff", "--name-only", `${baseSha}..HEAD`]); if (out !== null) { changed = new Set(out.split("\n").map((l) => l.trim()).filter(Boolean)); } else { - log(`diff ${lastSha}..HEAD unavailable — full-candidate cycle`); + log(`diff ${baseSha}..HEAD unavailable — full-candidate cycle`); } } else { log("first refresh cycle — full-candidate cycle"); @@ -318,7 +333,7 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise changed.has(f)); if (!touched && !membershipChanged) continue; - const diff = lastSha === "" ? null : args.git(["diff", `${lastSha}..HEAD`, "--", ...group.files]); + const diff = baseSha === "" ? null : args.git(["diff", `${baseSha}..HEAD`, "--", ...group.files]); if (diff === null && !membershipChanged) { // No usable diff to patch from → regenerate rather than guess. const res = await regenerate(group); diff --git a/tests/shared/docs-wiki-refresh.test.ts b/tests/shared/docs-wiki-refresh.test.ts index 25678a5e..c8e4d308 100644 --- a/tests/shared/docs-wiki-refresh.test.ts +++ b/tests/shared/docs-wiki-refresh.test.ts @@ -33,14 +33,14 @@ function snap(nodes: GraphNode[]): GraphSnapshot { * the modules under test. SELECTs are answered from state; DELETE/INSERT and * UPDATE mutate the meta row so lease semantics run for real. */ -function makeBackend(opts: { meta?: Record | null; metaUpdatedAt?: string; pages?: Array> }) { +function makeBackend(opts: { meta?: Record | null; metaUpdatedAt?: string; pages?: Array>; scope?: string }) { const state = { meta: opts.meta === undefined ? null : opts.meta, metaUpdatedAt: opts.metaUpdatedAt ?? "2026-07-08T00:00:00.000Z", pages: opts.pages ?? [], }; const calls: string[] = []; - const metaId = docRowId(P, "main", "_meta"); + const metaId = docRowId(P, opts.scope ?? "main", "_meta"); const query = vi.fn(async (sql: string) => { calls.push(sql); const s = sql.trim(); @@ -108,6 +108,38 @@ describe("runWikiRefreshCycle", () => { ...extra, }); + it("branch first cycle seeds the window from the merge-base with trunk (not full-candidate)", async () => { + const backend = makeBackend({ + scope: "b:feat", + meta: { last_refresh_sha: "", claimed_by: null, claimed_at: null, patch_counts: {} }, // fresh branch cursor + pages: [ + pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo."), + pageRow("pkg/io", ["pkg/io/b.ts"], "## Purpose\nbar."), + ], + }); + const MB = "aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee"; + const gitCalls: string[] = []; + const git: GitRunner = (args) => { + gitCalls.push(args.join(" ")); + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "merge-base") return `${MB}\n`; + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; // only core changed vs MB + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + const report = await runWikiRefreshCycle( + baseArgs(backend, git, { scope: "b:feat", force: true, run: async () => "## Purpose\nfoo v2." }), + ); + // The window is seeded from the merge-base, not treated as full-candidate. + expect(gitCalls).toContain("merge-base HEAD origin/main"); + expect(gitCalls).toContain(`diff --name-only ${MB}..HEAD`); + // Only the branch-changed page is a candidate; the untouched page is skipped free. + const acted = report.outcomes.map((o) => o.doc_id); + expect(acted).toContain("wiki/pkg/core"); + expect(acted).not.toContain("wiki/pkg/io"); + }); + it("HEAD == last_refresh_sha → up-to-date, no lease taken, no LLM", async () => { const backend = makeBackend({ meta: { last_refresh_sha: HEAD, claimed_by: null, claimed_at: null, patch_counts: {} } }); const run = vi.fn(async () => "NO_CHANGE"); From d93c2cd758e45419d309ae6dcceb1428e5ecd17d Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 05:33:47 +0000 Subject: [PATCH 09/21] feat: add per-page source fingerprint from git blob shas --- src/docs/fingerprint.ts | 87 +++++++++++++++++++++++++++ tests/shared/docs-fingerprint.test.ts | 83 +++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/docs/fingerprint.ts create mode 100644 tests/shared/docs-fingerprint.test.ts diff --git a/src/docs/fingerprint.ts b/src/docs/fingerprint.ts new file mode 100644 index 00000000..c3a266e7 --- /dev/null +++ b/src/docs/fingerprint.ts @@ -0,0 +1,87 @@ +/** + * Per-page source fingerprint = the git blob-sha of each of a page's member + * files, as of the committed tree (HEAD). This is the objective, content-defined + * freshness signal: + * - git already content-addresses every file, so the blob-sha is a pure + * function of the file's bytes — no reading/hashing here, we ask git; + * - a page is stale iff its stored fingerprint differs from the current one + * (a member file changed, joined, or left); + * - path-independent: a rebase/merge/branch that lands the SAME bytes yields + * the SAME fingerprint, so freshness never chases the commit graph. + * + * It is stored per doc row (`source_fp`, a JSON `{file: blob-sha}` map) and drives + * freshness, the overlay-divergence decision, the origin publish gate, and the + * merge-promotion match. + * + * NB: fingerprints reflect COMMITTED content (`git ls-tree HEAD`), not the dirty + * working tree — a doc is never fresher than a pushable commit. + */ + +import type { GitRunner } from "./branch-scope.js"; + +/** Map of `file path -> git blob-sha` for a page's member files. */ +export type SourceFingerprint = Record; + +/** + * Blob-sha per file at HEAD via a single `git ls-tree`. Files git can't resolve + * (deleted, untracked, or no git) are simply absent from the map — so a page + * whose file was deleted reads as "changed". Returns `{}` when git is + * unavailable, which downstream treats as "unknown → always stale". + */ +export function computeFingerprint(git: GitRunner, files: readonly string[]): SourceFingerprint { + const fp: SourceFingerprint = {}; + if (files.length === 0) return fp; + const out = git(["ls-tree", "HEAD", "--", ...files]); + if (out === null) return fp; + for (const line of out.split("\n")) { + const tab = line.indexOf("\t"); + if (tab < 0) continue; + // ` blob \t` + const meta = line.slice(0, tab).trim().split(/\s+/); + const path = line.slice(tab + 1); + if (meta.length >= 3 && meta[1] === "blob" && path) fp[path] = meta[2]; + } + return fp; +} + +/** Serialize a fingerprint for the `source_fp` TEXT column (stable key order). */ +export function serializeFingerprint(fp: SourceFingerprint): string { + const sorted: SourceFingerprint = {}; + for (const k of Object.keys(fp).sort()) sorted[k] = fp[k]; + return JSON.stringify(sorted); +} + +/** Parse the `source_fp` cell; garbage degrades to `{}` (→ treated as stale). */ +export function parseFingerprint(raw: unknown): SourceFingerprint { + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const out: SourceFingerprint = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === "string") out[k] = v; + } + return out; + } + if (typeof raw === "string" && raw.trim() !== "") { + try { + return parseFingerprint(JSON.parse(raw)); + } catch { + return {}; + } + } + return {}; +} + +/** + * Files whose blob differs between two fingerprints — including files that + * appear in only one (added/removed from the page). Sorted for determinism. + */ +export function changedFiles(a: SourceFingerprint, b: SourceFingerprint): string[] { + const changed: string[] = []; + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const k of keys) if (a[k] !== b[k]) changed.push(k); + return changed.sort(); +} + +/** A page is fresh iff its stored fingerprint matches the current one exactly. */ +export function isFresh(stored: SourceFingerprint, current: SourceFingerprint): boolean { + return changedFiles(stored, current).length === 0; +} diff --git a/tests/shared/docs-fingerprint.test.ts b/tests/shared/docs-fingerprint.test.ts new file mode 100644 index 00000000..679aadcc --- /dev/null +++ b/tests/shared/docs-fingerprint.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + computeFingerprint, + serializeFingerprint, + parseFingerprint, + changedFiles, + isFresh, +} from "../../src/docs/fingerprint.js"; +import type { GitRunner } from "../../src/docs/branch-scope.js"; + +const gitLsTree = (lines: string | null): GitRunner => (args) => + args[0] === "ls-tree" ? lines : null; + +describe("computeFingerprint", () => { + it("parses ` blob \\t` lines into a file->sha map", () => { + const out = + "100644 blob aaaa\tpkg/core/a.ts\n" + + "100644 blob bbbb\tpkg/core/b.ts\n"; + expect(computeFingerprint(gitLsTree(out), ["pkg/core/a.ts", "pkg/core/b.ts"])).toEqual({ + "pkg/core/a.ts": "aaaa", + "pkg/core/b.ts": "bbbb", + }); + }); + + it("omits files git can't resolve (deleted/untracked) — they read as changed", () => { + const out = "100644 blob aaaa\tpkg/core/a.ts\n"; // b.ts absent + expect(computeFingerprint(gitLsTree(out), ["pkg/core/a.ts", "pkg/core/b.ts"])).toEqual({ + "pkg/core/a.ts": "aaaa", + }); + }); + + it("ignores non-blob entries (trees/submodules)", () => { + const out = "040000 tree cccc\tpkg/sub\n100644 blob aaaa\tpkg/a.ts\n"; + expect(computeFingerprint(gitLsTree(out), ["pkg/sub", "pkg/a.ts"])).toEqual({ "pkg/a.ts": "aaaa" }); + }); + + it("empty file list makes no git call and returns {}", () => { + let called = false; + const git: GitRunner = () => { called = true; return "x"; }; + expect(computeFingerprint(git, [])).toEqual({}); + expect(called).toBe(false); + }); + + it("no git (null) → empty map (treated as unknown/stale downstream)", () => { + expect(computeFingerprint(gitLsTree(null), ["a.ts"])).toEqual({}); + }); + + it("preserves paths that contain spaces after the tab", () => { + const out = "100644 blob aaaa\tpkg/with space.ts\n"; + expect(computeFingerprint(gitLsTree(out), ["pkg/with space.ts"])).toEqual({ "pkg/with space.ts": "aaaa" }); + }); +}); + +describe("serialize/parse round-trip", () => { + it("serializes with sorted keys and round-trips", () => { + const fp = { "z.ts": "2", "a.ts": "1" }; + const s = serializeFingerprint(fp); + expect(s).toBe(`{"a.ts":"1","z.ts":"2"}`); // sorted + expect(parseFingerprint(s)).toEqual(fp); + }); + it("parses an already-object cell and degrades garbage to {}", () => { + expect(parseFingerprint({ "a.ts": "1", bad: 5 })).toEqual({ "a.ts": "1" }); + expect(parseFingerprint("not json")).toEqual({}); + expect(parseFingerprint("")).toEqual({}); + expect(parseFingerprint(null)).toEqual({}); + expect(parseFingerprint(["a"])).toEqual({}); + }); +}); + +describe("changedFiles / isFresh", () => { + it("detects a changed blob", () => { + expect(changedFiles({ "a.ts": "1" }, { "a.ts": "2" })).toEqual(["a.ts"]); + expect(isFresh({ "a.ts": "1" }, { "a.ts": "2" })).toBe(false); + }); + it("detects added and removed files (membership drift)", () => { + expect(changedFiles({ "a.ts": "1" }, { "a.ts": "1", "b.ts": "9" })).toEqual(["b.ts"]); + expect(changedFiles({ "a.ts": "1", "b.ts": "9" }, { "a.ts": "1" })).toEqual(["b.ts"]); + }); + it("identical fingerprints are fresh (edit-then-revert / rebase to same bytes)", () => { + expect(isFresh({ "a.ts": "1", "b.ts": "2" }, { "b.ts": "2", "a.ts": "1" })).toBe(true); + expect(changedFiles({ "a.ts": "1" }, { "a.ts": "1" })).toEqual([]); + }); +}); From 1f54f42ae16e2dfd7504f75d4e2ea6902a5cb68f Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 05:38:36 +0000 Subject: [PATCH 10/21] feat: persist per-page source fingerprint on wiki writes --- src/deeplake-schema.ts | 5 +++++ src/docs/read.ts | Bin 16213 -> 16452 bytes src/docs/wiki-generate.ts | 4 ++++ src/docs/wiki-update.ts | 5 +++++ src/docs/write.ts | 14 +++++++++++--- tests/shared/docs.test.ts | 14 +++++++------- 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/deeplake-schema.ts b/src/deeplake-schema.ts index 50f88bdb..81083d95 100644 --- a/src/deeplake-schema.ts +++ b/src/deeplake-schema.ts @@ -204,6 +204,11 @@ export const DOCS_COLUMNS: readonly ColumnDef[] = Object.freeze([ // (written only by the elected refresh turn); `u:|b:` = // a personal branch overlay (v2, opt-in). Reads default to `main`. { name: "scope", sql: "TEXT NOT NULL DEFAULT 'main'" }, + // Per-page source fingerprint: JSON `{file: git-blob-sha}` the page was + // generated from. Drives freshness (stale iff it differs from HEAD's), the + // overlay-divergence decision, the origin publish gate, and merge promotion. + // Read only where needed (scoped reads) so generic reads stay heal-safe. + { name: "source_fp", sql: "TEXT NOT NULL DEFAULT '{}'" }, { name: "version", sql: "BIGINT NOT NULL DEFAULT 1" }, { name: "created_at", sql: "TEXT NOT NULL DEFAULT ''" }, { name: "updated_at", sql: "TEXT NOT NULL DEFAULT ''" }, diff --git a/src/docs/read.ts b/src/docs/read.ts index d4d4542ced411c852fdc9939d7258654d95c91ce..aaa10ffa8e084e1adda5bfc102041b621c81087e 100644 GIT binary patch delta 238 zcmcawccg)FLlS$2zLu6kaB5LzVoqjNYKlT}erZv1szO?3UV3U#K~ZL2iGoH#by{Xl zs+B@gPJWVZaYkZof~KBAP-|>%m18?ClklfEKsgodW<_GF3MK delta 35 rcmX@oz<9N8LlXODUJhrT&Cw#y try { const content_embedding = args.embed ? (await args.embed(content)) ?? undefined : undefined; + const source_fp = serializeFingerprint(computeFingerprint(defaultGit(args.repoRoot), group.files)); await upsertDoc(args.query, args.tableName, { doc_id: docId, path: defaultWikiVfsPath(project, group.key), @@ -409,6 +412,7 @@ export async function generateWikiPages(args: WikiGenArgs): Promise tier: "slow", project, scope, + source_fp, agent: args.agent ?? "docs-wiki", plugin_version: args.pluginVersion, content_embedding, diff --git a/src/docs/wiki-update.ts b/src/docs/wiki-update.ts index 21a5de51..d2bae82e 100644 --- a/src/docs/wiki-update.ts +++ b/src/docs/wiki-update.ts @@ -25,6 +25,8 @@ import { gateDocEdit } from "./gate.js"; import { unwrapModelOutput } from "./refresh-llm.js"; import { editDoc, upsertDoc } from "./write.js"; +import { computeFingerprint, serializeFingerprint } from "./fingerprint.js"; +import { defaultGit } from "./candidates.js"; import { appendFilesIndex, collectWikiAnchors, stripFilesIndex, type RunPromptFn } from "./wiki-generate.js"; import type { DocEmbedder } from "./embed.js"; import type { DocRow, QueryFn } from "./read.js"; @@ -185,6 +187,7 @@ export async function updateWikiPage(args: WikiUpdateArgs): Promise { expect(calls[1]).toMatch(/^INSERT INTO "hivemind_docs"/); // id column is the deterministic composite, NOT a random uuid expect(calls[1]).toContain(`'p|main|src/a.ts', 'src/a.ts',`); - expect(calls[1]).toContain(`'p', 'main', 1, `); // project, scope, always version 1 + expect(calls[1]).toContain(`'p', 'main', E'{}', 1, `); // project, scope, source_fp, version 1 }); it("writing a branch overlay scopes the DELETE — never touches the main row", async () => { @@ -280,7 +280,7 @@ describe("upsertDoc", () => { `OR (doc_id = 'src/a.ts' AND project = 'p' AND scope = 'u:alice|b:feature')`, ); expect(calls[0]).not.toContain("scope = 'main'"); - expect(calls[1]).toContain(`'p', 'u:alice|b:feature', 1, `); // project, overlay scope + expect(calls[1]).toContain(`'p', 'u:alice|b:feature', E'{}', 1, `); // project, overlay scope, source_fp }); it("retry after a timeout re-runs DELETE+INSERT — never forks a second row", async () => { @@ -531,7 +531,7 @@ describe("listDocs", () => { expect(byId.get("A")).toBe("A overlay"); expect(byId.get("B")).toBe("B main"); expect(byId.has("C")).toBe(false); // foreign overlay never surfaces - expect(calls[0]).toContain(", scope FROM"); // scope column selected in this mode + expect(calls[0]).toContain(", scope, source_fp FROM"); // scope column selected in this mode }); it("union order cannot resurrect a stale version: v1 seen FIRST, v2 still wins", async () => { @@ -664,7 +664,7 @@ describe("getDocLatest", () => { fakeRow({ id: "o", doc_id: "X", version: 1, content: "OVERLAY", scope: "b:feat" }), ]]); const row = await getDocLatest(query, TBL, "X", { projectOrLegacy: "p", readerScope: "b:feat" }); - expect(calls[0]).toContain(", scope FROM"); // scope column is selected + expect(calls[0]).toContain(", scope, source_fp FROM"); // scope column is selected expect(calls[0]).not.toContain("AND scope ="); // NOT filtered — all scopes fetched expect(row?.content).toBe("OVERLAY"); }); @@ -685,13 +685,13 @@ describe("getDocLatest", () => { const calls: string[] = []; const query = vi.fn(async (sql: string) => { calls.push(sql); - if (call++ === 0 && sql.includes(", scope FROM")) throw new Error(`column "scope" does not exist`); + if (call++ === 0 && sql.includes(", scope, source_fp FROM")) throw new Error(`column "scope" does not exist`); return [fakeRow({ doc_id: "X", version: 2, content: "LEGACY" })]; }); const row = await getDocLatest(query, TBL, "X", { projectOrLegacy: "p", readerScope: "b:feat" }); expect(row?.content).toBe("LEGACY"); - expect(calls[0]).toContain(", scope FROM"); - expect(calls[1]).not.toContain(", scope FROM"); + expect(calls[0]).toContain(", scope, source_fp FROM"); + expect(calls[1]).not.toContain(", scope, source_fp FROM"); }); }); From dd0f54a9396d3849afd3f01a9fba05893a259f43 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 05:41:09 +0000 Subject: [PATCH 11/21] feat: serve docs with a staleness banner from the fingerprint --- src/docs/vfs-handler.ts | 26 +++++++++++++++++++++++++- src/hooks/pre-tool-use.ts | 3 ++- tests/shared/docs-vfs-handler.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/docs/vfs-handler.ts b/src/docs/vfs-handler.ts index de1b2182..efd28700 100644 --- a/src/docs/vfs-handler.ts +++ b/src/docs/vfs-handler.ts @@ -23,6 +23,9 @@ import { listDocMeta, listDocsByIds, getDocLatest, type QueryFn } from "./read.j import { buildDocsIndex, dirOf, firstDocLine, type DocMeta } from "./index-render.js"; import { searchDocs, type SearchOptions } from "../shell/grep-core.js"; import { sqlLike } from "../utils/sql.js"; +import { computeFingerprint, parseFingerprint, changedFiles } from "./fingerprint.js"; +import { parseFilesIndex, WIKI_DOC_PREFIX } from "./wiki-generate.js"; +import type { GitRunner } from "./branch-scope.js"; import type { DocEmbedder } from "./embed.js"; export type DocsVfsResult = @@ -41,6 +44,12 @@ export interface DocsVfsOptions { * legacy behavior (latest version, scope-agnostic). */ readerScope?: string; + /** + * Git runner for the read-time freshness verdict: when present, a leaf doc + * whose stored fingerprint differs from HEAD's is served WITH a banner naming + * the changed files, so the reader falls back to source and stays correct. + */ + git?: GitRunner; } /** Resolve a `/docs/` subpath to rendered text from the docs table. */ @@ -110,9 +119,24 @@ export async function handleDocsVfs( const docId = path.slice(0, -".md".length); const row = await getDocLatest(query, tableName, docId, { projectOrLegacy: opts.project, readerScope: opts.readerScope }); if (!row) return { kind: "not-found", message: `${subpath}: No such file or directory` }; + + // Read-time freshness verdict: compare the page's stored fingerprint to HEAD's + // current one. If member files changed since the page was written, serve it WITH + // a banner naming them, so the reader confirms against source (on-demand posture). + let banner = ""; + if (opts.git && row.source_fp) { + const files = row.doc_id.startsWith(WIKI_DOC_PREFIX) ? parseFilesIndex(row.content) : [row.doc_id]; + const changed = changedFiles(parseFingerprint(row.source_fp), computeFingerprint(opts.git, files)); + if (changed.length > 0) { + banner = + `> [!] This page may be stale — ${changed.length} source file(s) changed since it was written. ` + + `Confirm against the source before relying on it: ${changed.join(", ")}\n\n`; + } + } + const header = `# ${row.doc_id}\n` + `version: ${row.version} tier: ${row.tier} status: ${row.status} updated: ${row.updated_at}\n` + `---\n`; - return { kind: "ok", body: header + row.content }; + return { kind: "ok", body: header + banner + row.content }; } diff --git a/src/hooks/pre-tool-use.ts b/src/hooks/pre-tool-use.ts index a8653a34..e2944503 100644 --- a/src/hooks/pre-tool-use.ts +++ b/src/hooks/pre-tool-use.ts @@ -463,7 +463,8 @@ export async function processPreToolUse(input: PreToolUseInput, deps: ClaudePreT const subpath = virtualPath === "/docs" ? "" : virtualPath.slice("/docs/".length); logFn(`docs vfs: ${subpath || "(root)"}`); const docsCwd = input.cwd ?? process.cwd(); - const result = await handleDocsVfsFn(subpath, (sql) => api.query(sql), config.docsTableName, { embedQuery: makeQueryEmbedder(), project: deriveProjectKey(docsCwd).key, readerScope: currentScope(defaultGit(docsCwd)) }); + const docsGit = defaultGit(docsCwd); + const result = await handleDocsVfsFn(subpath, (sql) => api.query(sql), config.docsTableName, { embedQuery: makeQueryEmbedder(), project: deriveProjectKey(docsCwd).key, readerScope: currentScope(docsGit), git: docsGit }); const body = result.kind === "ok" ? result.body : `(${result.kind}) ${result.message}`; if (input.tool_name === "Read") { const file_path = writeReadCacheFileFn(input.session_id, virtualPath, body); diff --git a/tests/shared/docs-vfs-handler.test.ts b/tests/shared/docs-vfs-handler.test.ts index 0625c0aa..3bdd1a67 100644 --- a/tests/shared/docs-vfs-handler.test.ts +++ b/tests/shared/docs-vfs-handler.test.ts @@ -73,6 +73,30 @@ describe("handleDocsVfs", () => { expect(calls.some((c) => c.includes("WHERE doc_id = 'src/graph/diff.ts'"))).toBe(true); }); + it("prepends a staleness banner when a member file's fingerprint drifted from HEAD", async () => { + const { query } = router([], [], [ + fullRow("src/graph/diff.ts", "# diff\n\nBody.", { source_fp: JSON.stringify({ "src/graph/diff.ts": "oldsha" }) }), + ]); + const git = (args: string[]) => (args[0] === "ls-tree" ? "100644 blob newsha\tsrc/graph/diff.ts\n" : null); + const r = await handleDocsVfs("src/graph/diff.ts.md", query, TBL, { readerScope: "main", git }); + expect(r.kind).toBe("ok"); + if (r.kind === "ok") { + expect(r.body).toContain("This page may be stale"); + expect(r.body).toContain("src/graph/diff.ts"); // names the changed file + expect(r.body).toContain("Body."); // still serves the content + } + }); + + it("no banner when the fingerprint still matches HEAD", async () => { + const { query } = router([], [], [ + fullRow("src/graph/diff.ts", "# diff\n\nBody.", { source_fp: JSON.stringify({ "src/graph/diff.ts": "samesha" }) }), + ]); + const git = (args: string[]) => (args[0] === "ls-tree" ? "100644 blob samesha\tsrc/graph/diff.ts\n" : null); + const r = await handleDocsVfs("src/graph/diff.ts.md", query, TBL, { readerScope: "main", git }); + expect(r.kind).toBe("ok"); + if (r.kind === "ok") expect(r.body).not.toContain("may be stale"); + }); + it("returns not-found for a leaf whose doc does not exist", async () => { const { query } = router([], [], []); const r = await handleDocsVfs("src/graph/nope.ts.md", query, TBL); From add287ba46d81c8585733940172c112beefb1c29 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 05:47:30 +0000 Subject: [PATCH 12/21] feat: hold branch overlays until their source is pushed to origin --- src/docs/fingerprint.ts | 32 ++++++++++++++++++---- src/docs/wiki-refresh.ts | 24 +++++++++++++++- tests/shared/docs-fingerprint.test.ts | 38 ++++++++++++++++++++++++++ tests/shared/docs-wiki-refresh.test.ts | 24 ++++++++++++++++ 4 files changed, 111 insertions(+), 7 deletions(-) diff --git a/src/docs/fingerprint.ts b/src/docs/fingerprint.ts index c3a266e7..fb9e0ca4 100644 --- a/src/docs/fingerprint.ts +++ b/src/docs/fingerprint.ts @@ -23,15 +23,16 @@ import type { GitRunner } from "./branch-scope.js"; export type SourceFingerprint = Record; /** - * Blob-sha per file at HEAD via a single `git ls-tree`. Files git can't resolve - * (deleted, untracked, or no git) are simply absent from the map — so a page - * whose file was deleted reads as "changed". Returns `{}` when git is - * unavailable, which downstream treats as "unknown → always stale". + * Blob-sha per file at a git `ref` (a commit / branch / `origin/`) via a + * single `git ls-tree`. Files git can't resolve (deleted, untracked, absent at + * that ref, or no git) are simply absent from the map — so a page whose file was + * deleted or isn't on the ref reads as "changed". Returns `{}` when git or the + * ref is unavailable, which downstream treats as "unknown → stale / not there". */ -export function computeFingerprint(git: GitRunner, files: readonly string[]): SourceFingerprint { +export function computeFingerprintAt(git: GitRunner, ref: string, files: readonly string[]): SourceFingerprint { const fp: SourceFingerprint = {}; if (files.length === 0) return fp; - const out = git(["ls-tree", "HEAD", "--", ...files]); + const out = git(["ls-tree", ref, "--", ...files]); if (out === null) return fp; for (const line of out.split("\n")) { const tab = line.indexOf("\t"); @@ -44,6 +45,25 @@ export function computeFingerprint(git: GitRunner, files: readonly string[]): So return fp; } +/** Blob-sha per file at the committed tree (HEAD) — the freshness baseline. */ +export function computeFingerprint(git: GitRunner, files: readonly string[]): SourceFingerprint { + return computeFingerprintAt(git, "HEAD", files); +} + +/** + * Is a page's source PUBLISHED — i.e. does every member file have, on + * `origin/`, the exact same blob it has at HEAD? True → the doc can be + * shared to the cloud (teammates on the branch have this code). False → the code + * is only local (unpushed): the doc must stay private, never published early. + * A repo with no `origin/` yields false (nothing is shared yet). + */ +export function sourcePushed(git: GitRunner, files: readonly string[], branch: string): boolean { + if (files.length === 0) return true; + const head = computeFingerprint(git, files); + const origin = computeFingerprintAt(git, `origin/${branch}`, files); + return isFresh(head, origin); +} + /** Serialize a fingerprint for the `source_fp` TEXT column (stable key order). */ export function serializeFingerprint(fp: SourceFingerprint): string { const sorted: SourceFingerprint = {}; diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index adfe2431..989b67a9 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -29,6 +29,7 @@ import { commitRefresh, readRefreshMeta, releaseClaim, tryClaimTurn } from "./me import { gateDocEdit } from "./gate.js"; import { buildUpdatePrompt, updateWikiPage, DEFAULT_WIKI_MAX_CHANGED_LINES, NO_CHANGE } from "./wiki-update.js"; import { parseScope, trunkBranch } from "./branch-scope.js"; +import { sourcePushed } from "./fingerprint.js"; import { appendFilesIndex, generateWikiPages, @@ -88,7 +89,7 @@ export interface WikiRefreshArgs { export interface WikiRefreshOutcome { doc_id: string; - action: "patched" | "mechanics_refreshed" | "no_change" | "regenerated" | "generated" | "failed" | "skipped"; + action: "patched" | "mechanics_refreshed" | "no_change" | "regenerated" | "generated" | "failed" | "skipped" | "held"; reasons?: string[]; } @@ -306,12 +307,28 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise may be written to the SHARED cloud table. A page built from + // an unpushed local commit is HELD — never leaked as a doc describing code the + // team can't see. (Readers on the branch fall back to main + the staleness + // banner until the code is pushed and the next cycle publishes the overlay.) + const parsedScope = parseScope(scope); + const branchName = parsedScope.kind === "branch" ? parsedScope.branch : null; + // A page is publishable to the shared cloud only when its source is already on + // origin/. Checked at the write sites (not for skipped pages) so an + // unpushed branch holds exactly the pages it would otherwise publish. + const held = (files: string[]): boolean => branchName !== null && !sourcePushed(args.git, files, branchName); + for (const group of groups) { const docId = wikiDocId(group.key); const page = pages.get(docId); // New subsystem → fresh page. if (!page) { + if (held(group.files)) { + outcomes.push({ doc_id: docId, action: "held", reasons: ["source not pushed to origin"] }); + continue; + } const res = await regenerate(group); if (res === "skipped") { outcomes.push({ doc_id: docId, action: "skipped", reasons: ["below min size — no page wanted"] }); @@ -333,6 +350,11 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise changed.has(f)); if (!touched && !membershipChanged) continue; + if (held(group.files)) { + outcomes.push({ doc_id: docId, action: "held", reasons: ["source not pushed to origin"] }); + continue; + } + const diff = baseSha === "" ? null : args.git(["diff", `${baseSha}..HEAD`, "--", ...group.files]); if (diff === null && !membershipChanged) { // No usable diff to patch from → regenerate rather than guess. diff --git a/tests/shared/docs-fingerprint.test.ts b/tests/shared/docs-fingerprint.test.ts index 679aadcc..4ba62374 100644 --- a/tests/shared/docs-fingerprint.test.ts +++ b/tests/shared/docs-fingerprint.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { computeFingerprint, + computeFingerprintAt, + sourcePushed, serializeFingerprint, parseFingerprint, changedFiles, @@ -11,6 +13,10 @@ import type { GitRunner } from "../../src/docs/branch-scope.js"; const gitLsTree = (lines: string | null): GitRunner => (args) => args[0] === "ls-tree" ? lines : null; +/** ls-tree runner keyed by ref (args[1]) → its output. */ +const gitByRef = (byRef: Record): GitRunner => (args) => + args[0] === "ls-tree" ? (byRef[args[1]] ?? null) : null; + describe("computeFingerprint", () => { it("parses ` blob \\t` lines into a file->sha map", () => { const out = @@ -81,3 +87,35 @@ describe("changedFiles / isFresh", () => { expect(changedFiles({ "a.ts": "1" }, { "a.ts": "1" })).toEqual([]); }); }); + +describe("computeFingerprintAt / sourcePushed (publish gate)", () => { + it("reads a fingerprint at an explicit ref (origin/)", () => { + const git = gitByRef({ "origin/feat": "100644 blob abc\ta.ts\n" }); + expect(computeFingerprintAt(git, "origin/feat", ["a.ts"])).toEqual({ "a.ts": "abc" }); + }); + + it("pushed: HEAD blobs all match origin/", () => { + const git = gitByRef({ + HEAD: "100644 blob abc\ta.ts\n100644 blob def\tb.ts\n", + "origin/feat": "100644 blob abc\ta.ts\n100644 blob def\tb.ts\n", + }); + expect(sourcePushed(git, ["a.ts", "b.ts"], "feat")).toBe(true); + }); + + it("NOT pushed: a file's blob differs on origin (local-only commit)", () => { + const git = gitByRef({ + HEAD: "100644 blob NEW\ta.ts\n", + "origin/feat": "100644 blob OLD\ta.ts\n", + }); + expect(sourcePushed(git, ["a.ts"], "feat")).toBe(false); + }); + + it("NOT pushed: origin/ doesn't have the file at all (branch never pushed)", () => { + const git = gitByRef({ HEAD: "100644 blob abc\ta.ts\n", "origin/feat": null }); + expect(sourcePushed(git, ["a.ts"], "feat")).toBe(false); + }); + + it("empty file set is trivially pushed", () => { + expect(sourcePushed(gitByRef({}), [], "feat")).toBe(true); + }); +}); diff --git a/tests/shared/docs-wiki-refresh.test.ts b/tests/shared/docs-wiki-refresh.test.ts index c8e4d308..630a47be 100644 --- a/tests/shared/docs-wiki-refresh.test.ts +++ b/tests/shared/docs-wiki-refresh.test.ts @@ -140,6 +140,30 @@ describe("runWikiRefreshCycle", () => { expect(acted).not.toContain("wiki/pkg/io"); }); + it("publish gate: on a branch, a page whose source is NOT on origin is held (no cloud write, no LLM)", async () => { + const backend = makeBackend({ + scope: "b:feat", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + let ran = false; + const git: GitRunner = (args) => { + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "ls-tree" && args[1] === "HEAD") return "100644 blob NEW\tpkg/core/a.ts\n"; // local + if (args[0] === "ls-tree" && args[1] === "origin/feat") return null; // never pushed + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + const oneGroup = snap([node("pkg/core/a.ts:foo:function", "pkg/core/a.ts")]); + const report = await runWikiRefreshCycle( + baseArgs(backend, git, { scope: "b:feat", snap: oneGroup, run: async () => { ran = true; return "## Purpose\nfoo v2."; } }), + ); + expect(report.outcomes).toEqual([{ doc_id: "wiki/pkg/core", action: "held", reasons: ["source not pushed to origin"] }]); + expect(ran).toBe(false); // held before any LLM call + }); + it("HEAD == last_refresh_sha → up-to-date, no lease taken, no LLM", async () => { const backend = makeBackend({ meta: { last_refresh_sha: HEAD, claimed_by: null, claimed_at: null, patch_counts: {} } }); const run = vi.fn(async () => "NO_CHANGE"); From 408fb8e11172887b2e082d01c9c04392a108c92f Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 05:52:07 +0000 Subject: [PATCH 13/21] feat: promote merged branch overlays to main on refresh --- src/docs/promote.ts | 136 ++++++++++++++++++++++++++++++ src/docs/wiki-refresh.ts | 16 +++- tests/shared/docs-promote.test.ts | 91 ++++++++++++++++++++ 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/docs/promote.ts create mode 100644 tests/shared/docs-promote.test.ts diff --git a/src/docs/promote.ts b/src/docs/promote.ts new file mode 100644 index 00000000..a3504e4f --- /dev/null +++ b/src/docs/promote.ts @@ -0,0 +1,136 @@ +/** + * Merge promotion — when a branch's changes land in main, the overlay it built + * is REUSED as main's canonical page instead of regenerating it (saving LLM). + * + * The safety condition is content-defined: an overlay may be promoted only when + * its stored source fingerprint equals main's CURRENT source fingerprint for the + * same files — i.e. the exact bytes the overlay documents are now what main has. + * (After merging feature A into main with no other change to those files, A's + * overlay fingerprint == main's fingerprint → promote. If the merge combined A + * with other edits, the fingerprints differ → no promotion, main regenerates.) + * + * Promotion re-stamps the overlay content at scope `main` and archives the now + * redundant branch overlay. `planPromotions` is a pure decision (testable with a + * fake git); `promoteMergedOverlays` executes it against the table. + */ + +import { sqlIdent, sqlStr } from "../utils/sql.js"; +import { stableUnionRows } from "./stable-read.js"; +import { upsertDoc, editDoc } from "./write.js"; +import { parseFilesIndex, WIKI_DOC_PREFIX } from "./wiki-generate.js"; +import { computeFingerprint, parseFingerprint, serializeFingerprint, isFresh } from "./fingerprint.js"; +import { MAIN_SCOPE, parseScope, type GitRunner } from "./branch-scope.js"; +import type { QueryFn } from "./read.js"; + +/** Minimal row shape the planner needs. */ +export interface PromoteRow { + doc_id: string; + path: string; + content: string; + tier: string; + scope: string; + source_fp: string; +} + +export interface Promotion { + doc_id: string; + /** The overlay scope being promoted (e.g. `b:feat`). */ + fromScope: string; + path: string; + content: string; + tier: string; + /** Main's current fingerprint (what the promoted row is stamped with). */ + mainFp: string; +} + +/** + * Decide which branch overlays can be promoted to main. An overlay qualifies iff + * its `source_fp` equals main's current fingerprint (`git ls-tree HEAD`) for the + * page's files. Only wiki pages (files come from the `## Files` index) are + * considered. Pure — all git access is the injected runner. + */ +export function planPromotions(rows: readonly PromoteRow[], git: GitRunner): Promotion[] { + // Group rows per doc_id into { main, overlays }. + const byDoc = new Map(); + for (const r of rows) { + if (!r.doc_id.startsWith(WIKI_DOC_PREFIX)) continue; // fingerprint files come from ## Files + const g = byDoc.get(r.doc_id) ?? { overlays: [] }; + if (parseScope(r.scope).kind === "branch") g.overlays.push(r); + else g.main = r; + byDoc.set(r.doc_id, g); + } + + const out: Promotion[] = []; + for (const [doc_id, g] of byDoc) { + if (g.overlays.length === 0) continue; + // Files from whichever page we have (main preferred; else any overlay). + const files = parseFilesIndex((g.main ?? g.overlays[0]).content); + if (files.length === 0) continue; + const mainFp = computeFingerprint(git, files); + if (Object.keys(mainFp).length === 0) continue; // no git signal → don't promote + const mainFpStr = serializeFingerprint(mainFp); + for (const ov of g.overlays) { + if (isFresh(parseFingerprint(ov.source_fp), mainFp)) { + out.push({ doc_id, fromScope: ov.scope, path: ov.path, content: ov.content, tier: ov.tier, mainFp: mainFpStr }); + } + } + } + return out; +} + +export interface PromoteOutcome { + doc_id: string; + fromScope: string; + action: "promoted"; +} + +/** + * Execute merge promotions for a project: re-stamp each qualifying overlay as + * main, then archive the overlay. Runs on the trunk after a merge. Best-effort + * per page — one failure doesn't abort the rest. + */ +export async function promoteMergedOverlays( + query: QueryFn, + tableName: string, + project: string, + git: GitRunner, + opts: { agent?: string; pluginVersion?: string } = {}, +): Promise { + const safe = sqlIdent(tableName); + const raw = await stableUnionRows( + query, + `SELECT id, doc_id, path, content, tier, scope, source_fp FROM "${safe}" ` + + `WHERE project = '${sqlStr(project)}' AND status = 'active'`, + ); + const rows: PromoteRow[] = raw.map((r) => ({ + doc_id: String(r.doc_id ?? ""), + path: String(r.path ?? ""), + content: String(r.content ?? ""), + tier: String(r.tier ?? "slow"), + scope: String(r.scope ?? MAIN_SCOPE), + source_fp: String(r.source_fp ?? "{}"), + })); + + const outcomes: PromoteOutcome[] = []; + for (const p of planPromotions(rows, git)) { + try { + await upsertDoc(query, tableName, { + doc_id: p.doc_id, + path: p.path, + content: p.content, + tier: p.tier === "fast" ? "fast" : "slow", + project, + scope: MAIN_SCOPE, + source_fp: p.mainFp, + agent: opts.agent ?? "docs-wiki-promote", + plugin_version: opts.pluginVersion, + }); + // Archive the now-redundant overlay (its content is main's truth). + await editDoc(query, tableName, { doc_id: p.doc_id, status: "archived" }, { project, scope: p.fromScope }); + outcomes.push({ doc_id: p.doc_id, fromScope: p.fromScope, action: "promoted" }); + } catch { + // best-effort — leave this overlay for the next cycle + } + } + return outcomes; +} diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index 989b67a9..35b50ebd 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -30,6 +30,7 @@ import { gateDocEdit } from "./gate.js"; import { buildUpdatePrompt, updateWikiPage, DEFAULT_WIKI_MAX_CHANGED_LINES, NO_CHANGE } from "./wiki-update.js"; import { parseScope, trunkBranch } from "./branch-scope.js"; import { sourcePushed } from "./fingerprint.js"; +import { promoteMergedOverlays } from "./promote.js"; import { appendFilesIndex, generateWikiPages, @@ -89,7 +90,7 @@ export interface WikiRefreshArgs { export interface WikiRefreshOutcome { doc_id: string; - action: "patched" | "mechanics_refreshed" | "no_change" | "regenerated" | "generated" | "failed" | "skipped" | "held"; + action: "patched" | "mechanics_refreshed" | "no_change" | "regenerated" | "generated" | "failed" | "skipped" | "held" | "promoted"; reasons?: string[]; } @@ -314,6 +315,17 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise(); + if (branchName === null) { + for (const p of await promoteMergedOverlays(args.query, args.tableName, args.project, args.git, { agent: args.agent, pluginVersion: args.pluginVersion })) { + promotedIds.add(p.doc_id); + outcomes.push({ doc_id: p.doc_id, action: "promoted", reasons: [`from ${p.fromScope}`] }); + } + } // A page is publishable to the shared cloud only when its source is already on // origin/. Checked at the write sites (not for skipped pages) so an // unpushed branch holds exactly the pages it would otherwise publish. @@ -323,6 +335,8 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise ({ + stableUnionRows: (q: (sql: string) => unknown, sql: string) => q(sql), +})); + +import { planPromotions, promoteMergedOverlays, type PromoteRow } from "../../src/docs/promote.js"; +import { appendFilesIndex } from "../../src/docs/wiki-generate.js"; +import type { GitRunner } from "../../src/docs/branch-scope.js"; + +const FILES = ["pkg/core/a.ts"]; +const body = (n: string) => appendFilesIndex(`## Purpose\n${n}`, FILES); + +// HEAD has a.ts at blob SHA_MAIN. +const git: GitRunner = (args) => + args[0] === "ls-tree" && args[1] === "HEAD" ? "100644 blob SHA_MAIN\tpkg/core/a.ts\n" : null; + +function row(scope: string, source_fp: Record, content = body(scope)): PromoteRow { + return { doc_id: "wiki/pkg/core", path: "/docs/p/wiki/pkg/core.md", content, tier: "slow", scope, source_fp: JSON.stringify(source_fp) }; +} + +describe("planPromotions", () => { + it("promotes an overlay whose fingerprint equals main's current source", () => { + const rows = [ + row("main", { "pkg/core/a.ts": "SHA_OLD" }), // main is stale + row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" }), // overlay == what main now has + ]; + const plans = planPromotions(rows, git); + expect(plans).toHaveLength(1); + expect(plans[0]).toMatchObject({ doc_id: "wiki/pkg/core", fromScope: "b:feat", mainFp: `{"pkg/core/a.ts":"SHA_MAIN"}` }); + }); + + it("does NOT promote an overlay whose fingerprint differs from main (merge combined changes)", () => { + const rows = [ + row("main", { "pkg/core/a.ts": "SHA_MAIN" }), + row("b:feat", { "pkg/core/a.ts": "SHA_BRANCH" }), // not what main has → regenerate, not promote + ]; + expect(planPromotions(rows, git)).toHaveLength(0); + }); + + it("ignores non-wiki docs and pages with no overlay", () => { + const rows: PromoteRow[] = [ + { doc_id: "src/a.ts", path: "/p", content: "x", tier: "fast", scope: "b:feat", source_fp: `{"src/a.ts":"SHA_MAIN"}` }, + row("main", { "pkg/core/a.ts": "SHA_MAIN" }), // only main, no overlay + ]; + expect(planPromotions(rows, git)).toHaveLength(0); + }); + + it("promotes even when main has no row yet (branch created a brand-new page now merged)", () => { + const plans = planPromotions([row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" })], git); + expect(plans).toHaveLength(1); + }); +}); + +describe("promoteMergedOverlays", () => { + it("upserts the overlay content at main, then archives the overlay", async () => { + const calls: string[] = []; + // Full DocRow shape for editDoc's getDocLatest read (needs version etc.). + const overlayFull = { + id: "o", doc_id: "wiki/pkg/core", path: "/docs/p/wiki/pkg/core.md", content: body("b:feat"), + anchors: "[]", tier: "slow", status: "active", project: "p", scope: "b:feat", + source_fp: `{"pkg/core/a.ts":"SHA_MAIN"}`, version: 1, + created_at: "t0", updated_at: "t0", agent: "m", plugin_version: "0", + }; + const query = vi.fn(async (sql: string) => { + calls.push(sql); + const s = sql.trim(); + if (/^SELECT/i.test(s) && s.includes("status = 'active'")) { + // the promote scan — both scopes + return [ + { id: "m", ...row("main", { "pkg/core/a.ts": "SHA_OLD" }) }, + { id: "o", ...row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" }) }, + ]; + } + if (/^SELECT/i.test(s) && s.includes("scope = 'b:feat'")) { + return [overlayFull]; // editDoc's scoped read of the overlay + } + return []; + }); + const out = await promoteMergedOverlays(query, "hivemind_docs", "p", git); + expect(out).toEqual([{ doc_id: "wiki/pkg/core", fromScope: "b:feat", action: "promoted" }]); + + // The promoted row is written at scope main with main's fingerprint... + const insert = calls.find((c) => /^INSERT/i.test(c) && c.includes("'p|main|wiki/pkg/core'")); + expect(insert).toBeTruthy(); + expect(insert).toContain(`{"pkg/core/a.ts":"SHA_MAIN"}`); + // ...and the overlay row is archived (an UPDATE to status archived on b:feat). + const archive = calls.find((c) => /^UPDATE/i.test(c) && c.includes("'archived'")); + expect(archive).toBeTruthy(); + }); +}); From 2ffa545a11f2656d3d3e93a3136a863f0842f537 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 06:10:54 +0000 Subject: [PATCH 14/21] fix: harden branch-scope read isolation and scope-aware listings --- src/commands/docs.ts | 5 +++-- src/docs/branch-scope.ts | 2 +- src/docs/read.ts | Bin 16452 -> 19540 bytes src/docs/vfs-handler.ts | 4 ++-- tests/shared/docs.test.ts | 12 ++++++++++++ 5 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/commands/docs.ts b/src/commands/docs.ts index 44865235..c6737386 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -405,7 +405,8 @@ export async function runDocsCommand(args: string[]): Promise { } let row: DocRow | null = null; try { - row = await getDocLatest(query, tableName, docId); + const showCwd = flagValue(args, "--cwd") ?? process.cwd(); + row = await getDocLatest(query, tableName, docId, { readerScope: currentScope(defaultGit(showCwd)) }); } catch (err) { if (!isMissingTableError((err as Error).message)) throw err; } @@ -482,7 +483,7 @@ export async function runDocsCommand(args: string[]): Promise { try { rows = allView ? await listDocs(query, tableName, { status, limit }) - : await listDocs(query, tableName, { status, projectOrLegacy: explicitProject !== undefined ? resolveProjectArg(explicitProject) : headerProject, limit }); + : await listDocs(query, tableName, { status, projectOrLegacy: explicitProject !== undefined ? resolveProjectArg(explicitProject) : headerProject, limit, readerScope: explicitProject === undefined ? currentScope(defaultGit(flagValue(args, "--cwd") ?? process.cwd())) : undefined }); } catch (err) { if (!isMissingTableError((err as Error).message)) throw err; } diff --git a/src/docs/branch-scope.ts b/src/docs/branch-scope.ts index 817fbc03..4c8ea1c5 100644 --- a/src/docs/branch-scope.ts +++ b/src/docs/branch-scope.ts @@ -103,7 +103,7 @@ export function pickByScopePrecedenceUw!qy_uf~3d41)*pRfFG?d0qwm2-OG`5Ql`ex`^~wU?#&P*Rd{EjJDEyF!~P zaiuW*{_8&_GvJg-PCceMjR#zDN>nx)3Ayj)DzrtqmyLK+XSwR}+x-!Rked1`8{!B* zosi-zp_$O6S)VuPnw(IE0E+rdq&dPy%mp)I$g|wku(8W}uX34$WMUfT+RzARvwdcG zi?m3&H0gvAzOPu~6UvB5%4Imxh4ZJ5vph{G%`~4;S25WekkDDmjL0N5B*@q;(7Ecf z9>>YRhKqek*&VJ@HfbPIv(nFH&+icWrqCMa?RxC4JFFrg*22lJ%4{s-7Im{M<;Z1P zAw-wRd`R2GXG)R47jocPpZr$ZaW`xXxz=o-Q;YVDLU0!`ZezRRCRdW0k*g8OtSHif z%#bmz65u{4&OZ7~cwb2FF6Sy7H$}tnnKscdri~56C4}@q9V|+Bu)NkUZC4H}mFCu# z!*b8nxhUjVm;nLy9`ENV?Y?m1<(tnJg+OUWiuY03jt^g>Mf}dJ4Kk50^e|vp4@VY^ z>$5cVbtjV*+dITkN5DuMfyqVT>W)V>m)9aZ66mu$HQMi<9(LlkffO_3AAu9S z0qdrY*q{$cjTNAINVnrc7D*YEJAN%|gWiAREfk@ohK4X?P>rAAV9bP6Hb$Z}WJ1QE zx{D|?iK{;2#fHj4U08t94zO0%NV<(BfJq=6(-YaF1?>WF_;;oLYU7=a0MxFKP-t`;TcVD`VGVUFs zT&~WTSQ>rB5i#Y4Ql<@9yP&!ocTN(yNrAYR+xYbqWQ=1P5a_-cVC2%J*yliG0FM)e z`%(OUM{WD&`q|lQIe@ECd8x<;xFueRy#|O50ki?zBVI-#5`Btr=Jxin&9XUiHJ^D8H>-gmOuSx^q_RNM|veSe1K z%_e|n6972iLslPfmU@{@2zIuxKj&-I zJ^49bi|t3J+9xuVaP{;AH>=t|o@rjIy88t`0tAi+Au)^+e29&iFw40{MOi$<9JB0u zr|FUPX%QC+^E+Fvgb#$ZwsUp!;pG+W`6p-oal(Gktk3^*fmZFOXV1(JYCmi=j|pNd5sI1 z@VkxY=ouJ|4k2Beq3F0Bfg_n;@8PMZ@Mh4&aBb?)sdjy%%LnX^$dtqRp)R`PE=1^o zerLUIKfU;tm8bVw1%92NC0d^byn!`jSMFM94(7iYvmdUXd)AZxUg|sIUnnAdDW=)yOMmw<8q(x;k z;5wy715MJf!k`v?QZ+mhPV!afxYd{r+xE|E7oQ6hDhwppMT+N5Oz!{2DA$9?9Rqpt z6cloQi^NsAp}9f3bp))@EjMm2K6cgqbK|akym8{8a>iw2$LH%0gaP~Z`RS~jn-&Ca z(O5{yRim6J__G)t87HEB4>D^Lqd{sOUvJ@3^WL;u~fOFV(Ec_1h&qA8EJ% z{CD6ZFVjHlT|Df@~*Ul<7 z25atdH2co!GY=lKGhFdun>`t=#_jp9p8Uy!GuU|N;Gjd1duw%eb~<>^;Tdc2D1;D( zBjX$%Ez)&xhOW5JdXpyJ7lsY}`v}?qWBhv+*!Z?k8XF6b0`z-?#s{MWR1|nvQng9~ l#%9De;+p|8G{AT9iavr2PXqk;^N$n&fJzO_pWazr`9Hn)81?`F delta 659 zcmZuuL2DC16lPN+Sg8pyVVkuLuZx(Zo2Fe1EGCp~4fS9vsjYbN5Vx}vvrw{ecG6gC zpx{C9WO*R|0q@G5dg{f4XYuCAgU24di8{N}5(Vcn%=g}W?|a|(<*oAZP2;tqCnuD}c;*j5 zR2#T%=>MZNtztww^$%^Bz4rMs(wZ7xEkXic&%a6%zZY<983jQyjgRIRX5h|N>mKOW z4+5t{N8%oKDWjv92!GEO!ylC+1wF{%;?grbtQz=kDMzHpXd~ArZO=VhPrUai3p~F! z1_piC;nZzAe2fL$;e$Xp#+lc9QessJfNg`Wmt`NScTEMtQ*$SQKX0hv^V&BBH;q}L zyouDBxe&Dpg)fl6G}^a5;4ia?$2l!Lt-ncL+;IX5%V=6^rXr&fmp@>HwmW_Nv|^_s z>j8Et2j&dxfQ4)!Co#_U&tib6hUp3vBEC4SabD*p$d-JROD3ZHJ8&VOgVKqjC3E|dyLiq!~alwoL diff --git a/src/docs/vfs-handler.ts b/src/docs/vfs-handler.ts index efd28700..07d057a5 100644 --- a/src/docs/vfs-handler.ts +++ b/src/docs/vfs-handler.ts @@ -96,7 +96,7 @@ export async function handleDocsVfs( else if (!path.endsWith(".md")) dir = path; // a bare directory (e.g. `cat .../docs/src/graph`) if (dir !== null) { - const meta: DocMeta[] = (await listDocMeta(query, tableName, { dirPrefix: dir, project: opts.project })).map((r) => ({ + const meta: DocMeta[] = (await listDocMeta(query, tableName, { dirPrefix: dir, project: opts.project, readerScope: opts.readerScope })).map((r) => ({ doc_id: r.doc_id, version: r.version, updated_at: r.updated_at, @@ -108,7 +108,7 @@ export async function handleDocsVfs( .map((m) => m.doc_id); const summaries = new Map(); if (directFiles.length > 0) { - for (const d of await listDocsByIds(query, tableName, directFiles, { projectOrLegacy: opts.project })) { + for (const d of await listDocsByIds(query, tableName, directFiles, { projectOrLegacy: opts.project, readerScope: opts.readerScope })) { summaries.set(d.doc_id, firstDocLine(d.content)); } } diff --git a/tests/shared/docs.test.ts b/tests/shared/docs.test.ts index e091435d..5c999a6a 100644 --- a/tests/shared/docs.test.ts +++ b/tests/shared/docs.test.ts @@ -735,6 +735,18 @@ describe("listDocMeta", () => { // ── listDocsByIds (filtered read for index summaries + the scale path) ──────── describe("listDocsByIds", () => { + it("readerScope resolves per doc_id by branch precedence, never a foreign overlay", async () => { + const { query } = mockQuery([() => [ + fakeRow({ id: "am", doc_id: "a.ts", version: 9, content: "a main", scope: "main" }), + fakeRow({ id: "ao", doc_id: "a.ts", version: 1, content: "a overlay", scope: "b:feat" }), + fakeRow({ id: "bx", doc_id: "b.ts", version: 5, content: "b other", scope: "b:other" }), // foreign only + ]]); + const rows = await listDocsByIds(query, TBL, ["a.ts", "b.ts"], { readerScope: "b:feat" }); + const byId = new Map(rows.map((r) => [r.doc_id, r.content])); + expect(byId.get("a.ts")).toBe("a overlay"); // reader's overlay wins over main + expect(byId.has("b.ts")).toBe(false); // foreign overlay hidden + }); + it("builds a de-duplicated IN list and returns latest-per-doc", async () => { const { calls, query } = mockQuery([ () => [ From 55af4178376b44279bd8988bbc0a4a3e20e3e477 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 06:11:04 +0000 Subject: [PATCH 15/21] fix: hold uncommitted/detached docs and guard promotion membership --- src/docs/fingerprint.ts | 16 ++++++++++++ src/docs/promote.ts | 36 ++++++++++++++++++++------- src/docs/wiki-refresh.ts | 35 ++++++++++++++++++-------- tests/shared/docs-fingerprint.test.ts | 24 ++++++++++++++++++ tests/shared/docs-promote.test.ts | 28 +++++++++++++++++---- 5 files changed, 114 insertions(+), 25 deletions(-) diff --git a/src/docs/fingerprint.ts b/src/docs/fingerprint.ts index fb9e0ca4..e8d40b28 100644 --- a/src/docs/fingerprint.ts +++ b/src/docs/fingerprint.ts @@ -50,6 +50,22 @@ export function computeFingerprint(git: GitRunner, files: readonly string[]): So return computeFingerprintAt(git, "HEAD", files); } +/** + * True iff every member file is committed-clean — working tree == HEAD, with no + * modified or untracked bytes among them (`git status --porcelain -- ` + * is empty). Doc content is read from the WORKING TREE, while the fingerprint + * and publish gate reason about committed HEAD; publishing is only consistent + * when the two agree. A dirty or untracked member file means the page would + * document uncommitted bytes stamped as committed HEAD — so it must be held. + * No git (null) → true: a non-git repo has no notion of "committed". + */ +export function workingTreeClean(git: GitRunner, files: readonly string[]): boolean { + if (files.length === 0) return true; + const out = git(["status", "--porcelain", "--", ...files]); + if (out === null) return true; + return out.trim() === ""; +} + /** * Is a page's source PUBLISHED — i.e. does every member file have, on * `origin/`, the exact same blob it has at HEAD? True → the doc can be diff --git a/src/docs/promote.ts b/src/docs/promote.ts index a3504e4f..a0cfc7bb 100644 --- a/src/docs/promote.ts +++ b/src/docs/promote.ts @@ -45,15 +45,23 @@ export interface Promotion { /** * Decide which branch overlays can be promoted to main. An overlay qualifies iff - * its `source_fp` equals main's current fingerprint (`git ls-tree HEAD`) for the - * page's files. Only wiki pages (files come from the `## Files` index) are - * considered. Pure — all git access is the injected runner. + * its `source_fp` equals main's current fingerprint for the page's CURRENT group + * membership (`groupFiles`, from the live snapshot — NOT the possibly-stale + * `## Files` index of a stored page). This is what makes promotion safe under a + * membership change: if the merge added/removed a member, the current + * fingerprint won't match the overlay's, so it is not promoted and the normal + * refresh regenerates it. A page whose group no longer exists is skipped. Pure — + * all git access is the injected runner. */ -export function planPromotions(rows: readonly PromoteRow[], git: GitRunner): Promotion[] { +export function planPromotions( + rows: readonly PromoteRow[], + git: GitRunner, + groupFiles: ReadonlyMap, +): Promotion[] { // Group rows per doc_id into { main, overlays }. const byDoc = new Map(); for (const r of rows) { - if (!r.doc_id.startsWith(WIKI_DOC_PREFIX)) continue; // fingerprint files come from ## Files + if (!r.doc_id.startsWith(WIKI_DOC_PREFIX)) continue; const g = byDoc.get(r.doc_id) ?? { overlays: [] }; if (parseScope(r.scope).kind === "branch") g.overlays.push(r); else g.main = r; @@ -63,13 +71,15 @@ export function planPromotions(rows: readonly PromoteRow[], git: GitRunner): Pro const out: Promotion[] = []; for (const [doc_id, g] of byDoc) { if (g.overlays.length === 0) continue; - // Files from whichever page we have (main preferred; else any overlay). - const files = parseFilesIndex((g.main ?? g.overlays[0]).content); - if (files.length === 0) continue; + const files = groupFiles.get(doc_id); + if (!files || files.length === 0) continue; // group gone / empty → let refresh handle it const mainFp = computeFingerprint(git, files); if (Object.keys(mainFp).length === 0) continue; // no git signal → don't promote const mainFpStr = serializeFingerprint(mainFp); for (const ov of g.overlays) { + // The overlay must ALSO cover exactly the current membership (its ## Files + // == the group's files) — else it documents a stale file set. + if (!sameFileSet(parseFilesIndex(ov.content), files)) continue; if (isFresh(parseFingerprint(ov.source_fp), mainFp)) { out.push({ doc_id, fromScope: ov.scope, path: ov.path, content: ov.content, tier: ov.tier, mainFp: mainFpStr }); } @@ -78,6 +88,13 @@ export function planPromotions(rows: readonly PromoteRow[], git: GitRunner): Pro return out; } +/** Order-independent equality of two file lists. */ +function sameFileSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const s = new Set(a); + return b.every((f) => s.has(f)); +} + export interface PromoteOutcome { doc_id: string; fromScope: string; @@ -94,6 +111,7 @@ export async function promoteMergedOverlays( tableName: string, project: string, git: GitRunner, + groupFiles: ReadonlyMap, opts: { agent?: string; pluginVersion?: string } = {}, ): Promise { const safe = sqlIdent(tableName); @@ -112,7 +130,7 @@ export async function promoteMergedOverlays( })); const outcomes: PromoteOutcome[] = []; - for (const p of planPromotions(rows, git)) { + for (const p of planPromotions(rows, git, groupFiles)) { try { await upsertDoc(query, tableName, { doc_id: p.doc_id, diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index 35b50ebd..9ba22514 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -28,8 +28,8 @@ import { join } from "node:path"; import { commitRefresh, readRefreshMeta, releaseClaim, tryClaimTurn } from "./meta.js"; import { gateDocEdit } from "./gate.js"; import { buildUpdatePrompt, updateWikiPage, DEFAULT_WIKI_MAX_CHANGED_LINES, NO_CHANGE } from "./wiki-update.js"; -import { parseScope, trunkBranch } from "./branch-scope.js"; -import { sourcePushed } from "./fingerprint.js"; +import { parseScope, trunkBranch, currentBranch } from "./branch-scope.js"; +import { sourcePushed, workingTreeClean } from "./fingerprint.js"; import { promoteMergedOverlays } from "./promote.js"; import { appendFilesIndex, @@ -321,15 +321,26 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise(); if (branchName === null) { - for (const p of await promoteMergedOverlays(args.query, args.tableName, args.project, args.git, { agent: args.agent, pluginVersion: args.pluginVersion })) { + const groupFiles = new Map(groups.map((g) => [wikiDocId(g.key), g.files])); + for (const p of await promoteMergedOverlays(args.query, args.tableName, args.project, args.git, groupFiles, { agent: args.agent, pluginVersion: args.pluginVersion })) { promotedIds.add(p.doc_id); outcomes.push({ doc_id: p.doc_id, action: "promoted", reasons: [`from ${p.fromScope}`] }); } } - // A page is publishable to the shared cloud only when its source is already on - // origin/. Checked at the write sites (not for skipped pages) so an - // unpushed branch holds exactly the pages it would otherwise publish. - const held = (files: string[]): boolean => branchName !== null && !sourcePushed(args.git, files, branchName); + // A page may be written to the shared cloud only when it is committed-clean + // (content == committed HEAD, so it never documents uncommitted bytes) AND — + // on a branch — its source is already on origin/. Checked at the write + // sites (not for skipped pages). Returns the hold reason, or null if writable. + // Detached HEAD resolves to `main` scope but has no branch identity — writing + // a detached commit's docs to the canonical corpus would bypass the branch + // gate, so hold everything until on a real branch. + const detached = branchName === null && currentBranch(args.git) === null; + const holdReason = (files: string[]): string | null => { + if (detached) return "detached HEAD — ambiguous branch identity"; + if (!workingTreeClean(args.git, files)) return "uncommitted changes in member files"; + if (branchName !== null && !sourcePushed(args.git, files, branchName)) return "source not pushed to origin"; + return null; + }; for (const group of groups) { const docId = wikiDocId(group.key); @@ -339,8 +350,9 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise changed.has(f)); if (!touched && !membershipChanged) continue; - if (held(group.files)) { - outcomes.push({ doc_id: docId, action: "held", reasons: ["source not pushed to origin"] }); + const hr = holdReason(group.files); + if (hr) { + outcomes.push({ doc_id: docId, action: "held", reasons: [hr] }); continue; } diff --git a/tests/shared/docs-fingerprint.test.ts b/tests/shared/docs-fingerprint.test.ts index 4ba62374..d315bfce 100644 --- a/tests/shared/docs-fingerprint.test.ts +++ b/tests/shared/docs-fingerprint.test.ts @@ -3,6 +3,7 @@ import { computeFingerprint, computeFingerprintAt, sourcePushed, + workingTreeClean, serializeFingerprint, parseFingerprint, changedFiles, @@ -119,3 +120,26 @@ describe("computeFingerprintAt / sourcePushed (publish gate)", () => { expect(sourcePushed(gitByRef({}), [], "feat")).toBe(true); }); }); + +describe("workingTreeClean", () => { + const gitStatus = (out: string | null): GitRunner => (args) => + args[0] === "status" ? out : null; + + it("clean when `git status --porcelain` is empty", () => { + expect(workingTreeClean(gitStatus(""), ["a.ts"])).toBe(true); + }); + it("dirty when a member file is modified", () => { + expect(workingTreeClean(gitStatus(" M a.ts\n"), ["a.ts"])).toBe(false); + }); + it("dirty when a member file is untracked (?? — the leak codex flagged)", () => { + expect(workingTreeClean(gitStatus("?? a.ts\n"), ["a.ts"])).toBe(false); + }); + it("empty file set is clean without a git call", () => { + let called = false; + expect(workingTreeClean(() => { called = true; return "x"; }, [])).toBe(true); + expect(called).toBe(false); + }); + it("no git (null) → clean (non-git repo has no committed notion)", () => { + expect(workingTreeClean(gitStatus(null), ["a.ts"])).toBe(true); + }); +}); diff --git a/tests/shared/docs-promote.test.ts b/tests/shared/docs-promote.test.ts index c7a1868c..af1d5613 100644 --- a/tests/shared/docs-promote.test.ts +++ b/tests/shared/docs-promote.test.ts @@ -10,6 +10,7 @@ import type { GitRunner } from "../../src/docs/branch-scope.js"; const FILES = ["pkg/core/a.ts"]; const body = (n: string) => appendFilesIndex(`## Purpose\n${n}`, FILES); +const GROUP_FILES = new Map([["wiki/pkg/core", FILES]]); // HEAD has a.ts at blob SHA_MAIN. const git: GitRunner = (args) => @@ -25,7 +26,7 @@ describe("planPromotions", () => { row("main", { "pkg/core/a.ts": "SHA_OLD" }), // main is stale row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" }), // overlay == what main now has ]; - const plans = planPromotions(rows, git); + const plans = planPromotions(rows, git, GROUP_FILES); expect(plans).toHaveLength(1); expect(plans[0]).toMatchObject({ doc_id: "wiki/pkg/core", fromScope: "b:feat", mainFp: `{"pkg/core/a.ts":"SHA_MAIN"}` }); }); @@ -35,7 +36,7 @@ describe("planPromotions", () => { row("main", { "pkg/core/a.ts": "SHA_MAIN" }), row("b:feat", { "pkg/core/a.ts": "SHA_BRANCH" }), // not what main has → regenerate, not promote ]; - expect(planPromotions(rows, git)).toHaveLength(0); + expect(planPromotions(rows, git, GROUP_FILES)).toHaveLength(0); }); it("ignores non-wiki docs and pages with no overlay", () => { @@ -43,13 +44,30 @@ describe("planPromotions", () => { { doc_id: "src/a.ts", path: "/p", content: "x", tier: "fast", scope: "b:feat", source_fp: `{"src/a.ts":"SHA_MAIN"}` }, row("main", { "pkg/core/a.ts": "SHA_MAIN" }), // only main, no overlay ]; - expect(planPromotions(rows, git)).toHaveLength(0); + expect(planPromotions(rows, git, GROUP_FILES)).toHaveLength(0); }); it("promotes even when main has no row yet (branch created a brand-new page now merged)", () => { - const plans = planPromotions([row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" })], git); + const plans = planPromotions([row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" })], git, GROUP_FILES); expect(plans).toHaveLength(1); }); + + it("does NOT promote when the overlay's membership differs from the current group (codex #6)", () => { + // Overlay documents [a.ts] (its ## Files), but the group now has [a.ts, b.ts] + // (a member joined via the merge). Promoting would carry a stale file set → + // skip promotion so the normal refresh regenerates with the new membership. + const twoFileGroup = new Map([["wiki/pkg/core", ["pkg/core/a.ts", "pkg/core/b.ts"]]]); + const gitTwo: GitRunner = (args) => + args[0] === "ls-tree" && args[1] === "HEAD" + ? "100644 blob SHA_MAIN\tpkg/core/a.ts\n100644 blob SHA_B\tpkg/core/b.ts\n" + : null; + const rows = [row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" })]; // overlay ## Files = [a.ts] only + expect(planPromotions(rows, gitTwo, twoFileGroup)).toHaveLength(0); + }); + + it("skips a page whose group no longer exists", () => { + expect(planPromotions([row("b:feat", { "pkg/core/a.ts": "SHA_MAIN" })], git, new Map())).toHaveLength(0); + }); }); describe("promoteMergedOverlays", () => { @@ -77,7 +95,7 @@ describe("promoteMergedOverlays", () => { } return []; }); - const out = await promoteMergedOverlays(query, "hivemind_docs", "p", git); + const out = await promoteMergedOverlays(query, "hivemind_docs", "p", git, GROUP_FILES); expect(out).toEqual([{ doc_id: "wiki/pkg/core", fromScope: "b:feat", action: "promoted" }]); // The promoted row is written at scope main with main's fingerprint... From d03568a793569d378019486f37de835711690122 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 06:45:29 +0000 Subject: [PATCH 16/21] feat: materialize private branch docs in a local store until pushed --- src/docs/private-store.ts | 91 +++++++++++++++++++++++++ src/docs/vfs-handler.ts | 45 ++++++++---- src/docs/wiki-refresh.ts | 25 ++++++- src/docs/wiki-update.ts | 15 ++++ tests/shared/docs-private-store.test.ts | 65 ++++++++++++++++++ tests/shared/docs-vfs-handler.test.ts | 28 ++++++++ tests/shared/docs-wiki-refresh.test.ts | 50 ++++++++++++-- 7 files changed, 297 insertions(+), 22 deletions(-) create mode 100644 src/docs/private-store.ts create mode 100644 tests/shared/docs-private-store.test.ts diff --git a/src/docs/private-store.ts b/src/docs/private-store.ts new file mode 100644 index 00000000..94ab1f6e --- /dev/null +++ b/src/docs/private-store.ts @@ -0,0 +1,91 @@ +/** + * Local private doc store — the on-disk home for docs generated from a branch's + * COMMITTED-but-UNPUSHED code. Such a doc describes code teammates don't have + * yet, so it must never reach the shared cloud table (the publish gate holds + * it). Instead it lives here, readable only by this machine's owner, until the + * source is pushed and the next refresh promotes it to the cloud branch overlay. + * + * Layout: one JSON file per (project, scope) under + * ~/.hivemind/docs-private/__.json + * holding a map `{ [doc_id]: PrivateDoc }`. A whole-file read/write keeps it + * trivially consistent at the per-branch scale (dozens of pages). + * + * Each entry carries the `source_fp` it was generated from, so the reader can + * apply the same freshness verdict as cloud docs (stale banner) and drop an + * entry once it no longer matches HEAD. + */ + +import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface PrivateDoc { + doc_id: string; + path: string; + content: string; + /** Serialized fingerprint (`{file: blob-sha}`) the doc was generated from. */ + source_fp: string; + tier: "fast" | "slow"; + updated_at: string; +} + +/** Root dir for the private store (overridable for tests). */ +export function privateStoreRoot(): string { + return process.env.HIVEMIND_DOCS_PRIVATE_DIR ?? join(homedir(), ".hivemind", "docs-private"); +} + +/** Filesystem-safe slug for a scope value (`b:feat/x` → `b_feat_x`). */ +function scopeSlug(scope: string): string { + return scope.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +function storeFile(project: string, scope: string): string { + const safeProject = project.replace(/[^a-zA-Z0-9._-]/g, "_") || "default"; + return join(privateStoreRoot(), `${safeProject}__${scopeSlug(scope)}.json`); +} + +function readMap(file: string): Record { + try { + if (!existsSync(file)) return {}; + const raw = JSON.parse(readFileSync(file, "utf-8")) as unknown; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return raw as Record; + } catch { + return {}; // corrupt file → empty, never throw on the read path + } +} + +function writeMap(file: string, map: Record): void { + mkdirSync(dirname(file), { recursive: true }); + const tmp = `${file}.tmp`; + writeFileSync(tmp, JSON.stringify(map, null, 1) + "\n"); + renameSync(tmp, file); // atomic replace +} + +/** The private doc for (project, scope, doc_id), or null. */ +export function readPrivateDoc(project: string, scope: string, docId: string): PrivateDoc | null { + return readMap(storeFile(project, scope))[docId] ?? null; +} + +/** All private docs for (project, scope). */ +export function listPrivateDocs(project: string, scope: string): PrivateDoc[] { + return Object.values(readMap(storeFile(project, scope))); +} + +/** Upsert a private doc for (project, scope). */ +export function writePrivateDoc(project: string, scope: string, doc: PrivateDoc): void { + const file = storeFile(project, scope); + const map = readMap(file); + map[doc.doc_id] = doc; + writeMap(file, map); +} + +/** Remove a private doc (e.g. after it's promoted to the cloud on push). */ +export function deletePrivateDoc(project: string, scope: string, docId: string): void { + const file = storeFile(project, scope); + const map = readMap(file); + if (docId in map) { + delete map[docId]; + writeMap(file, map); + } +} diff --git a/src/docs/vfs-handler.ts b/src/docs/vfs-handler.ts index 07d057a5..10fbf59a 100644 --- a/src/docs/vfs-handler.ts +++ b/src/docs/vfs-handler.ts @@ -25,7 +25,8 @@ import { searchDocs, type SearchOptions } from "../shell/grep-core.js"; import { sqlLike } from "../utils/sql.js"; import { computeFingerprint, parseFingerprint, changedFiles } from "./fingerprint.js"; import { parseFilesIndex, WIKI_DOC_PREFIX } from "./wiki-generate.js"; -import type { GitRunner } from "./branch-scope.js"; +import { readPrivateDoc } from "./private-store.js"; +import { MAIN_SCOPE, type GitRunner } from "./branch-scope.js"; import type { DocEmbedder } from "./embed.js"; export type DocsVfsResult = @@ -117,26 +118,42 @@ export async function handleDocsVfs( // Leaf: ".md" → doc for that file. const docId = path.slice(0, -".md".length); - const row = await getDocLatest(query, tableName, docId, { projectOrLegacy: opts.project, readerScope: opts.readerScope }); - if (!row) return { kind: "not-found", message: `${subpath}: No such file or directory` }; - // Read-time freshness verdict: compare the page's stored fingerprint to HEAD's - // current one. If member files changed since the page was written, serve it WITH - // a banner naming them, so the reader confirms against source (on-demand posture). - let banner = ""; - if (opts.git && row.source_fp) { - const files = row.doc_id.startsWith(WIKI_DOC_PREFIX) ? parseFilesIndex(row.content) : [row.doc_id]; - const changed = changedFiles(parseFingerprint(row.source_fp), computeFingerprint(opts.git, files)); - if (changed.length > 0) { - banner = - `> [!] This page may be stale — ${changed.length} source file(s) changed since it was written. ` + - `Confirm against the source before relying on it: ${changed.join(", ")}\n\n`; + // Highest precedence: THIS machine's private doc for the current branch (a doc + // built from committed-but-unpushed code, held out of the shared cloud). Only + // the owner, on that branch, sees it. + if (opts.project && opts.readerScope && opts.readerScope !== MAIN_SCOPE) { + const priv = readPrivateDoc(opts.project, opts.readerScope, docId); + if (priv) { + const banner = staleBanner(opts.git, priv.doc_id, priv.content, priv.source_fp); + const header = `# ${priv.doc_id}\nvisibility: private (this branch, not pushed) updated: ${priv.updated_at}\n---\n`; + return { kind: "ok", body: header + banner + priv.content }; } } + const row = await getDocLatest(query, tableName, docId, { projectOrLegacy: opts.project, readerScope: opts.readerScope }); + if (!row) return { kind: "not-found", message: `${subpath}: No such file or directory` }; + + const banner = staleBanner(opts.git, row.doc_id, row.content, row.source_fp); const header = `# ${row.doc_id}\n` + `version: ${row.version} tier: ${row.tier} status: ${row.status} updated: ${row.updated_at}\n` + `---\n`; return { kind: "ok", body: header + banner + row.content }; } + +/** + * Read-time freshness verdict: compare the page's stored fingerprint to HEAD's + * current one. If member files changed since it was written, return a banner + * naming them so the reader confirms against source (on-demand posture); else "". + */ +function staleBanner(git: GitRunner | undefined, docId: string, content: string, source_fp?: string): string { + if (!git || !source_fp) return ""; + const files = docId.startsWith(WIKI_DOC_PREFIX) ? parseFilesIndex(content) : [docId]; + const changed = changedFiles(parseFingerprint(source_fp), computeFingerprint(git, files)); + if (changed.length === 0) return ""; + return ( + `> [!] This page may be stale — ${changed.length} source file(s) changed since it was written. ` + + `Confirm against the source before relying on it: ${changed.join(", ")}\n\n` + ); +} diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index 9ba22514..cd8c86e9 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -31,6 +31,7 @@ import { buildUpdatePrompt, updateWikiPage, DEFAULT_WIKI_MAX_CHANGED_LINES, NO_C import { parseScope, trunkBranch, currentBranch } from "./branch-scope.js"; import { sourcePushed, workingTreeClean } from "./fingerprint.js"; import { promoteMergedOverlays } from "./promote.js"; +import { writePrivateDoc, deletePrivateDoc } from "./private-store.js"; import { appendFilesIndex, generateWikiPages, @@ -338,9 +339,14 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise { if (detached) return "detached HEAD — ambiguous branch identity"; if (!workingTreeClean(args.git, files)) return "uncommitted changes in member files"; - if (branchName !== null && !sourcePushed(args.git, files, branchName)) return "source not pushed to origin"; return null; }; + // A committed-clean page on a branch not yet on origin is PRIVATE: written to + // the local store, never the shared cloud, until the source is pushed. + const isPrivate = (files: string[]): boolean => + branchName !== null && !sourcePushed(args.git, files, branchName); + const stampPrivate = (doc: { doc_id: string; path: string; content: string; source_fp: string; tier: "fast" | "slow" }): void => + writePrivateDoc(args.project, scope, { ...doc, updated_at: nowFn().toISOString() }); for (const group of groups) { const docId = wikiDocId(group.key); @@ -355,6 +361,14 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise void; } export type WikiUpdateOutcome = @@ -188,6 +194,15 @@ export async function updateWikiPage(args: WikiUpdateArgs): Promise = {}): PrivateDoc => ({ + doc_id: "wiki/pkg/core", path: "/docs/p/wiki/pkg/core.md", content: "# core\n\nbody", + source_fp: `{"pkg/core/a.ts":"SHA"}`, tier: "slow", updated_at: "t0", ...over, +}); + +describe("private-store", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "priv-store-")); + process.env.HIVEMIND_DOCS_PRIVATE_DIR = dir; + }); + afterEach(() => { + delete process.env.HIVEMIND_DOCS_PRIVATE_DIR; + rmSync(dir, { recursive: true, force: true }); + }); + + it("round-trips a private doc per (project, scope, doc_id)", () => { + expect(readPrivateDoc("p", "b:feat", "wiki/pkg/core")).toBeNull(); + writePrivateDoc("p", "b:feat", doc({ content: "PRIVATE" })); + expect(readPrivateDoc("p", "b:feat", "wiki/pkg/core")?.content).toBe("PRIVATE"); + expect(privateStoreRoot()).toBe(dir); + }); + + it("isolates by project and by scope (no cross-branch/-project bleed)", () => { + writePrivateDoc("p", "b:feat", doc({ content: "FEAT" })); + expect(readPrivateDoc("p", "b:other", "wiki/pkg/core")).toBeNull(); // other branch + expect(readPrivateDoc("q", "b:feat", "wiki/pkg/core")).toBeNull(); // other project + }); + + it("upserts (second write replaces) and lists", () => { + writePrivateDoc("p", "b:feat", doc({ doc_id: "wiki/a", content: "A1" })); + writePrivateDoc("p", "b:feat", doc({ doc_id: "wiki/a", content: "A2" })); + writePrivateDoc("p", "b:feat", doc({ doc_id: "wiki/b", content: "B" })); + expect(readPrivateDoc("p", "b:feat", "wiki/a")?.content).toBe("A2"); + expect(listPrivateDocs("p", "b:feat").map((d) => d.doc_id).sort()).toEqual(["wiki/a", "wiki/b"]); + }); + + it("deletes a doc (e.g. after promotion on push)", () => { + writePrivateDoc("p", "b:feat", doc()); + deletePrivateDoc("p", "b:feat", "wiki/pkg/core"); + expect(readPrivateDoc("p", "b:feat", "wiki/pkg/core")).toBeNull(); + }); + + it("a corrupt store file degrades to empty, never throws", () => { + writePrivateDoc("p", "b:feat", doc()); + const file = join(dir, "p__b_feat.json"); + expect(existsSync(file)).toBe(true); + require("node:fs").writeFileSync(file, "{ not json"); + expect(readPrivateDoc("p", "b:feat", "wiki/pkg/core")).toBeNull(); + expect(listPrivateDocs("p", "b:feat")).toEqual([]); + }); +}); diff --git a/tests/shared/docs-vfs-handler.test.ts b/tests/shared/docs-vfs-handler.test.ts index 3bdd1a67..e2fa0fd4 100644 --- a/tests/shared/docs-vfs-handler.test.ts +++ b/tests/shared/docs-vfs-handler.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; // Pass-through the read-stability gate so SQL shapes stay exact (see docs.test.ts). vi.mock("../../src/docs/stable-read.js", () => ({ @@ -97,6 +100,31 @@ describe("handleDocsVfs", () => { if (r.kind === "ok") expect(r.body).not.toContain("may be stale"); }); + it("serves THIS machine's private branch doc ahead of the cloud (and marks it private)", async () => { + const privDir = mkdtempSync(join(tmpdir(), "vfs-priv-")); + process.env.HIVEMIND_DOCS_PRIVATE_DIR = privDir; + try { + const { writePrivateDoc } = await import("../../src/docs/private-store.js"); + writePrivateDoc("p", "b:feat", { + doc_id: "src/graph/diff.ts", path: "/docs/p/src/graph/diff.ts.md", + content: "# private\n\nMy unpushed doc.", source_fp: `{"src/graph/diff.ts":"h"}`, tier: "fast", updated_at: "t1", + }); + // Cloud has a main row; the private one must win for a b:feat reader. + const { query } = router([], [], [fullRow("src/graph/diff.ts", "# cloud main", { scope: "main" })]); + const git = (a: string[]) => (a[0] === "ls-tree" ? "100644 blob h\tsrc/graph/diff.ts\n" : null); + const r = await handleDocsVfs("src/graph/diff.ts.md", query, TBL, { project: "p", readerScope: "b:feat", git }); + expect(r.kind).toBe("ok"); + if (r.kind === "ok") { + expect(r.body).toContain("My unpushed doc."); + expect(r.body).toContain("visibility: private"); + expect(r.body).not.toContain("cloud main"); + } + } finally { + delete process.env.HIVEMIND_DOCS_PRIVATE_DIR; + rmSync(privDir, { recursive: true, force: true }); + } + }); + it("returns not-found for a leaf whose doc does not exist", async () => { const { query } = router([], [], []); const r = await handleDocsVfs("src/graph/nope.ts.md", query, TBL); diff --git a/tests/shared/docs-wiki-refresh.test.ts b/tests/shared/docs-wiki-refresh.test.ts index 630a47be..8bf9dc6e 100644 --- a/tests/shared/docs-wiki-refresh.test.ts +++ b/tests/shared/docs-wiki-refresh.test.ts @@ -11,6 +11,7 @@ vi.mock("../../src/docs/stable-read.js", () => ({ import { runWikiRefreshCycle, runLocalWikiRefresh, DEFAULT_MIN_PERIOD_MS, type WikiRefreshArgs } from "../../src/docs/wiki-refresh.js"; import { appendFilesIndex, collectWikiAnchors } from "../../src/docs/wiki-generate.js"; import { docRowId } from "../../src/docs/write.js"; +import { readPrivateDoc } from "../../src/docs/private-store.js"; import type { GitRunner } from "../../src/docs/candidates.js"; import type { GraphNode, GraphSnapshot } from "../../src/graph/types.js"; @@ -140,7 +141,45 @@ describe("runWikiRefreshCycle", () => { expect(acted).not.toContain("wiki/pkg/io"); }); - it("publish gate: on a branch, a page whose source is NOT on origin is held (no cloud write, no LLM)", async () => { + it("private: on a branch, a committed-clean UNPUSHED page is written to the local store, not the cloud", async () => { + const privDir = mkdtempSync(join(tmpdir(), "wref-priv-")); + process.env.HIVEMIND_DOCS_PRIVATE_DIR = privDir; + try { + const backend = makeBackend({ + scope: "b:feat", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + const git: GitRunner = (args) => { + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return ""; // committed-clean + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "ls-tree" && args[1] === "HEAD") return "100644 blob NEW\tpkg/core/a.ts\n"; // local commit + if (args[0] === "ls-tree" && args[1] === "origin/feat") return null; // never pushed + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + const oneGroup = snap([node("pkg/core/a.ts:foo:function", "pkg/core/a.ts")]); + const report = await runWikiRefreshCycle( + baseArgs(backend, git, { scope: "b:feat", snap: oneGroup, run: async () => "## Purpose\nfoo v2 (private)." }), + ); + expect(report.outcomes).toEqual([{ doc_id: "wiki/pkg/core", action: "patched" }]); + // No cloud write for the page ROW (the _meta lease row references the + // doc_id in its patch_counts JSON, so filter on the page's namespaced id). + const pageRowId = docRowId(P, "b:feat", "wiki/pkg/core"); + const pageWrites = backend.calls.filter((c) => /^(INSERT|UPDATE)/i.test(c) && c.includes(pageRowId)); + expect(pageWrites).toHaveLength(0); + // The private doc landed in the local store for (project, branch). + const priv = readPrivateDoc(P, "b:feat", "wiki/pkg/core"); + expect(priv?.content).toContain("foo v2 (private)"); + } finally { + delete process.env.HIVEMIND_DOCS_PRIVATE_DIR; + rmSync(privDir, { recursive: true, force: true }); + } + }); + + it("dirty working tree is held (never documents uncommitted bytes)", async () => { const backend = makeBackend({ scope: "b:feat", meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, @@ -150,18 +189,17 @@ describe("runWikiRefreshCycle", () => { const git: GitRunner = (args) => { if (args[0] === "rev-parse") return `${HEAD}\n`; if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return " M pkg/core/a.ts\n"; // DIRTY if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; - if (args[0] === "ls-tree" && args[1] === "HEAD") return "100644 blob NEW\tpkg/core/a.ts\n"; // local - if (args[0] === "ls-tree" && args[1] === "origin/feat") return null; // never pushed if (args[0] === "diff") return "- old\n+ new\n"; return null; }; const oneGroup = snap([node("pkg/core/a.ts:foo:function", "pkg/core/a.ts")]); const report = await runWikiRefreshCycle( - baseArgs(backend, git, { scope: "b:feat", snap: oneGroup, run: async () => { ran = true; return "## Purpose\nfoo v2."; } }), + baseArgs(backend, git, { scope: "b:feat", snap: oneGroup, run: async () => { ran = true; return "x"; } }), ); - expect(report.outcomes).toEqual([{ doc_id: "wiki/pkg/core", action: "held", reasons: ["source not pushed to origin"] }]); - expect(ran).toBe(false); // held before any LLM call + expect(report.outcomes).toEqual([{ doc_id: "wiki/pkg/core", action: "held", reasons: ["uncommitted changes in member files"] }]); + expect(ran).toBe(false); }); it("HEAD == last_refresh_sha → up-to-date, no lease taken, no LLM", async () => { From 6188d54efc10aa5218f122fd12828bf115e2923c Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 06:55:24 +0000 Subject: [PATCH 17/21] fix: keep private pages publishable and isolate the private store --- src/docs/private-store.ts | Bin 3498 -> 4098 bytes src/docs/wiki-refresh.ts | 26 +++++++++++++++++------- src/docs/wiki-update.ts | 6 ++++-- tests/shared/docs-private-store.test.ts | 15 ++++++++++---- tests/shared/docs-wiki-refresh.test.ts | 4 ++++ 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/docs/private-store.ts b/src/docs/private-store.ts index 94ab1f6e8ed0e1ac2ef662d7f6f48fe7cf0981dd..afe613211d826265ff4293a354321ea44b96a0f8 100644 GIT binary patch delta 941 zcmYLIU279T6t%6bn7<&%B^0tN&8DdJp;8J;#VUx<;%j$y=5BU$GBeCa(%6KEAow2f zNBAW42MNCV6MXT`ChAi*ckY~f&OK+o-T4ULe{J364q3qG%r`KH+L;k_wGnv8-P8ti z{bTF%#$qslayBa=(>mng^&H(|pmOAFOA2&`9cm8>P|)j1DFt8cm!Mv7aBl5T;d3YTylX zeIF|V3o%He!K*k-yX5^a(2)|CsYOco|80hJ-P6>>xJ%IYHZ z(`mj3lSc9eCT$aU#```eR%|3Sxtxe4yjDol^I)AEr@AoQ!)gOWra{;91vL&jbtK^} z*W}VO(zN`v^Wf|qPT>gtMq0Caj((!34){d5JnbSE6EPh!=rPk15BI@=seNa zy#QR9svddy5|XUaQWHz`D-skQwM|k;NQwt><0}$0^%S)9xzbAWl1no4 z^B|gnfre;+wOc6^mlS2@rE9_%3e{W+3Pq_Ur9~jM8bBNLic$-55|dLk^rPbvb*mh8 zqYQK{_2PA7_0x3}l;V{%t+{HsCO=|#a|SsupeR2pHMvB=R-pjGBB))Vrba<2B{eOv zG^a#qay5s#0+Knk@$o9vV3#7?thsq92N#oYa(-TMi9$(k0nn}lmFhHLKyBX6?acxJ Dn163n diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index cd8c86e9..998568f4 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -308,6 +308,12 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise may be written to the SHARED cloud table. A page built from @@ -367,6 +373,7 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise 0) { - log(`${failures} page(s) failed — sha NOT advanced, next turn redoes the window`); + // Commit point: the sha advances ONLY on a fully clean cycle with nothing left + // to publish. Failures OR pending-private pages leave it untouched so the next + // turn redoes the window (idempotent by design) — critically, so a page that + // was private this cycle gets published once its source is pushed (the push + // doesn't move HEAD, so an advanced cursor would strand it forever). + if (failures > 0 || pendingPublish > 0) { + log(`${failures} failed, ${pendingPublish} pending publish — sha NOT advanced, next turn redoes the window`); // Free the lease so the retry does not have to wait out the 30-min TTL; // the untouched sha makes the redo idempotent. await releaseClaim(args.query, args.tableName, args.project, scope, { diff --git a/src/docs/wiki-update.ts b/src/docs/wiki-update.ts index c9a860fc..07a39395 100644 --- a/src/docs/wiki-update.ts +++ b/src/docs/wiki-update.ts @@ -192,10 +192,11 @@ export async function updateWikiPage(args: WikiUpdateArgs): Promise { it("a corrupt store file degrades to empty, never throws", () => { writePrivateDoc("p", "b:feat", doc()); - const file = join(dir, "p__b_feat.json"); - expect(existsSync(file)).toBe(true); - require("node:fs").writeFileSync(file, "{ not json"); + const files = readdirSync(dir).filter((f) => f.endsWith(".json")); + expect(files).toHaveLength(1); + writeFileSync(join(dir, files[0]), "{ not json"); expect(readPrivateDoc("p", "b:feat", "wiki/pkg/core")).toBeNull(); expect(listPrivateDocs("p", "b:feat")).toEqual([]); }); + + it("distinct scopes with slug-colliding names do NOT share a file (injective)", () => { + writePrivateDoc("p", "b:feat/x", doc({ content: "SLASH" })); + writePrivateDoc("p", "b:feat_x", doc({ content: "UNDERSCORE" })); + expect(readPrivateDoc("p", "b:feat/x", "wiki/pkg/core")?.content).toBe("SLASH"); + expect(readPrivateDoc("p", "b:feat_x", "wiki/pkg/core")?.content).toBe("UNDERSCORE"); + }); }); diff --git a/tests/shared/docs-wiki-refresh.test.ts b/tests/shared/docs-wiki-refresh.test.ts index 8bf9dc6e..82e8bfc7 100644 --- a/tests/shared/docs-wiki-refresh.test.ts +++ b/tests/shared/docs-wiki-refresh.test.ts @@ -173,6 +173,10 @@ describe("runWikiRefreshCycle", () => { // The private doc landed in the local store for (project, branch). const priv = readPrivateDoc(P, "b:feat", "wiki/pkg/core"); expect(priv?.content).toContain("foo v2 (private)"); + // Cursor must NOT advance while a page is pending publish — else after push + // (HEAD unchanged) the next cycle short-circuits and never publishes it. + expect(report.status).toBe("incomplete"); + expect((backend.state.meta as { last_refresh_sha: string }).last_refresh_sha).toBe(PREV); } finally { delete process.env.HIVEMIND_DOCS_PRIVATE_DIR; rmSync(privDir, { recursive: true, force: true }); From cb15f56ac33007fba6f07767cb041393c0ea9997 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 07:01:54 +0000 Subject: [PATCH 18/21] fix: never regenerate a private page to the shared cloud --- src/docs/private-store.ts | Bin 4098 -> 4247 bytes src/docs/wiki-refresh.ts | 30 ++++++++++++++++++++----- tests/shared/docs-wiki-refresh.test.ts | 24 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/docs/private-store.ts b/src/docs/private-store.ts index afe613211d826265ff4293a354321ea44b96a0f8..ae8fe3f75f535aeb5a4ff85ffc9f1166c0a134de 100644 GIT binary patch delta 202 zcmXAjO=I6c|^=;Pq|PUueNnuQ>|b3LPKZ2P^3zD(AV2t(Fpm>H7qcJp~dM zASDSD**cI=mNy(g-ZKSg3r?3*{GP8+&KMoFv;9?I{vUlH)ex;UId5Y7Mq diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index 998568f4..9cad359c 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -403,9 +403,20 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise { } }); + it("private page with no usable diff is HELD, never regenerated to the cloud (codex r3 leak)", async () => { + const backend = makeBackend({ + scope: "b:feat", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + const regenerate = vi.fn(async () => "created" as const); // the CLOUD write seam + const git: GitRunner = (args) => { + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return ""; // clean + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "diff") return null; // per-group diff unavailable → would regenerate + if (args[0] === "ls-tree" && args[1] === "HEAD") return "100644 blob NEW\tpkg/core/a.ts\n"; + if (args[0] === "ls-tree" && args[1] === "origin/feat") return null; // unpushed + return null; + }; + const oneGroup = snap([node("pkg/core/a.ts:foo:function", "pkg/core/a.ts")]); + const report = await runWikiRefreshCycle(baseArgs(backend, git, { scope: "b:feat", snap: oneGroup, regenerate, run: async () => "x" })); + expect(regenerate).not.toHaveBeenCalled(); // NEVER regenerate a private page to cloud + expect(report.outcomes[0].action).toBe("held"); + expect(report.status).toBe("incomplete"); // pending publish → cursor not advanced + }); + it("dirty working tree is held (never documents uncommitted bytes)", async () => { const backend = makeBackend({ scope: "b:feat", From 97a0bc49567ae93bffdd0fad4ddc5c0eb5c93671 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 22:55:14 +0000 Subject: [PATCH 19/21] feat: offer auto-refresh after manual docs generation --- src/commands/docs.ts | 79 ++++++++++++++++++- tests/shared/docs-wiki-freshness-hint.test.ts | 45 +++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 tests/shared/docs-wiki-freshness-hint.test.ts diff --git a/src/commands/docs.ts b/src/commands/docs.ts index c6737386..ff76f645 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -23,7 +23,7 @@ * SQL escaping and version-bump logic lives in the docs module. */ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { loadConfig } from "../config.js"; import { DeeplakeApi } from "../deeplake-api.js"; import { getVersion } from "../cli/version.js"; @@ -55,7 +55,7 @@ import { setAuto, findEntry, listEntries } from "../docs/auto-registry.js"; import { defaultIo, runDocsOnboarding } from "../docs/onboarding.js"; import { isAutoEnabled } from "../docs/auto-registry.js"; import { readRefreshMeta } from "../docs/meta.js"; -import { tryGitTopLevel } from "../graph/git-hook-install.js"; +import { tryGitTopLevel, postCommitHookPath, containsOurMarkers } from "../graph/git-hook-install.js"; import { runWikiRefreshCycle, runLocalWikiRefresh } from "../docs/wiki-refresh.js"; import { loadSnapshotByCommit } from "../graph/diff.js"; import { repoDir } from "../graph/snapshot.js"; @@ -121,6 +121,74 @@ function gitHeadOf(cwd: string): string | null { } } +/** + * True iff a hivemind-managed post-commit hook is installed for `cwd`. The hook + * is one of the two refresh triggers; its presence is what upgrades the wiki + * from "refreshes on session start" to "refreshes on every commit". + */ +function hookInstalledAt(cwd: string): boolean { + const p = postCommitHookPath(cwd); + if (!p) return false; + try { + return existsSync(p) && containsOurMarkers(readFileSync(p, "utf8")); + } catch { + return false; // unreadable hook → treat as absent, never throw on a hint path + } +} + +/** + * The freshness hint printed after a manual generation command when the user + * declines (or can't be asked about) auto refresh. A corpus is only kept up to + * date when the auto-registry flag is ON — it gates BOTH refresh triggers (the + * post-commit hook AND the SessionStart catch-up tick). The hook adds instant + * per-commit refresh on top. With the flag OFF nothing refreshes the corpus at + * all, so a freshly-generated corpus silently goes stale — this hint is the + * source-level close of that gap. `subject` names what stays fresh, phrased to + * open a sentence ("This wiki" / "These docs"), so the message matches the + * command that printed it. Returns null when both are wired (no nag). + */ +export function wikiFreshnessHint(state: { autoEnabled: boolean; hookInstalled: boolean; subject: string }): string | null { + if (state.autoEnabled && state.hookInstalled) return null; // fully wired + if (state.autoEnabled) { + // Refreshes on session start already; only the per-commit hook is missing. + return "Auto refresh is ON (updates on session start). For instant per-commit refresh: hivemind graph init"; + } + // Flag OFF → the corpus will NOT refresh until enabled. + const base = `${state.subject} will NOT stay fresh — nothing refreshes it yet. Enable per-session refresh: hivemind docs auto on`; + return state.hookInstalled ? base : `${base} (and per-commit refresh: hivemind graph init)`; +} + +/** + * After a manual generation command (`docs wiki` / `docs generate`) writes a + * corpus, wire freshness. A human is ASKED to turn on auto refresh; on yes we + * flip the same per-(org, project) registry flag `docs auto on` sets, so both + * the post-commit hook and the SessionStart tick will keep the corpus fresh. + * Automation (no TTY) is never blocked on stdin — it only sees the passive + * `wikiFreshnessHint`. `subject` names what stays fresh in the prompt + * ("this wiki" / "these docs"). No-op when auto is already on. + */ +async function offerAutoRefresh( + cfg: ReturnType, + project: string, + cwd: string, + subject: string, +): Promise { + if (isAutoEnabled(cfg.orgId, project)) return; + const io = defaultIo(); + if (io.interactive) { + const a = await io.ask(`\nKeep ${subject} fresh automatically on every commit? [y/N] `); + if (/^y(es)?$/i.test(a.trim())) { + setAuto({ orgId: cfg.orgId, orgName: cfg.orgName, project, path: cwd, auto: true }); + console.log("Auto refresh ON. For instant per-commit refresh also run: hivemind graph init"); + return; + } + } + // Capitalize the subject to open the hint sentence ("this wiki" → "This wiki"). + const sentenceSubject = subject.charAt(0).toUpperCase() + subject.slice(1); + const hint = wikiFreshnessHint({ autoEnabled: false, hookInstalled: hookInstalledAt(cwd), subject: sentenceSubject }); + if (hint) console.log(`\n${hint}`); +} + function requireConfig(): NonNullable> { const cfg = loadConfig(); if (!cfg) { @@ -879,6 +947,10 @@ export async function runDocsCommand(args: string[]): Promise { if (o.status === "created") console.log(` created ${o.doc_id} (${o.files} files, ${o.chunks} chunk${o.chunks === 1 ? "" : "s"})`); else console.log(` ${o.status} ${o.doc_id}: ${o.reason ?? ""}`); } + // Freshness wiring: a just-generated corpus is a still photo unless auto + // refresh is on. Offer to enable it (a human gets asked; automation only + // sees the passive hint and is never blocked on stdin). + await offerAutoRefresh(cfg, project, cwd, "this wiki"); return; } @@ -965,6 +1037,9 @@ export async function runDocsCommand(args: string[]): Promise { for (const o of report.outcomes) { if (o.status !== "created") console.log(` ${o.status} ${o.doc_id}: ${o.reason ?? ""}`); } + // Same freshness wiring as `docs wiki`: offer to keep these per-file docs + // fresh, or fall back to the passive hint under automation. + await offerAutoRefresh(cfg, project, cwd, "these docs"); return; } diff --git a/tests/shared/docs-wiki-freshness-hint.test.ts b/tests/shared/docs-wiki-freshness-hint.test.ts new file mode 100644 index 00000000..3c3a5ae3 --- /dev/null +++ b/tests/shared/docs-wiki-freshness-hint.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { wikiFreshnessHint } from "../../src/commands/docs.js"; + +// The hint is the passive fallback shown after `docs wiki` when the user +// declines (or can't be asked about) auto refresh. Each of the four +// (autoEnabled, hookInstalled) states must yield the RIGHT message — the bug +// this guards is "correct text for the wrong state", so we assert both what +// each message says and what it must NOT say. +describe("wikiFreshnessHint", () => { + it("stays silent when fully wired (auto on + hook)", () => { + expect(wikiFreshnessHint({ autoEnabled: true, hookInstalled: true, subject: "This wiki" })).toBeNull(); + }); + + it("auto ON, no hook → offers per-commit upgrade, never claims it's stale", () => { + const h = wikiFreshnessHint({ autoEnabled: true, hookInstalled: false, subject: "This wiki" }); + expect(h).not.toBeNull(); + expect(h!).toContain("graph init"); + expect(h!).not.toMatch(/NOT stay fresh/); + expect(h!).not.toContain("docs auto on"); + }); + + it("auto OFF, hook present → warns it won't refresh, names docs auto on, NOT graph init", () => { + const h = wikiFreshnessHint({ autoEnabled: false, hookInstalled: true, subject: "This wiki" }); + expect(h).not.toBeNull(); + expect(h!).toMatch(/NOT stay fresh/); + expect(h!).toContain("docs auto on"); + expect(h!).not.toContain("graph init"); + }); + + it("auto OFF, no hook → names BOTH remedies", () => { + const h = wikiFreshnessHint({ autoEnabled: false, hookInstalled: false, subject: "This wiki" }); + expect(h).not.toBeNull(); + expect(h!).toMatch(/NOT stay fresh/); + expect(h!).toContain("docs auto on"); + expect(h!).toContain("graph init"); + }); + + it("subject drives the noun — per-file path must not say 'wiki'", () => { + const wiki = wikiFreshnessHint({ autoEnabled: false, hookInstalled: false, subject: "This wiki" }); + const perFile = wikiFreshnessHint({ autoEnabled: false, hookInstalled: false, subject: "These docs" }); + expect(wiki!).toContain("This wiki will NOT stay fresh"); + expect(perFile!).toContain("These docs will NOT stay fresh"); + expect(perFile!).not.toContain("wiki"); + }); +}); From 30430f685402d1355f7df0d7e0be4c057eeaf9f5 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 22:55:20 +0000 Subject: [PATCH 20/21] fix: never promote overlays into main on detached HEAD --- src/docs/wiki-refresh.ts | 15 +++++---- tests/shared/docs-wiki-refresh.test.ts | 46 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/docs/wiki-refresh.ts b/src/docs/wiki-refresh.ts index 9cad359c..02cb3f32 100644 --- a/src/docs/wiki-refresh.ts +++ b/src/docs/wiki-refresh.ts @@ -322,12 +322,19 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise(); - if (branchName === null) { + if (branchName === null && !detached) { const groupFiles = new Map(groups.map((g) => [wikiDocId(g.key), g.files])); for (const p of await promoteMergedOverlays(args.query, args.tableName, args.project, args.git, groupFiles, { agent: args.agent, pluginVersion: args.pluginVersion })) { promotedIds.add(p.doc_id); @@ -338,10 +345,6 @@ export async function runWikiRefreshCycle(args: WikiRefreshArgs): Promise. Checked at the write // sites (not for skipped pages). Returns the hold reason, or null if writable. - // Detached HEAD resolves to `main` scope but has no branch identity — writing - // a detached commit's docs to the canonical corpus would bypass the branch - // gate, so hold everything until on a real branch. - const detached = branchName === null && currentBranch(args.git) === null; const holdReason = (files: string[]): string | null => { if (detached) return "detached HEAD — ambiguous branch identity"; if (!workingTreeClean(args.git, files)) return "uncommitted changes in member files"; diff --git a/tests/shared/docs-wiki-refresh.test.ts b/tests/shared/docs-wiki-refresh.test.ts index 3fd34c7f..974363c7 100644 --- a/tests/shared/docs-wiki-refresh.test.ts +++ b/tests/shared/docs-wiki-refresh.test.ts @@ -207,6 +207,52 @@ describe("runWikiRefreshCycle", () => { expect(report.status).toBe("incomplete"); // pending publish → cursor not advanced }); + // The promotion read has a distinctive column list; its presence in the + // captured SQL tells us whether the promotion block ran. + const PROMOTE_SELECT = "SELECT id, doc_id, path, content, tier, scope, source_fp"; + + it("detached HEAD never promotes overlays into main (CodeRabbit Major)", async () => { + const backend = makeBackend({ + scope: "main", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + const git: GitRunner = (args) => { + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") return "HEAD\n"; // DETACHED + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return ""; + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "ls-tree") return "100644 blob X\tpkg/core/a.ts\n"; + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + const report = await runWikiRefreshCycle(baseArgs(backend, git, { scope: "main" })); + // The promotion block must be skipped entirely on a detached HEAD. + expect(backend.calls.some((c) => c.includes(PROMOTE_SELECT))).toBe(false); + expect(report.outcomes.every((o) => o.action !== "promoted")).toBe(true); + }); + + it("trunk (not detached) DOES run the promotion block (control for the detached guard)", async () => { + const backend = makeBackend({ + scope: "main", + meta: { last_refresh_sha: PREV, claimed_by: null, claimed_at: null, patch_counts: {} }, + pages: [pageRow("pkg/core", ["pkg/core/a.ts"], "## Purpose\nfoo.")], + }); + const git: GitRunner = (args) => { + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") return "main\n"; // ON TRUNK + if (args[0] === "rev-parse") return `${HEAD}\n`; + if (args[0] === "symbolic-ref") return "origin/main\n"; + if (args[0] === "status") return ""; + if (args[0] === "diff" && args[1] === "--name-only") return "pkg/core/a.ts\n"; + if (args[0] === "ls-tree") return "100644 blob X\tpkg/core/a.ts\n"; + if (args[0] === "diff") return "- old\n+ new\n"; + return null; + }; + await runWikiRefreshCycle(baseArgs(backend, git, { scope: "main" })); + expect(backend.calls.some((c) => c.includes(PROMOTE_SELECT))).toBe(true); + }); + it("dirty working tree is held (never documents uncommitted bytes)", async () => { const backend = makeBackend({ scope: "b:feat", From 9947bf0f117a959741b2fef1ef39d9e2429cbef6 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 22:55:25 +0000 Subject: [PATCH 21/21] fix: serve private docs for empty-string project key --- src/docs/vfs-handler.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/docs/vfs-handler.ts b/src/docs/vfs-handler.ts index 10fbf59a..e9bace03 100644 --- a/src/docs/vfs-handler.ts +++ b/src/docs/vfs-handler.ts @@ -121,8 +121,10 @@ export async function handleDocsVfs( // Highest precedence: THIS machine's private doc for the current branch (a doc // built from committed-but-unpushed code, held out of the shared cloud). Only - // the owner, on that branch, sees it. - if (opts.project && opts.readerScope && opts.readerScope !== MAIN_SCOPE) { + // the owner, on that branch, sees it. `project === ""` is a legitimate legacy/ + // default key, so test for presence (!== undefined), not truthiness — an + // empty-string project must still resolve its private docs. + if (opts.project !== undefined && opts.readerScope && opts.readerScope !== MAIN_SCOPE) { const priv = readPrivateDoc(opts.project, opts.readerScope, docId); if (priv) { const banner = staleBanner(opts.git, priv.doc_id, priv.content, priv.source_fp);