@ericrisco/rsc 2.0.10 → 2.0.12

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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.10",
2
+ "version": "2.0.12",
3
3
  "counts": {
4
4
  "skills": 273,
5
5
  "agents": 33,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
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": {
@@ -56,15 +56,35 @@ function hookEventName(target, event, native) {
56
56
  return names[event] || event;
57
57
  }
58
58
 
59
+ const COMPACTION_HINT = 'rsc memory: consider compacting at the next phase boundary; nothing was compacted automatically.';
60
+
61
+ /**
62
+ * Two different things used to share one channel, and that is the whole bug.
63
+ *
64
+ * The resume CONTEXT is information for the model, and it is only ever produced on `start` —
65
+ * `SessionStart`, one of the few events whose output may carry `hookSpecificOutput.additionalContext`.
66
+ * The compaction HINT is a message for the PERSON, and it was going out through the same field on
67
+ * whatever event happened to be running. On `Stop`, `PreCompact` and `SessionEnd` the client rejects
68
+ * that outright ("Hook JSON output validation failed — hookSpecificOutput.hookEventName: expected one
69
+ * of …"), so the hook meant to help ended up printing an error after every turn, and after
70
+ * `/compact` in particular — which is exactly what the hint had just told the person to run.
71
+ *
72
+ * So the hint moves to `systemMessage`, which every event accepts. This needs no table of which
73
+ * event carries what, and it cannot rot when the client adds an event: anything addressed to the
74
+ * person travels by the universal channel, and only genuine model context uses the narrow one.
75
+ */
59
76
  function nativeOutput(target, eventName, context = '', notice = null, compactionHint = false) {
60
- const hint = compactionHint ? 'rsc memory: consider compacting at the next phase boundary; nothing was compacted automatically.' : '';
61
- const extra = [context, hint].filter(Boolean).join('\n');
62
- if (!extra && !notice) return {};
63
- if (target === 'cursor') return { ...(extra ? { additional_context: extra } : {}), ...(notice ? { user_message: notice } : {}) };
64
- if (target === 'opencode') return { context: extra, notice };
77
+ const hint = compactionHint ? COMPACTION_HINT : '';
78
+ if (!context && !hint && !notice) return {};
79
+ if (target === 'cursor') {
80
+ const message = [notice, hint].filter(Boolean).join('\n');
81
+ return { ...(context ? { additional_context: context } : {}), ...(message ? { user_message: message } : {}) };
82
+ }
83
+ if (target === 'opencode') return { context, notice: [notice, hint].filter(Boolean).join('\n') || null };
84
+ const message = [notice, hint].filter(Boolean).join('\n');
65
85
  return {
66
- ...(notice ? { systemMessage: notice } : {}),
67
- ...(extra ? { hookSpecificOutput: { hookEventName: eventName, additionalContext: extra } } : {}),
86
+ ...(message ? { systemMessage: message } : {}),
87
+ ...(context ? { hookSpecificOutput: { hookEventName: eventName, additionalContext: context } } : {}),
68
88
  };
69
89
  }
70
90
 
@@ -78,15 +98,18 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
78
98
  try {
79
99
  if (!LOCAL_TARGETS.has(target)) throw new Error(`unsupported memory target: ${target}`);
80
100
  if (isRemote(target, native)) return { output: {}, capture: null, remote: true, degraded: false };
81
- const project = nearestHarness(resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd()));
101
+ // Two answers from one path, kept apart on purpose: `project` is where the journal is KEPT (the
102
+ // nearest harness), `here` is where the agent WORKS and therefore who the session is.
103
+ const here = resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd());
104
+ const project = nearestHarness(here);
82
105
  if (!existsSync(project)) throw new Error('project directory unavailable');
83
106
  const config = settings || projectSettings(project);
84
107
  if (config.enabled === false) return { output: {}, capture: null, remote: false, degraded: false };
85
108
  const id = sessionId(native, target);
86
109
  const eventName = hookEventName(target, event, native);
87
110
  if (event === 'start') {
88
- const started = capture({ cwd: project, sessionId: id, target, event: 'start', settings: config });
89
- const resumed = resume({ cwd: project, target, settings: config });
111
+ const started = capture({ cwd: project, worktreeCwd: here, sessionId: id, target, event: 'start', settings: config });
112
+ const resumed = resume({ cwd: project, worktreeCwd: here, target, settings: config });
90
113
  return {
91
114
  output: nativeOutput(target, eventName, resumed.context),
92
115
  capture: started,
@@ -97,6 +120,7 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
97
120
  }
98
121
  const captureInput = {
99
122
  cwd: project,
123
+ worktreeCwd: here,
100
124
  sessionId: id,
101
125
  target,
102
126
  event: event === 'end' ? 'sessionEnd' : event,
@@ -106,8 +130,13 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
106
130
  if (typeof native.cost === 'number') captureInput.cost = native.cost;
107
131
  if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
108
132
  const captured = capture(captureInput);
133
+ // `stop_hook_active` is the client saying "this turn is already running BECAUSE a stop hook
134
+ // asked for it". Speaking again here is how a Stop hook wakes itself: the student's session did
135
+ // nine rounds of hook → "—" → hook before the client broke the loop. Record the turn, say
136
+ // nothing.
137
+ const silenced = event === 'turn' && Boolean(native?.stop_hook_active);
109
138
  return {
110
- output: nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
139
+ output: silenced ? {} : nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
111
140
  capture: captured,
112
141
  remote: false,
113
142
  degraded: false,
@@ -284,6 +284,12 @@ function readRecords(root) {
284
284
 
285
285
  export function capture(input = {}) {
286
286
  const cwd = resolve(input.cwd || process.cwd());
287
+ // WHERE the journal lives and WHO the session is are two different questions. `cwd` answers the
288
+ // first — the nearest harness, so a child with no manifest of its own does not scatter journals.
289
+ // The second has to be asked of git from where the agent actually works: asked from the harness,
290
+ // every worktree nested under it came back as the harness itself, and a sibling's record then
291
+ // "matched exactly". See GHSA-8gjp-3r7c-33f4.
292
+ const here = resolve(input.worktreeCwd || cwd);
287
293
  const config = settings(input.settings);
288
294
  if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
289
295
  const store = chooseMemoryRoot(cwd);
@@ -299,7 +305,7 @@ export function capture(input = {}) {
299
305
  const recordPath = join(store.root, 'sessions', `${storageId}.json`);
300
306
  let anchor = readJson(anchorPath);
301
307
  let existing = readJson(recordPath);
302
- const firstSnapshot = snapshot(cwd, null);
308
+ const firstSnapshot = snapshot(here, null);
303
309
  if (!anchor) {
304
310
  anchor = {
305
311
  sessionId,
@@ -312,7 +318,7 @@ export function capture(input = {}) {
312
318
  }
313
319
  if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
314
320
 
315
- const repo = snapshot(cwd, anchor.baselineHead);
321
+ const repo = snapshot(here, anchor.baselineHead);
316
322
  const editCount = Math.max(0, (existing?.editCount || 0) + (finiteOrNull(input.editDelta, true) || 0));
317
323
  const baselineFiles = new Set(anchor.baselineFiles || []);
318
324
  const newDirtyPath = repo.files.some((path) => !baselineFiles.has(path));
@@ -352,11 +358,22 @@ export function capture(input = {}) {
352
358
  if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
353
359
  atomicJson(recordPath, record);
354
360
  existing = record;
361
+ // Once per session, not once per turn. `editCount` only ever grows, so `editCount >= threshold`
362
+ // was true for every remaining event of a working afternoon — the twentieth edit turned a useful
363
+ // nudge into a line after every single turn, for good. Worse, compaction does not reset the
364
+ // count, so following the advice changed nothing and the hint kept coming.
365
+ //
366
+ // The flag lives on the ANCHOR and not on the record on purpose: the record's schema is closed
367
+ // and every field of it is required, so a new field there would fail validation on every record
368
+ // already written and `readRecords` would quietly drop the project's whole history.
369
+ let compactionHint = Boolean(config.compactionHint && editCount >= config.editThreshold);
370
+ if (compactionHint && anchor.compactionHintedAt) compactionHint = false;
371
+ else if (compactionHint) atomicJson(anchorPath, { ...anchor, compactionHintedAt: now });
355
372
  return {
356
373
  record,
357
374
  path: recordPath,
358
375
  notice: consumeNotice(store),
359
- compactionHint: Boolean(config.compactionHint && editCount >= config.editThreshold),
376
+ compactionHint,
360
377
  };
361
378
  }
362
379
 
@@ -408,13 +425,19 @@ export function resume(input = {}) {
408
425
  ensureStore(store.root);
409
426
  const now = iso(input.now);
410
427
  prune(store.root, now, config);
411
- const current = snapshot(cwd, null);
412
- const records = readRecords(store.root).filter((record) => validAnchor(cwd, record))
428
+ const here = resolve(input.worktreeCwd || cwd);
429
+ const current = snapshot(here, null);
430
+ const records = readRecords(store.root).filter((record) => validAnchor(here, record))
413
431
  .sort((a, b) => b.timestamps.updatedAt.localeCompare(a.timestamps.updatedAt));
414
432
  let record = records.find((candidate) => candidate.branch === current.branch && candidate.worktree === current.worktree) || null;
415
433
  let match = record ? 'exact' : 'none';
434
+ // Only ever this worktree's own records. The chain used to end in `|| records[0]` — the newest
435
+ // record in the store, whoever wrote it — which a moved or renamed checkout reached every time,
436
+ // because its stale records match nothing. A shared store is a place to KEEP several worktrees'
437
+ // journals, never a reason to hand one of them to another. No continuation is the honest answer
438
+ // when there is nothing of yours to continue.
416
439
  if (!record) {
417
- record = records.find((candidate) => candidate.worktree === current.worktree) || records[0] || null;
440
+ record = records.find((candidate) => candidate.worktree === current.worktree) || null;
418
441
  if (record) match = 'nearby';
419
442
  }
420
443
  const lessons = selectedLessons(store.root, config);