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,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'
@@ -552,7 +554,10 @@ export function claudeUserConfigFile() {
552
554
  * 2. Fall back to manual: write cache files + update settings.json
553
555
  * 3. Register MCP server for memtrace tools
554
556
  */
555
- export async function installClaudePlugin(skills, memtraceBinaryPath) {
557
+ export async function installClaudePlugin(skills, memtraceBinaryPath, options = {}) {
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,44 +587,57 @@ 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();
597
599
  // Step 6: Register MCP server
598
- await registerMcpServer(memtraceBinaryPath);
600
+ if (!options.skipMcp)
601
+ await registerMcpServer(memtraceBinaryPath);
599
602
  // Skills ship via the memtrace-skills@memtrace plugin only. Writing
600
603
  // bare copies to ~/.claude/skills/memtrace-* duplicates every skill
601
604
  // in Claude Code when the plugin is also enabled (issue #11).
602
- return { cacheDir, skillCount: skills.length };
605
+ return { cacheDir, skillCount };
606
+ }
607
+ function refreshEnabledClaudePlugin(skills) {
608
+ const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
609
+ if (!fs.existsSync(settingsFile))
610
+ return 0;
611
+ const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
612
+ if (settings.enabledPlugins?.[PLUGIN_KEY] !== true)
613
+ return 0;
614
+ const root = getPluginCacheRoot();
615
+ if (!fs.existsSync(root))
616
+ return 0;
617
+ let skillCount = 0;
618
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
619
+ if (!entry.isDirectory())
620
+ continue;
621
+ const cacheDir = path.join(root, entry.name);
622
+ copyBundledPluginAssetsAt(cacheDir);
623
+ for (const skill of skills) {
624
+ for (const asset of transformForClaude(skill)) {
625
+ const name = path.basename(path.dirname(asset.relativePath));
626
+ if (writeManagedSkill(path.join(cacheDir, 'skills'), name, asset.content))
627
+ skillCount++;
628
+ }
629
+ }
630
+ }
631
+ return skillCount;
603
632
  }
604
633
  /**
605
634
  * Write skills to ~/.claude/skills/ for SDK-based integrations.
606
635
  */
607
636
  function writeUserLevelSkills(skills) {
608
637
  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
638
  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);
639
+ const content = transformForClaude(skill)[0].content;
640
+ writeManagedSkill(userSkillsDir, skill.filename.replace(/\.md$/, ''), content);
623
641
  }
624
642
  }
625
643
  /**
@@ -652,22 +670,8 @@ export async function uninstallClaudePlugin() {
652
670
  }
653
671
  removeClaudeMarketplaceCacheDirs();
654
672
  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
- }
673
+ // Customized skills and their extra assets are not installer-owned.
674
+ removeManagedSkills(path.join(os.homedir(), '.claude', 'skills'));
671
675
  }
672
676
  /**
673
677
  * Write/merge the memtrace MCP server entry into a project-local .mcp.json.
@@ -700,7 +704,9 @@ export const claudeTransformer = {
700
704
  name: 'claude',
701
705
  async install(skills, ctx) {
702
706
  if (ctx.scope === 'global') {
703
- const { skillCount } = await installClaudePlugin(skills, ctx.memtraceBinary);
707
+ const skillCount = ctx.mode === 'refresh'
708
+ ? refreshEnabledClaudePlugin(skills)
709
+ : (await installClaudePlugin(skills, ctx.memtraceBinary, { skipMcp: ctx.skipMcp })).skillCount;
704
710
  return {
705
711
  agent: 'claude',
706
712
  skillsWritten: skillCount,
@@ -712,22 +718,11 @@ export const claudeTransformer = {
712
718
  }
713
719
  // Local scope: write skills into <cwd>/.claude/skills/
714
720
  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
721
  let count = 0;
723
722
  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++;
723
+ const content = transformForClaude(skill)[0].content;
724
+ if (writeManagedSkill(skillsDir, skill.filename.replace(/\.md$/, ''), content))
725
+ count++;
731
726
  }
732
727
  // MCP for local scope: .mcp.json at project root (Claude's project-level convention)
733
728
  const mcpConfigPath = path.join(ctx.cwd, '.mcp.json');
@@ -751,13 +746,7 @@ export const claudeTransformer = {
751
746
  }
752
747
  // Local: remove <cwd>/.claude/skills/memtrace-* and the memtrace entry from <cwd>/.mcp.json
753
748
  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
- }
749
+ removeManagedSkills(skillsDir);
761
750
  const mcpPath = path.join(ctx.cwd, '.mcp.json');
762
751
  const { value, corrupted } = safeReadJson(mcpPath);
763
752
  if (!corrupted && value?.mcpServers?.['memtrace']) {
@@ -0,0 +1,2 @@
1
+ export declare function hasCodexMemtraceServer(raw: string): boolean;
2
+ export declare function refreshCodexCommand(raw: string, command: string): string;
@@ -0,0 +1,33 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { parse } from 'smol-toml';
3
+ const readConfig = (raw) => parse(raw, { integersAsBigInt: 'asNeeded' });
4
+ function memtraceServer(config) {
5
+ const servers = config.mcp_servers;
6
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers))
7
+ return undefined;
8
+ const server = servers.memtrace;
9
+ return server && typeof server === 'object' && !Array.isArray(server) ? server : undefined;
10
+ }
11
+ export function hasCodexMemtraceServer(raw) {
12
+ return memtraceServer(readConfig(raw)) !== undefined;
13
+ }
14
+ export function refreshCodexCommand(raw, command) {
15
+ const expected = readConfig(raw);
16
+ const server = memtraceServer(expected);
17
+ if (!server || typeof server.command !== 'string' || server.command === command)
18
+ return raw;
19
+ server.command = command;
20
+ let inServer = false;
21
+ const next = raw.split(/(?<=\n)/).map(line => {
22
+ if (/^\s*\[/.test(line)) {
23
+ inServer = /^\s*\[\s*mcp_servers\s*\.\s*(?:memtrace|"memtrace"|'memtrace')\s*\]\s*(?:#.*)?$/.test(line.trimEnd());
24
+ }
25
+ if (!inServer)
26
+ return line;
27
+ return line.replace(/^(\s*command\s*=\s*)(["'])(.*?)\2([ \t]*(?:#.*)?)(\r?\n)?$/, (_match, prefix, _quote, _value, comment, newline) => (`${prefix}${JSON.stringify(command)}${comment}${newline ?? ''}`));
28
+ }).join('');
29
+ if (!isDeepStrictEqual(readConfig(next), expected)) {
30
+ throw new Error('Cannot safely update the Codex MCP command; existing configuration was left unchanged.');
31
+ }
32
+ return next;
33
+ }
@@ -1,7 +1,9 @@
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';
6
+ import { hasCodexMemtraceServer, refreshCodexCommand } from './codex-config.js';
5
7
  import { registerSettingsHook, removeSettingsHook, CODEX_MATCHER, } from './rail-hooks.js';
6
8
  const MCP_SERVER_NAME = 'memtrace';
7
9
  const LEGACY_MCP_SERVER_NAME = 'memfleet';
@@ -19,11 +21,9 @@ export function codexConfigPath(ctx) {
19
21
  }
20
22
  function writeSkill(skill, rootDir) {
21
23
  const name = skill.filename.replace(/\.md$/, '');
22
- const outDir = path.join(rootDir, name);
23
- fs.mkdirSync(outDir, { recursive: true });
24
24
  const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
25
25
  const content = `---\nname: ${name}\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
26
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
26
+ return writeManagedSkill(rootDir, name, content);
27
27
  }
28
28
  function tomlString(value) {
29
29
  return `"${value
@@ -124,6 +124,12 @@ function extractMemtraceEnvBlock(raw) {
124
124
  }
125
125
  export function registerCodexMcpAt(configFile, binary) {
126
126
  const existing = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf-8') : '';
127
+ if (hasCodexMemtraceServer(existing)) {
128
+ const next = refreshCodexCommand(existing, binary);
129
+ if (next !== existing)
130
+ writeTextAtomic(configFile, next);
131
+ return { registered: true };
132
+ }
127
133
  // Strip leaves the env sub-table intact; split it out so we can reorder it
128
134
  // after the freshly written parent block for readability.
129
135
  const stripped = stripCodexMcpServer(existing);
@@ -145,6 +151,8 @@ export function registerCodexMcpAt(configFile, binary) {
145
151
  if (envBlock)
146
152
  parts.push(envBlock);
147
153
  const next = `${parts.join('\n\n')}\n`;
154
+ if (!hasCodexMemtraceServer(next))
155
+ throw new Error('Codex MCP registration could not be validated.');
148
156
  writeTextAtomic(configFile, next);
149
157
  return { registered: true };
150
158
  }
@@ -168,20 +176,13 @@ export const codexTransformer = {
168
176
  name: 'codex',
169
177
  async install(skills, ctx) {
170
178
  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);
179
+ const written = skills.filter(skill => writeSkill(skill, rootDir)).length;
180
180
  let mcpRegistered = false;
181
181
  const warnings = [];
182
182
  const mcpConfigPath = codexConfigPath(ctx);
183
183
  if (!ctx.skipMcp) {
184
- const cliRegistered = ctx.scope === 'global' && await tryCodexMcpAdd(ctx.memtraceBinary);
184
+ const cliRegistered = ctx.scope === 'global' && !fs.existsSync(mcpConfigPath)
185
+ && await tryCodexMcpAdd(ctx.memtraceBinary);
185
186
  registerCodexMcpAt(mcpConfigPath, ctx.memtraceBinary);
186
187
  mcpRegistered = true;
187
188
  if (ctx.scope === 'global' && !cliRegistered) {
@@ -191,14 +192,15 @@ export const codexTransformer = {
191
192
  // ─── memtrace-rail ─── Wire the discovery hook into .codex/hooks.json
192
193
  // (PreToolUse, Bash matcher — Codex code discovery runs through the shell).
193
194
  try {
194
- registerSettingsHook(codexRailHooksPath(ctx), ctx.memtraceBinary, 'codex', 'PreToolUse', CODEX_MATCHER);
195
+ if (ctx.mode !== 'refresh')
196
+ registerSettingsHook(codexRailHooksPath(ctx), ctx.memtraceBinary, 'codex', 'PreToolUse', CODEX_MATCHER);
195
197
  }
196
198
  catch (e) {
197
199
  warnings.push(`failed to register Codex Rail hook: ${e.message}`);
198
200
  }
199
201
  return {
200
202
  agent: 'codex',
201
- skillsWritten: skills.length,
203
+ skillsWritten: written,
202
204
  skillsDir: rootDir,
203
205
  mcpConfigPath,
204
206
  mcpRegistered,
@@ -207,13 +209,7 @@ export const codexTransformer = {
207
209
  },
208
210
  async uninstall(ctx) {
209
211
  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
- }
212
+ removeManagedSkills(rootDir);
217
213
  try {
218
214
  removeSettingsHook(codexRailHooksPath(ctx), 'PreToolUse');
219
215
  }
@@ -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);
@@ -116,14 +107,15 @@ export const cursorTransformer = {
116
107
  // ─── memtrace-rail ─── Wire the Memtrace-first discovery hook (default
117
108
  // observe). Failure must not block the rest of the install.
118
109
  try {
119
- registerCursorRailHook(railHooksPath(ctx), ctx.memtraceBinary);
110
+ if (ctx.mode !== 'refresh')
111
+ registerCursorRailHook(railHooksPath(ctx), ctx.memtraceBinary);
120
112
  }
121
113
  catch (e) {
122
114
  console.warn(`memtrace: failed to register Cursor Rail hook: ${e.message}`);
123
115
  }
124
116
  return {
125
117
  agent: 'cursor',
126
- skillsWritten: skills.length,
118
+ skillsWritten: written,
127
119
  skillsDir: rootDir,
128
120
  mcpConfigPath: mcpPath(ctx),
129
121
  mcpRegistered,
@@ -133,13 +125,7 @@ export const cursorTransformer = {
133
125
  async uninstall(ctx) {
134
126
  // 1. Remove memtrace-* skill dirs
135
127
  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
- }
128
+ removeManagedSkills(rootDir);
143
129
  // 1b. Remove the Rail hook.
144
130
  try {
145
131
  removeCursorRailHook(railHooksPath(ctx));
@@ -46,10 +46,11 @@ export const geminiTransformer = {
46
46
  // (PR #14460) — without them a registered BeforeTool hook NEVER fires, so we
47
47
  // enable both here (only if the user hasn't already set them).
48
48
  try {
49
- registerSettingsHook(settingsPath, ctx.memtraceBinary, 'gemini', 'BeforeTool', GEMINI_MATCHER, {
50
- enableHooks: true,
51
- enableMessageBusIntegration: true,
52
- });
49
+ if (ctx.mode !== 'refresh')
50
+ registerSettingsHook(settingsPath, ctx.memtraceBinary, 'gemini', 'BeforeTool', GEMINI_MATCHER, {
51
+ enableHooks: true,
52
+ enableMessageBusIntegration: true,
53
+ });
53
54
  }
54
55
  catch (e) {
55
56
  warnings.push(`failed to register Gemini Rail hook: ${e.message}`);
@@ -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,
@@ -31,13 +31,14 @@ export const opencodeTransformer = {
31
31
  // ─── memtrace-rail ─── Write the in-process discovery plugin (OpenCode
32
32
  // has no stdio hook). Defaults to observe; fail-open. Best effort.
33
33
  try {
34
- const src = openCodePluginSource(ctx.memtraceBinary);
35
- if (railIsActive()) {
36
- writeOpenCodeRailPlugin(ctx, src);
37
- }
38
- else {
39
- for (const pluginPath of openCodeRailPluginPaths(ctx)) {
40
- fs.rmSync(pluginPath, { force: true });
34
+ if (ctx.mode !== 'refresh') {
35
+ if (railIsActive()) {
36
+ writeOpenCodeRailPlugin(ctx, openCodePluginSource(ctx.memtraceBinary));
37
+ }
38
+ else {
39
+ for (const pluginPath of openCodeRailPluginPaths(ctx)) {
40
+ fs.rmSync(pluginPath, { force: true });
41
+ }
41
42
  }
42
43
  }
43
44
  }
@@ -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')
@@ -153,7 +153,9 @@ export const piTransformer = {
153
153
  warnings: [`${e.message}`],
154
154
  };
155
155
  }
156
- const result = registerPiPackageAt(settingsFile, packagePath);
156
+ const result = ctx.mode === 'refresh'
157
+ ? { registered: false, packagePath, backupPath: undefined }
158
+ : registerPiPackageAt(settingsFile, packagePath);
157
159
  if (!result.registered && result.backupPath) {
158
160
  warnings.push(`Pi settings.json was malformed; backed up to ${result.backupPath}.`);
159
161
  }
@@ -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
  }