@damiangilgonzalez/harness 1.3.18 → 1.3.20

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,196 @@
1
+ // Wiring for knowledge sync (`knowledge-sync.mjs`): two lifecycle events per assistant — the person
2
+ // sends a message, the agent finishes — in the same project-local config files the session memory
3
+ // uses, under a needle of its own so either can be wired or removed without touching the other.
4
+ //
5
+ // Kept apart from `memory.js` on purpose. The memory's promise is that it makes no network request,
6
+ // and a test holds every file it wires to that; knowledge sync exists to talk to `origin`. Riding
7
+ // inside the memory would have broken the promise and tied the two switches together.
8
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ const NEEDLE = '.rsc/knowledge-sync.mjs';
15
+ const PLUGIN_MARKER = 'rsc-knowledge:managed';
16
+
17
+ /** Same files as the memory: project-local, untracked, the assistant's own hook config. */
18
+ const CONFIG = Object.freeze({
19
+ claude: '.claude/settings.local.json',
20
+ codex: '.codex/hooks.json',
21
+ cursor: '.cursor/hooks.json',
22
+ gemini: '.gemini/settings.json',
23
+ opencode: '.opencode/plugins/rsc-knowledge.js',
24
+ });
25
+
26
+ const EVENTS = Object.freeze({
27
+ claude: [['UserPromptSubmit', 'request'], ['Stop', 'turn']],
28
+ codex: [['UserPromptSubmit', 'request'], ['Stop', 'turn']],
29
+ gemini: [['BeforeAgent', 'request'], ['AfterAgent', 'turn']],
30
+ cursor: [['beforeSubmitPrompt', 'request'], ['afterAgentResponse', 'turn']],
31
+ });
32
+
33
+ export const KNOWLEDGE_TARGETS = Object.freeze(Object.keys(CONFIG));
34
+
35
+ const git = (cwd, args) => {
36
+ try { return execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; }
37
+ };
38
+ const rel = (cwd, path) => relative(cwd, path).split(sep).join('/');
39
+ const tracked = (cwd, path) => Boolean(git(cwd, ['ls-files', '--', rel(cwd, path)]));
40
+
41
+ function exclude(cwd, path) {
42
+ if (git(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return;
43
+ const value = git(cwd, ['rev-parse', '--git-path', 'info/exclude']);
44
+ if (!value) return;
45
+ const file = isAbsolute(value) ? value : resolve(cwd, value);
46
+ const pattern = `/${rel(cwd, path)}`;
47
+ mkdirSync(dirname(file), { recursive: true });
48
+ const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
49
+ if (!body.split('\n').includes(pattern)) appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${pattern}\n`);
50
+ }
51
+
52
+ function readConfig(path) {
53
+ if (!existsSync(path)) return {};
54
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
55
+ }
56
+
57
+ const validHookConfig = (c) => Boolean(c) && typeof c === 'object' && !Array.isArray(c)
58
+ && (!Object.hasOwn(c, 'hooks') || (c.hooks && typeof c.hooks === 'object' && !Array.isArray(c.hooks) && Object.values(c.hooks).every(Array.isArray)));
59
+
60
+ function handler(target, event) {
61
+ const script = target === 'claude'
62
+ ? '"${CLAUDE_PROJECT_DIR}/.rsc/knowledge-sync.mjs"'
63
+ : target === 'codex'
64
+ ? '"$(git rev-parse --show-toplevel)/.rsc/knowledge-sync.mjs"'
65
+ : '".rsc/knowledge-sync.mjs"';
66
+ const value = { type: 'command', command: `node ${script} hook ${target} ${event}`, timeout: target === 'gemini' ? 10000 : 10 };
67
+ if (target === 'codex') value.commandWindows = `node "$((git rev-parse --show-toplevel).Trim())/.rsc/knowledge-sync.mjs" hook ${target} ${event}`;
68
+ if (target === 'gemini') value.name = `rsc-knowledge-${event}`;
69
+ return value;
70
+ }
71
+
72
+ function strip(config) {
73
+ if (!validHookConfig(config) || !config.hooks) return config;
74
+ for (const event of Object.keys(config.hooks)) {
75
+ config.hooks[event] = config.hooks[event].filter((e) => !JSON.stringify(e).replaceAll('\\\\', '/').includes(NEEDLE));
76
+ if (!config.hooks[event].length) delete config.hooks[event];
77
+ }
78
+ if (!Object.keys(config.hooks).length) delete config.hooks;
79
+ return config;
80
+ }
81
+
82
+ const PLUGIN = `// ${PLUGIN_MARKER}
83
+ // rsc knowledge sync for OpenCode. OpenCode has no prompt event and no channel to the person, so what
84
+ // sync has to say goes to the model, to be relayed. Cheap on every call: the fetch is throttled and
85
+ // every notice is said once. Turn it off with \`rsc knowledge-sync off\`.
86
+ // One file for both plugin APIs (issue #289): V1 calls the named export or server(); V2 reads id + setup.
87
+ import { onRequest, onTurn } from '../../.rsc/knowledge-sync.mjs';
88
+
89
+ const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
90
+ const tell = (cwd) => {
91
+ if (!local()) return '';
92
+ try { const said = onRequest(cwd); return said ? \`Cuéntale esto al usuario en una línea:\\n\${said}\` : ''; } catch { return ''; }
93
+ };
94
+ const turn = (cwd) => { if (local()) { try { onTurn(cwd); } catch { /* fail open */ } } };
95
+
96
+ export const RscKnowledgePlugin = async ({ directory, worktree }) => {
97
+ const cwd = worktree || directory;
98
+ return {
99
+ 'experimental.chat.system.transform': async (_input, output) => {
100
+ const text = tell(cwd);
101
+ if (text && Array.isArray(output?.system)) output.system.push(text);
102
+ },
103
+ event: async ({ event }) => { if (event?.type === 'session.idle') turn(cwd); },
104
+ };
105
+ };
106
+
107
+ // V2 has no session.idle: a turn ends with session.execution.succeeded (or .failed).
108
+ export default {
109
+ id: 'rsc.knowledge',
110
+ server: RscKnowledgePlugin,
111
+ async setup(ctx) {
112
+ const cwd = ctx.location?.project?.directory || ctx.location?.directory || process.cwd();
113
+ await ctx.session.hook('context', async (event) => {
114
+ const text = tell(cwd);
115
+ if (text && Array.isArray(event?.system)) event.system.push({ type: 'text', text });
116
+ });
117
+ const stop = new AbortController();
118
+ (async () => {
119
+ try {
120
+ for await (const event of ctx.event.subscribe({ signal: stop.signal })) {
121
+ if (event?.type === 'session.execution.succeeded' || event?.type === 'session.execution.failed') turn(cwd);
122
+ }
123
+ } catch { /* unloading, or the stream ended: fail open */ }
124
+ })();
125
+ return () => stop.abort();
126
+ },
127
+ };
128
+ `;
129
+
130
+ export function knowledgeManagedPaths(target, cwd = process.cwd()) {
131
+ if (!CONFIG[target]) return [];
132
+ return [join(cwd, '.rsc', 'knowledge-sync.mjs'), join(cwd, '.rsc', 'trunk-policy.mjs'), join(cwd, ...CONFIG[target].split('/'))];
133
+ }
134
+
135
+ /** Wire it. Always, when the assistant supports it: being off is the runtime's `.no-knowledge-sync`. */
136
+ export function wireKnowledge(target, cwd = process.cwd()) {
137
+ if (!CONFIG[target]) return { mode: 'unsupported', paths: [] };
138
+ const configPath = join(cwd, ...CONFIG[target].split('/'));
139
+ if (tracked(cwd, configPath)) return { mode: 'degraded', reason: 'config-tracked', paths: [] };
140
+ if (target === 'opencode' && existsSync(configPath) && !readFileSync(configPath, 'utf8').includes(PLUGIN_MARKER)) {
141
+ return { mode: 'degraded', reason: 'plugin-collision', paths: [] };
142
+ }
143
+ const config = target === 'opencode' ? null : readConfig(configPath);
144
+ if (target !== 'opencode' && !validHookConfig(config)) return { mode: 'degraded', reason: 'config-invalid', paths: [] };
145
+
146
+ const script = join(cwd, '.rsc', 'knowledge-sync.mjs');
147
+ mkdirSync(dirname(script), { recursive: true });
148
+ copyFileSync(join(HERE, 'knowledge-sync.mjs'), script);
149
+ // Its sibling import: whether the default branch is closed decides if sync may commit there.
150
+ copyFileSync(join(HERE, 'trunk-policy.mjs'), join(cwd, '.rsc', 'trunk-policy.mjs'));
151
+ mkdirSync(dirname(configPath), { recursive: true });
152
+ if (target === 'opencode') writeFileSync(configPath, PLUGIN);
153
+ else {
154
+ strip(config);
155
+ config.hooks ||= {};
156
+ if (target === 'cursor') config.version ||= 1;
157
+ for (const [event, op] of EVENTS[target]) {
158
+ config.hooks[event] ||= [];
159
+ const h = handler(target, op);
160
+ if (target === 'cursor') { delete h.type; delete h.timeout; config.hooks[event].push(h); }
161
+ else config.hooks[event].push({ hooks: [h] });
162
+ }
163
+ // A fixed event order. The memory rewires by stripping and re-appending its own events, which
164
+ // moves them behind ours; without this, install and re-install write the same hooks in a
165
+ // different order and the onboarding receipt reads it as drift ("governed content differs").
166
+ config.hooks = Object.fromEntries(Object.keys(config.hooks).sort().map((k) => [k, config.hooks[k]]));
167
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
168
+ }
169
+ const paths = knowledgeManagedPaths(target, cwd);
170
+ for (const path of paths) exclude(cwd, path);
171
+ return { mode: 'wired', paths };
172
+ }
173
+
174
+ export function unwireKnowledge(target, cwd = process.cwd()) {
175
+ if (!CONFIG[target]) return [];
176
+ const path = join(cwd, ...CONFIG[target].split('/'));
177
+ const touched = [];
178
+ if (target === 'opencode') {
179
+ if (existsSync(path) && readFileSync(path, 'utf8').includes(PLUGIN_MARKER)) { rmSync(path, { force: true }); touched.push(path); }
180
+ } else if (existsSync(path) && readFileSync(path, 'utf8').replaceAll('\\\\', '/').includes(NEEDLE)) {
181
+ const config = readConfig(path);
182
+ if (config) { strip(config); writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); touched.push(path); }
183
+ }
184
+ return touched;
185
+ }
186
+
187
+ export function knowledgeArtifactsPresent(target, cwd = process.cwd()) {
188
+ if (!CONFIG[target]) return [];
189
+ const out = [join(cwd, '.rsc', 'knowledge-sync.mjs')].filter(existsSync);
190
+ const path = join(cwd, ...CONFIG[target].split('/'));
191
+ if (existsSync(path)) {
192
+ const body = readFileSync(path, 'utf8').replaceAll('\\\\', '/');
193
+ if (body.includes(NEEDLE) || body.includes(PLUGIN_MARKER)) out.push(path);
194
+ }
195
+ return out;
196
+ }
package/targets/memory.js CHANGED
@@ -104,6 +104,7 @@ const nestedEvents = {
104
104
  ['PostToolUse', 'edit', 'apply_patch|Edit|Write'], ['PostToolUse', 'boundary', 'Bash'],
105
105
  ['Stop', 'turn'], ['PreCompact', 'compact'], ['SessionEnd', 'end'],
106
106
  ],
107
+ // DeepSeek Harness runs Codex-format hooks through rsc's bridge: only these five points exist there.
107
108
  gemini: [
108
109
  ['SessionStart', 'start'], ['BeforeAgent', 'request'], ['AfterTool', 'edit', 'write_file|replace'],
109
110
  ['AfterTool', 'boundary', 'run_shell_command'], ['AfterAgent', 'turn'], ['PreCompress', 'compact'], ['SessionEnd', 'end'],
@@ -1,4 +1,6 @@
1
1
  // rsc-memory:managed
2
+ // One file for both OpenCode plugin APIs (issue #289). V1 (1.x) calls the named export, or the default
3
+ // export's server(); V2 (2.x) reads the default export's id + setup(ctx) and ignores the rest.
2
4
  import { readFileSync } from 'node:fs';
3
5
  import { join } from 'node:path';
4
6
  import { capture, resume } from '../../.rsc/session-memory-core.mjs';
@@ -15,51 +17,94 @@ function memorySettings(cwd) {
15
17
 
16
18
  function sessionId(input) {
17
19
  return input?.sessionID || input?.session_id || input?.event?.properties?.info?.id
18
- || input?.event?.properties?.sessionID || null;
20
+ || input?.event?.properties?.sessionID || input?.event?.data?.sessionID || null;
19
21
  }
20
22
 
21
- export const RscMemoryPlugin = async ({ directory, worktree }) => {
22
- const cwd = worktree || directory;
23
+ /** What the plugin does, whichever API delivers the moment. `put(text)` adds to the system prompt. */
24
+ function memory(cwd) {
23
25
  const injected = new Set();
24
26
  const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
25
27
  const save = (id, event, editDelta = 0) => {
26
28
  if (!id || !local()) return;
27
29
  try { capture({ cwd, sessionId: id, target: 'opencode', event, editDelta, settings: memorySettings(cwd) }); } catch { /* fail open */ }
28
30
  };
29
-
30
31
  return {
31
- 'experimental.chat.system.transform': async (input, output) => {
32
- const id = sessionId(input);
32
+ save,
33
+ context(id, put) {
33
34
  if (!id || injected.has(id) || !local()) return;
34
35
  injected.add(id);
35
36
  try {
36
- const config = memorySettings(cwd);
37
37
  save(id, 'start');
38
- const result = resume({ cwd, target: 'opencode', settings: config });
39
- if (!result.context || !Array.isArray(output?.system)) return;
40
- if (output.system.length === 0) output.system.push(result.context);
41
- else output.system[0] = `${output.system[0]}\n\n${result.context}`;
38
+ const result = resume({ cwd, target: 'opencode', settings: memorySettings(cwd) });
39
+ if (result.context) put(result.context);
42
40
  } catch { /* fail open */ }
43
41
  },
44
- 'tool.execute.after': async (input) => {
45
- const tool = String(input?.tool || '').toLowerCase();
46
- save(sessionId(input), 'boundary', /write|edit|patch/u.test(tool) ? 1 : 0);
42
+ tool(id, name) {
43
+ save(id, 'boundary', /write|edit|patch/u.test(String(name || '').toLowerCase()) ? 1 : 0);
47
44
  },
48
- 'experimental.session.compacting': async (input, output) => {
49
- const id = sessionId(input);
45
+ compact(id, put) {
50
46
  save(id, 'compact');
51
47
  try {
52
48
  const result = resume({ cwd, target: 'opencode', settings: memorySettings(cwd) });
53
- if (result.context && Array.isArray(output?.context)) output.context.push(result.context);
49
+ if (result.context) put(result.context);
54
50
  } catch { /* fail open */ }
55
51
  },
52
+ };
53
+ }
54
+
55
+ // ---------------------------------------------------------------- OpenCode 1.x (V1 plugin API)
56
+
57
+ export const RscMemoryPlugin = async ({ directory, worktree }) => {
58
+ const m = memory(worktree || directory);
59
+ return {
60
+ 'experimental.chat.system.transform': async (input, output) => {
61
+ if (!Array.isArray(output?.system)) return;
62
+ m.context(sessionId(input), (text) => {
63
+ if (output.system.length === 0) output.system.push(text);
64
+ else output.system[0] = `${output.system[0]}\n\n${text}`;
65
+ });
66
+ },
67
+ 'tool.execute.after': async (input) => m.tool(sessionId(input), input?.tool),
68
+ 'experimental.session.compacting': async (input, output) => {
69
+ m.compact(sessionId(input), (text) => { if (Array.isArray(output?.context)) output.context.push(text); });
70
+ },
56
71
  event: async ({ event }) => {
57
72
  const id = sessionId({ event });
58
- if (event?.type === 'session.created') save(id, 'start');
59
- else if (event?.type === 'file.edited') save(id, 'edit', 1);
60
- else if (event?.type === 'session.compacted') save(id, 'compact');
61
- else if (event?.type === 'session.idle') save(id, 'turn');
62
- else if (event?.type === 'session.deleted') save(id, 'sessionEnd');
73
+ if (event?.type === 'session.created') m.save(id, 'start');
74
+ else if (event?.type === 'file.edited') m.save(id, 'edit', 1);
75
+ else if (event?.type === 'session.compacted') m.save(id, 'compact');
76
+ else if (event?.type === 'session.idle') m.save(id, 'turn');
77
+ else if (event?.type === 'session.deleted') m.save(id, 'sessionEnd');
63
78
  },
64
79
  };
65
80
  };
81
+
82
+ // ---------------------------------------------------------------- OpenCode 2.x (V2 plugin API)
83
+ // Hooks register through the context; the end of a turn is `session.execution.*` (there is no
84
+ // `session.idle`), and every event carries its session in `data.sessionID`.
85
+
86
+ const TURN_END = new Set(['session.execution.succeeded', 'session.execution.failed']);
87
+
88
+ export default {
89
+ id: 'rsc.memory',
90
+ server: RscMemoryPlugin,
91
+ async setup(ctx) {
92
+ const m = memory(ctx.location?.project?.directory || ctx.location?.directory || process.cwd());
93
+ const part = (event) => (text) => { if (Array.isArray(event?.system)) event.system.push({ type: 'text', text }); };
94
+ await ctx.session.hook('context', async (event) => m.context(event?.sessionID, part(event)));
95
+ await ctx.session.hook('compaction', async (event) => m.compact(event?.sessionID, part(event)));
96
+ await ctx.tool.hook('execute.after', async (event) => m.tool(event?.sessionID, event?.tool));
97
+ const stop = new AbortController();
98
+ (async () => {
99
+ try {
100
+ for await (const event of ctx.event.subscribe({ signal: stop.signal })) {
101
+ const id = event?.data?.sessionID;
102
+ if (event?.type === 'session.created') m.save(id, 'start');
103
+ else if (TURN_END.has(event?.type)) m.save(id, 'turn');
104
+ else if (event?.type === 'session.deleted') m.save(id, 'sessionEnd');
105
+ }
106
+ } catch { /* unloading, or the stream ended: fail open */ }
107
+ })();
108
+ return () => stop.abort();
109
+ },
110
+ };
@@ -22,8 +22,12 @@ export const PROJECT_OPT_OUTS = [
22
22
  'danger-guard', // foot-gun denial, keyed to the committed user profile
23
23
  'feature-gate', // per-turn re-injection of the SDD gate
24
24
  'gitmoji', // the commit-message convention
25
+ 'knowledge-sync', // 01-TOOLS/ + docs/ sync: one person off and the team stops seeing their work
25
26
  'ship-guard', // branch/trunk discipline
27
+ 'trunk-guard', // «desbloquea main»: the default branch open for the agent, for the whole team
28
+ 'trunk-open', // «ramas y PR»: the default branch closed even with nothing that looks complex
26
29
  'worktree-cleanup', // the reaper's notice (it only ever names, never acts)
30
+ 'worktree-isolation', // «no uses worktrees»: same-folder sessions allowed to share a checkout
27
31
  ];
28
32
 
29
33
  /** Decisions of one machine: never written to the manifest, never applied from it. */
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
4
- import { resolve, join, dirname } from 'node:path';
5
- import { capture, resume } from './session-memory-core.mjs';
4
+ import { resolve, join, dirname, isAbsolute, sep } from 'node:path';
5
+ import { capture, otherActiveSessions, resume } from './session-memory-core.mjs';
6
6
 
7
7
  const LOCAL_TARGETS = new Set(['claude', 'codex', 'cursor', 'gemini', 'opencode']);
8
8
 
@@ -20,16 +20,39 @@ function projectSettings(cwd) {
20
20
  // The hook payload's `cwd` is only where the tool call happened to run; inside a container of child
21
21
  // repos that is routinely a subdirectory with no harness of its own, and anchoring there scatters
22
22
  // one session's journal across children. With no harness anywhere above, the cwd stays the project.
23
- function nearestHarness(dir) {
23
+ //
24
+ // One exception, and it is the team workflow: a worktree under `<project>/.worktrees/<branch>/`
25
+ // checks out its OWN committed `.rsc.json`, so the walk stopped there and the session's later events
26
+ // went to a second store inside the worktree — while its open record stayed in the project's store
27
+ // and kept telling every later session in the main checkout that someone was working there (team
28
+ // simulation W1). A worktree the project made belongs to the project: its journal lives in the
29
+ // project's store, with the worktree recorded as WHERE the session works.
30
+ export function nearestHarness(dir) {
24
31
  let current = dir;
25
32
  for (;;) {
26
- if (existsSync(join(current, '.rsc.json'))) return current;
33
+ if (existsSync(join(current, '.rsc.json'))) return parentProjectOf(current) || current;
27
34
  const parent = dirname(current);
28
35
  if (parent === current) return dir;
29
36
  current = parent;
30
37
  }
31
38
  }
32
39
 
40
+ function parentProjectOf(dir) {
41
+ const parts = resolve(dir).split(sep);
42
+ for (let index = 1; index < parts.length; index += 1) {
43
+ if (parts[index] !== '.worktrees') continue;
44
+ const project = parts.slice(0, index).join(sep) || sep;
45
+ if (existsSync(join(project, '.rsc.json'))) return project;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function editedPath(native) {
51
+ const input = native?.tool_input || native?.toolInput || {};
52
+ const path = input.file_path || input.notebook_path || input.path || native?.file_path || null;
53
+ return typeof path === 'string' && isAbsolute(path) ? path : null;
54
+ }
55
+
33
56
  function sessionId(native, target) {
34
57
  return native?.session_id || native?.sessionId || native?.sessionID || native?.conversation_id
35
58
  || native?.thread_id || `${target}-${process.ppid}`;
@@ -88,6 +111,23 @@ function nativeOutput(target, eventName, context = '', notice = null, compaction
88
111
  };
89
112
  }
90
113
 
114
+ /**
115
+ * team-safe-default B, the half the skills act on: when another session is working in this same
116
+ * checkout, the model is told — every message, while it lasts — to do new work in a worktree. Local
117
+ * journal only, no network (the memory's promise stands). `.no-worktree-isolation` turns it off.
118
+ */
119
+ export function isolationContext({ project, here, sessionId, target }) {
120
+ if (existsSync(join(project, '.rsc', '.no-worktree-isolation'))) return '';
121
+ const others = otherActiveSessions({ cwd: project, worktreeCwd: here, sessionId, target });
122
+ if (!others.length) return '';
123
+ const who = others.map((o) => `${o.target}${o.branch ? ` en «${o.branch}»` : ''}`).join(', ');
124
+ return `rsc · aislamiento: otra sesión está trabajando en esta misma carpeta (${who}). ` +
125
+ 'Cualquier cambio de esta petición se hace en un worktree en `.worktrees/` de la raíz, nunca dentro de una carpeta de proyecto: ' +
126
+ '`git worktree add .worktrees/<rama> -b <rama>` (si el proyecto está en una subcarpeta, `git -C <proyecto> worktree add <raíz>/.worktrees/<proyecto>-<rama> -b <rama>`, con la ruta absoluta) y se trabaja dentro. ' +
127
+ 'No cambies de rama en esta carpeta. Dile a la persona en una línea dónde estás trabajando, para que ' +
128
+ 'abra o levante el proyecto desde allí.';
129
+ }
130
+
91
131
  export function contextFromNativeOutput(target, output = {}) {
92
132
  if (target === 'cursor') return output.additional_context || '';
93
133
  if (target === 'opencode') return output.context || '';
@@ -130,6 +170,7 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
130
170
  editDelta: event === 'edit' ? 1 : 0,
131
171
  settings: config,
132
172
  };
173
+ if (event === 'edit' && editedPath(native)) captureInput.filePath = editedPath(native);
133
174
  if (typeof native.cost === 'number') captureInput.cost = native.cost;
134
175
  if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
135
176
  const captured = capture(captureInput);
@@ -138,8 +179,9 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
138
179
  // nine rounds of hook → "—" → hook before the client broke the loop. Record the turn, say
139
180
  // nothing.
140
181
  const silenced = event === 'turn' && Boolean(native?.stop_hook_active);
182
+ const isolation = event === 'request' ? isolationContext({ project, here, sessionId: id, target }) : '';
141
183
  return {
142
- output: silenced ? {} : nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
184
+ output: silenced ? {} : nativeOutput(target, eventName, isolation, captured.notice, captured.compactionHint),
143
185
  capture: captured,
144
186
  remote: false,
145
187
  degraded: false,