Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -395,6 +396,9 @@ async function runInstallAll(args: string[]): Promise<void> {
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.");
}
Expand Down
125 changes: 125 additions & 0 deletions src/docs/docs-suggest.ts
Original file line number Diff line number Diff line change
@@ -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<SuggestedRegistry>;
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 "";
}
}
19 changes: 19 additions & 0 deletions src/docs/install-hint.ts
Original file line number Diff line number Diff line change
@@ -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",
];
}
23 changes: 21 additions & 2 deletions src/hooks/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -326,10 +328,27 @@ async function main(): Promise<void> {
// 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;
Expand All @@ -349,7 +368,7 @@ async function main(): Promise<void> {
const withRules = rulesBlock
? `${baseContext}\n\n${rulesBlock}`
: baseContext;
const additionalContext = `${withRules}${graphNote}${docsNote}`;
const additionalContext = `${withRules}${graphNote}${docsNote}${suggestNote}`;

console.log(JSON.stringify({
hookSpecificOutput: {
Expand Down
20 changes: 20 additions & 0 deletions tests/shared/docs-install-hint.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
110 changes: 110 additions & 0 deletions tests/shared/docs-suggest.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});