@ericrisco/rsc 3.0.0 โ†’ 3.0.2

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 CHANGED
@@ -442,6 +442,8 @@ new ยท clones main has it all after the first message
442
442
  - **When a turn ends**, your changes in those folders are committed as `๐Ÿ“ docs(auto): โ€ฆ [skip ci]`
443
443
  and sent to `rsc/knowledge` on `origin`, from whatever branch you are on. If you are on a closed
444
444
  `main`, nothing is committed locally: the snapshot is built aside and only goes to `rsc/knowledge`.
445
+ Knowledge that you or the agent already **committed** on the branch during the turn goes up too โ€”
446
+ only its `01-TOOLS/` and `02-DOCS/` part, under a `docs(auto) โ€ฆ [skip ci]` message.
445
447
  - **Before each message**, what teammates sent is brought into **the branch you are on**, and you are
446
448
  told in one line. Only the knowledge folders are touched.
447
449
  - **It reaches `main` the normal way.** Your feature branch now carries the team's knowledge, so it
@@ -455,7 +457,7 @@ What it will not do, by design:
455
457
  | | |
456
458
  | --- | --- |
457
459
  | Push to `main` | Never. Only `rsc/knowledge` receives anything. |
458
- | Push your code or your unpushed commits | Only its own snapshot of the knowledge folders goes up. Your code waits for you. |
460
+ | Push your code or your unpushed commits | Only the knowledge folders go up, even from a commit that also has code. Your code waits for you. |
459
461
  | Trigger CI or a deploy | Every automatic commit says `[skip ci]`. |
460
462
  | Bring in anybody else's code | Only knowledge paths come down. Code, config and `.claude/` are never pulled. |
461
463
  | Sync `02-DOCS/wiki/harness/user-profile.md` | Those are one person's dials. |
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.0.0",
2
+ "version": "3.0.2",
3
3
  "counts": {
4
4
  "skills": 270,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -256,16 +256,38 @@ export function doctor({ target, home, cwd }) {
256
256
  missingCommands,
257
257
  commandOrphans,
258
258
  commandCollisions: state.commandCollisions || [],
259
+ // #289 โ€” OpenCode 2.x only loads a plugin from a default export with an id and setup(ctx); the
260
+ // V1-only files older versions wrote fail to load and nothing on rsc's side noticed. A plugin
261
+ // without the V2 entry point is a problem, and `sync` rewrites it.
262
+ opencodePluginsOutdated: target === 'opencode' ? opencodePluginsOutdated(root) : [],
259
263
  };
260
264
  // #277 โ€” the three "missing" lists share one shape, { id, path, action }, so whoever builds on top
261
265
  // of doctor reads them the same way; and `healthy` is what the exit code says.
262
266
  for (const [id, e] of Object.entries(state.skills)) {
263
267
  for (const f of e.files) if (!existsSync(f)) report.missing.push({ id, path: f, action: 'Run `npx @ericrisco/rsc sync` to restore this managed skill.' });
264
268
  }
265
- report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length;
269
+ report.healthy = report.hookWired && !report.missing.length && !missingAgents.length && !missingCommands.length
270
+ && !report.opencodePluginsOutdated.length;
266
271
  return report;
267
272
  }
268
273
 
274
+ /** rsc's OpenCode plugins that OpenCode 2.x cannot load (no default export with id + setup). */
275
+ export function opencodePluginsOutdated(root) {
276
+ const dir = join(root, '.opencode', 'plugins');
277
+ const out = [];
278
+ for (const name of ['rsc-update.js', 'rsc-knowledge.js', 'rsc-memory.js']) {
279
+ const path = join(dir, name);
280
+ if (!existsSync(path)) continue;
281
+ let body = '';
282
+ try { body = readFileSync(path, 'utf8'); } catch { continue; }
283
+ if (!/:managed/.test(body)) continue; // hand-written: not ours to judge
284
+ if (!/export default\s*\{/.test(body) || !/\bsetup\s*\(/.test(body)) {
285
+ out.push({ path, action: 'OpenCode 2.x cannot load this plugin (V1 API only). Run `npx @ericrisco/rsc@latest sync` to rewrite it.' });
286
+ }
287
+ }
288
+ return out;
289
+ }
290
+
269
291
  // ---------------------------------------------------------------------------------------
270
292
  // Context budget โ€” what the harness costs in tokens before the user has typed anything.
271
293
  //
@@ -104,7 +104,7 @@ const worktreeBlob = (root, path) => (existsSync(join(root, path)) ? line(root,
104
104
  function readState(root) {
105
105
  let s = {};
106
106
  try { s = JSON.parse(readFileSync(join(root, '.rsc', STATE), 'utf8')); } catch { /* first run */ }
107
- return { announced: false, seen: null, queue: [], ours: [], notices: [], lastFetch: 0, snap: null, ...s };
107
+ return { announced: false, seen: null, queue: [], ours: [], notices: [], lastFetch: 0, snap: null, head: null, ...s };
108
108
  }
109
109
 
110
110
  function writeState(root, s) {
@@ -232,6 +232,26 @@ export function commitLocal(root, s) {
232
232
  return sha;
233
233
  }
234
234
 
235
+ /**
236
+ * Knowledge the agent (or the person) committed on the branch since the last turn goes up too. Without
237
+ * this only what was still uncommitted when the turn ended travelled, and an agent that commits its
238
+ * feature document with its code in one long turn left nothing to send (E2E 2026-10-04). The first
239
+ * time, and after a branch switch, it starts where the branch left the remote default branch.
240
+ */
241
+ export function queueCommitted(root, s) {
242
+ const branch = line(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
243
+ if (!branch || branch === 'HEAD') return;
244
+ const head = line(root, ['rev-parse', 'HEAD']);
245
+ const def = defaultBranch(root);
246
+ const remoteDef = def && line(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${def}`]);
247
+ const valid = s.head && line(root, ['rev-parse', '--verify', '--quiet', `${s.head}^{commit}`])
248
+ && run(root, ['merge-base', '--is-ancestor', s.head, head]).ok;
249
+ const from = valid ? s.head : (remoteDef && line(root, ['merge-base', 'HEAD', remoteDef]));
250
+ if (!head || !from || from === head) return;
251
+ const commits = git(root, ['rev-list', '--reverse', '--no-merges', `${from}..${head}`, '--', ...KNOWLEDGE]).split('\n').filter(Boolean);
252
+ for (const c of commits) if (!s.queue.includes(c) && !s.ours.includes(c)) s.queue.push(c);
253
+ }
254
+
235
255
  // ------------------------------------------------------------------ network: up
236
256
 
237
257
  /**
@@ -241,7 +261,9 @@ export function commitLocal(root, s) {
241
261
  */
242
262
  function replay(root, base, sha, ours = []) {
243
263
  if (!line(root, ['rev-parse', '--verify', '--quiet', `${sha}^`])) return null;
244
- const patch = run(root, ['diff-tree', '-p', '--binary', '--full-index', `${sha}^`, sha], { timeout: NET_TIMEOUT_MS });
264
+ // Only the knowledge paths: a commit of the agent's own on a branch also carries code, and code never
265
+ // travels this way.
266
+ const patch = run(root, ['diff-tree', '-p', '--binary', '--full-index', `${sha}^`, sha, '--', ...KNOWLEDGE], { timeout: NET_TIMEOUT_MS });
245
267
  if (!patch.ok) return null;
246
268
  if (!patch.out.trim()) return line(root, ['rev-parse', `${base}^{tree}`]);
247
269
  const env = { GIT_INDEX_FILE: join(root, '.rsc', 'knowledge-sync.index') };
@@ -250,7 +272,7 @@ function replay(root, base, sha, ours = []) {
250
272
  // File by file: one that is already there as it should be is skipped, not read as a clash (a
251
273
  // teammate's change taken uncommitted on a closed trunk comes back up in the next snapshot), and
252
274
  // one real clash does not hold back the rest of the patch's reasoning.
253
- const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '--no-renames', '-r', '-z', `${sha}^`, sha]));
275
+ const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '--no-renames', '-r', '-z', `${sha}^`, sha, '--', ...KNOWLEDGE])).filter(isKnowledge);
254
276
  for (const f of files) {
255
277
  const next = blob(root, sha, f);
256
278
  const there = blob(root, base, f);
@@ -304,13 +326,19 @@ export function ship(root, s) {
304
326
  if (!line(root, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`])) continue; // rewritten away
305
327
  const tree = replay(root, base, sha, s.ours);
306
328
  if (tree === null) {
307
- const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha]));
329
+ const files = zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge);
308
330
  note(s, `No he podido subir ${listed(files)}: choca con un cambio que ya estรก en ${KNOWLEDGE_BRANCH}. ` +
309
331
  'Tu versiรณn sigue aquรญ; hay que juntarlas a mano.');
310
332
  continue;
311
333
  }
312
334
  if (tree === line(root, ['rev-parse', `${base}^{tree}`])) { kept.push(sha); continue; } // already there
313
- const [name, email, date, ...body] = git(root, ['log', '-1', '--format=%an%n%ae%n%aI%n%B', sha]).split('\n');
335
+ const [name, email, date, ...rest] = git(root, ['log', '-1', '--format=%an%n%ae%n%aI%n%B', sha]).split('\n');
336
+ // A commit of somebody's own (code + docs) goes up as its knowledge part, under a message that
337
+ // says so and carries [skip ci]: its own message would describe code that is not there, and
338
+ // would run CI on the exchange branch.
339
+ const body = rest.join('\n').includes(SKIP_CI) ? rest : [
340
+ `๐Ÿ“ docs(auto): ${summary(zlist(git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', sha, '--', ...KNOWLEDGE])).filter(isKnowledge))} ${SKIP_CI}`,
341
+ '', `Desde: ${rest[0] || sha.slice(0, 7)}`];
314
342
  base = git(root, ['commit-tree', tree, '-p', base, '-F', '-'], {
315
343
  input: body.join('\n'),
316
344
  env: { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email, GIT_AUTHOR_DATE: date },
@@ -448,7 +476,9 @@ export function onTurn(root, { spawnShip = true } = {}) {
448
476
  try {
449
477
  if (inactiveReason(root) || busy(root)) return;
450
478
  const pending = locked(root, (s) => {
479
+ try { queueCommitted(root, s); } catch { /* the next turn looks again */ }
451
480
  try { commitLocal(root, s); } catch (e) { note(s, `La sincronizaciรณn del conocimiento no pudo comitear: ${clean(e.message, 160)}`); }
481
+ s.head = line(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) || s.head;
452
482
  return s.queue.length;
453
483
  });
454
484
  if (pending && spawnShip) background(root, 'ship');
@@ -83,24 +83,48 @@ const PLUGIN = `// ${PLUGIN_MARKER}
83
83
  // rsc knowledge sync for OpenCode. OpenCode has no prompt event and no channel to the person, so what
84
84
  // sync has to say goes to the model, to be relayed. Cheap on every call: the fetch is throttled and
85
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.
86
87
  import { onRequest, onTurn } from '../../.rsc/knowledge-sync.mjs';
87
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
+
88
96
  export const RscKnowledgePlugin = async ({ directory, worktree }) => {
89
97
  const cwd = worktree || directory;
90
- const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
91
98
  return {
92
99
  'experimental.chat.system.transform': async (_input, output) => {
93
- if (!local()) return;
94
- try {
95
- const said = onRequest(cwd);
96
- if (said && Array.isArray(output?.system)) output.system.push(\`Cuรฉntale esto al usuario en una lรญnea:\\n\${said}\`);
97
- } catch { /* fail open */ }
98
- },
99
- event: async ({ event }) => {
100
- if (event?.type === 'session.idle' && local()) { try { onTurn(cwd); } catch { /* fail open */ } }
100
+ const text = tell(cwd);
101
+ if (text && Array.isArray(output?.system)) output.system.push(text);
101
102
  },
103
+ event: async ({ event }) => { if (event?.type === 'session.idle') turn(cwd); },
102
104
  };
103
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
+ };
104
128
  `;
105
129
 
106
130
  export function knowledgeManagedPaths(target, cwd = process.cwd()) {
@@ -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
+ };
@@ -78,19 +78,34 @@ const PLUGIN = `// ${PLUGIN_MARKER}
78
78
  // rsc update check for OpenCode: checked once per OpenCode run, and the notice goes to the model to be
79
79
  // relayed. It rides on EVERY system prompt of the run, not just the first: OpenCode's first call is
80
80
  // its title generator, which would swallow a once-only notice before the agent ever sees it.
81
+ // One file for both plugin APIs (issue #289): V1 calls the named export or server(); V2 reads id + setup.
81
82
  // Turn auto-update off with .rsc/.no-auto-update.
82
83
  import { updateNotice } from '../../.rsc/auto-update.mjs';
83
84
 
85
+ const NOTE = '\\nMention this only in your first reply of the session.';
86
+ const check = (cwd) => (process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve('') : updateNotice(cwd).catch(() => ''));
87
+
84
88
  export const RscUpdatePlugin = async ({ directory, worktree }) => {
85
- const cwd = worktree || directory;
86
- const pending = process.env.RSC_NO_UPDATE_CHECK ? Promise.resolve('') : updateNotice(cwd).catch(() => '');
89
+ const pending = check(worktree || directory);
87
90
  return {
88
91
  'experimental.chat.system.transform': async (_input, output) => {
89
92
  const said = (await pending).trim();
90
- if (said && Array.isArray(output?.system)) output.system.push(said + '\\nMention this only in your first reply of the session.');
93
+ if (said && Array.isArray(output?.system)) output.system.push(said + NOTE);
91
94
  },
92
95
  };
93
96
  };
97
+
98
+ export default {
99
+ id: 'rsc.update',
100
+ server: RscUpdatePlugin,
101
+ async setup(ctx) {
102
+ const pending = check(ctx.location?.project?.directory || ctx.location?.directory || process.cwd());
103
+ await ctx.session.hook('context', async (event) => {
104
+ const said = (await pending).trim();
105
+ if (said && Array.isArray(event?.system)) event.system.push({ type: 'text', text: said + NOTE });
106
+ });
107
+ },
108
+ };
94
109
  `;
95
110
 
96
111
  export function updateManagedPaths(target, cwd = process.cwd()) {