@ericrisco/rsc 1.1.6 → 1.2.0

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.
package/targets/agents.js CHANGED
@@ -10,6 +10,10 @@
10
10
  // written by `init` at onboarding and read here so re-syncs honor it.
11
11
  import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
12
12
  import { join, dirname } from 'node:path';
13
+ import {
14
+ stackAgents, stackAgentNames, stackAgentByName,
15
+ resolveStackAgentNames, validateAgentCatalog,
16
+ } from './agent-catalog.js';
13
17
 
14
18
  // Concrete model per provider per tier. June 2026 defaults — EDIT to your account's
15
19
  // models; the TIER is the contract, the id is yours to change. `light` is deliberately
@@ -42,13 +46,15 @@ export function targetHasAgents(target) { return Boolean(AGENT_TARGETS[target]);
42
46
  // (decided in clarify 2026-08-18: a lens passed as a parameter reintroduces the
43
47
  // does-anyone-remember dependency this whole spec exists to remove) — but P5 says length is a cost,
44
48
  // so the contract they share is composed, not written three times.
45
- const REFUTER_CONTRACT = `Your mandate is to **refute readiness**, not confirm it. A reviewer looking for confirmation finds confirmation; the asymmetry is the point.
46
-
47
- **You get exactly four inputs, and nothing else:**
49
+ export const REFUTER_FOUR_INPUTS = `**You get exactly four inputs, and nothing else:**
48
50
  1. The task contract — the original request **plus every scope change a human explicitly approved since**. Without the approved changes, a legitimate scope revision reads as a spec gap and you will report a confident false positive.
49
51
  2. The approved spec.
50
52
  3. The exact source state (commit SHA, or a tree hash when git is absent). A verdict attaches to the state you saw, not to the project.
51
- 4. The entry point — the one command that reruns the checks.
53
+ 4. The entry point — the one command that reruns the checks.`;
54
+
55
+ const REFUTER_CONTRACT = `Your mandate is to **refute readiness**, not confirm it. A reviewer looking for confirmation finds confirmation; the asymmetry is the point.
56
+
57
+ ${REFUTER_FOUR_INPUTS}
52
58
 
53
59
  **You do NOT get** the builder's conversation, reasoning, defences, or draft verdict. If a claim needs the builder's justification to stand, it is not proven.
54
60
 
@@ -56,6 +62,16 @@ const REFUTER_CONTRACT = `Your mandate is to **refute readiness**, not confirm i
56
62
 
57
63
  **The attack list is the deliverable, not just the findings.** "Nothing found" without saying where you looked is indistinguishable from not having looked.
58
64
 
65
+ **Before reporting any finding, answer all four questions:**
66
+ 1. Can you cite the **exact changed line**?
67
+ 2. Can you state the **concrete input, state, and wrong result**? The concrete input and state must be explicit.
68
+ 3. Did you inspect the relevant **caller, import, and relevant test**?
69
+ 4. Can the severity survive the **existing guards** you verified?
70
+
71
+ If an answer is no, lower the severity or omit the finding. Every **HIGH or CRITICAL** needs the line and failure mode in the report. **Zero findings with an attack list is valid.**
72
+
73
+ **Common false positives to reject:** an equivalent mutant with no diverging input; a documented dummy value that never reaches a sink; a deliberate boundary already enforced by a caller; generated/vendor code outside the change; style preference presented as correctness; and a theoretical race with no shared state or overlapping lifetime.
74
+
59
75
  **A finding blocks only if it is caused by this change, is severe, and carries evidence** — a repro or a concrete failure scenario. A suspicion without one is a question, and questions do not block. You fix nothing: findings return through the normal loop, and a SPEC gap goes to the human, never to the builder to self-amend.`;
60
76
 
61
77
  const AGENTS = [
@@ -112,11 +128,18 @@ ${REFUTER_CONTRACT}
112
128
  },
113
129
  ];
114
130
 
131
+ export const BASE_AGENT_NAMES = Object.freeze(AGENTS.map((agent) => agent.name));
132
+ export { stackAgents, stackAgentNames, validateAgentCatalog };
133
+ export const allAgentNames = () => [...BASE_AGENT_NAMES, ...stackAgentNames()];
134
+ export function resolveAgentNames(skillIds = [], explicitAgentIds = []) {
135
+ return [...BASE_AGENT_NAMES, ...resolveStackAgentNames(skillIds, explicitAgentIds)];
136
+ }
137
+
115
138
  // Back-compat: the tier file and its reader are named for `developer` because that is what they
116
139
  // configure. The refuters run at the same tier — a deliberate, reversible default: nobody has measured
117
140
  // whether a heavier model finds more here, and silently tripling tier-2 cost on an unmeasured hunch is
118
141
  // the wrong way to find out.
119
- const byName = (name) => AGENTS.find((a) => a.name === name);
142
+ const byName = (name) => AGENTS.find((a) => a.name === name) || stackAgentByName(name);
120
143
  export const agentNames = () => AGENTS.map((a) => a.name);
121
144
 
122
145
  // `.rsc/developer.json` — the chosen tier (balanced default; never light). `init` writes
@@ -137,14 +160,16 @@ export function writeDeveloperTier(cwd, tier) {
137
160
  function renderMd(spec, model, agent) {
138
161
  const fm = ['---', `name: ${agent.name}`, `description: "${agent.desc}"`, `model: ${model}`];
139
162
  if (spec.mode) fm.push(`mode: ${spec.mode}`);
163
+ if (agent.tools) fm.push(`tools: [${agent.tools.join(', ')}]`);
140
164
  fm.push('---', '');
141
165
  return `${fm.join('\n')}${agent.body}\n`;
142
166
  }
143
- const renderJson = (model, agent) => `${JSON.stringify({ name: agent.name, description: agent.desc, model, prompt: agent.body }, null, 2)}\n`;
167
+ const renderJson = (model, agent) => `${JSON.stringify({ name: agent.name, description: agent.desc, model, ...(agent.tools ? { tools: agent.tools } : {}), prompt: agent.body }, null, 2)}\n`;
144
168
  function renderToml(model, agent) {
145
169
  const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
146
170
  // body as a TOML multiline LITERAL string ('''…''') — no escape processing.
147
- return `name = "${agent.name}"\ndescription = "${esc(agent.desc)}"\nmodel = "${model}"\ndeveloper_instructions = '''\n${agent.body}\n'''\n`;
171
+ const tools = agent.tools ? `tools = [${agent.tools.map((tool) => `"${tool}"`).join(', ')}]\n` : '';
172
+ return `name = "${agent.name}"\ndescription = "${esc(agent.desc)}"\nmodel = "${model}"\n${tools}developer_instructions = '''\n${agent.body}\n'''\n`;
148
173
  }
149
174
 
150
175
  export function agentPath(target, cwd, name = 'developer') {
@@ -152,12 +177,15 @@ export function agentPath(target, cwd, name = 'developer') {
152
177
  return spec ? join(cwd, ...spec.dir.split('/'), `${name}${spec.ext}`) : null;
153
178
  }
154
179
 
155
- export function writeAgents(target, cwd, tier = readDeveloperTier(cwd)) {
180
+ export function writeAgents(target, cwd, tier = readDeveloperTier(cwd), names = agentNames()) {
156
181
  const spec = AGENT_TARGETS[target];
157
182
  if (!spec) return [];
158
- const model = spec.model(tier);
159
183
  const written = [];
160
- for (const agent of AGENTS) {
184
+ for (const name of names) {
185
+ const agent = byName(name);
186
+ if (!agent) continue;
187
+ const effectiveTier = agent.tier === 'heavy' ? 'heavy' : tier;
188
+ const model = spec.model(effectiveTier);
161
189
  const content = spec.format === 'json' ? renderJson(model, agent)
162
190
  : spec.format === 'toml' ? renderToml(model, agent)
163
191
  : renderMd(spec, model, agent);
@@ -169,14 +197,34 @@ export function writeAgents(target, cwd, tier = readDeveloperTier(cwd)) {
169
197
  return written;
170
198
  }
171
199
 
200
+ export function reconcileAgents(target, cwd, tier, previousNames = [], desiredNames = []) {
201
+ if (!targetHasAgents(target)) return { written: [], removed: [], collisions: [], names: [] };
202
+ const previous = new Set(previousNames);
203
+ const desired = new Set(desiredNames);
204
+ const removed = removeAgents(target, cwd, [...previous].filter((name) => !desired.has(name)));
205
+ const written = [];
206
+ const collisions = [];
207
+ const names = [];
208
+ for (const name of desiredNames) {
209
+ const path = agentPath(target, cwd, name);
210
+ if (existsSync(path) && !previous.has(name)) {
211
+ collisions.push(path);
212
+ continue;
213
+ }
214
+ written.push(...writeAgents(target, cwd, tier, [name]));
215
+ if (existsSync(path)) names.push(name);
216
+ }
217
+ return { written, removed, collisions, names };
218
+ }
219
+
172
220
  /**
173
221
  * Remove only the agents this catalog ships. An uninstaller that takes an agent the user wrote by hand
174
222
  * is worse than one that leaves residue.
175
223
  */
176
- export function removeAgents(target, cwd) {
224
+ export function removeAgents(target, cwd, names = allAgentNames()) {
177
225
  const removed = [];
178
- for (const agent of AGENTS) {
179
- const path = agentPath(target, cwd, agent.name);
226
+ for (const name of names) {
227
+ const path = agentPath(target, cwd, name);
180
228
  if (path && existsSync(path)) { rmSync(path, { force: true }); removed.push(path); }
181
229
  }
182
230
  return removed;
@@ -0,0 +1,212 @@
1
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { agentByName, allAgentNames } from './agents.js';
4
+
5
+ const COMMAND_TARGETS = Object.freeze({
6
+ claude: { dir: '.claude/commands', ext: '.md', format: 'claude', skillsAreCommands: true },
7
+ cursor: { dir: '.cursor/commands', ext: '.md', format: 'plain' },
8
+ gemini: { dir: '.gemini/commands', ext: '.toml', format: 'gemini' },
9
+ opencode: { dir: '.opencode/commands', ext: '.md', format: 'opencode' },
10
+ copilot: { dir: '.github/prompts', ext: '.prompt.md', format: 'copilot' },
11
+ windsurf: { dir: '.windsurf/workflows', ext: '.md', format: 'windsurf' },
12
+ cline: { dir: '.clinerules/workflows', ext: '.md', format: 'plain', invocationSuffix: '.md' },
13
+ roo: { dir: '.roo/commands', ext: '.md', format: 'roo' },
14
+ });
15
+
16
+ export const COMMAND_TARGET_IDS = Object.freeze(Object.keys(COMMAND_TARGETS));
17
+ export const targetHasCommands = (target) => Boolean(COMMAND_TARGETS[target]);
18
+
19
+ const delegate = (kind, backing) => `Delegate this request to the ${backing} ${kind}. Pass the invocation arguments unchanged: {{ARGS}}\nDo not reproduce that ${kind}'s method here; load it and follow its own stopping and verification rules.`;
20
+
21
+ const skillCommand = (name, backing = name) => ({
22
+ name,
23
+ kind: 'skill',
24
+ backing,
25
+ description: `Run the ${backing} skill through a short, non-duplicating entry point.`,
26
+ body: delegate('skill', backing),
27
+ });
28
+
29
+ const FIXED_COMMANDS = Object.freeze([
30
+ ...['specify', 'clarify', 'plan', 'tasks', 'analyze', 'implement', 'verify', 'review', 'ship', 'debug'].map((name) => skillCommand(name)),
31
+ {
32
+ name: 'build-fix', kind: 'resolver-selector', backing: 'build-resolver',
33
+ description: 'Route a build failure to the installed stack resolver without guessing.',
34
+ body: `Use an installed build-resolver for this failure: {{ARGS}}\nChoose from diagnostic text and affected paths. If more than one remains plausible, list the candidates and ask the user; never dispatch at random.`,
35
+ },
36
+ skillCommand('refactor-clean', 'simplify-code'),
37
+ {
38
+ name: 'test-coverage', kind: 'testing-skill', backing: 'testing-*',
39
+ description: 'Delegate coverage work to the installed stack testing skill.',
40
+ body: delegate('skill', 'testing-*'),
41
+ },
42
+ skillCommand('update-docs', 'knowledge-ops'),
43
+ {
44
+ name: 'checkpoint', kind: 'capability', backing: 'sello',
45
+ description: 'Freeze the current candidate for review and seal it only after the required lenses approve.',
46
+ body: `Use the sello backing for this checkpoint: {{ARGS}}\nRun \`npx @ericrisco/rsc sello freeze\`, complete the required review, then run \`sello approve\` only with the observed lenses. Do not treat freeze as approval.`,
47
+ },
48
+ skillCommand('harness-audit', 'harness'),
49
+ skillCommand('security-scan', 'security-scan'),
50
+ {
51
+ name: 'learn', kind: 'memory', backing: 'memory:learn',
52
+ description: 'Propose one local lesson; saving still requires individual explicit approval.',
53
+ body: `Invoke memory:learn for exactly one proposed lesson: {{ARGS}}\nAfter individual explicit approval, run \`npx @ericrisco/rsc memory learn\` with its text, evidence, scope, confidence and \`--approve\`. Without that approval, write nothing.`,
54
+ },
55
+ {
56
+ name: 'save-session', kind: 'memory', backing: 'memory:save',
57
+ description: 'Force a local deterministic session checkpoint.',
58
+ body: `Invoke memory:save with the current local session identifiers: {{ARGS}}\nRun \`npx @ericrisco/rsc memory save --session <id>\`. Persist only allowed git and SDD ledger metadata; never include conversation or file content.`,
59
+ },
60
+ {
61
+ name: 'resume-session', kind: 'memory', backing: 'memory:resume',
62
+ description: 'Read the bounded local continuation record for this branch and worktree.',
63
+ body: `Invoke memory:resume for the current branch and worktree: {{ARGS}}\nRun \`npx @ericrisco/rsc memory resume\`. Label a nearby branch result as nearby; never merge it silently into the exact continuation.`,
64
+ },
65
+ ]);
66
+
67
+ export const fixedCommands = () => FIXED_COMMANDS.map((command) => ({ ...command }));
68
+ export const fixedCommandNames = () => FIXED_COMMANDS.map((command) => command.name);
69
+
70
+ function withBacking(command, backing, body) {
71
+ return { ...command, backing, body: body || command.body.replace(command.backing, backing) };
72
+ }
73
+
74
+ function fixedFor({ target, skills, agents, memoryMode }) {
75
+ const spec = COMMAND_TARGETS[target];
76
+ if (!spec) return [];
77
+ const skillSet = new Set(skills);
78
+ const resolverIds = agents.filter((id) => agentByName(id)?.role === 'build-resolver');
79
+ const testing = skills.filter((id) => id.startsWith('testing-')).sort();
80
+ const out = [];
81
+ for (const command of FIXED_COMMANDS) {
82
+ if (command.kind === 'skill') {
83
+ if (!skillSet.has(command.backing) || spec.skillsAreCommands) continue;
84
+ out.push({ ...command });
85
+ } else if (command.kind === 'testing-skill') {
86
+ if (!testing.length || spec.skillsAreCommands) continue;
87
+ out.push(withBacking(command, testing[0]));
88
+ } else if (command.kind === 'resolver-selector') {
89
+ if (!resolverIds.length) continue;
90
+ const backing = resolverIds.length === 1 ? resolverIds[0] : 'build-resolver';
91
+ const candidates = resolverIds.join(', ');
92
+ out.push(withBacking(command, backing,
93
+ `Choose the matching installed build-resolver from: ${candidates}. Pass the failure and invocation arguments unchanged: {{ARGS}}\nUse diagnostics and affected paths. If the choice is ambiguous, list candidates and ask; never dispatch at random.`));
94
+ } else if (command.kind === 'memory') {
95
+ if (!['full', 'assisted'].includes(memoryMode)) continue;
96
+ out.push({ ...command });
97
+ } else {
98
+ out.push({ ...command });
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ function stackAliases({ skills, agents }) {
105
+ const skillSet = new Set(skills);
106
+ const out = [];
107
+ for (const id of agents) {
108
+ const agent = agentByName(id);
109
+ if (!agent || !['reviewer', 'build-resolver'].includes(agent.role) || !agent.skills?.length) continue;
110
+ const installedAliases = agent.skills.filter((skill) => skillSet.has(skill));
111
+ const aliases = installedAliases.length ? installedAliases : [agent.skills[0]];
112
+ const suffix = agent.role === 'reviewer' ? 'review' : 'build';
113
+ for (const skill of aliases) {
114
+ out.push({
115
+ name: `${skill}-${suffix}`,
116
+ kind: 'agent',
117
+ backing: id,
118
+ description: `Delegate ${skill} ${suffix} work to the installed ${id} agent.`,
119
+ body: delegate('agent', id),
120
+ });
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ export function resolveCommands({ target, skills = [], agents = [], memoryMode = 'unsupported' }) {
127
+ if (!targetHasCommands(target)) return [];
128
+ const byName = new Map();
129
+ for (const command of [...fixedFor({ target, skills, agents, memoryMode }), ...stackAliases({ skills, agents })]) {
130
+ byName.set(command.name, command);
131
+ }
132
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
133
+ }
134
+
135
+ export function validateCommandCatalog(definitions = FIXED_COMMANDS) {
136
+ const errors = [];
137
+ const seen = new Set();
138
+ for (const command of definitions) {
139
+ const prefix = command?.name || '<unnamed>';
140
+ if (!command?.name || seen.has(command.name)) errors.push(`${prefix}: unique name required`);
141
+ seen.add(command?.name);
142
+ if (!command?.backing || !command?.body?.includes(command.backing)) errors.push(`${prefix}: body must name its exact backing`);
143
+ if ((command?.body || '').split('\n').length > 40) errors.push(`${prefix}: body exceeds 40 lines`);
144
+ if (/everything[- ]claude|affaan|worldflow/iu.test(`${command?.description || ''}\n${command?.body || ''}`)) errors.push(`${prefix}: foreign project vocabulary`);
145
+ }
146
+ return errors;
147
+ }
148
+
149
+ export function commandPath(target, cwd, name) {
150
+ const spec = COMMAND_TARGETS[target];
151
+ return spec ? join(cwd, ...spec.dir.split('/'), `${name}${spec.ext}`) : null;
152
+ }
153
+
154
+ function renderArgs(target, body) {
155
+ const placeholder = target === 'claude' || target === 'opencode' ? '$ARGUMENTS'
156
+ : target === 'gemini' ? '{{args}}'
157
+ : 'any text following the slash command';
158
+ return body.replaceAll('{{ARGS}}', placeholder);
159
+ }
160
+
161
+ function markdown(command, target) {
162
+ const body = renderArgs(target, command.body);
163
+ if (target === 'cursor' || target === 'cline') return `# /${command.name}\n\n${command.description}\n\n${body}\n`;
164
+ if (target === 'windsurf') return `# /${command.name}\n\n${command.description}\n\n1. ${body.replaceAll('\n', '\n2. ')}\n`;
165
+ const fm = ['---', `description: "${command.description.replaceAll('"', '\\"')}"`, 'argument-hint: "[arguments]"'];
166
+ if (target === 'copilot') fm.push('mode: agent');
167
+ if (target === 'opencode' && command.kind === 'agent') fm.push(`agent: ${command.backing}`);
168
+ fm.push('---', '');
169
+ return `${fm.join('\n')}${body}\n`;
170
+ }
171
+
172
+ function render(command, target) {
173
+ if (target !== 'gemini') return markdown(command, target);
174
+ const body = renderArgs(target, command.body);
175
+ return `description = ${JSON.stringify(command.description)}\nprompt = ${JSON.stringify(body)}\n`;
176
+ }
177
+
178
+ export function reconcileCommands(target, cwd, previousNames = [], desiredCommands = []) {
179
+ if (!targetHasCommands(target)) return { written: [], removed: [], collisions: [], names: [] };
180
+ const previous = new Set(previousNames);
181
+ const desiredNames = new Set(desiredCommands.map((command) => command.name));
182
+ const removed = [];
183
+ for (const name of previous) {
184
+ if (desiredNames.has(name)) continue;
185
+ const path = commandPath(target, cwd, name);
186
+ if (path && existsSync(path)) { rmSync(path, { force: true }); removed.push(path); }
187
+ }
188
+ const written = [];
189
+ const collisions = [];
190
+ const names = [];
191
+ for (const command of desiredCommands) {
192
+ const path = commandPath(target, cwd, command.name);
193
+ if (existsSync(path) && !previous.has(command.name)) {
194
+ collisions.push(path);
195
+ continue;
196
+ }
197
+ mkdirSync(dirname(path), { recursive: true });
198
+ writeFileSync(path, render(command, target));
199
+ written.push(path);
200
+ names.push(command.name);
201
+ }
202
+ return { written, removed, collisions, names };
203
+ }
204
+
205
+ export function allPotentialCommandNames() {
206
+ const names = new Set(fixedCommandNames());
207
+ // Every stack alias follows directly from catalog metadata; no second mapping.
208
+ const agents = allAgentNames();
209
+ const skills = [...new Set(agents.flatMap((id) => agentByName(id)?.skills || []))];
210
+ for (const command of stackAliases({ skills, agents })) names.add(command.name);
211
+ return [...names];
212
+ }
@@ -0,0 +1,276 @@
1
+ import {
2
+ appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync,
3
+ } from 'node:fs';
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ export { handleLifecycle, contextFromNativeOutput } from './session-memory-adapter.mjs';
8
+
9
+ const HERE = dirname(fileURLToPath(import.meta.url));
10
+ const NEEDLE = '.rsc/session-memory-adapter.mjs';
11
+ const PLUGIN_MARKER = 'rsc-memory:managed';
12
+
13
+ export const MEMORY_TARGETS = Object.freeze({
14
+ claude: 'full',
15
+ codex: 'full',
16
+ // Cursor documents sessionStart as fire-and-forget. Its best-effort hook is useful,
17
+ // but only the always-on rule can make the read-before-action obligation explicit.
18
+ cursor: 'assisted',
19
+ gemini: 'full',
20
+ opencode: 'full',
21
+ });
22
+
23
+ const CONFIG = Object.freeze({
24
+ claude: '.claude/settings.local.json',
25
+ codex: '.codex/hooks.json',
26
+ cursor: '.cursor/hooks.json',
27
+ gemini: '.gemini/settings.json',
28
+ opencode: '.opencode/plugins/rsc-memory.js',
29
+ });
30
+
31
+ export const memoryModeFor = (target) => MEMORY_TARGETS[target] || 'unsupported';
32
+
33
+ export function memoryEnabledForProject(cwd = process.cwd()) {
34
+ try { return JSON.parse(readFileSync(join(cwd, '.rsc.json'), 'utf8')).memory !== false; } catch { return true; }
35
+ }
36
+
37
+ function git(cwd, args) {
38
+ try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
39
+ }
40
+
41
+ function rel(cwd, path) {
42
+ return relative(cwd, path).split(sep).join('/');
43
+ }
44
+
45
+ function tracked(cwd, path) {
46
+ const output = git(cwd, ['ls-files', '--', rel(cwd, path)]);
47
+ return Boolean(output);
48
+ }
49
+
50
+ function exclude(cwd, path) {
51
+ if (git(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return;
52
+ const value = git(cwd, ['rev-parse', '--git-path', 'info/exclude']);
53
+ if (!value) return;
54
+ const file = isAbsolute(value) ? value : resolve(cwd, value);
55
+ const pattern = `/${rel(cwd, path)}`;
56
+ mkdirSync(dirname(file), { recursive: true });
57
+ const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
58
+ if (!body.split('\n').includes(pattern)) appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${pattern}\n`);
59
+ }
60
+
61
+ function readConfig(path) {
62
+ if (!existsSync(path)) return {};
63
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
64
+ }
65
+
66
+ function validHookConfig(config) {
67
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return false;
68
+ if (!Object.hasOwn(config, 'hooks')) return true;
69
+ if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) return false;
70
+ return Object.values(config.hooks).every(Array.isArray);
71
+ }
72
+
73
+ function handler(target, event) {
74
+ const unixScript = target === 'claude'
75
+ ? '"${CLAUDE_PROJECT_DIR}/.rsc/session-memory-adapter.mjs"'
76
+ : target === 'codex'
77
+ ? '"$(git rev-parse --show-toplevel)/.rsc/session-memory-adapter.mjs"'
78
+ : '".rsc/session-memory-adapter.mjs"';
79
+ const value = {
80
+ type: 'command',
81
+ command: `node ${unixScript} ${target} ${event}`,
82
+ timeout: target === 'gemini' ? 5000 : event === 'end' && target === 'codex' ? 3 : 5,
83
+ };
84
+ if (target === 'codex') {
85
+ value.commandWindows = `node \"$((git rev-parse --show-toplevel).Trim())/.rsc/session-memory-adapter.mjs\" ${target} ${event}`;
86
+ value.additionalContextLimit = 1400;
87
+ }
88
+ if (target === 'gemini') value.name = `rsc-memory-${event}`;
89
+ return value;
90
+ }
91
+
92
+ const nestedEvents = {
93
+ claude: [
94
+ ['SessionStart', 'start'], ['UserPromptSubmit', 'request'], ['PostToolUse', 'edit', 'Edit|Write|NotebookEdit'],
95
+ ['PostToolUse', 'boundary', 'Bash'], ['Stop', 'turn'], ['PreCompact', 'compact'], ['SessionEnd', 'end'],
96
+ ],
97
+ codex: [
98
+ ['SessionStart', 'start', 'startup|resume|clear|compact'], ['UserPromptSubmit', 'request'],
99
+ ['PostToolUse', 'edit', 'apply_patch|Edit|Write'], ['PostToolUse', 'boundary', 'Bash'],
100
+ ['Stop', 'turn'], ['PreCompact', 'compact'], ['SessionEnd', 'end'],
101
+ ],
102
+ gemini: [
103
+ ['SessionStart', 'start'], ['BeforeAgent', 'request'], ['AfterTool', 'edit', 'write_file|replace'],
104
+ ['AfterTool', 'boundary', 'run_shell_command'], ['AfterAgent', 'turn'], ['PreCompress', 'compact'], ['SessionEnd', 'end'],
105
+ ],
106
+ };
107
+
108
+ const cursorEvents = [
109
+ ['sessionStart', 'start'], ['beforeSubmitPrompt', 'request'], ['afterFileEdit', 'edit'],
110
+ ['afterShellExecution', 'boundary'], ['afterAgentResponse', 'turn'], ['preCompact', 'compact'], ['sessionEnd', 'end'],
111
+ ];
112
+
113
+ function stripManaged(config) {
114
+ if (!validHookConfig(config) || !config.hooks) return config;
115
+ for (const event of Object.keys(config.hooks)) {
116
+ config.hooks[event] = (config.hooks[event] || []).filter((entry) => !JSON.stringify(entry).replaceAll('\\\\', '/').includes(NEEDLE));
117
+ if (!config.hooks[event].length) delete config.hooks[event];
118
+ }
119
+ if (!Object.keys(config.hooks).length) delete config.hooks;
120
+ return config;
121
+ }
122
+
123
+ function writeJsonWiring(target, path, config) {
124
+ stripManaged(config);
125
+ config.hooks ||= {};
126
+ if (target === 'cursor') {
127
+ config.version ||= 1;
128
+ for (const [event, operation] of cursorEvents) {
129
+ config.hooks[event] ||= [];
130
+ const entry = handler(target, operation);
131
+ delete entry.type;
132
+ delete entry.timeout;
133
+ config.hooks[event].push(entry);
134
+ }
135
+ } else {
136
+ for (const [event, operation, matcher] of nestedEvents[target]) {
137
+ config.hooks[event] ||= [];
138
+ config.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [handler(target, operation)] });
139
+ }
140
+ }
141
+ mkdirSync(dirname(path), { recursive: true });
142
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
143
+ }
144
+
145
+ function commonPaths(cwd) {
146
+ return [
147
+ join(cwd, '.rsc', 'session-memory-core.mjs'),
148
+ join(cwd, '.rsc', 'session-memory-adapter.mjs'),
149
+ join(cwd, '.rsc', 'session-memory.mjs'),
150
+ ];
151
+ }
152
+
153
+ export function memoryManagedPaths(target, cwd = process.cwd()) {
154
+ if (!MEMORY_TARGETS[target]) return [];
155
+ const paths = [...commonPaths(cwd), join(cwd, ...CONFIG[target].split('/'))];
156
+ if (target === 'cursor') paths.push(join(cwd, '.cursor', 'rules', 'rsc-memory.mdc'));
157
+ return paths;
158
+ }
159
+
160
+ export function wireMemory(target, cwd = process.cwd()) {
161
+ const mode = memoryModeFor(target);
162
+ if (mode === 'unsupported') return { mode, reason: 'no-local-lifecycle', paths: [] };
163
+ if (!memoryEnabledForProject(cwd)) {
164
+ unwireMemory(target, cwd);
165
+ return { mode: 'disabled', reason: 'project-disabled', paths: [] };
166
+ }
167
+ const configPath = join(cwd, ...CONFIG[target].split('/'));
168
+ if (tracked(cwd, configPath)) return { mode: 'degraded', reason: 'config-tracked', paths: [] };
169
+ const config = target === 'opencode' ? null : readConfig(configPath);
170
+ if (target !== 'opencode' && !validHookConfig(config)) return { mode: 'degraded', reason: 'config-invalid', paths: [] };
171
+ if (target === 'opencode' && existsSync(configPath) && !readFileSync(configPath, 'utf8').includes(PLUGIN_MARKER)) {
172
+ return { mode: 'degraded', reason: 'plugin-collision', paths: [] };
173
+ }
174
+ const assistedRule = join(cwd, '.cursor', 'rules', 'rsc-memory.mdc');
175
+ if (target === 'cursor' && tracked(cwd, assistedRule)) return { mode: 'degraded', reason: 'rule-tracked', paths: [] };
176
+ if (target === 'cursor' && existsSync(assistedRule) && !readFileSync(assistedRule, 'utf8').includes('rsc-memory:managed')) {
177
+ return { mode: 'degraded', reason: 'rule-collision', paths: [] };
178
+ }
179
+
180
+ const [core, adapter, cli] = commonPaths(cwd);
181
+ mkdirSync(dirname(core), { recursive: true });
182
+ copyFileSync(join(HERE, 'session-memory-core.mjs'), core);
183
+ copyFileSync(join(HERE, 'session-memory-adapter.mjs'), adapter);
184
+ copyFileSync(join(HERE, 'session-memory.mjs'), cli);
185
+ if (target === 'opencode') {
186
+ mkdirSync(dirname(configPath), { recursive: true });
187
+ copyFileSync(join(HERE, 'opencode-memory-plugin.js'), configPath);
188
+ } else {
189
+ writeJsonWiring(target, configPath, config);
190
+ }
191
+ if (target === 'cursor') {
192
+ mkdirSync(dirname(assistedRule), { recursive: true });
193
+ writeFileSync(assistedRule, `---\ndescription: Read rsc local continuation before the first action in a new desktop session.\nalwaysApply: true\n---\n<!-- rsc-memory:managed -->\nOn the first turn of a local desktop session, run \`node .rsc/session-memory.mjs resume\` and read its \`context\` field before acting. Skip this rule for background or cloud agents.\n`);
194
+ }
195
+ const paths = memoryManagedPaths(target, cwd);
196
+ for (const path of paths) exclude(cwd, path);
197
+ return {
198
+ mode,
199
+ reason: target === 'codex' ? 'hook-trust-required' : target === 'cursor' ? 'start-hook-fire-and-forget' : 'wired',
200
+ paths,
201
+ };
202
+ }
203
+
204
+ export function inspectMemoryWiring(target, cwd = process.cwd(), recorded = null, installed = false) {
205
+ const supportedMode = memoryModeFor(target);
206
+ if (supportedMode === 'unsupported') return { supportedMode, mode: 'unsupported', status: 'unsupported', reason: 'no-local-lifecycle', missing: [], tracked: [] };
207
+ const mode = recorded?.mode || 'not-installed';
208
+ if (mode === 'disabled' || !memoryEnabledForProject(cwd)) {
209
+ return { supportedMode, mode: 'disabled', status: 'disabled', reason: 'project-disabled', missing: [], tracked: [] };
210
+ }
211
+ // A capability that has never been installed is absent, not broken. Keep the
212
+ // degraded state for pre-memory installs so `sync` can repair those projects.
213
+ if (!recorded && !installed) {
214
+ return { supportedMode, mode, status: 'not-installed', reason: 'not-wired', missing: [], tracked: [] };
215
+ }
216
+ const paths = recorded?.paths?.length ? recorded.paths : memoryManagedPaths(target, cwd);
217
+ const configPath = join(cwd, ...CONFIG[target].split('/'));
218
+ const cursorRule = join(cwd, '.cursor', 'rules', 'rsc-memory.mdc');
219
+ const missing = paths.filter((path) => {
220
+ if (!existsSync(path)) return true;
221
+ if (path === configPath) {
222
+ const body = readFileSync(path, 'utf8').replaceAll('\\', '/');
223
+ return target === 'opencode' ? !body.includes(PLUGIN_MARKER) : !body.includes(NEEDLE);
224
+ }
225
+ if (target === 'cursor' && path === cursorRule) return !readFileSync(path, 'utf8').includes(PLUGIN_MARKER);
226
+ return false;
227
+ });
228
+ const trackedPaths = paths.filter((path) => tracked(cwd, path));
229
+ const status = mode === 'degraded' || mode === 'not-installed' || missing.length || trackedPaths.length ? 'degraded' : 'ready';
230
+ return {
231
+ supportedMode,
232
+ mode,
233
+ status,
234
+ reason: mode === 'degraded' ? (recorded?.reason || 'adapter-degraded')
235
+ : trackedPaths.length ? 'memory-path-tracked'
236
+ : missing.length ? 'memory-path-missing'
237
+ : recorded?.reason || 'wired',
238
+ missing,
239
+ tracked: trackedPaths,
240
+ action: status === 'degraded' ? 'Run `npx @ericrisco/rsc sync`; if a config is tracked, move memory hooks to an untracked local config first.' : null,
241
+ trustRequired: target === 'codex' && status === 'ready',
242
+ };
243
+ }
244
+
245
+ export function unwireMemory(target, cwd = process.cwd()) {
246
+ if (!MEMORY_TARGETS[target]) return [];
247
+ const path = join(cwd, ...CONFIG[target].split('/'));
248
+ const touched = [];
249
+ if (target === 'opencode') {
250
+ if (existsSync(path) && readFileSync(path, 'utf8').includes(PLUGIN_MARKER)) { rmSync(path, { force: true }); touched.push(path); }
251
+ } else {
252
+ const raw = existsSync(path) ? readFileSync(path, 'utf8') : '';
253
+ const config = raw.replaceAll('\\\\', '/').includes(NEEDLE) ? readConfig(path) : null;
254
+ if (config) { stripManaged(config); writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); touched.push(path); }
255
+ }
256
+ if (target === 'cursor') {
257
+ const rule = join(cwd, '.cursor', 'rules', 'rsc-memory.mdc');
258
+ if (existsSync(rule) && readFileSync(rule, 'utf8').includes('rsc-memory:managed')) { rmSync(rule, { force: true }); touched.push(rule); }
259
+ }
260
+ return touched;
261
+ }
262
+
263
+ export function memoryArtifactsPresent(target, cwd = process.cwd()) {
264
+ if (!MEMORY_TARGETS[target]) return [];
265
+ const out = commonPaths(cwd).filter(existsSync);
266
+ const config = join(cwd, ...CONFIG[target].split('/'));
267
+ if (existsSync(config)) {
268
+ const body = readFileSync(config, 'utf8').replaceAll('\\\\', '/');
269
+ if (body.includes(NEEDLE) || body.includes(PLUGIN_MARKER)) out.push(config);
270
+ }
271
+ if (target === 'cursor') {
272
+ const rule = join(cwd, '.cursor', 'rules', 'rsc-memory.mdc');
273
+ if (existsSync(rule) && readFileSync(rule, 'utf8').includes('rsc-memory:managed')) out.push(rule);
274
+ }
275
+ return out;
276
+ }