@damiangilgonzalez/harness 1.3.17 → 1.3.19

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.
Files changed (37) hide show
  1. package/README.md +117 -657
  2. package/manifest.json +1 -1
  3. package/package.json +1 -1
  4. package/scripts/doctor.js +1 -1
  5. package/scripts/install-apply.js +9 -0
  6. package/scripts/rsc.js +90 -2
  7. package/skills/building-agents/SKILL.md +1 -1
  8. package/skills/grill-with-docs/SKILL.md +0 -1
  9. package/skills/harness/references/daily-curation-automation.md +2 -1
  10. package/skills/harness/references/wiki-protocol.md +3 -1
  11. package/skills/implement/SKILL.md +0 -1
  12. package/skills/implement-spec/SKILL.md +1 -2
  13. package/skills/init/SKILL.md +7 -5
  14. package/skills/postgresdb/SKILL.md +1 -1
  15. package/skills/suggest/SKILL.md +17 -13
  16. package/skills/suggest/evals/cases.yaml +6 -0
  17. package/skills/to-spec/SKILL.md +0 -1
  18. package/skills/to-tickets/SKILL.md +0 -1
  19. package/skills/wayfinder/SKILL.md +0 -1
  20. package/targets/_md-block.js +1 -1
  21. package/targets/claude.js +2 -1
  22. package/targets/clone-bootstrap.mjs +1 -1
  23. package/targets/danger-guard.mjs +114 -52
  24. package/targets/hook-once.mjs +44 -13
  25. package/targets/knowledge-sync.mjs +759 -0
  26. package/targets/knowledge-wiring.js +196 -0
  27. package/targets/memory.js +7 -1
  28. package/targets/opencode-memory-plugin.js +67 -22
  29. package/targets/opt-outs.js +4 -0
  30. package/targets/session-memory-adapter.mjs +51 -6
  31. package/targets/session-memory-core.mjs +284 -37
  32. package/targets/session-start.mjs +2 -1
  33. package/targets/ship-guard.mjs +26 -9
  34. package/targets/trunk-policy.mjs +86 -0
  35. package/targets/userprompt-gate.mjs +7 -2
  36. package/targets/worklog-checkpoint.mjs +16 -1
  37. package/targets/worktree-reaper.mjs +144 -4
@@ -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
@@ -76,10 +76,15 @@ function handler(target, event) {
76
76
  : target === 'codex'
77
77
  ? '"$(git rev-parse --show-toplevel)/.rsc/session-memory-adapter.mjs"'
78
78
  : '".rsc/session-memory-adapter.mjs"';
79
+ // PreCompact gets more room than the per-turn events. It runs once, right before a compaction that
80
+ // itself takes tens of seconds, so a longer ceiling costs nothing in the normal case; and a
81
+ // cancelled PreCompact is the one that loses the budget reset. The per-turn events keep 5 s:
82
+ // they run on every tool call and must stay cheap.
83
+ const seconds = event === 'compact' ? 15 : event === 'end' && target === 'codex' ? 3 : 5;
79
84
  const value = {
80
85
  type: 'command',
81
86
  command: `node ${unixScript} ${target} ${event}`,
82
- timeout: target === 'gemini' ? 5000 : event === 'end' && target === 'codex' ? 3 : 5,
87
+ timeout: target === 'gemini' ? seconds * 1000 : seconds,
83
88
  };
84
89
  if (target === 'codex') {
85
90
  value.commandWindows = `node \"$((git rev-parse --show-toplevel).Trim())/.rsc/session-memory-adapter.mjs\" ${target} ${event}`;
@@ -99,6 +104,7 @@ const nestedEvents = {
99
104
  ['PostToolUse', 'edit', 'apply_patch|Edit|Write'], ['PostToolUse', 'boundary', 'Bash'],
100
105
  ['Stop', 'turn'], ['PreCompact', 'compact'], ['SessionEnd', 'end'],
101
106
  ],
107
+ // DeepSeek Harness runs Codex-format hooks through rsc's bridge: only these five points exist there.
102
108
  gemini: [
103
109
  ['SessionStart', 'start'], ['BeforeAgent', 'request'], ['AfterTool', 'edit', 'write_file|replace'],
104
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 || '';
@@ -108,7 +148,10 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
108
148
  const id = sessionId(native, target);
109
149
  const eventName = hookEventName(target, event, native);
110
150
  if (event === 'start') {
111
- const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', settings: config });
151
+ // `source: "compact"` is the SessionStart that follows a compaction. It resets the edit budget
152
+ // on its own, so the count still restarts when the PreCompact hook itself never finished.
153
+ const reset = native?.source === 'compact';
154
+ const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', reset, settings: config });
112
155
  const resumed = resume({ cwd: project, worktreeCwd: here, target, settings: config });
113
156
  return {
114
157
  output: nativeOutput(target, eventName, resumed.context),
@@ -127,6 +170,7 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
127
170
  editDelta: event === 'edit' ? 1 : 0,
128
171
  settings: config,
129
172
  };
173
+ if (event === 'edit' && editedPath(native)) captureInput.filePath = editedPath(native);
130
174
  if (typeof native.cost === 'number') captureInput.cost = native.cost;
131
175
  if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
132
176
  const captured = capture(captureInput);
@@ -135,8 +179,9 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
135
179
  // nine rounds of hook → "—" → hook before the client broke the loop. Record the turn, say
136
180
  // nothing.
137
181
  const silenced = event === 'turn' && Boolean(native?.stop_hook_active);
182
+ const isolation = event === 'request' ? isolationContext({ project, here, sessionId: id, target }) : '';
138
183
  return {
139
- output: silenced ? {} : nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
184
+ output: silenced ? {} : nativeOutput(target, eventName, isolation, captured.notice, captured.compactionHint),
140
185
  capture: captured,
141
186
  remote: false,
142
187
  degraded: false,