@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.
- package/README.md +57 -5
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/install-apply.js +9 -0
- package/scripts/rsc.js +27 -1
- package/skills/suggest/SKILL.md +12 -8
- package/skills/suggest/evals/cases.yaml +6 -0
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/knowledge-sync.mjs +759 -0
- package/targets/knowledge-wiring.js +196 -0
- package/targets/memory.js +1 -0
- package/targets/opencode-memory-plugin.js +67 -22
- package/targets/opt-outs.js +4 -0
- package/targets/session-memory-adapter.mjs +47 -5
- package/targets/session-memory-core.mjs +244 -29
- package/targets/trunk-policy.mjs +86 -0
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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:
|
|
39
|
-
if (
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|
package/targets/opt-outs.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
184
|
+
output: silenced ? {} : nativeOutput(target, eventName, isolation, captured.notice, captured.compactionHint),
|
|
143
185
|
capture: captured,
|
|
144
186
|
remote: false,
|
|
145
187
|
degraded: false,
|