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."); } 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/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/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: { 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); + }); +}); 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); + }); +});