Skip to content
Merged
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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,45 @@ All notable changes to engram are documented here. Format based on
now regenerate their output files on source-file delete (not just on
reindex), so generated artifacts no longer keep stale references to
deleted sources.
- **`engram reindex <file>` CLI subcommand**
([#8](https://github.com/NickCirv/engram/issues/8)) — re-indexes a
single file into the knowledge graph. The missing primitive for per-
edit freshness: Claude Code PostToolUse hooks, editor plugins, and CI
can now keep the graph in sync without running a long-lived watcher.
Reuses `syncFile()` so semantics match `engram watch`: exists →
reindex; missing-but-previously-indexed → prune; unsupported ext or
ignored directory → silent exit 0 (safe to fire on every edit). On
success prints a single line `engram: reindexed <file> (<N> nodes)`
(or `pruned`) using locale-stable `formatThousands`. `--verbose`
surfaces stack traces; default error output is a single stderr line.
Missing graph exits 1 with `engram: no graph found at <root>. Run
'engram init' first.`, matching `engram watch`.
- **`formatReindexLine(result, displayPath)`** exported from
`src/watcher.ts` — pure formatter shared by the new subcommand. Returns
`null` for skipped results so callers stay silent.
- **`engram reindex-hook` subcommand + `engram install-hook --auto-reindex`**
([#8](https://github.com/NickCirv/engram/issues/8), opt-in auto-wire).
`reindex-hook` reads Claude Code's PostToolUse payload from stdin and
re-indexes `tool_input.file_path` via the shared `syncFile()` primitive.
Contract: ALWAYS exits 0 — malformed JSON, missing fields, non-project
`cwd`, and all internal errors resolve to a silent no-op so the hook
can never fail Claude Code's tool cycle. `install-hook --auto-reindex`
appends a second PostToolUse entry with matcher `Edit|Write|MultiEdit`
calling `engram reindex-hook`; off by default so existing users aren't
surprised. The new entry is recognized by `isEngramHookEntry()` so
`engram uninstall-hook` strips it alongside the primary intercept
entries. Idempotent — reinstalling with `--auto-reindex` is a no-op
when the entry already exists.
- **`runReindexHook(payload)`** exported from `src/watcher.ts` — the
pure async handler behind the `reindex-hook` subcommand. Validates
payload shape, resolves project root from `cwd`, delegates to
`syncFile`. Swallows every error.
- **`buildReindexHookEntry()` + `ENGRAM_REINDEX_HOOK_MATCHER`
(`"Edit|Write|MultiEdit"`) + `DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND`
(`"engram reindex-hook"`)** exported from `src/intercept/installer.ts`
— the data primitives for the optional entry. Added
`InstallOptions.autoReindex` and `InstallResult.autoReindexAdded` to
thread the opt-in through the existing installer surface.

### Notes

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ engram install-hook # default: .claude/settings.local.json (git
engram install-hook --scope project # .claude/settings.json (committed)
engram install-hook --scope user # ~/.claude/settings.json (global)
engram install-hook --dry-run # preview changes without writing
engram install-hook --auto-reindex # also keep the graph fresh after every Edit/Write/MultiEdit (#8)
```

**Kill switch (if anything goes wrong):**
Expand Down Expand Up @@ -336,6 +337,8 @@ engram hook-enable # remove kill switch

```bash
engram watch [path] # live file watcher — incremental re-index on save
engram reindex <file> # re-index one file (editor/hook/CI primitive, issue #8)
engram reindex-hook # PostToolUse hook entry point (reads JSON from stdin, always exits 0)
engram dashboard [path] # live terminal dashboard
engram hud-label [path] # JSON label for Claude HUD --extra-cmd integration
engram hooks install # install post-commit + post-checkout git hooks
Expand Down
134 changes: 131 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ import { install as installHooks, uninstall as uninstallHooks, status as hooksSt
import { formatThousands } from "./graph/render-utils.js";
import { autogen } from "./autogen.js";
import { dispatchHook } from "./intercept/dispatch.js";
import { watchProject } from "./watcher.js";
import {
watchProject,
syncFile,
formatReindexLine,
runReindexHook,
} from "./watcher.js";
import { startDashboard } from "./dashboard.js";
import { handleCursorBeforeReadFile } from "./intercept/cursor-adapter.js";
import {
Expand Down Expand Up @@ -198,6 +203,105 @@ program
await new Promise(() => {});
});

/**
* engram reindex <file> — re-index a single file into the knowledge
* graph. Primitive for per-edit freshness via Claude Code PostToolUse
* hooks, editor plugins, or CI ([#8](https://github.com/NickCirv/engram/issues/8)).
*
* Shares `syncFile()` with `engram watch`, so semantics match: exists
* → reindex; missing-but-previously-indexed → prune; unsupported ext or
* ignored dir → silent skip. Silent skips keep stdout/stderr clean so
* the command is safe to fire on every edit from a hook.
*/
program
.command("reindex")
.description("Re-index a single file into the knowledge graph")
.argument("<file>", "File path (absolute or relative to --project)")
.option("-p, --project <path>", "Project directory", ".")
.option("--verbose", "Print stack traces on error", false)
.action(
async (file: string, opts: { project: string; verbose: boolean }) => {
const root = pathResolve(opts.project);
if (!existsSync(join(root, ".engram", "graph.db"))) {
console.error(
`engram: no graph found at ${root}. Run 'engram init' first.`
);
process.exit(1);
}
const absFile = pathResolve(root, file);
try {
const result = await syncFile(absFile, root);
const line = formatReindexLine(result, file);
if (line !== null) console.log(line);
process.exitCode = 0;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`engram: ${msg}`);
if (opts.verbose && err instanceof Error && err.stack) {
console.error(err.stack);
}
process.exit(1);
}
}
);

/**
* engram reindex-hook — PostToolUse hook entry point for the optional
* auto-reindex wiring ([#8](https://github.com/NickCirv/engram/issues/8)).
* Reads Claude Code's JSON payload from stdin, extracts
* `tool_input.file_path`, and delegates to `syncFile` (via
* `runReindexHook`). ALWAYS exits 0 — never blocks the hook.
*
* Shape contract matches `engram intercept`: bounded stdin read with a
* 3s watchdog, swallows parse errors, and sets `process.exitCode = 0`
* without calling `process.exit` so sql.js's WASM handle can drain
* cleanly (see the note on `intercept`).
*/
program
.command("reindex-hook")
.description(
"PostToolUse hook entry point: reads JSON from stdin, reindexes tool_input.file_path (always exits 0)"
)
.action(async () => {
const stdinTimeout = setTimeout(() => {
process.exit(0);
}, 3000);
stdinTimeout.unref();

let input = "";
let stdinFailed = false;
try {
for await (const chunk of process.stdin) {
input += chunk;
if (input.length > 1_000_000) break;
}
} catch {
stdinFailed = true;
}
clearTimeout(stdinTimeout);

if (stdinFailed || !input.trim()) {
process.exitCode = 0;
return;
}

let payload: unknown;
try {
payload = JSON.parse(input);
} catch {
process.exitCode = 0;
return;
}

try {
await runReindexHook(payload);
} catch {
// runReindexHook already swallows errors; this is belt-and-braces.
}

process.exitCode = 0;
});

program
.command("dashboard")
.alias("hud")
Expand Down Expand Up @@ -765,11 +869,17 @@ program
.option("--scope <scope>", "local | project | user", "local")
.option("--dry-run", "Show diff without writing", false)
.option("-p, --project <path>", "Project directory", ".")
.option(
"--auto-reindex",
"Also register a PostToolUse Edit|Write|MultiEdit entry calling 'engram reindex-hook' (keeps graph fresh after every edit, #8)",
false
)
.action(
async (opts: {
scope: string;
dryRun: boolean;
project: string;
autoReindex: boolean;
}) => {
const settingsPath = resolveSettingsPath(opts.scope, opts.project);
if (!settingsPath) {
Expand Down Expand Up @@ -802,14 +912,25 @@ program
}
}

const result = installEngramHooks(existing);
const result = installEngramHooks(existing, undefined, {
autoReindex: opts.autoReindex,
});

console.log(
chalk.bold(`\n📌 engram install-hook (scope: ${opts.scope})`)
);
console.log(chalk.dim(` Target: ${settingsPath}`));
if (opts.autoReindex) {
console.log(
chalk.dim(" Auto-reindex: enabled (engram reindex-hook)")
);
}

if (result.added.length === 0 && !result.statusLineAdded) {
if (
result.added.length === 0 &&
!result.statusLineAdded &&
!result.autoReindexAdded
) {
console.log(
chalk.yellow(
`\n All engram hooks already installed (${result.alreadyPresent.join(", ")}).`
Expand Down Expand Up @@ -869,6 +990,13 @@ program
chalk.green(" ✅ StatusLine: engram hud-label (HUD visible in Claude Code)")
);
}
if (result.autoReindexAdded) {
console.log(
chalk.green(
" ✅ PostToolUse: engram reindex-hook (matcher: Edit|Write|MultiEdit)"
)
);
}
if (result.alreadyPresent.length > 0) {
console.log(
chalk.dim(
Expand Down
97 changes: 93 additions & 4 deletions src/intercept/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ export const ENGRAM_PRETOOL_MATCHER = "Read|Edit|Write|Bash";
*/
export const DEFAULT_ENGRAM_COMMAND = "engram intercept";

/**
* Matcher for the optional auto-reindex PostToolUse entry installed by
* `engram install-hook --auto-reindex` (#8). Broader than the issue's
* initial `Edit|Write` because MultiEdit also produces file writes.
*/
export const ENGRAM_REINDEX_HOOK_MATCHER = "Edit|Write|MultiEdit";

/**
* Default command for the optional auto-reindex PostToolUse entry.
* Reads Claude Code's PostToolUse payload from stdin and re-indexes
* `tool_input.file_path`. Always exits 0 — never blocks a hook.
*/
export const DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND = "engram reindex-hook";

/**
* Default per-invocation timeout in seconds. Kept short (5s) because
* the Sentinel handlers should complete in well under 500ms each;
Expand Down Expand Up @@ -128,6 +142,24 @@ export function buildEngramHookEntries(
};
}

/**
* Build the optional auto-reindex PostToolUse entry (#8). Off by default
* when `engram install-hook` runs; added when the user passes
* `--auto-reindex` so existing installs aren't disturbed.
*
* Recognized by `isEngramHookEntry()` so `engram uninstall-hook` strips
* it alongside the primary `engram intercept` entries.
*/
export function buildReindexHookEntry(
command: string = DEFAULT_ENGRAM_REINDEX_HOOK_COMMAND,
timeout: number = DEFAULT_HOOK_TIMEOUT_SEC
): HookEntry {
return {
matcher: ENGRAM_REINDEX_HOOK_MATCHER,
hooks: [{ type: "command", command, timeout }],
};
}

/**
* Check whether a hook entry is engram-owned (based on command string
* inspection). Used to detect existing installs and target uninstalls.
Expand All @@ -139,7 +171,11 @@ export function isEngramHookEntry(entry: unknown): entry is HookEntry {
for (const h of e.hooks) {
if (h === null || typeof h !== "object") continue;
const cmd = (h as HookCommand).command;
if (typeof cmd === "string" && cmd.includes("engram intercept")) {
if (typeof cmd !== "string") continue;
if (
cmd.includes("engram intercept") ||
cmd.includes("engram reindex-hook")
) {
return true;
}
}
Expand All @@ -158,6 +194,21 @@ export interface InstallResult {
readonly alreadyPresent: readonly EngramHookEvent[];
/** Whether a statusLine entry was added for `engram hud-label`. */
readonly statusLineAdded: boolean;
/**
* Whether the optional `engram reindex-hook` PostToolUse entry was
* added this run (#8, opt-in via `--auto-reindex`). `false` when the
* option was disabled OR when the entry was already present.
*/
readonly autoReindexAdded: boolean;
}

/** Options for `installEngramHooks`. */
export interface InstallOptions {
/**
* Also register the optional `engram reindex-hook` PostToolUse entry
* (#8). Off by default so existing users aren't surprised.
*/
readonly autoReindex?: boolean;
}

/**
Expand All @@ -169,7 +220,8 @@ export interface InstallResult {
*/
export function installEngramHooks(
settings: ClaudeCodeSettings,
command: string = DEFAULT_ENGRAM_COMMAND
command: string = DEFAULT_ENGRAM_COMMAND,
options: InstallOptions = {}
): InstallResult {
const entries = buildEngramHookEntries(command);
const added: EngramHookEvent[] = [];
Expand All @@ -186,8 +238,14 @@ export function installEngramHooks(

for (const event of ENGRAM_HOOK_EVENTS) {
const eventArr = hooksClone[event] ?? [];
const hasEngram = eventArr.some((e) => isEngramHookEntry(e));
if (hasEngram) {
// Idempotence check targets the PRIMARY intercept entry specifically.
// Using `isEngramHookEntry` here would false-positive once the
// opt-in reindex-hook entry lands, causing install to skip adding
// the missing intercept entry.
const hasIntercept = eventArr.some((e) =>
entryContainsCommand(e, "engram intercept")
);
if (hasIntercept) {
alreadyPresent.push(event);
hooksClone[event] = eventArr;
continue;
Expand All @@ -196,6 +254,20 @@ export function installEngramHooks(
added.push(event);
}

// Optional auto-reindex entry — appended as a SECOND PostToolUse entry
// so it's orthogonal to the observer. Idempotent.
let autoReindexAdded = false;
if (options.autoReindex) {
const postToolArr = hooksClone.PostToolUse ?? [];
const hasReindexHook = postToolArr.some((e) =>
entryContainsCommand(e, "engram reindex-hook")
);
if (!hasReindexHook) {
hooksClone.PostToolUse = [...postToolArr, buildReindexHookEntry()];
autoReindexAdded = true;
}
}

// StatusLine: set `engram hud-label` only if no statusLine is configured.
// This gives users a visible HUD out of the box without overwriting any
// existing statusLine (e.g., claude-hud plugin or a custom command).
Expand All @@ -215,9 +287,26 @@ export function installEngramHooks(
added,
alreadyPresent,
statusLineAdded,
autoReindexAdded,
};
}

/**
* True when any of the entry's commands contains the given substring.
* Used for targeted idempotence checks in `installEngramHooks` — each
* engram-owned entry has a distinguishing command, so substring match
* is sufficient.
*/
function entryContainsCommand(entry: HookEntry, substring: string): boolean {
if (!Array.isArray(entry.hooks)) return false;
for (const h of entry.hooks) {
if (h === null || typeof h !== "object") continue;
const cmd = (h as HookCommand).command;
if (typeof cmd === "string" && cmd.includes(substring)) return true;
}
return false;
}

/**
* Result of an uninstall operation. `removed` lists events where an
* engram entry was removed. Empty arrays and empty `hooks` object are
Expand Down
Loading
Loading