@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.
- package/README.md +117 -657
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/doctor.js +1 -1
- package/scripts/install-apply.js +9 -0
- package/scripts/rsc.js +90 -2
- package/skills/building-agents/SKILL.md +1 -1
- package/skills/grill-with-docs/SKILL.md +0 -1
- package/skills/harness/references/daily-curation-automation.md +2 -1
- package/skills/harness/references/wiki-protocol.md +3 -1
- package/skills/implement/SKILL.md +0 -1
- package/skills/implement-spec/SKILL.md +1 -2
- package/skills/init/SKILL.md +7 -5
- package/skills/postgresdb/SKILL.md +1 -1
- package/skills/suggest/SKILL.md +17 -13
- package/skills/suggest/evals/cases.yaml +6 -0
- package/skills/to-spec/SKILL.md +0 -1
- package/skills/to-tickets/SKILL.md +0 -1
- package/skills/wayfinder/SKILL.md +0 -1
- package/targets/_md-block.js +1 -1
- package/targets/claude.js +2 -1
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +114 -52
- package/targets/hook-once.mjs +44 -13
- package/targets/knowledge-sync.mjs +759 -0
- package/targets/knowledge-wiring.js +196 -0
- package/targets/memory.js +7 -1
- package/targets/opencode-memory-plugin.js +67 -22
- package/targets/opt-outs.js +4 -0
- package/targets/session-memory-adapter.mjs +51 -6
- package/targets/session-memory-core.mjs +284 -37
- package/targets/session-start.mjs +2 -1
- package/targets/ship-guard.mjs +26 -9
- package/targets/trunk-policy.mjs +86 -0
- package/targets/userprompt-gate.mjs +7 -2
- package/targets/worklog-checkpoint.mjs +16 -1
- 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' ?
|
|
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
|
-
|
|
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 || '';
|
|
@@ -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
|
-
|
|
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,
|
|
184
|
+
output: silenced ? {} : nativeOutput(target, eventName, isolation, captured.notice, captured.compactionHint),
|
|
140
185
|
capture: captured,
|
|
141
186
|
remote: false,
|
|
142
187
|
degraded: false,
|