From 64832dc022aa03ea47e7989747afec085184f4df Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 21:03:06 +0000 Subject: [PATCH 1/4] feat(docs): add once-per-repo "docs not set up" suggestion module New docs-suggest.ts is the inverse gate of docsWikiContextNote: it produces a SessionStart note for a real, indexed repo that has NOT opted into docs, so the agent can surface `hivemind docs sync` if the user asks. Firing is deduped through ~/.deeplake/docs-suggested.json (sibling of docs-auto.json), keyed on (orgId, projectKey) so the note shows once per repo, never every session. Reads are hook-safe (missing/corrupt registry => empty), and docsSuggestNote is pure so the caller guards the write. Wording is descriptive capability disclosure, not an imperative to the agent, and ends with "no action needed unless the user asks" to stay clear of prompt-injection heuristics. Tests cover every gate, fail-closed behavior, dedup persistence and idempotent writes. --- src/docs/docs-suggest.ts | 125 ++++++++++++++++++++++++++++++ tests/shared/docs-suggest.test.ts | 110 ++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/docs/docs-suggest.ts create mode 100644 tests/shared/docs-suggest.test.ts diff --git a/src/docs/docs-suggest.ts b/src/docs/docs-suggest.ts new file mode 100644 index 00000000..e206e74a --- /dev/null +++ b/src/docs/docs-suggest.ts @@ -0,0 +1,125 @@ +/** + * SessionStart "docs not set up" suggestion — the one place the agent learns + * that Hivemind CAN maintain docs for a repo that hasn't opted in yet. + * + * The wiki note (docs-context.ts) fires only for repos ALREADY opted into auto + * sync. That leaves a gap: a user in a real, indexed repo with docs OFF never + * hears the feature exists. This module fills exactly that gap, and only that + * gap — it is the inverse gate of `isAutoEnabled`. + * + * Two hard constraints shape the wording and the firing: + * 1. Anti prompt-injection. The note is descriptive capability disclosure, + * never an imperative to the agent. It ends with an explicit "no action + * needed unless the user asks" so the harness's injection heuristics read + * it as context, not a smuggled instruction. Match docs-context.ts tone. + * 2. Show once per (org, project). A suggestion that reappears every session + * is nagware. Firing is deduped through `~/.deeplake/docs-suggested.json` + * (sibling of docs-auto.json), keyed on (orgId, projectKey) so nested + * checkouts / worktrees of the same repo share one entry. + * + * Every read is hook-safe: a missing or corrupt registry is an empty registry, + * never a throw. `docsSuggestNote` is a PURE function (no writes) so the caller + * decides — and best-effort guards — the `markSuggested` write. + */ + +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface SuggestedEntry { + orgId: string; + /** Repo project key (deriveProjectKey(cwd).key). */ + project: string; + /** Display-only: where the suggestion last fired from. */ + path: string; + suggestedAt: string; +} + +export interface SuggestedRegistry { + entries: SuggestedEntry[]; +} + +export function suggestedRegistryPath(): string { + return process.env.HIVEMIND_DOCS_SUGGESTED_FILE ?? join(homedir(), ".deeplake", "docs-suggested.json"); +} + +/** Read the registry. Missing/corrupt file → empty (hooks must never crash). */ +export function readSuggestedRegistry(file = suggestedRegistryPath()): SuggestedRegistry { + try { + const raw = JSON.parse(readFileSync(file, "utf-8")) as Partial; + if (!Array.isArray(raw.entries)) return { entries: [] }; + const entries = raw.entries.filter( + (e): e is SuggestedEntry => + !!e && typeof e === "object" && + typeof (e as SuggestedEntry).orgId === "string" && + typeof (e as SuggestedEntry).project === "string", + ); + return { entries }; + } catch { + return { entries: [] }; + } +} + +/** Has the docs suggestion already fired for (org, project)? Hook-safe, read-only. */ +export function wasSuggested(orgId: string, project: string, file = suggestedRegistryPath()): boolean { + return readSuggestedRegistry(file).entries.some( + (e) => e.orgId === orgId && e.project === project, + ); +} + +/** + * Record that the suggestion fired for (org, project). Atomic (tmp + rename) + * and idempotent — a repeat call updates `path`/`suggestedAt` in place rather + * than appending a duplicate. Best-effort by contract: the caller wraps this + * so a write failure never breaks SessionStart. + */ +export function markSuggested( + orgId: string, + project: string, + path: string, + suggestedAt: string, + file = suggestedRegistryPath(), +): void { + const reg = readSuggestedRegistry(file); + const existing = reg.entries.find((e) => e.orgId === orgId && e.project === project); + if (existing) { + existing.path = path; + existing.suggestedAt = suggestedAt; + } else { + reg.entries.push({ orgId, project, path, suggestedAt }); + } + mkdirSync(dirname(file), { recursive: true }); + const tmp = `${file}.tmp`; + writeFileSync(tmp, JSON.stringify(reg, null, 1) + "\n"); + renameSync(tmp, file); +} + +const NOTE = ` + +DOCS (not set up for this repo): Hivemind can maintain per-file and per-subsystem documentation that stays in sync with the code on every commit, searchable under ~/.deeplake/memory/docs/. This repo hasn't opted in. If the user would find persistent, commit-fresh code docs useful, they can enable them by running \`hivemind docs sync\` (one-time consent, then auto-maintained on commits). Purely informational — no action needed unless the user asks about it.`; + +/** + * The suggestion note, or "" when it should not fire. Returns "" — never + * throws — when docs are already enabled, when the suggestion already fired, + * when this is not an indexed code repo (no local graph), or on any registry + * read failure. PURE: does not write; the caller records via `markSuggested`. + */ +export function docsSuggestNote(args: { + orgId: string; + project: string; + /** True when a local code graph exists for this repo — the "real repo" gate. */ + graphPresent: boolean; + isAutoEnabledFn: (orgId: string, project: string) => boolean; + wasSuggestedFn?: (orgId: string, project: string) => boolean; +}): string { + const { orgId, project, graphPresent, isAutoEnabledFn } = args; + const wasSuggestedFn = args.wasSuggestedFn ?? wasSuggested; + try { + if (!graphPresent) return ""; + if (isAutoEnabledFn(orgId, project)) return ""; + if (wasSuggestedFn(orgId, project)) return ""; + return NOTE; + } catch { + return ""; + } +} diff --git a/tests/shared/docs-suggest.test.ts b/tests/shared/docs-suggest.test.ts new file mode 100644 index 00000000..5773f551 --- /dev/null +++ b/tests/shared/docs-suggest.test.ts @@ -0,0 +1,110 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + docsSuggestNote, + markSuggested, + readSuggestedRegistry, + wasSuggested, +} from "../../src/docs/docs-suggest.js"; + +describe("docsSuggestNote (SessionStart 'docs not set up' hint)", () => { + it("fires for a real indexed repo that hasn't opted in and wasn't suggested", () => { + const note = docsSuggestNote({ + orgId: "org-a", + project: "proj-1", + graphPresent: true, + isAutoEnabledFn: () => false, + wasSuggestedFn: () => false, + }); + expect(note).toContain("DOCS (not set up for this repo)"); + expect(note).toContain("hivemind docs sync"); + // Anti prompt-injection: descriptive, ends with an explicit no-op-by-default. + expect(note).toContain("no action needed unless the user asks"); + expect(note).not.toMatch(/\byou (must|should)\b/i); + expect(note.startsWith("\n\n")).toBe(true); // appends cleanly after docsNote + }); + + it("returns '' when docs are already enabled (inverse gate of the wiki note)", () => { + expect( + docsSuggestNote({ + orgId: "org-a", + project: "proj-1", + graphPresent: true, + isAutoEnabledFn: () => true, + wasSuggestedFn: () => false, + }), + ).toBe(""); + }); + + it("returns '' when the suggestion already fired for this (org, project)", () => { + expect( + docsSuggestNote({ + orgId: "org-a", + project: "proj-1", + graphPresent: true, + isAutoEnabledFn: () => false, + wasSuggestedFn: () => true, + }), + ).toBe(""); + }); + + it("returns '' when this is not an indexed code repo (no local graph)", () => { + let asked = false; + const note = docsSuggestNote({ + orgId: "org-a", + project: "proj-1", + graphPresent: false, + isAutoEnabledFn: () => { asked = true; return false; }, + wasSuggestedFn: () => false, + }); + expect(note).toBe(""); + expect(asked).toBe(false); // graph gate short-circuits before any registry read + }); + + it("fail-closed: a throwing registry read yields '' — never breaks SessionStart", () => { + expect( + docsSuggestNote({ + orgId: "org-a", + project: "proj-1", + graphPresent: true, + isAutoEnabledFn: () => { throw new Error("corrupt registry"); }, + wasSuggestedFn: () => false, + }), + ).toBe(""); + }); +}); + +describe("suggested registry (dedup persistence)", () => { + let dir: string; + let file: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "docs-suggested-")); + file = join(dir, "docs-suggested.json"); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("markSuggested then wasSuggested round-trips for the same (org, project)", () => { + expect(wasSuggested("org-a", "proj-1", file)).toBe(false); + markSuggested("org-a", "proj-1", "/repo", "2026-07-10T00:00:00.000Z", file); + expect(wasSuggested("org-a", "proj-1", file)).toBe(true); + // A different project under the same org is independent. + expect(wasSuggested("org-a", "proj-2", file)).toBe(false); + }); + + it("markSuggested is idempotent — a repeat updates in place, no duplicate row", () => { + markSuggested("org-a", "proj-1", "/repo", "2026-07-10T00:00:00.000Z", file); + markSuggested("org-a", "proj-1", "/repo/again", "2026-07-11T00:00:00.000Z", file); + const reg = readSuggestedRegistry(file); + expect(reg.entries).toHaveLength(1); + expect(reg.entries[0].path).toBe("/repo/again"); + expect(reg.entries[0].suggestedAt).toBe("2026-07-11T00:00:00.000Z"); + }); + + it("a corrupt registry file reads as empty — never throws", () => { + writeFileSync(file, "{ not json"); + expect(readSuggestedRegistry(file).entries).toEqual([]); + expect(wasSuggested("org-a", "proj-1", file)).toBe(false); + }); +}); From 0789e9fee1ad2f4cfe506bcab6c10b101399e412 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 21:03:06 +0000 Subject: [PATCH 2/4] feat(docs): surface the docs suggestion from the SessionStart hook Compute suggestNote after docsNote: only for a logged-in session in an indexed repo (graph present) with docs off and no prior suggestion. Record the firing via markSuggested in a best-effort try/catch so a registry write can never break SessionStart, then append the note to additionalContext. --- src/hooks/session-start.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index cc71ec92..e0440ff1 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -9,6 +9,8 @@ import { fileURLToPath } from "node:url"; import { maybeSpawnDocsRefresh } from "../docs/auto-refresh-trigger.js"; import { docsWikiContextNote } from "../docs/docs-context.js"; +import { docsSuggestNote, markSuggested } from "../docs/docs-suggest.js"; +import { isAutoEnabled } from "../docs/auto-registry.js"; import { deriveProjectKey } from "../utils/repo-identity.js"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; @@ -326,10 +328,27 @@ async function main(): Promise { // Docs wiki note — the agent has no other way to learn the wiki exists. // Gated on the same local consent registry as auto sync (no network), // and worded on-demand, never wiki-first (see docs-context.ts). + const docsProject = deriveProjectKey(input.cwd ?? process.cwd()).key; const docsNote = creds?.token - ? docsWikiContextNote(creds.orgId ?? "", deriveProjectKey(input.cwd ?? process.cwd()).key) + ? docsWikiContextNote(creds.orgId ?? "", docsProject) : ""; + // Docs "not set up" suggestion — inverse gate of docsNote. Fires once per + // (org, project) for a real indexed repo (graph present) that hasn't opted + // into docs, so the agent can surface the feature IF the user asks. Wording + // is descriptive, not imperative (anti prompt-injection); see docs-suggest.ts. + let suggestNote = ""; + if (creds?.token && !docsNote && graphLine) { + const docsOrg = creds.orgId ?? ""; + suggestNote = docsSuggestNote({ orgId: docsOrg, project: docsProject, graphPresent: true, isAutoEnabledFn: isAutoEnabled }); + if (suggestNote) { + // Best-effort: a suggestion write must never break SessionStart. + try { + markSuggested(docsOrg, docsProject, input.cwd ?? process.cwd(), new Date().toISOString()); + } catch { /* ignore — worst case the note shows again next session */ } + } + } + // Disclose the EFFECTIVE identity (after any `.hivemind` overlay), so a // directory that routes elsewhere (or opts out) is never silent. const effConfig = dirRes?.config ?? baseConfig; @@ -349,7 +368,7 @@ async function main(): Promise { const withRules = rulesBlock ? `${baseContext}\n\n${rulesBlock}` : baseContext; - const additionalContext = `${withRules}${graphNote}${docsNote}`; + const additionalContext = `${withRules}${graphNote}${docsNote}${suggestNote}`; console.log(JSON.stringify({ hookSpecificOutput: { From 4ba8224fa76bd3dc67b810c39f02ccf3fc6caa5a Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 23:58:55 +0000 Subject: [PATCH 3/4] feat(docs): add install-time docs onboarding hint (enable + disable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hivemind install` runs on a TTY, so it is the visible place to tell the user the docs feature exists — unlike the SessionStart note, which only the model sees. docsInstallLines() returns the block, covering BOTH directions: enable per repo with `hivemind docs sync`, turn off with `hivemind docs auto off`, and check state with `hivemind docs list`, so the user is never stuck after opting in. Test locks the enable/disable/status commands in place. --- src/docs/install-hint.ts | 19 +++++++++++++++++++ tests/shared/docs-install-hint.test.ts | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/docs/install-hint.ts create mode 100644 tests/shared/docs-install-hint.test.ts diff --git a/src/docs/install-hint.ts b/src/docs/install-hint.ts new file mode 100644 index 00000000..adece8be --- /dev/null +++ b/src/docs/install-hint.ts @@ -0,0 +1,19 @@ +/** + * Docs onboarding shown once, in the `hivemind install` output — the visible + * counterpart to the (model-only) SessionStart note. + * + * `hivemind install` runs on a TTY, so this is the honest place to TELL the + * user the docs feature exists and, crucially, how to turn it ON and OFF. + * Both directions in one block so the user is never stuck: enabling and + * disabling are one command each, and status is discoverable. + */ + +/** Lines describing docs enable/disable, for the install summary. */ +export function docsInstallLines(): string[] { + return [ + "Docs (optional): keep per-file and per-subsystem documentation in sync with your code on every commit.", + " Enable in a repo: hivemind docs sync (one-time consent; opt into per-commit auto-sync when asked)", + " Turn it off later: hivemind docs auto off", + " Check status: hivemind docs list", + ]; +} diff --git a/tests/shared/docs-install-hint.test.ts b/tests/shared/docs-install-hint.test.ts new file mode 100644 index 00000000..8e0c832d --- /dev/null +++ b/tests/shared/docs-install-hint.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { docsInstallLines } from "../../src/docs/install-hint.js"; + +describe("docsInstallLines (install-time docs onboarding)", () => { + it("explains both enabling and disabling in one block", () => { + const text = docsInstallLines().join("\n"); + // Enable path. + expect(text).toContain("hivemind docs sync"); + // Disable path — the user must never be stuck after opting in. + expect(text).toContain("hivemind docs auto off"); + // Status is discoverable. + expect(text).toContain("hivemind docs list"); + }); + + it("is a non-empty list of plain strings (safe to feed line-by-line to log)", () => { + const lines = docsInstallLines(); + expect(lines.length).toBeGreaterThan(0); + expect(lines.every((l) => typeof l === "string" && l.length > 0)).toBe(true); + }); +}); From 938dceab36745e7db4162db6046d2209dcb48733 Mon Sep 17 00:00:00 2001 From: Emanuele Fenocchi Date: Fri, 10 Jul 2026 23:58:55 +0000 Subject: [PATCH 4/4] feat(docs): print the docs onboarding hint in the install summary Emit docsInstallLines() near the end of runInstallAll, right before the "Done. Restart" line, so it lands in the normal install output series. --- src/cli/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/cli/index.ts b/src/cli/index.ts index 3b153983..9b38b33c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -33,6 +33,7 @@ import { runFlushMemory } from "../commands/flush-memory.js"; import { maybeAutoBackfillMemory } from "../skillify/spawn-backfill-memory-worker.js"; import { confirm, detectPlatforms, allPlatformIds, log, promptLine, warn, type PlatformId } from "./util.js"; import { getVersion } from "./version.js"; +import { docsInstallLines } from "../docs/install-hint.js"; import { runUpdate } from "./update.js"; import { renderCliHelpBlock } from "./skillify-spec.js"; import { maybeAutoMineLocal } from "../skillify/spawn-mine-local-worker.js"; @@ -395,6 +396,9 @@ async function runInstallAll(args: string[]): Promise { log("Mining your past sessions for team memory in the background — sign in, then run `hivemind memory flush` to push."); } + log(""); + for (const line of docsInstallLines()) log(line); + log(""); log("Done. Restart each assistant to activate hooks."); }