@ericrisco/rsc 2.0.9 → 2.0.11

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.9",
2
+ "version": "2.0.11",
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.9",
3
+ "version": "2.0.11",
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": {
@@ -4,6 +4,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { applyInstall, pruneSharedBases, removeTargetInstall } from '../install-apply.js';
6
6
  import { targetPaths } from '../../targets/index.js';
7
+ import { targetHasAgents } from '../../targets/agents.js';
7
8
  import { readState } from './state.js';
8
9
  import { readManifest, writeManifest } from './manifest-file.js';
9
10
  import { encodeGoal, identifyPlan } from './onboarding.js';
@@ -11,6 +12,22 @@ import { createBackup, restoreBackup } from './backups.js';
11
12
 
12
13
  const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
13
14
 
15
+ /**
16
+ * What THIS target should be holding, which is not the same question as what the project decided.
17
+ *
18
+ * The policy is the PROJECT's: "these are the agents this project wants." Whether an assistant can
19
+ * hold a subagent at all is the TARGET's business, and nine of the seventeen cannot — amp, jules,
20
+ * zed, antigravity, windsurf, cline, roo, continue and aider have no such concept. The installer
21
+ * always knew this and correctly wrote none; the verifier did not, and demanded the project's list
22
+ * from every target, so onboarding could never complete on any of the nine. Since the failure rolls
23
+ * back, what the person got was empty directory shells and no harness — with a recovery command
24
+ * that leads straight back to the same wall.
25
+ *
26
+ * Asking amp for a subagent is not a divergence. It is a category error, and this is where the two
27
+ * categories stop being confused.
28
+ */
29
+ export const expectedAgentsFor = (target, plan) => (targetHasAgents(target) ? (plan.policy.agents || []) : []);
30
+
14
31
  function inside(root, path) {
15
32
  return path === root || path.startsWith(`${root}${sep}`);
16
33
  }
@@ -92,7 +109,7 @@ export function verifyOnboarding(cwd, plan, planId) {
92
109
  for (const target of plan.policy.targets) {
93
110
  const state = readState(targetPaths(target, undefined, cwd).stateFile);
94
111
  if (!sameSet(Object.keys(state.skills || {}), plan.policy.skills)) differences.push(`${target}: installed skills differ from accepted policy`);
95
- if (!sameSet(state.agents || [], plan.policy.agents || [])) differences.push(`${target}: installed agents differ from accepted policy`);
112
+ if (!sameSet(state.agents || [], expectedAgentsFor(target, plan))) differences.push(`${target}: installed agents differ from accepted policy`);
96
113
  if (state.policy?.alwaysOn !== plan.policy.alwaysOn) differences.push(`${target}: always-on policy differs`);
97
114
  if (state.policy?.codeHooks !== plan.policy.codeHooks) differences.push(`${target}: code-hook policy differs`);
98
115
  if (state.policy?.memory !== plan.policy.memory) differences.push(`${target}: memory policy differs`);
@@ -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
 
@@ -106,8 +126,13 @@ export function handleLifecycle({ target, event, native = {}, cwd, settings } =
106
126
  if (typeof native.cost === 'number') captureInput.cost = native.cost;
107
127
  if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
108
128
  const captured = capture(captureInput);
129
+ // `stop_hook_active` is the client saying "this turn is already running BECAUSE a stop hook
130
+ // asked for it". Speaking again here is how a Stop hook wakes itself: the student's session did
131
+ // nine rounds of hook → "—" → hook before the client broke the loop. Record the turn, say
132
+ // nothing.
133
+ const silenced = event === 'turn' && Boolean(native?.stop_hook_active);
109
134
  return {
110
- output: nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
135
+ output: silenced ? {} : nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
111
136
  capture: captured,
112
137
  remote: false,
113
138
  degraded: false,
@@ -352,11 +352,22 @@ export function capture(input = {}) {
352
352
  if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
353
353
  atomicJson(recordPath, record);
354
354
  existing = record;
355
+ // Once per session, not once per turn. `editCount` only ever grows, so `editCount >= threshold`
356
+ // was true for every remaining event of a working afternoon — the twentieth edit turned a useful
357
+ // nudge into a line after every single turn, for good. Worse, compaction does not reset the
358
+ // count, so following the advice changed nothing and the hint kept coming.
359
+ //
360
+ // The flag lives on the ANCHOR and not on the record on purpose: the record's schema is closed
361
+ // and every field of it is required, so a new field there would fail validation on every record
362
+ // already written and `readRecords` would quietly drop the project's whole history.
363
+ let compactionHint = Boolean(config.compactionHint && editCount >= config.editThreshold);
364
+ if (compactionHint && anchor.compactionHintedAt) compactionHint = false;
365
+ else if (compactionHint) atomicJson(anchorPath, { ...anchor, compactionHintedAt: now });
355
366
  return {
356
367
  record,
357
368
  path: recordPath,
358
369
  notice: consumeNotice(store),
359
- compactionHint: Boolean(config.compactionHint && editCount >= config.editThreshold),
370
+ compactionHint,
360
371
  };
361
372
  }
362
373