Skip to content

Commit 77bbb93

Browse files
authored
feat(coding-agents): optInOnly — run memory only in projects that were opted in (#3433)
Closes #3427. By default every project gets memory, which is what makes the plugin zero-setup. For shared machines and client work that is the wrong default: unrelated directories create banks nobody asked for, and there was no way to say "off unless I name it". { "optInOnly": true, "optInPaths": ["~/work/client-x", "~/oss"] } Anything outside those paths is inert — no bank created, nothing retained, no seed — and the agent behaves as it would without the plugin. Approval is deliberately separate from routing. `optInPaths` says WHICH PROJECTS, not which bank, so an approved repo keeps its usual coding-agent::{gitProject} name and approving costs no naming decisions. Paths are prefixes with `~` expanded, so approving ~/work approves the repos under it while each still gets its own bank. That is why this is not built on mapPathToBank, which the issue reporter and I both first reached for: using it for approval forces you to name a bank per project and collapses a whole tree into one. A mapPathToBank entry does count as opted in — routing a path to a named bank already declares that project. A bare bankId does not: it names a bank rather than a project, so it cannot express which work may be remembered, and a privacy switch has to fail closed. Enforced through the `disabled` gate every entry point already checks after bank resolution: applyBankConfig takes the directory the bank came from and returns a disabled config when it is not opted in. That reuses a path already known to stop a run before anything creates a bank, rather than adding a second thing nine call sites must remember. Verified end to end against a live server as well as in unit tests: an unlisted project produced no plugin events and no bank, while an opted-in one under the same config seeded, injected and retained normally. Why not a `.hindsight.json` in the repo (the issue's Idea C): the config module deliberately reads no repo-carried file, because an untrusted repository must not be able to influence memory behaviour — and here it would let a cloned repo turn memory ON, which is exactly backwards for a privacy control.
1 parent 64ede36 commit 77bbb93

15 files changed

Lines changed: 266 additions & 14 deletions

File tree

hindsight-docs/docs-integrations/coding-agents.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,32 @@ The map-valued settings (`mapPathToBank`, `harnesses`, `banks`, `retainMetadata`
284284
per-key branching doesn't survive flattening into one variable. `maxParallelRetains` is available
285285
as `HINDSIGHT_MAX_PARALLEL_RETAINS` for containers and CI.
286286

287+
### Opt-in only
288+
289+
By default every project gets memory — that is what makes the plugin zero-setup. If you would
290+
rather nothing be remembered until you say so, turn memory off everywhere and name the projects
291+
that may use it:
292+
293+
```jsonc
294+
{
295+
"optInOnly": true,
296+
"optInPaths": ["~/work/client-x", "~/oss"],
297+
}
298+
```
299+
300+
Anything outside those paths is **inert**: no bank is created, nothing is retained, no seed runs,
301+
and the agent behaves exactly as it would without the plugin. Approving costs nothing else —
302+
`optInPaths` says _which projects_, not _which bank_, so an approved repo keeps its usual
303+
`coding-agent::{gitProject}` name. Paths are prefixes, so approving `~/work` approves every repo
304+
under it while each still gets its own bank.
305+
306+
A `mapPathToBank` entry counts as opted in too, since routing a path to a named bank already
307+
declares that project. A bare `bankId` does not: it names a bank rather than a project, so it
308+
cannot say which work may be remembered, and a privacy switch has to fail closed.
309+
310+
There is no per-repo opt-in file, for the same reason there is no repo-carried config at all: a
311+
cloned repository must not be able to turn memory on.
312+
287313
There is deliberately no repo-carried config file — per-repo bank routing is `mapPathToBank`,
288314
per-agent differences are `harnesses.<name>`.
289315

@@ -310,6 +336,8 @@ hook by Codex...), so one shared config serves several agents side by side:
310336
| `dynamicBankId` | dynamic iff no `bankId` | force dynamic (`true`) or static (`false`) resolution |
311337
| `bankIdTemplate` | `"coding-agent::{gitProject}"` | dynamic bank id format; the default makes every agent share one bank per repo |
312338
| `mapPathToBank` || absolute path → bank; **longest prefix wins**; overrides everything |
339+
| `optInOnly` | `false` | run memory ONLY in opted-in projects — everything else is inert, with no bank created; see Opt-in only |
340+
| `optInPaths` || directories opted in, matched as prefixes with `~` expanded; each repo beneath keeps its own dynamic bank |
313341
| `resolveWorktrees` | `true` | `{gitProject}`: linked worktrees share the main repo's bank |
314342
| `retainTags` || extra tags on every document written by the integration, e.g. `["project:{gitProject}"]` — see **Recording where a memory came from** below |
315343
| `retainMetadata` || extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` |

hindsight-integrations/coding-agents/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,32 @@ The map-valued settings (`mapPathToBank`, `harnesses`, `banks`, `retainMetadata`
277277
per-key branching doesn't survive flattening into one variable. `maxParallelRetains` is available
278278
as `HINDSIGHT_MAX_PARALLEL_RETAINS` for containers and CI.
279279

280+
### Opt-in only
281+
282+
By default every project gets memory — that is what makes the plugin zero-setup. If you would
283+
rather nothing be remembered until you say so, turn memory off everywhere and name the projects
284+
that may use it:
285+
286+
```jsonc
287+
{
288+
"optInOnly": true,
289+
"optInPaths": ["~/work/client-x", "~/oss"],
290+
}
291+
```
292+
293+
Anything outside those paths is **inert**: no bank is created, nothing is retained, no seed runs,
294+
and the agent behaves exactly as it would without the plugin. Approving costs nothing else —
295+
`optInPaths` says _which projects_, not _which bank_, so an approved repo keeps its usual
296+
`coding-agent::{gitProject}` name. Paths are prefixes, so approving `~/work` approves every repo
297+
under it while each still gets its own bank.
298+
299+
A `mapPathToBank` entry counts as opted in too, since routing a path to a named bank already
300+
declares that project. A bare `bankId` does not: it names a bank rather than a project, so it
301+
cannot say which work may be remembered, and a privacy switch has to fail closed.
302+
303+
There is no per-repo opt-in file, for the same reason there is no repo-carried config at all: a
304+
cloned repository must not be able to turn memory on.
305+
280306
There is deliberately no repo-carried config file — per-repo bank routing is `mapPathToBank`,
281307
per-agent differences are `harnesses.<name>`.
282308

@@ -303,6 +329,8 @@ hook by Codex...), so one shared config serves several agents side by side:
303329
| `dynamicBankId` | dynamic iff no `bankId` | force dynamic (`true`) or static (`false`) resolution |
304330
| `bankIdTemplate` | `"coding-agent::{gitProject}"` | dynamic bank id format; the default makes every agent share one bank per repo |
305331
| `mapPathToBank` || absolute path → bank; **longest prefix wins**; overrides everything |
332+
| `optInOnly` | `false` | run memory ONLY in opted-in projects — everything else is inert, with no bank created; see [Opt-in only](#opt-in-only) |
333+
| `optInPaths` || directories opted in, matched as prefixes with `~` expanded; each repo beneath keeps its own dynamic bank |
306334
| `resolveWorktrees` | `true` | `{gitProject}`: linked worktrees share the main repo's bank |
307335
| `retainTags` || extra tags on every document written by the integration, e.g. `["project:{gitProject}"]` — see **Recording where a memory came from** below |
308336
| `retainMetadata` || extra metadata on every document written by the integration, e.g. `{"repo": "{gitProject}"}` |

hindsight-integrations/coding-agents/src/antigravity-statusline.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function buildAntigravityStatusLine(state: AntigravityStatusLineState, cf
1919
const cwd = state.cwd || state.workspace?.current_dir;
2020
if (!cwd) return brandWord();
2121

22-
const resolved = applyBankConfig(cfg, deriveBankId(cfg, cwd, "antigravity-cli"));
22+
const resolved = applyBankConfig(cfg, deriveBankId(cfg, cwd, "antigravity-cli"), cwd);
2323
return resolved.cfg.disabled ? "" : `${brandWord()} · ${resolved.bankId}`;
2424
}
2525

hindsight-integrations/coding-agents/src/cline.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,11 @@ export function createClineHooks(
185185
function createRuntime(workspaceRoot: string | undefined): RuntimeCore | undefined {
186186
let cfg = loadConfig({ harness: HARNESS });
187187
if (cfg.disabled) return undefined;
188-
const resolved = applyBankConfig(cfg, deriveBankId(cfg, workspaceRoot || process.cwd(), HARNESS));
188+
const resolved = applyBankConfig(
189+
cfg,
190+
deriveBankId(cfg, workspaceRoot || process.cwd(), HARNESS),
191+
workspaceRoot || process.cwd()
192+
);
189193
cfg = resolved.cfg;
190194
if (cfg.disabled) return undefined;
191195
const client = new HindsightClient({

hindsight-integrations/coding-agents/src/core/bank.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export interface BankConfig {
3232
bankIdTemplate?: string;
3333
mapPathToBank?: Record<string, string>;
3434
resolveWorktrees?: boolean; // default true: worktrees share the main repo's bank
35+
optInOnly?: boolean; // memory runs ONLY where opted in (see isOptedIn)
36+
optInPaths?: string[]; // directories opted in, matched as prefixes
3537
}
3638

3739
const DEFAULT_BANK_NAME = "coding";
@@ -123,20 +125,55 @@ function gitProjectName(directory: string, resolveWorktrees: boolean): string {
123125
return dirName(directory);
124126
}
125127

128+
/** A configured directory, `~`-expanded and normalised, without a trailing separator. */
129+
function configuredDir(dir: string): string {
130+
const expanded = dir === "~" || dir.startsWith("~/") ? join(homedir(), dir.slice(1)) : dir;
131+
return normalize(expanded).replace(new RegExp(`\\${sep}+$`), "");
132+
}
133+
134+
/** Whether `directory` IS `configured`, or lives under it. */
135+
function isWithin(directory: string, configured: string): boolean {
136+
return directory === configured || directory.startsWith(configured + sep);
137+
}
138+
126139
/** Longest-prefix match of `directory` against the map's absolute paths (exact or ancestor). */
127140
function mapLookup(map: Record<string, string>, directory: string): string | undefined {
128141
const cwd = normalize(directory);
129142
let best: { len: number; bank: string } | undefined;
130143
for (const [dir, bank] of Object.entries(map)) {
131-
const expanded = dir === "~" || dir.startsWith("~/") ? join(homedir(), dir.slice(1)) : dir;
132-
const p = normalize(expanded).replace(new RegExp(`\\${sep}+$`), "");
133-
if (cwd === p || cwd.startsWith(p + sep)) {
144+
const p = configuredDir(dir);
145+
if (isWithin(cwd, p)) {
134146
if (!best || p.length > best.len) best = { len: p.length, bank };
135147
}
136148
}
137149
return best?.bank;
138150
}
139151

152+
/**
153+
* Whether memory may run for this directory at all.
154+
*
155+
* Off by default: without `optInOnly` every project gets memory, which is what makes the plugin
156+
* zero-setup. With it, the plugin stays inert — no bank, no retain, no seed — unless the directory
157+
* was named on purpose, which means one of:
158+
*
159+
* - it is under an `optInPaths` entry (prefix-matched, so approving a directory approves the
160+
* repos beneath it while each keeps its own dynamic bank), or
161+
* - it is under a `mapPathToBank` entry, since routing a path to a named bank is already a
162+
* deliberate declaration of that project.
163+
*
164+
* A bare `bankId` deliberately does NOT approve anything: it names a bank, not a project, so it
165+
* cannot express which work is allowed to be remembered. Under `optInOnly` an unlisted project is
166+
* inert even then — a privacy switch has to fail closed.
167+
*/
168+
export function isOptedIn(config: BankConfig, directory: string): boolean {
169+
if (!config.optInOnly) return true;
170+
if (!directory) return false;
171+
const cwd = normalize(directory);
172+
if ((config.optInPaths ?? []).some((dir) => dir && isWithin(cwd, configuredDir(dir))))
173+
return true;
174+
return Boolean(config.mapPathToBank && mapLookup(config.mapPathToBank, directory));
175+
}
176+
140177
/** Derive the bank id for a working directory (see module doc for the resolution order). */
141178
export function deriveBankId(config: BankConfig, directory: string, harness = "coding"): string {
142179
const mapped =

hindsight-integrations/coding-agents/src/core/config.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { readFileSync } from "node:fs";
1515
import { homedir } from "node:os";
1616
import { join } from "node:path";
1717
import { DEFAULT_SEED_LIMIT } from "./seed";
18+
import { isOptedIn } from "./bank";
1819

1920
/** Default config-file path: ~/.hindsight/coding-agent.json */
2021
export // HINDSIGHT_CONFIG joins the two env exceptions (diag/log files): it points at THE config file,
@@ -63,6 +64,14 @@ export interface RawConfig {
6364
// placeholders: {gitProject} {project} {harness} {channel} {user} (see core/bank.ts)
6465
mapPathToBank?: Record<string, string>; // absolute path -> bank; longest prefix wins; overrides everything
6566
resolveWorktrees?: boolean; // {gitProject}: worktrees share the main repo's bank (default true)
67+
/** Run memory ONLY in projects that were opted in (default false: every project gets memory,
68+
* which is what makes the plugin zero-setup). With it on, an unlisted project is inert — no
69+
* bank is created, nothing is retained, no seed runs — so unrelated work leaves no trace. */
70+
optInOnly?: boolean;
71+
/** Directories opted in, matched as PREFIXES with `~` expanded: approving a directory approves
72+
* the repos beneath it, and each still gets its own dynamic bank. A `mapPathToBank` entry counts
73+
* as opted in too, since routing a path to a named bank already declares that project. */
74+
optInPaths?: string[];
6675
harness?: string; // runtime adapter (default "opencode")
6776
disabled?: boolean; // hard off-switch — inert plugin, for a no-memory baseline (default false)
6877
retainSessions?: boolean; // opencode plugin write-back (default true; set false to opt out). Hook harnesses always write back on Stop and ignore this flag.
@@ -127,6 +136,8 @@ export interface Config {
127136
bankIdTemplate?: string;
128137
mapPathToBank?: Record<string, string>;
129138
resolveWorktrees?: boolean;
139+
optInOnly: boolean;
140+
optInPaths: string[];
130141
harness: string;
131142
disabled: boolean;
132143
retainSessions: boolean;
@@ -173,6 +184,11 @@ export function resolveConfig(raw: RawConfig = {}): Config {
173184
bankIdTemplate: raw.bankIdTemplate,
174185
mapPathToBank: raw.mapPathToBank,
175186
resolveWorktrees: raw.resolveWorktrees,
187+
optInOnly: raw.optInOnly ?? false,
188+
// Same shape as retainTags: a config typo must not become a path that silently approves nothing.
189+
optInPaths: Array.isArray(raw.optInPaths)
190+
? raw.optInPaths.filter((p): p is string => typeof p === "string" && p.trim() !== "")
191+
: [],
176192
harness: raw.harness ?? "opencode",
177193
disabled: raw.disabled ?? false,
178194
retainSessions: raw.retainSessions ?? true, // opencode: write back by default (parity with hook-harness Stop)
@@ -273,6 +289,8 @@ const ENV_KEYS = {
273289
dynamicBankId: "HINDSIGHT_DYNAMIC_BANK_ID",
274290
bankIdTemplate: "HINDSIGHT_BANK_ID_TEMPLATE",
275291
resolveWorktrees: "HINDSIGHT_RESOLVE_WORKTREES",
292+
optInOnly: "HINDSIGHT_OPT_IN_ONLY",
293+
optInPaths: "HINDSIGHT_OPT_IN_PATHS",
276294
harness: "HINDSIGHT_HARNESS",
277295
disabled: "HINDSIGHT_DISABLED",
278296
retainSessions: "HINDSIGHT_RETAIN_SESSIONS",
@@ -297,13 +315,14 @@ const ENV_KEYS = {
297315
const ENV_BOOLEANS = new Set<keyof RawConfig>([
298316
"dynamicBankId",
299317
"resolveWorktrees",
318+
"optInOnly",
300319
"disabled",
301320
"retainSessions",
302321
"autoReflect",
303322
"autoSeed",
304323
"codebaseSurvey",
305324
]);
306-
const ENV_LISTS = new Set<keyof RawConfig>(["retainTags"]);
325+
const ENV_LISTS = new Set<keyof RawConfig>(["retainTags", "optInPaths"]);
307326
const ENV_NUMBERS = new Set<keyof RawConfig>([
308327
"apiPort",
309328
"daemonIdleTimeout",
@@ -366,6 +385,10 @@ const BANK_OVERRIDE_EXCLUDED = [
366385
"mapPathToBank",
367386
"dynamicBankId",
368387
"resolveWorktrees",
388+
// Approval is decided BEFORE the bank is resolved, so a `banks.<id>` section naming these could
389+
// only ever arrive too late to matter — strip them rather than let them read as effective.
390+
"optInOnly",
391+
"optInPaths",
369392
"harness",
370393
] as const;
371394

@@ -374,7 +397,18 @@ const BANK_OVERRIDE_EXCLUDED = [
374397
* that runs AFTER bank resolution, giving per-repo opt-in/out (disable, retainSessions, gitIngest,
375398
* survey settings, ...) from the ONE global config file.
376399
*/
377-
export function applyBankConfig(cfg: Config, resolvedId: string): { cfg: Config; bankId: string } {
400+
export function applyBankConfig(
401+
cfg: Config,
402+
resolvedId: string,
403+
/** The directory the bank was resolved FROM. Supplying it enforces `optInOnly`; every entry
404+
* point already has it to hand, having just passed it to `deriveBankId`. */
405+
directory?: string
406+
): { cfg: Config; bankId: string } {
407+
// Rides the `disabled` gate every entry point already checks after bank resolution, rather than
408+
// adding a second thing eight call sites must remember — and that gate is known to stop the run
409+
// before anything creates a bank.
410+
if (directory !== undefined && !isOptedIn(cfg, directory))
411+
return { cfg: { ...cfg, disabled: true }, bankId: resolvedId };
378412
const section = cfg.banks[resolvedId];
379413
if (!section) return { cfg, bankId: resolvedId };
380414
const safe: Record<string, unknown> = { ...section };

hindsight-integrations/coding-agents/src/core/hook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export async function runHook(
228228
const out = (context: string | undefined, notice?: string) =>
229229
process.stdout.write(JSON.stringify(spec.emit(context ?? "", notice, ev)));
230230

231-
const resolved = applyBankConfig(cfg, deriveBankId(cfg, cwd, spec.harness));
231+
const resolved = applyBankConfig(cfg, deriveBankId(cfg, cwd, spec.harness), cwd);
232232
cfg = resolved.cfg;
233233
const bankId = resolved.bankId;
234234
if (cfg.disabled) {

0 commit comments

Comments
 (0)