memtrace-skills 1.2.10 → 1.2.11

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.
@@ -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
@@ -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();
@@ -169,7 +170,7 @@ async function main() {
169
170
  // JSON-mode/OpenCode and Windsurf retain their CLI response contracts.
170
171
  // Other hosts use the in-process daemon router with no native binary spawn.
171
172
  if (!jsonMode && host !== 'windsurf' && !runtime.routeBinary) {
172
- const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '',
173
+ const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '', enrich: env.MEMTRACE_ENRICH || '',
173
174
  escape: env.MEMTRACE_RAW_SEARCH || '' });
174
175
  const result = await postToDaemon(runtime, `/api/rail/hook?${query}`, input, 5500);
175
176
  if (result.status === 200 && result.body && alive()) {
@@ -34,10 +34,11 @@ export interface RailConfig {
34
34
  export declare function railConfig(): RailConfig;
35
35
  /**
36
36
  * Grep enrichment on/off: `MEMTRACE_ENRICH`, then rail.json `"enrich"`, then
37
- * OFF — the default stays off until the A/B gate passes.
37
+ * non-blocking Nudge mode. Explicit Observe, Off, Rail and Strict modes do
38
+ * not implicitly enable this additional post-search path.
38
39
  */
39
40
  export declare function enrichEnabled(env?: NodeJS.ProcessEnv, config?: RailConfig): boolean;
40
- /** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then observe. */
41
+ /** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then non-blocking nudge. */
41
42
  export declare function railMode(env?: NodeJS.ProcessEnv, config?: RailConfig): string;
42
43
  /**
43
44
  * Live leases. Reading them also deletes garbage: a lease whose owner and
@@ -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
@@ -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;
@@ -3,9 +3,10 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { copyCanonicalPluginAssets, copyCanonicalPluginManifest, readCanonicalPluginVersion, } from '../../plugin-assets/index.js';
6
+ import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
6
7
  import { execCommand, commandExists } from '../utils.js';
7
8
  import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
8
- import { railIsActive, railHookInvocation, railHookCommand, RAIL_GUARD_MARKER, enrichEnabled, railMode } from '../rail-runtime.js';
9
+ import { railIsActive, railHookInvocation, railHookCommand, RAIL_GUARD_MARKER, enrichEnabled, railMode, railConfig } from '../rail-runtime.js';
9
10
  import { LEGACY_MCP_SERVER_NAME, MEMTRACE_MCP_ENV, removeLegacyMcpServer, } from './shared.js';
10
11
  const PLUGIN_NAME = 'memtrace-skills';
11
12
  const MARKETPLACE_NAME = 'memtrace';
@@ -262,8 +263,8 @@ export function claudeRailHookCommand(_memtraceBinary, platform = process.platfo
262
263
  * This is what makes Rail "part of memtrace" rather than a manual install:
263
264
  * `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
264
265
  * attempt through the Rail router (see {@link claudeRailHookCommand} for the
265
- * transport). While a daemon is running the hook defaults to **observe** —
266
- * `MEMTRACE_RAIL=nudge|rail|strict` opts into more. Mode is intentionally NOT
266
+ * transport). While a daemon is running the hook defaults to **nudge** —
267
+ * context alongside ordinary tools. Explicit modes are preserved. Mode is intentionally NOT
267
268
  * baked into the command so the user can change it via env without
268
269
  * re-installing.
269
270
  *
@@ -284,8 +285,9 @@ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platf
284
285
  const settings = (value ?? {});
285
286
  settings.hooks = isRecord(settings.hooks) ? settings.hooks : {};
286
287
  pruneMemtraceOwnedHookEntries(settings.hooks);
287
- const enrich = options.enrich ?? enrichEnabled();
288
- const mode = options.mode ?? railMode();
288
+ const config = railConfig();
289
+ const mode = options.mode ?? railMode(process.env, config);
290
+ const enrich = options.enrich ?? enrichEnabled(process.env, { ...config, mode });
289
291
  const hookFor = (extra) => ({
290
292
  type: 'command',
291
293
  ...(platform === 'win32'
@@ -553,6 +555,9 @@ export function claudeUserConfigFile() {
553
555
  * 3. Register MCP server for memtrace tools
554
556
  */
555
557
  export async function installClaudePlugin(skills, memtraceBinaryPath) {
558
+ // Retire known stock bare copies when moving to plugin delivery. Customized
559
+ // strict policies/opt-outs survive; names alone never prove ownership.
560
+ removeManagedSkills(path.join(os.homedir(), '.claude', 'skills'));
556
561
  // Step 1: make sure Claude sees the public HTTPS marketplace source before
557
562
  // the CLI attempts to clone/update it.
558
563
  enablePluginInSettings();
@@ -582,15 +587,12 @@ export async function installClaudePlugin(skills, memtraceBinaryPath) {
582
587
  // Step 5: Write plugin assets and skills
583
588
  copyBundledPluginAssetsAt(cacheDir);
584
589
  const skillsDir = path.join(cacheDir, 'skills');
585
- if (fs.existsSync(skillsDir)) {
586
- fs.rmSync(skillsDir, { recursive: true });
587
- }
590
+ let skillCount = 0;
588
591
  for (const skill of skills) {
589
592
  const results = transformForClaude(skill);
590
593
  for (const { relativePath, content } of results) {
591
- const fullPath = path.join(cacheDir, relativePath);
592
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
593
- fs.writeFileSync(fullPath, content);
594
+ if (writeManagedSkill(skillsDir, path.basename(path.dirname(relativePath)), content))
595
+ skillCount++;
594
596
  }
595
597
  }
596
598
  enablePluginInSettings();
@@ -599,27 +601,16 @@ export async function installClaudePlugin(skills, memtraceBinaryPath) {
599
601
  // Skills ship via the memtrace-skills@memtrace plugin only. Writing
600
602
  // bare copies to ~/.claude/skills/memtrace-* duplicates every skill
601
603
  // in Claude Code when the plugin is also enabled (issue #11).
602
- return { cacheDir, skillCount: skills.length };
604
+ return { cacheDir, skillCount };
603
605
  }
604
606
  /**
605
607
  * Write skills to ~/.claude/skills/ for SDK-based integrations.
606
608
  */
607
609
  function writeUserLevelSkills(skills) {
608
610
  const userSkillsDir = path.join(os.homedir(), '.claude', 'skills');
609
- if (fs.existsSync(userSkillsDir)) {
610
- for (const entry of fs.readdirSync(userSkillsDir)) {
611
- if (entry.startsWith('memtrace-')) {
612
- fs.rmSync(path.join(userSkillsDir, entry), { recursive: true, force: true });
613
- }
614
- }
615
- }
616
611
  for (const skill of skills) {
617
- const skillName = skill.filename.replace(/\.md$/, '');
618
- const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
619
- const content = `---\nname: ${skillName}\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
620
- const outDir = path.join(userSkillsDir, skillName);
621
- fs.mkdirSync(outDir, { recursive: true });
622
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
612
+ const content = transformForClaude(skill)[0].content;
613
+ writeManagedSkill(userSkillsDir, skill.filename.replace(/\.md$/, ''), content);
623
614
  }
624
615
  }
625
616
  /**
@@ -652,22 +643,8 @@ export async function uninstallClaudePlugin() {
652
643
  }
653
644
  removeClaudeMarketplaceCacheDirs();
654
645
  removeClaudeInstalledPluginMetadata();
655
- // Clean up user-level skills
656
- const userSkillsDir = path.join(os.homedir(), '.claude', 'skills');
657
- if (fs.existsSync(userSkillsDir)) {
658
- for (const entry of fs.readdirSync(userSkillsDir)) {
659
- if (entry.startsWith('memtrace-')) {
660
- const entryPath = path.join(userSkillsDir, entry);
661
- const stat = fs.statSync(entryPath);
662
- if (stat.isDirectory()) {
663
- fs.rmSync(entryPath, { recursive: true });
664
- }
665
- else if (entry.endsWith('.md')) {
666
- fs.unlinkSync(entryPath);
667
- }
668
- }
669
- }
670
- }
646
+ // Customized skills and their extra assets are not installer-owned.
647
+ removeManagedSkills(path.join(os.homedir(), '.claude', 'skills'));
671
648
  }
672
649
  /**
673
650
  * Write/merge the memtrace MCP server entry into a project-local .mcp.json.
@@ -712,22 +689,11 @@ export const claudeTransformer = {
712
689
  }
713
690
  // Local scope: write skills into <cwd>/.claude/skills/
714
691
  const skillsDir = path.join(ctx.cwd, '.claude', 'skills');
715
- if (fs.existsSync(skillsDir)) {
716
- for (const entry of fs.readdirSync(skillsDir)) {
717
- if (entry.startsWith('memtrace-')) {
718
- fs.rmSync(path.join(skillsDir, entry), { recursive: true, force: true });
719
- }
720
- }
721
- }
722
692
  let count = 0;
723
693
  for (const skill of skills) {
724
- const skillName = skill.filename.replace(/\.md$/, '');
725
- const outDir = path.join(skillsDir, skillName);
726
- fs.mkdirSync(outDir, { recursive: true });
727
- const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
728
- const content = `---\nname: ${skillName}\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
729
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
730
- count++;
694
+ const content = transformForClaude(skill)[0].content;
695
+ if (writeManagedSkill(skillsDir, skill.filename.replace(/\.md$/, ''), content))
696
+ count++;
731
697
  }
732
698
  // MCP for local scope: .mcp.json at project root (Claude's project-level convention)
733
699
  const mcpConfigPath = path.join(ctx.cwd, '.mcp.json');
@@ -751,13 +717,7 @@ export const claudeTransformer = {
751
717
  }
752
718
  // Local: remove <cwd>/.claude/skills/memtrace-* and the memtrace entry from <cwd>/.mcp.json
753
719
  const skillsDir = path.join(ctx.cwd, '.claude', 'skills');
754
- if (fs.existsSync(skillsDir)) {
755
- for (const entry of fs.readdirSync(skillsDir)) {
756
- if (entry.startsWith('memtrace-')) {
757
- fs.rmSync(path.join(skillsDir, entry), { recursive: true, force: true });
758
- }
759
- }
760
- }
720
+ removeManagedSkills(skillsDir);
761
721
  const mcpPath = path.join(ctx.cwd, '.mcp.json');
762
722
  const { value, corrupted } = safeReadJson(mcpPath);
763
723
  if (!corrupted && value?.mcpServers?.['memtrace']) {
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
4
5
  import { commandExists, execCommand } from '../utils.js';
5
6
  import { registerSettingsHook, removeSettingsHook, CODEX_MATCHER, } from './rail-hooks.js';
6
7
  const MCP_SERVER_NAME = 'memtrace';
@@ -19,11 +20,9 @@ export function codexConfigPath(ctx) {
19
20
  }
20
21
  function writeSkill(skill, rootDir) {
21
22
  const name = skill.filename.replace(/\.md$/, '');
22
- const outDir = path.join(rootDir, name);
23
- fs.mkdirSync(outDir, { recursive: true });
24
23
  const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
25
24
  const content = `---\nname: ${name}\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
26
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
25
+ return writeManagedSkill(rootDir, name, content);
27
26
  }
28
27
  function tomlString(value) {
29
28
  return `"${value
@@ -168,15 +167,7 @@ export const codexTransformer = {
168
167
  name: 'codex',
169
168
  async install(skills, ctx) {
170
169
  const rootDir = skillsRoot(ctx);
171
- if (fs.existsSync(rootDir)) {
172
- for (const entry of fs.readdirSync(rootDir)) {
173
- if (entry.startsWith('memtrace-')) {
174
- fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
175
- }
176
- }
177
- }
178
- for (const s of skills)
179
- writeSkill(s, rootDir);
170
+ const written = skills.filter(skill => writeSkill(skill, rootDir)).length;
180
171
  let mcpRegistered = false;
181
172
  const warnings = [];
182
173
  const mcpConfigPath = codexConfigPath(ctx);
@@ -198,7 +189,7 @@ export const codexTransformer = {
198
189
  }
199
190
  return {
200
191
  agent: 'codex',
201
- skillsWritten: skills.length,
192
+ skillsWritten: written,
202
193
  skillsDir: rootDir,
203
194
  mcpConfigPath,
204
195
  mcpRegistered,
@@ -207,13 +198,7 @@ export const codexTransformer = {
207
198
  },
208
199
  async uninstall(ctx) {
209
200
  const rootDir = skillsRoot(ctx);
210
- if (fs.existsSync(rootDir)) {
211
- for (const entry of fs.readdirSync(rootDir)) {
212
- if (entry.startsWith('memtrace-')) {
213
- fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
214
- }
215
- }
216
- }
201
+ removeManagedSkills(rootDir);
217
202
  try {
218
203
  removeSettingsHook(codexRailHooksPath(ctx), 'PreToolUse');
219
204
  }
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
4
5
  import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
5
6
  import { MEMTRACE_MCP_ENV, removeLegacyMcpServer } from './shared.js';
6
7
  import { registerCursorRailHook, removeCursorRailHook } from './rail-hooks.js';
@@ -18,12 +19,10 @@ function railHooksPath(ctx) {
18
19
  }
19
20
  function writeSkill(skill, rootDir) {
20
21
  const name = skill.filename.replace(/\.md$/, '');
21
- const outDir = path.join(rootDir, name);
22
- fs.mkdirSync(outDir, { recursive: true });
23
22
  const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
24
23
  // Cursor requires frontmatter `name` matching the folder name.
25
24
  const content = `---\nname: ${name}\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
26
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
25
+ return writeManagedSkill(rootDir, name, content);
27
26
  }
28
27
  function isRecord(value) {
29
28
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -96,15 +95,7 @@ export const cursorTransformer = {
96
95
  async install(skills, ctx) {
97
96
  const rootDir = skillsRoot(ctx);
98
97
  const warnings = cursorWorkspaceBoundaryWarnings(ctx);
99
- if (fs.existsSync(rootDir)) {
100
- for (const entry of fs.readdirSync(rootDir)) {
101
- if (entry.startsWith('memtrace-')) {
102
- fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
103
- }
104
- }
105
- }
106
- for (const s of skills)
107
- writeSkill(s, rootDir);
98
+ const written = skills.filter(skill => writeSkill(skill, rootDir)).length;
108
99
  let mcpRegistered = false;
109
100
  if (!ctx.skipMcp) {
110
101
  const result = registerCursorMcpAt(mcpPath(ctx), ctx.memtraceBinary);
@@ -123,7 +114,7 @@ export const cursorTransformer = {
123
114
  }
124
115
  return {
125
116
  agent: 'cursor',
126
- skillsWritten: skills.length,
117
+ skillsWritten: written,
127
118
  skillsDir: rootDir,
128
119
  mcpConfigPath: mcpPath(ctx),
129
120
  mcpRegistered,
@@ -133,13 +124,7 @@ export const cursorTransformer = {
133
124
  async uninstall(ctx) {
134
125
  // 1. Remove memtrace-* skill dirs
135
126
  const rootDir = skillsRoot(ctx);
136
- if (fs.existsSync(rootDir)) {
137
- for (const entry of fs.readdirSync(rootDir)) {
138
- if (entry.startsWith('memtrace-')) {
139
- fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
140
- }
141
- }
142
- }
127
+ removeManagedSkills(rootDir);
143
128
  // 1b. Remove the Rail hook.
144
129
  try {
145
130
  removeCursorRailHook(railHooksPath(ctx));
@@ -1,6 +1,6 @@
1
- import fs from 'fs';
2
1
  import os from 'os';
3
2
  import path from 'path';
3
+ import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
4
4
  import { registerMcpServersJsonAt, removeMcpServersJsonAt, skillName, } from './shared.js';
5
5
  function steeringRoot(ctx) {
6
6
  return ctx.scope === 'global'
@@ -24,25 +24,16 @@ function writeSteering(skill, rootDir) {
24
24
  '',
25
25
  skill.body,
26
26
  ].join('\n');
27
- fs.mkdirSync(rootDir, { recursive: true });
28
- fs.writeFileSync(path.join(rootDir, `${name}.md`), content);
27
+ return writeManagedSkill(rootDir, name, content, true);
29
28
  }
30
29
  function removeMemtraceSteering(rootDir) {
31
- if (!fs.existsSync(rootDir))
32
- return;
33
- for (const entry of fs.readdirSync(rootDir)) {
34
- if (entry.startsWith('memtrace-') && entry.endsWith('.md')) {
35
- fs.rmSync(path.join(rootDir, entry), { force: true });
36
- }
37
- }
30
+ removeManagedSkills(rootDir, true);
38
31
  }
39
32
  export const kiroTransformer = {
40
33
  name: 'kiro',
41
34
  async install(skills, ctx) {
42
35
  const rootDir = steeringRoot(ctx);
43
- removeMemtraceSteering(rootDir);
44
- for (const skill of skills)
45
- writeSteering(skill, rootDir);
36
+ const written = skills.filter(skill => writeSteering(skill, rootDir)).length;
46
37
  let mcpRegistered = false;
47
38
  const warnings = [];
48
39
  const mcpConfigPath = mcpPath(ctx);
@@ -55,7 +46,7 @@ export const kiroTransformer = {
55
46
  }
56
47
  return {
57
48
  agent: 'kiro',
58
- skillsWritten: skills.length,
49
+ skillsWritten: written,
59
50
  skillsDir: rootDir,
60
51
  mcpConfigPath,
61
52
  mcpRegistered,
@@ -3,6 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { removeMemtraceSkills, skillName } from './shared.js';
6
+ import { writeManagedSkill } from '../managed-skills.js';
6
7
  import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
7
8
  import { commandExists } from '../utils.js';
8
9
  import { MEMTRACE_TOOL_NAMES } from '../tool-names.js';
@@ -80,15 +81,14 @@ function skillMarkdownForPi(skill, toolNames) {
80
81
  return `---\nname: ${name}\ndescription: "${safeDesc}"${toolsLine}\n---\n\n${body}`;
81
82
  }
82
83
  function writeSkillsForPi(skills, rootDir) {
83
- removeMemtraceSkills(rootDir);
84
+ let written = 0;
84
85
  const toolNames = memtraceToolNames(skills);
85
86
  for (const skill of skills) {
86
87
  const name = skillName(skill);
87
- const outDir = path.join(rootDir, name);
88
- fs.mkdirSync(outDir, { recursive: true });
89
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), skillMarkdownForPi(skill, toolNames));
88
+ if (writeManagedSkill(rootDir, name, skillMarkdownForPi(skill, toolNames)))
89
+ written++;
90
90
  }
91
- return skills.length;
91
+ return written;
92
92
  }
93
93
  function packageEntrySource(entry) {
94
94
  if (typeof entry === 'string')
@@ -3,8 +3,8 @@
3
3
  * hook. Each supported agent intercepts tool calls differently, so each gets a
4
4
  * thin host-specific registration that points at the SAME router
5
5
  * (`memtrace route --hook --host <h>`). All entries are idempotent and remove
6
- * cleanly. Hooks default to observe mode (zero behavior change); the user opts
7
- * into more via `MEMTRACE_RAIL=nudge|rail|strict`.
6
+ * cleanly. Hooks default to nudge mode (context without suppressing ordinary
7
+ * tools); explicit `MEMTRACE_RAIL` and rail.json modes remain authoritative.
8
8
  *
9
9
  * Host families:
10
10
  * - Claude Code → `.claude/settings.json` hooks.PreToolUse (in claude.ts)
@@ -182,7 +182,8 @@ export function registerCursorRailHook(hooksPath, binary, options = {}) {
182
182
  preserved.push({ command: cmd });
183
183
  cfg.hooks[event] = preserved;
184
184
  // Grep enrichment: Cursor `postToolUse` on its Shell and Grep tools answers
185
- // with `additional_context` (design section 9). Opt-in like everywhere.
185
+ // with `additional_context` (design section 9). Fresh Nudge enables it;
186
+ // explicit mode/enrichment settings keep their existing meaning.
186
187
  const post = Array.isArray(cfg.hooks[CURSOR_ENRICH_EVENT]) ? cfg.hooks[CURSOR_ENRICH_EVENT] : [];
187
188
  const postPreserved = post.filter((e) => !(isRecord(e) && typeof e.command === 'string' && isRailCommand(e.command)));
188
189
  if (options.enrich ?? enrichEnabled()) {
@@ -356,19 +357,28 @@ export function removeCursorRailHook(hooksPath) {
356
357
  */
357
358
  export function openCodePluginSource(binary) {
358
359
  const hook = railHookInvocation('open-code');
360
+ const runtimeModule = new URL('../rail-runtime.js', import.meta.url).href;
359
361
  return `// AUTO-GENERATED by memtrace install — Memtrace Rail discovery hook.
360
- // Routes OpenCode tool calls through \`${binary} route\`. Defaults to observe
361
- // (no-op) unless MEMTRACE_RAIL=nudge|rail|strict. Remove via 'memtrace uninstall'.
362
+ // Routes OpenCode tool calls through \`${binary} route\`.
363
+ // Defaults to non-blocking context; explicit Rail mode overrides are retained.
364
+ // Remove via 'memtrace uninstall'.
362
365
  import { execFileSync } from 'node:child_process';
366
+ import { enrichEnabled, railConfig, railMode } from ${JSON.stringify(runtimeModule)};
363
367
 
364
368
  const BIN = ${JSON.stringify(hook.command)};
365
369
  const ARGS = ${JSON.stringify([...hook.args, '--json'])};
370
+ const POST_ARGS = ${JSON.stringify([...hook.args, '--phase', 'post'])};
366
371
 
367
372
  export const MemtraceRail = async ({ directory }) => ({
368
373
  'tool.execute.before': async (input, output) => {
369
374
  try {
370
375
  const tool = input?.tool;
371
376
  if (tool !== 'bash' && tool !== 'grep' && tool !== 'glob') return;
377
+ const config = railConfig();
378
+ const mode = railMode(process.env, config);
379
+ // The post hook supplies Nudge context once, after the ordinary search.
380
+ // Rail and Strict retain their existing pre-execution enforcement.
381
+ if (enrichEnabled(process.env, config) && mode !== 'rail' && mode !== 'strict') return;
372
382
  const attempt = {
373
383
  host: 'open-code',
374
384
  cwd: output?.args?.cwd || directory || process.cwd(),
@@ -389,6 +399,33 @@ export const MemtraceRail = async ({ directory }) => ({
389
399
  if (e && String(e.message || '').startsWith('[Memtrace Rail]')) throw e;
390
400
  }
391
401
  },
402
+ // OpenCode @opencode-ai/plugin 1.19.4: native registry tools expose the
403
+ // mutable {title, output, metadata} tool.execute.after contract.
404
+ 'tool.execute.after': async (input, output) => {
405
+ try {
406
+ const tool = input?.tool;
407
+ if ((tool !== 'bash' && tool !== 'grep') || typeof output?.output !== 'string'
408
+ || !enrichEnabled() || typeof input?.sessionID !== 'string') return;
409
+ const payload = {
410
+ cwd: input?.args?.cwd || directory || process.cwd(),
411
+ session_id: input.sessionID,
412
+ tool_use_id: input.callID,
413
+ tool_name: tool === 'bash' ? 'Bash' : 'Grep',
414
+ tool_input: input.args || {},
415
+ tool_response: { ...(output.metadata || {}), stdout: output.output },
416
+ };
417
+ const raw = execFileSync(BIN, POST_ARGS, {
418
+ input: JSON.stringify(payload), timeout: 2000, encoding: 'utf8', windowsHide: true,
419
+ });
420
+ const text = JSON.parse(raw)?.hookSpecificOutput?.additionalContext;
421
+ if (typeof text === 'string' && text.trim() && Buffer.byteLength(text, 'utf8') <= 4000) {
422
+ output.output += '\\n\\n' + text;
423
+ }
424
+ } catch {
425
+ // Missing owner, invalid query, timeout or malformed output leaves the
426
+ // original ordinary-tool response byte-for-byte unchanged.
427
+ }
428
+ },
392
429
  });
393
430
  `;
394
431
  }