memtrace-skills 1.2.10 → 1.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@ export interface InstallOptions {
3
3
  only?: string[];
4
4
  skipMcp?: boolean;
5
5
  yes?: boolean;
6
+ mode?: 'refresh';
6
7
  }
7
8
  export declare function resolveMemtraceBinary(): Promise<string | null>;
8
9
  export declare function runInstall(options: InstallOptions): Promise<void>;
@@ -48,7 +48,7 @@ export async function runInstall(options) {
48
48
  console.error(' Error: No skills found. Ensure skills/ directory exists.\n');
49
49
  process.exit(1);
50
50
  }
51
- const memtraceBin = await resolveMemtraceBinary();
51
+ const memtraceBin = options.mode === 'refresh' ? '' : await resolveMemtraceBinary();
52
52
  if (memtraceBin === null) {
53
53
  console.warn(' ⚠ memtrace binary not found on PATH.');
54
54
  console.warn(' MCP registration will be skipped for every agent.\n');
@@ -58,7 +58,8 @@ export async function runInstall(options) {
58
58
  scope: options.scope,
59
59
  cwd: process.cwd(),
60
60
  memtraceBinary: memtraceBin ?? '',
61
- skipMcp: options.skipMcp || memtraceBin === null,
61
+ skipMcp: options.mode === 'refresh' || options.skipMcp || memtraceBin === null,
62
+ mode: options.mode,
62
63
  };
63
64
  for (const t of transformers) {
64
65
  console.log(` [${t.name}] installing...`);
package/dist/index.js CHANGED
@@ -30,6 +30,15 @@ program
30
30
  };
31
31
  await runInstall(options);
32
32
  });
33
+ program
34
+ .command('refresh')
35
+ .description('Update skills without registering MCP servers or enabling plugins and hooks')
36
+ .option('--only <agents>', 'comma-separated agent names', parseOnly)
37
+ .option('--local', 'refresh project-scoped skills', false)
38
+ .option('-y, --yes', 'non-interactive')
39
+ .action(async (opts) => {
40
+ await runInstall({ scope: opts.local ? 'local' : 'global', only: opts.only, yes: opts.yes, mode: 'refresh' });
41
+ });
33
42
  program
34
43
  .command('doctor')
35
44
  .description('Check memtrace install health across all agents')
@@ -0,0 +1,4 @@
1
+ export declare const RECEIPT_SUFFIX = ".memtrace-managed.sha256";
2
+ export declare function skillDigest(content: string): string;
3
+ export declare function writeManagedSkill(rootDir: string, name: string, content: string, steering?: boolean): boolean;
4
+ export declare function removeManagedSkills(rootDir: string, steering?: boolean): void;
@@ -0,0 +1,95 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { createHash } from 'crypto';
4
+ import { fileURLToPath } from 'url';
5
+ // A name is not ownership. Receipts bind the exact last-installed bytes; the
6
+ // stock catalogue admits upgrades from installers that predate receipts.
7
+ const stockFile = fileURLToPath(new URL('../skills/managed-stock-sha256.txt', import.meta.url));
8
+ const stock = new Set(fs.readFileSync(stockFile, 'utf8').split(/\r?\n/).filter(line => !line.startsWith('#')));
9
+ export const RECEIPT_SUFFIX = '.memtrace-managed.sha256';
10
+ export function skillDigest(content) {
11
+ return createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex');
12
+ }
13
+ function safeFile(rootDir, relative) {
14
+ const parts = relative.split(/[\\/]/);
15
+ if (parts.some(part => !part || part === '.' || part === '..'))
16
+ return;
17
+ let current = path.resolve(rootDir);
18
+ if (fs.existsSync(current) && (!fs.lstatSync(current).isDirectory() || fs.lstatSync(current).isSymbolicLink()))
19
+ return;
20
+ for (const part of parts) {
21
+ current = path.join(current, part);
22
+ try {
23
+ const stat = fs.lstatSync(current);
24
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && current !== path.resolve(rootDir, relative)))
25
+ return;
26
+ }
27
+ catch (error) {
28
+ if (error.code !== 'ENOENT')
29
+ throw error;
30
+ }
31
+ }
32
+ try {
33
+ const receipt = fs.lstatSync(current + RECEIPT_SUFFIX);
34
+ if (!receipt.isFile() || receipt.isSymbolicLink())
35
+ return;
36
+ }
37
+ catch (error) {
38
+ if (error.code !== 'ENOENT')
39
+ throw error;
40
+ }
41
+ return current;
42
+ }
43
+ function owned(file, name, content) {
44
+ const digest = skillDigest(content);
45
+ if (stock.has(`${name} ${digest}`))
46
+ return true;
47
+ try {
48
+ return fs.readFileSync(file + RECEIPT_SUFFIX, 'utf8').trim() === digest;
49
+ }
50
+ catch (error) {
51
+ if (error.code !== 'ENOENT')
52
+ throw error;
53
+ return false;
54
+ }
55
+ }
56
+ export function writeManagedSkill(rootDir, name, content, steering = false) {
57
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name))
58
+ throw new Error(`Invalid managed skill name: ${name}`);
59
+ const file = safeFile(rootDir, steering ? `${name}.md` : `${name}/SKILL.md`);
60
+ if (!file)
61
+ return false;
62
+ if (fs.existsSync(file)) {
63
+ if (!fs.lstatSync(file).isFile())
64
+ return false;
65
+ const prior = fs.readFileSync(file, 'utf8');
66
+ if (prior !== content && !owned(file, name, prior)) {
67
+ console.warn(`memtrace: preserved customized skill ${file}`);
68
+ return false;
69
+ }
70
+ }
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ fs.writeFileSync(file, content);
73
+ fs.writeFileSync(file + RECEIPT_SUFFIX, `${skillDigest(content)}\n`);
74
+ return true;
75
+ }
76
+ export function removeManagedSkills(rootDir, steering = false) {
77
+ if (!fs.existsSync(rootDir) || fs.lstatSync(rootDir).isSymbolicLink())
78
+ return;
79
+ for (const entry of fs.readdirSync(rootDir)) {
80
+ const name = steering ? entry.replace(/\.md$/, '') : entry;
81
+ if (!/^memtrace-[a-z0-9-]+$/.test(name) || (steering && !entry.endsWith('.md')))
82
+ continue;
83
+ const file = safeFile(rootDir, steering ? entry : `${entry}/SKILL.md`);
84
+ if (!file || !fs.existsSync(file) || !fs.lstatSync(file).isFile())
85
+ continue;
86
+ if (!owned(file, name, fs.readFileSync(file, 'utf8')))
87
+ continue;
88
+ fs.unlinkSync(file);
89
+ if (fs.existsSync(file + RECEIPT_SUFFIX))
90
+ fs.unlinkSync(file + RECEIPT_SUFFIX);
91
+ // Never recursively delete a skill folder: user references/assets survive.
92
+ if (!steering && fs.readdirSync(path.dirname(file)).length === 0)
93
+ fs.rmdirSync(path.dirname(file));
94
+ }
95
+ }
package/dist/rail-hook.js CHANGED
@@ -5,7 +5,7 @@ import http from 'node:http';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { activeRailRuntimes, enrichEnabled, pidAlive, runtimeForCwd, ENRICH_HOOK_KILL_MS, SEARCH_COMMAND_RE, } from './rail-runtime.js';
8
+ import { activeRailRuntimes, bindSearch, enrichEnabled, pidAlive, ENRICH_HOOK_KILL_MS, SEARCH_COMMAND_RE, } from './rail-runtime.js';
9
9
  const argv = process.argv;
10
10
  function arg(name) {
11
11
  const index = argv.indexOf(name);
@@ -118,7 +118,8 @@ async function main() {
118
118
  const phase = arg('--phase');
119
119
  const event = arg('--event');
120
120
  const enrichPath = phase === 'post' || event === 'precompact';
121
- // Grep enrichment is opt-in: off means no stdin parse, no lease read, no HTTP.
121
+ // Explicit off (or a non-Nudge mode without opt-in) means no stdin parse,
122
+ // no lease read and no HTTP. Fresh Nudge enables bounded context by default.
122
123
  if (enrichPath && !enrichEnabled(env))
123
124
  return;
124
125
  const input = await readStdin();
@@ -131,19 +132,34 @@ async function main() {
131
132
  if (typeof cwd !== 'string')
132
133
  return;
133
134
  // Leases are read ONCE per call.
134
- const runtime = runtimeForCwd(cwd, activeRailRuntimes());
135
- if (!runtime)
136
- return;
137
- const alive = () => pidAlive(runtime.pid) && pidAlive(runtime.guardianPid);
135
+ const runtimes = activeRailRuntimes();
138
136
  if (event === 'precompact') {
137
+ // The dedupe lives in whichever runtime answered this session's
138
+ // searches, which a session above its repositories cannot tell.
139
139
  const session = typeof payload.session_id === 'string' ? payload.session_id : '';
140
- await postToDaemon(runtime, `/api/rail/enrich/reset?${new URLSearchParams({ session })}`, input, ENRICH_HOOK_KILL_MS);
140
+ const owner = bindSearch(payload, cwd, runtimes).runtime;
141
+ await Promise.all((owner ? [owner] : runtimes).map(runtime => postToDaemon(runtime, `/api/rail/enrich/reset?${new URLSearchParams({ session })}`, input, ENRICH_HOOK_KILL_MS)));
142
+ return;
143
+ }
144
+ const bound = bindSearch(payload, cwd, runtimes);
145
+ const runtime = bound.runtime;
146
+ if (!runtime) {
147
+ // Say why nothing was added: Memtrace runs, but for other folders. With
148
+ // nothing running at all the call stays silent and writes nothing.
149
+ if (phase === 'post' && runtimes.length) {
150
+ logHookTiming({ ts: new Date().toISOString(), host, phase: 'post', tool: payload.tool_name ?? null,
151
+ status: 0, outcome: 'no_runtime_for_cwd', bytes: 0, stdout_bytes: 0,
152
+ server_ms: 0, hook_ms: Date.now() - started });
153
+ }
141
154
  return;
142
155
  }
156
+ // The daemon resolves the repository from `cwd`: send the one bound.
157
+ const body = bound.cwd === cwd ? input : JSON.stringify({ ...payload, cwd: bound.cwd });
158
+ const alive = () => pidAlive(runtime.pid) && pidAlive(runtime.guardianPid);
143
159
  if (phase === 'post') {
144
160
  const query = new URLSearchParams({ host, phase: 'post', enrich: env.MEMTRACE_ENRICH || '',
145
161
  mode: env.MEMTRACE_RAIL || '', escape: env.MEMTRACE_RAW_SEARCH || '' });
146
- const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`, input, ENRICH_HOOK_KILL_MS);
162
+ const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`, body, ENRICH_HOOK_KILL_MS);
147
163
  let wrote = 0;
148
164
  let malformed = false;
149
165
  if (result.status === 200 && result.body && alive()) {
@@ -169,9 +185,9 @@ async function main() {
169
185
  // JSON-mode/OpenCode and Windsurf retain their CLI response contracts.
170
186
  // Other hosts use the in-process daemon router with no native binary spawn.
171
187
  if (!jsonMode && host !== 'windsurf' && !runtime.routeBinary) {
172
- const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '',
188
+ const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '', enrich: env.MEMTRACE_ENRICH || '',
173
189
  escape: env.MEMTRACE_RAW_SEARCH || '' });
174
- const result = await postToDaemon(runtime, `/api/rail/hook?${query}`, input, 5500);
190
+ const result = await postToDaemon(runtime, `/api/rail/hook?${query}`, body, 5500);
175
191
  if (result.status === 200 && result.body && alive()) {
176
192
  JSON.parse(result.body); // never emit malformed endpoint output into an agent
177
193
  process.stdout.write(result.body);
@@ -179,7 +195,7 @@ async function main() {
179
195
  return;
180
196
  }
181
197
  const child = spawn(runtime.routeBinary || runtime.binary, jsonMode ? ['route', '--json', '-'] : ['route', '--hook', '--host', host], {
182
- cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
198
+ cwd: bound.cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
183
199
  env: { ...env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
184
200
  });
185
201
  let output = '';
@@ -189,7 +205,7 @@ async function main() {
189
205
  child.stderr.on('data', chunk => { if (stderr.length < 2_000_000)
190
206
  stderr += chunk; });
191
207
  child.stdin.on('error', () => { });
192
- child.stdin.end(input);
208
+ child.stdin.end(body);
193
209
  const timer = setTimeout(() => child.kill(), 5500);
194
210
  // If the daemon exits while a request is in flight, discard the response.
195
211
  const monitor = setInterval(() => { if (!alive())
@@ -14,6 +14,48 @@ const port = Number(arg('--port'));
14
14
  const binary = arg('--binary');
15
15
  const cwd = arg('--cwd');
16
16
  const routeBinary = arg('--route-binary') || undefined;
17
+ /** Every repository the owner serves (`--root`, repeated). */
18
+ const roots = process.argv.flatMap((value, index) => process.argv[index - 1] === '--root' && path.isAbsolute(value) ? [value] : []);
19
+ const settingsFile = arg('--settings-file');
20
+ /** Saved Settings keys that decide which hooks are registered. */
21
+ const SETTINGS_KEYS = ['MEMTRACE_RAIL', 'MEMTRACE_ENRICH'];
22
+ const startupEnv = Object.fromEntries(SETTINGS_KEYS.map(key => [key, process.env[key]]));
23
+ /**
24
+ * Follow a Settings change while the owner runs. The daemon handed down the
25
+ * values it applied from the settings file (MEMTRACE_SETTINGS_APPLIED); those
26
+ * - and unset keys - track the file, while a variable the user set in the
27
+ * environment still wins, as everywhere else.
28
+ */
29
+ function followSavedSettings() {
30
+ if (!settingsFile)
31
+ return;
32
+ let applied = {};
33
+ try {
34
+ applied = JSON.parse(process.env.MEMTRACE_SETTINGS_APPLIED || '{}') ?? {};
35
+ }
36
+ catch { /* none applied */ }
37
+ let saved;
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
40
+ saved = parsed && typeof parsed.values === 'object' && parsed.values ? parsed.values : {};
41
+ }
42
+ catch (error) {
43
+ // A file being rewritten keeps the current choice; a deleted one is "nothing saved".
44
+ if (error.code !== 'ENOENT')
45
+ return;
46
+ saved = {};
47
+ }
48
+ for (const key of SETTINGS_KEYS) {
49
+ const initial = startupEnv[key];
50
+ if (initial !== undefined && applied[key] !== initial)
51
+ continue;
52
+ const value = saved[key];
53
+ if (typeof value === 'string' && value.trim())
54
+ process.env[key] = value;
55
+ else
56
+ delete process.env[key];
57
+ }
58
+ }
17
59
  async function main() {
18
60
  if (process.argv.includes('--sync')) {
19
61
  const live = activeRailRuntimes();
@@ -41,10 +83,11 @@ async function main() {
41
83
  stop();
42
84
  return;
43
85
  }
44
- writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, binary, port, routeBinary });
86
+ writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, roots, binary, port, routeBinary });
87
+ followSavedSettings();
45
88
  const enabled = !railDisabled();
46
89
  // Grep enrichment and the Rail mode decide WHICH hooks are registered,
47
- // so a rail.json change to either re-wires them without a restart.
90
+ // so a rail.json or Settings change to either re-wires them without a restart.
48
91
  const signature = enabled ? `on:${enrichEnabled()}:${railMode()}` : 'off';
49
92
  if (signature === wired)
50
93
  return;
@@ -7,6 +7,11 @@ export interface RailRuntime {
7
7
  port: number;
8
8
  /** Local development override; packaged hooks normally use the daemon HTTP API. */
9
9
  routeBinary?: string;
10
+ /**
11
+ * Repository roots the runtime serves besides `cwd` (its workspace
12
+ * members), so a session in a member it was not started in still reaches it.
13
+ */
14
+ roots?: string[];
10
15
  }
11
16
  export declare const LEASE_MAX_AGE_MS = 3000;
12
17
  export declare const LEASE_REFRESH_INTERVAL_MS = 1000;
@@ -34,10 +39,11 @@ export interface RailConfig {
34
39
  export declare function railConfig(): RailConfig;
35
40
  /**
36
41
  * Grep enrichment on/off: `MEMTRACE_ENRICH`, then rail.json `"enrich"`, then
37
- * OFF — the default stays off until the A/B gate passes.
42
+ * non-blocking Nudge mode. Explicit Observe, Off, Rail and Strict modes do
43
+ * not implicitly enable this additional post-search path.
38
44
  */
39
45
  export declare function enrichEnabled(env?: NodeJS.ProcessEnv, config?: RailConfig): boolean;
40
- /** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then observe. */
46
+ /** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then non-blocking nudge. */
41
47
  export declare function railMode(env?: NodeJS.ProcessEnv, config?: RailConfig): string;
42
48
  /**
43
49
  * Live leases. Reading them also deletes garbage: a lease whose owner and
@@ -46,7 +52,37 @@ export declare function railMode(env?: NodeJS.ProcessEnv, config?: RailConfig):
46
52
  */
47
53
  export declare function activeRailRuntimes(): RailRuntime[];
48
54
  export declare function railIsActive(): boolean;
55
+ /** The live runtime whose start folder or served repository contains `cwd`; the innermost wins. */
49
56
  export declare function runtimeForCwd(cwd: string, runtimes?: RailRuntime[]): RailRuntime | undefined;
57
+ /** The git checkout containing `start` (a directory or a file), if any. */
58
+ export declare function gitRootOf(start: string): string | undefined;
59
+ /**
60
+ * A linked worktree (`git worktree add ../x`) to its main checkout, through
61
+ * `.git` -> gitdir -> commondir. Anything else (a normal checkout, a
62
+ * submodule, an unreadable file) is returned unchanged.
63
+ */
64
+ export declare function mainCheckout(root: string): string;
65
+ /**
66
+ * Where a search looked, from the search itself: the Grep path, a leading
67
+ * `cd`, absolute path arguments, and the files its results name. Existing
68
+ * paths only, resolved against the session's working directory.
69
+ */
70
+ export declare function searchTargets(payload: any, cwd: string): string[];
71
+ /**
72
+ * Which runtime answers a search, and the directory to send it as `cwd`.
73
+ *
74
+ * The session's cwd alone misses common layouts: an orchestrator (and every
75
+ * subagent, which keeps its cwd) opened in a folder ABOVE the repositories, a
76
+ * daemon started in a repository's subfolder, and a linked worktree beside
77
+ * the main checkout. A session inside a covered repository keeps the plain
78
+ * cwd match; otherwise the repository the search itself names is bound, by
79
+ * its git root or its worktree's main checkout, and sent as the cwd so the
80
+ * daemon resolves that repository.
81
+ */
82
+ export declare function bindSearch(payload: any, cwd: string, runtimes: RailRuntime[]): {
83
+ runtime?: RailRuntime;
84
+ cwd: string;
85
+ };
50
86
  /**
51
87
  * Exec-form hook invocation. `extra` selects the hook's role: none for the
52
88
  * Rail PreToolUse router, `--phase post` for grep enrichment, `--event
@@ -63,7 +63,7 @@ export const ENRICH_HOOK_KILL_MS = 600;
63
63
  * searches worth a loopback round trip (design 4.1). `git tag v1` must not
64
64
  * match `ag`, so a program must start a word.
65
65
  */
66
- export const SEARCH_COMMAND_RE = /(^|[\s;|&(`])(rg|grep|egrep|fgrep|rgrep|ugrep|ug|ag|ack|git\s+grep)(\s|$)/;
66
+ export const SEARCH_COMMAND_RE = /(^|[\s;|&(`])(rg|grep|egrep|fgrep|rgrep|ugrep|ug|ag|ack|git\s+grep|select-string|sls|findstr(?:\.exe)?)(\s|$)/i;
67
67
  /** `~/.memtrace/rail.json` (or `MEMTRACE_RAIL_CONFIG`); `{}` when absent or unreadable. */
68
68
  export function railConfig() {
69
69
  const configPath = process.env.MEMTRACE_RAIL_CONFIG?.trim()
@@ -93,19 +93,28 @@ function flag(value) {
93
93
  }
94
94
  /**
95
95
  * Grep enrichment on/off: `MEMTRACE_ENRICH`, then rail.json `"enrich"`, then
96
- * OFF — the default stays off until the A/B gate passes.
96
+ * non-blocking Nudge mode. Explicit Observe, Off, Rail and Strict modes do
97
+ * not implicitly enable this additional post-search path.
97
98
  */
98
99
  export function enrichEnabled(env = process.env, config = railConfig()) {
99
- return flag(env.MEMTRACE_ENRICH) ?? (typeof config.enrich === 'boolean' ? config.enrich : false);
100
+ // Durable opt-outs remain effective even when an old agent inherited an
101
+ // enabling environment. This is the same no-hooks gate used by the lease.
102
+ if (env.MEMTRACE_INSTALL_NO_HOOKS === '1' || config.hooksEnabled === false
103
+ || (typeof config.mode === 'string' && config.mode.trim().toLowerCase() === 'off')
104
+ || env.MEMTRACE_RAIL?.trim().toLowerCase() === 'off'
105
+ || env.MEMTRACE_RAW_SEARCH?.trim().toLowerCase() === 'allow')
106
+ return false;
107
+ return flag(env.MEMTRACE_ENRICH)
108
+ ?? (typeof config.enrich === 'boolean' ? config.enrich : railMode(env, config) === 'nudge');
100
109
  }
101
- /** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then observe. */
110
+ /** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then non-blocking nudge. */
102
111
  export function railMode(env = process.env, config = railConfig()) {
103
112
  const valid = ['off', 'observe', 'nudge', 'rail', 'strict'];
104
113
  const fromEnv = env.MEMTRACE_RAIL?.trim().toLowerCase();
105
114
  if (fromEnv && valid.includes(fromEnv))
106
115
  return fromEnv;
107
116
  const fromConfig = typeof config.mode === 'string' ? config.mode.trim().toLowerCase() : '';
108
- return valid.includes(fromConfig) ? fromConfig : 'observe';
117
+ return valid.includes(fromConfig) ? fromConfig : 'nudge';
109
118
  }
110
119
  /**
111
120
  * Live leases. Reading them also deletes garbage: a lease whose owner and
@@ -137,6 +146,10 @@ export function activeRailRuntimes() {
137
146
  || typeof runtime.cwd !== 'string' || !path.isAbsolute(runtime.cwd)
138
147
  || !Number.isInteger(runtime.port) || runtime.port < 1 || runtime.port > 65535)
139
148
  return [];
149
+ // Older leases have no roots; a malformed entry never widens a lease.
150
+ runtime.roots = Array.isArray(runtime.roots)
151
+ ? runtime.roots.filter(root => typeof root === 'string' && path.isAbsolute(root))
152
+ : [];
140
153
  return [runtime];
141
154
  }
142
155
  catch {
@@ -155,16 +168,136 @@ function normalizedWorkspacePath(directory) {
155
168
  resolved = fs.realpathSync.native(resolved);
156
169
  }
157
170
  catch { /* Unavailable paths retain the existing lexical matching behavior. */ }
158
- const normalized = resolved.replace(/\\/g, '/').replace(/\/$/, '');
171
+ // A verbatim `\\?\C:\x` (a canonical Windows path) is the same folder as `C:\x`.
172
+ const normalized = resolved.replace(/\\/g, '/').replace(/^\/\/\?\/(UNC\/)?/i, (_m, unc) => unc ? '//' : '')
173
+ .replace(/\/$/, '');
159
174
  return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
160
175
  }
176
+ function leaseRoots(runtime) {
177
+ return [runtime.cwd, ...(runtime.roots ?? [])];
178
+ }
179
+ /** The live runtime whose start folder or served repository contains `cwd`; the innermost wins. */
161
180
  export function runtimeForCwd(cwd, runtimes = activeRailRuntimes()) {
162
181
  const target = normalizedWorkspacePath(cwd);
163
182
  return runtimes
164
- .map(runtime => ({ runtime, root: normalizedWorkspacePath(runtime.cwd) }))
165
- .filter(({ root }) => target === root || target.startsWith(root + '/'))
183
+ .map(runtime => ({ runtime, root: leaseRoots(runtime).map(normalizedWorkspacePath)
184
+ .filter(root => target === root || target.startsWith(root + '/'))
185
+ .sort((a, b) => b.length - a.length)[0] ?? '' }))
186
+ .filter(({ root }) => root !== '')
166
187
  .sort((a, b) => b.root.length - a.root.length || b.runtime.updatedAt - a.runtime.updatedAt)[0]?.runtime;
167
188
  }
189
+ function samePath(a, b) {
190
+ return normalizedWorkspacePath(a) === normalizedWorkspacePath(b);
191
+ }
192
+ /** The git checkout containing `start` (a directory or a file), if any. */
193
+ export function gitRootOf(start) {
194
+ let dir = path.resolve(start);
195
+ for (let depth = 0; depth < 64; depth++) {
196
+ if (fs.existsSync(path.join(dir, '.git')))
197
+ return dir;
198
+ const parent = path.dirname(dir);
199
+ if (parent === dir)
200
+ return undefined;
201
+ dir = parent;
202
+ }
203
+ return undefined;
204
+ }
205
+ /**
206
+ * A linked worktree (`git worktree add ../x`) to its main checkout, through
207
+ * `.git` -> gitdir -> commondir. Anything else (a normal checkout, a
208
+ * submodule, an unreadable file) is returned unchanged.
209
+ */
210
+ export function mainCheckout(root) {
211
+ try {
212
+ const dotGit = path.join(root, '.git');
213
+ if (!fs.statSync(dotGit).isFile())
214
+ return root;
215
+ const gitdir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'))?.[1]?.trim();
216
+ if (!gitdir)
217
+ return root;
218
+ const absoluteGitdir = path.resolve(root, gitdir);
219
+ const common = path.resolve(absoluteGitdir, fs.readFileSync(path.join(absoluteGitdir, 'commondir'), 'utf8').trim());
220
+ return path.basename(common) === '.git' ? path.dirname(common) : root;
221
+ }
222
+ catch {
223
+ return root;
224
+ }
225
+ }
226
+ const CD_RE = /(?:^|&&|;|\|\|)\s*(?:cd|pushd|set-location|sl)\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/i;
227
+ const ABSOLUTE_ARG_RE = /"((?:[A-Za-z]:)?[\\/][^"]+)"|'((?:[A-Za-z]:)?[\\/][^']+)'|(?:^|\s)((?:[A-Za-z]:)?[\\/][^\s"';|&]+)/g;
228
+ const RESULT_LINE_RE = /^((?:[A-Za-z]:)?[^:\r\n]+?):\d+[:-]/;
229
+ /**
230
+ * Where a search looked, from the search itself: the Grep path, a leading
231
+ * `cd`, absolute path arguments, and the files its results name. Existing
232
+ * paths only, resolved against the session's working directory.
233
+ */
234
+ export function searchTargets(payload, cwd) {
235
+ const found = [];
236
+ const add = (value) => {
237
+ if (typeof value !== 'string' || !value.trim() || found.length >= 12)
238
+ return;
239
+ const resolved = path.resolve(cwd, value.trim().replace(/^["']|["']$/g, ''));
240
+ if (!found.some(existing => samePath(existing, resolved)) && fs.existsSync(resolved))
241
+ found.push(resolved);
242
+ };
243
+ const input = payload?.tool_input ?? {};
244
+ add(input.path);
245
+ add(input.file_path);
246
+ const command = typeof input.command === 'string' ? input.command : '';
247
+ const cd = CD_RE.exec(command);
248
+ if (cd)
249
+ add(cd[1] ?? cd[2] ?? cd[3]);
250
+ for (const match of command.matchAll(ABSOLUTE_ARG_RE))
251
+ add(match[1] ?? match[2] ?? match[3]);
252
+ const response = payload?.tool_response ?? payload?.tool_output ?? {};
253
+ if (Array.isArray(response?.filenames))
254
+ response.filenames.slice(0, 5).forEach(add);
255
+ const text = typeof response === 'string' ? response
256
+ : String(response?.stdout ?? response?.content ?? response?.output ?? '');
257
+ for (const line of text.split('\n').slice(0, 20))
258
+ add(RESULT_LINE_RE.exec(line.trim())?.[1]);
259
+ return found;
260
+ }
261
+ /**
262
+ * Which runtime answers a search, and the directory to send it as `cwd`.
263
+ *
264
+ * The session's cwd alone misses common layouts: an orchestrator (and every
265
+ * subagent, which keeps its cwd) opened in a folder ABOVE the repositories, a
266
+ * daemon started in a repository's subfolder, and a linked worktree beside
267
+ * the main checkout. A session inside a covered repository keeps the plain
268
+ * cwd match; otherwise the repository the search itself names is bound, by
269
+ * its git root or its worktree's main checkout, and sent as the cwd so the
270
+ * daemon resolves that repository.
271
+ */
272
+ export function bindSearch(payload, cwd, runtimes) {
273
+ const direct = runtimeForCwd(cwd, runtimes);
274
+ const cwdRoot = gitRootOf(cwd);
275
+ if (direct && cwdRoot)
276
+ return { runtime: direct, cwd };
277
+ const forRepository = (root) => {
278
+ const main = mainCheckout(root);
279
+ const runtime = runtimeForCwd(root, runtimes) ?? runtimeForCwd(main, runtimes)
280
+ ?? runtimes.find(candidate => leaseRoots(candidate).some(leaseRoot => {
281
+ const leaseRepository = gitRootOf(leaseRoot);
282
+ return leaseRepository !== undefined && (samePath(leaseRepository, root) || samePath(leaseRepository, main));
283
+ }));
284
+ return { main, runtime };
285
+ };
286
+ for (const target of searchTargets(payload, cwd)) {
287
+ const root = gitRootOf(target);
288
+ if (!root)
289
+ continue;
290
+ const { main, runtime } = forRepository(root);
291
+ if (runtime)
292
+ return { runtime, cwd: main };
293
+ }
294
+ if (cwdRoot) {
295
+ const { main, runtime } = forRepository(cwdRoot);
296
+ if (runtime)
297
+ return { runtime, cwd: samePath(main, cwdRoot) ? cwd : path.join(main, path.relative(cwdRoot, cwd)) };
298
+ }
299
+ return { runtime: direct, cwd };
300
+ }
168
301
  /**
169
302
  * Exec-form hook invocation. `extra` selects the hook's role: none for the
170
303
  * Rail PreToolUse router, `--phase post` for grep enrichment, `--event
@@ -62,6 +62,7 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
62
62
  'get_codebase_briefing',
63
63
  'get_daily_briefing',
64
64
  'get_directory_tree',
65
+ 'get_env_contract',
65
66
  'get_episode_replay',
66
67
  'get_evolution',
67
68
  'get_function_quality_metrics',
@@ -32,8 +32,8 @@ export declare function claudeRailHookCommand(_memtraceBinary: string, platform?
32
32
  * This is what makes Rail "part of memtrace" rather than a manual install:
33
33
  * `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
34
34
  * attempt through the Rail router (see {@link claudeRailHookCommand} for the
35
- * transport). While a daemon is running the hook defaults to **observe** —
36
- * `MEMTRACE_RAIL=nudge|rail|strict` opts into more. Mode is intentionally NOT
35
+ * transport). While a daemon is running the hook defaults to **nudge** —
36
+ * context alongside ordinary tools. Explicit modes are preserved. Mode is intentionally NOT
37
37
  * baked into the command so the user can change it via env without
38
38
  * re-installing.
39
39
  *
@@ -49,7 +49,7 @@ export declare const ENRICH_HOOK_MATCHER = "Grep|Bash|PowerShell";
49
49
  /** Claude hook timeout for the enrichment hooks, in seconds. */
50
50
  export declare const ENRICH_HOOK_TIMEOUT_S = 2;
51
51
  export interface RailHookOptions {
52
- /** Register the grep-enrichment hooks. Default: `MEMTRACE_ENRICH`, then rail.json, then off. */
52
+ /** Register grep-enrichment hooks. Explicit env/config wins; implicit on only in Nudge. */
53
53
  enrich?: boolean;
54
54
  /** The Rail mode the PreToolUse decision depends on. Default: env, then rail.json. */
55
55
  mode?: string;
@@ -88,7 +88,9 @@ export declare function claudeUserConfigFile(): string;
88
88
  * 2. Fall back to manual: write cache files + update settings.json
89
89
  * 3. Register MCP server for memtrace tools
90
90
  */
91
- export declare function installClaudePlugin(skills: Skill[], memtraceBinaryPath: string): Promise<{
91
+ export declare function installClaudePlugin(skills: Skill[], memtraceBinaryPath: string, options?: {
92
+ skipMcp?: boolean;
93
+ }): Promise<{
92
94
  cacheDir: string;
93
95
  skillCount: number;
94
96
  }>;