@ericrisco/rsc 2.0.16 → 2.0.17

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.16",
2
+ "version": "2.0.17",
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.16",
3
+ "version": "2.0.17",
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": {
@@ -93,6 +93,10 @@
93
93
  {
94
94
  "fixedIn": "2.0.16",
95
95
  "symptom": "Non-technical or mixed users in a non-software project had no guard against irreversible commands; repair wired guards the plan had declined"
96
+ },
97
+ {
98
+ "fixedIn": "2.0.17",
99
+ "symptom": "Re-accepting an onboarding plan erased what init had learned about you, reset your explanation level, or dropped an assistant you added later"
96
100
  }
97
101
  ]
98
102
  }
@@ -83,18 +83,69 @@ function recoveryCommand(plan, planId) {
83
83
  }
84
84
 
85
85
  export function renderOnboardingDocuments(plan, planId) {
86
- const profile = `---\ntechnical_level: ${plan.record.technicalLevel}\naccompaniment: ${plan.record.accompaniment}\nproject_kind: ${plan.record.projectKind}\n---\n\n# User profile\n\nGoal: ${plan.record.goal}\n`;
86
+ // `accompaniment_level`, never `accompaniment` (#276). It sits beside `technical_level` in the same
87
+ // block, and it is the name the nine skill files that read the dial — orient first among them —
88
+ // have always used. This was the only writer of the short form, which left the dial chosen at
89
+ // onboarding invisible to the compass.
90
+ const profile = `---\ntechnical_level: ${plan.record.technicalLevel}\naccompaniment_level: ${plan.record.accompaniment}\nproject_kind: ${plan.record.projectKind}\n---\n\n# User profile\n\nGoal: ${plan.record.goal}\n`;
87
91
  const rows = plan.decisions.map((d) => `| ${d.kind} | ${d.id} | ${d.state} | ${d.reason} | ${d.reevaluateWhen.join('; ') || '—'} |`).join('\n');
88
92
  const installation = `# Accepted harness plan\n\nPlan id: \`${planId}\`\n\n| Kind | Component | Decision | Reason | Reevaluate when |\n| --- | --- | --- | --- | --- |\n${rows}\n`;
89
93
  const decisions = `# Harness decisions\n\n- Accepted plan \`${planId}\`.\n- Project kind: ${plan.record.projectKind}.\n- SDD: ${plan.decisions.find((d) => d.id === 'sdd')?.state || 'selected through profile'}.\n`;
90
94
  return { profile, installation, decisions };
91
95
  }
92
96
 
93
- export function writeOnboardingDocuments(cwd, plan, planId) {
97
+ const PROFILE_DIALS = [['technical_level', 'technicalLevel'], ['accompaniment_level', 'accompaniment']];
98
+
99
+ /**
100
+ * The profile is the USER's file; accepting a plan seeds it, it does not own it (#278).
101
+ *
102
+ * `init` records there what it learns — the domain, the tools, what must not be touched — and `orient`
103
+ * rewrites the dial when someone asks for more or less explanation. Regenerating the file on every
104
+ * accept erased both, and re-accepting is routine: after an update, after a reassess.
105
+ *
106
+ * So: everything the plan does not own is kept byte for byte. `project_kind` and the goal are facts of
107
+ * the plan and are updated. The two dials are the subtle part — the plan carries them, but after first
108
+ * contact the user adjusts them — so an existing value stands when the plan did not CHANGE it relative
109
+ * to the previous receipt, and an explicit new value (a receipt that said L3, a plan that says L2) is
110
+ * applied. A profile written before the rename carries `accompaniment:`; it is migrated, never
111
+ * duplicated.
112
+ */
113
+ export function mergeProfile(existing, plan, previousRecord) {
114
+ const fresh = renderOnboardingDocuments(plan, '').profile;
115
+ if (!existing || !existing.trim()) return fresh;
116
+ const text = existing.replace(/\r\n/g, '\n');
117
+ const block = /^---\n([\s\S]*?)\n---\n?/.exec(text);
118
+ let front = block ? block[1].split('\n') : [];
119
+ let body = block ? text.slice(block[0].length) : text;
120
+ const has = (key) => front.some((line) => line.startsWith(`${key}:`));
121
+ front = has('accompaniment_level')
122
+ ? front.filter((line) => !/^accompaniment:\s/.test(line))
123
+ : front.map((line) => line.replace(/^accompaniment:(\s)/, 'accompaniment_level:$1'));
124
+ const get = (key) => front.find((line) => line.startsWith(`${key}:`))?.slice(key.length + 1).trim() || null;
125
+ const set = (key, value) => {
126
+ const at = front.findIndex((line) => line.startsWith(`${key}:`));
127
+ if (at >= 0) front[at] = `${key}: ${value}`;
128
+ else front.push(`${key}: ${value}`);
129
+ };
130
+ for (const [key, field] of PROFILE_DIALS) {
131
+ const planned = plan.record[field];
132
+ const keptByUser = get(key) && previousRecord && previousRecord[field] === planned;
133
+ if (!keptByUser) set(key, planned);
134
+ }
135
+ set('project_kind', plan.record.projectKind);
136
+ const goal = `Goal: ${plan.record.goal}`;
137
+ if (/^Goal: .*$/m.test(body)) body = body.replace(/^Goal: .*$/m, () => goal);
138
+ else if (/^# User profile[ \t]*$/m.test(body)) body = body.replace(/^# User profile[ \t]*$/m, () => `# User profile\n\n${goal}`);
139
+ else body = `${body.replace(/\s*$/, '')}\n\n${goal}\n`;
140
+ return `---\n${front.join('\n')}\n---\n${body.startsWith('\n') ? '' : '\n'}${body}`;
141
+ }
142
+
143
+ export function writeOnboardingDocuments(cwd, plan, planId, previousRecord = null) {
94
144
  const dir = join(cwd, '02-DOCS', 'wiki', 'harness');
95
145
  mkdirSync(dir, { recursive: true });
96
146
  const docs = renderOnboardingDocuments(plan, planId);
97
- writeFileSync(join(dir, 'user-profile.md'), docs.profile);
147
+ const profilePath = join(dir, 'user-profile.md');
148
+ writeFileSync(profilePath, mergeProfile(existsSync(profilePath) ? readFileSync(profilePath, 'utf8') : '', plan, previousRecord));
98
149
  writeFileSync(join(dir, 'installation-plan.md'), docs.installation);
99
150
  const decisionsPath = join(dir, 'decisions.md');
100
151
  if (!existsSync(decisionsPath)) writeFileSync(decisionsPath, docs.decisions);
@@ -134,12 +185,15 @@ export function verifyOnboarding(cwd, plan, planId) {
134
185
  }
135
186
  const docsDir = join(cwd, '02-DOCS', 'wiki', 'harness');
136
187
  const profile = existsSync(join(docsDir, 'user-profile.md')) ? readFileSync(join(docsDir, 'user-profile.md'), 'utf8') : '';
137
- for (const line of [
138
- `technical_level: ${plan.record.technicalLevel}`,
139
- `accompaniment: ${plan.record.accompaniment}`,
140
- `project_kind: ${plan.record.projectKind}`,
141
- `Goal: ${plan.record.goal}`,
142
- ]) if (!profile.split('\n').includes(line)) differences.push(`profile content differs: ${line.split(':')[0]}`);
188
+ // The plan's facts must be exactly there. The dials must be there under their one name with a valid
189
+ // value — not necessarily the plan's, because a dial the user adjusted and the plan did not change
190
+ // is theirs (see mergeProfile).
191
+ for (const line of [`project_kind: ${plan.record.projectKind}`, `Goal: ${plan.record.goal}`]) {
192
+ if (!profile.split('\n').includes(line)) differences.push(`profile content differs: ${line.split(':')[0]}`);
193
+ }
194
+ if (!/^technical_level: (non-technical|mixed|technical)$/m.test(profile)) differences.push('profile content differs: technical_level');
195
+ if (!/^accompaniment_level: L[0-3]$/m.test(profile)) differences.push('profile content differs: accompaniment_level');
196
+ if (/^accompaniment: /m.test(profile)) differences.push('profile carries the retired accompaniment key');
143
197
  const installation = existsSync(join(docsDir, 'installation-plan.md')) ? readFileSync(join(docsDir, 'installation-plan.md'), 'utf8') : '';
144
198
  if (!installation.includes(`Plan id: \`${planId}\``)) differences.push('installation plan identity differs');
145
199
  for (const decision of plan.decisions || []) {
@@ -148,7 +202,8 @@ export function verifyOnboarding(cwd, plan, planId) {
148
202
  }
149
203
  const decisions = existsSync(join(docsDir, 'decisions.md')) ? readFileSync(join(docsDir, 'decisions.md'), 'utf8') : '';
150
204
  if (!decisions.includes(`Accepted plan \`${planId}\``)) differences.push('decision ledger omits accepted plan');
151
- if (!sameSet(manifest?.targets || [], plan.policy.targets)) differences.push('manifest targets differ from accepted policy');
205
+ // Every planned assistant is declared; an extra one added outside the plan may be too (#278).
206
+ if (!plan.policy.targets.every((t) => (manifest?.targets || []).includes(t))) differences.push('manifest targets differ from accepted policy');
152
207
  if (!sameSet(manifest?.skills || [], plan.policy.skills)) differences.push('manifest skills differ from accepted policy');
153
208
  if (!sameSet(manifest?.agents || [], plan.policy.agents || [])) differences.push('manifest agents differ from accepted policy');
154
209
  return differences;
@@ -200,7 +255,7 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
200
255
  });
201
256
  }
202
257
  pruneSharedBases({ cwd, skillIds: plan.policy.skills });
203
- writeOnboardingDocuments(cwd, plan, planId);
258
+ writeOnboardingDocuments(cwd, plan, planId, previousPlan?.record || null);
204
259
  receipt.provenance.paths = Object.fromEntries((plan.governedPaths || []).map((path) => [
205
260
  path,
206
261
  existed[path] ? (previousReceipt ? 'conserved' : 'preexisting') : 'installed',
@@ -209,7 +264,13 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
209
264
  const manifest = readManifest(cwd);
210
265
  writeManifest(cwd, {
211
266
  ...manifest,
212
- targets: [...plan.policy.targets],
267
+ // Union, with the distinction the removal loop above already makes (#278). An assistant that was
268
+ // in the previous PLAN and is not in this one was removed on purpose. One added OUTSIDE the plan
269
+ // — `sync --target codex` — is still installed, and dropping it from the declaration meant a
270
+ // clone would never build it again.
271
+ targets: [...plan.policy.targets, ...(previousManifest?.targets || []).filter((t) => !plan.policy.targets.includes(t)
272
+ && !(previousPlan?.policy?.targets || []).includes(t)
273
+ && existsSync(targetPaths(t, undefined, cwd).stateFile))],
213
274
  skills: [...plan.policy.skills],
214
275
  agents: [...(plan.policy.agents || [])],
215
276
  onboarding: receipt,
@@ -41,7 +41,7 @@ Defaults when the user gives no preference: `non-technical → L3`, `mixed → L
41
41
 
42
42
  ## How every skill adapts
43
43
 
44
- Both values combine. `accompaniment_level` controls **how much** is said and **how many questions** are asked; `technical_level` controls **the vocabulary**.
44
+ Both values combine. `accompaniment_level` controls **how much** is said and **how many questions** are asked; `technical_level` controls **the vocabulary**. A profile written by an older `onboard` may carry `accompaniment:` instead — it is the same dial; read it, and write `accompaniment_level` the next time you touch the profile.
45
45
 
46
46
  | Level | Output verbosity | Questions before acting | On a decision |
47
47
  | --- | --- | --- | --- |
@@ -38,7 +38,7 @@ Knowledge map or the repo before writing 📍 — an invented state is worse tha
38
38
  ## Calibrate to the dial
39
39
 
40
40
  Read `02-DOCS/wiki/harness/user-profile.md` before writing the block. Two fields combine:
41
- `accompaniment_level` sets the depth, `technical_level` sets the vocabulary.
41
+ `accompaniment_level` sets the depth, `technical_level` sets the vocabulary. A profile written by an older `onboard` may carry `accompaniment:` instead — it is the same dial; read it, and write `accompaniment_level` the next time you touch the profile.
42
42
 
43
43
  | accompaniment_level | How the block behaves |
44
44
  |---------------------|-----------------------|
@@ -19,7 +19,7 @@ could leave the user unsure closes with the brújula block.
19
19
 
20
20
  ## Calibration (the dial)
21
21
 
22
- Read `02-DOCS/wiki/harness/user-profile.md`. Two fields combine: `accompaniment_level` (L0 | L1 | L2 | L3) sets how deep the block goes; `technical_level` (non-technical | mixed | technical) sets the vocabulary.
22
+ Read `02-DOCS/wiki/harness/user-profile.md`. Two fields combine: `accompaniment_level` (L0 | L1 | L2 | L3) sets how deep the block goes; `technical_level` (non-technical | mixed | technical) sets the vocabulary. A profile written by an older `onboard` may carry `accompaniment:` instead — it is the same dial; read it, and write `accompaniment_level` the next time you touch the profile.
23
23
 
24
24
  - **L0 — cavernícola:** only `✅` + `➡️`, one option, yes/no question.
25
25
  - **L1 — breve:** the four lines; one line of why.