@ericrisco/rsc 2.0.16 → 2.0.18
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 +3 -0
- package/manifest.json +1 -1
- package/package.json +9 -1
- package/scripts/install-apply.js +8 -0
- package/scripts/lib/backups.js +13 -0
- package/scripts/lib/onboarding-apply.js +73 -12
- package/scripts/rsc.js +17 -2
- package/skills/init/references/accompaniment-and-profile.md +1 -1
- package/skills/orient/SKILL.md +1 -1
- package/skills/orient/references/orientation-contract.md +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,9 @@ accompaniment, project kind and goal **before it writes anything**, reads only t
|
|
|
71
71
|
root, and shows every selected or deferred skill, agent and hook with its reason. Relay those
|
|
72
72
|
questions to the user; never invent or default their answers. The first pass writes nothing and
|
|
73
73
|
prints a SHA-256 plan id. Only rerun with `--accept-plan <id>` after the user accepts that exact plan.
|
|
74
|
+
If the project already has a skill of its own with a catalog name, the plan lists it under
|
|
75
|
+
**Your own skills this plan replaces** — tell the user before accepting; renaming their folder and
|
|
76
|
+
regenerating the plan keeps theirs.
|
|
74
77
|
|
|
75
78
|
For a non-interactive agent, collect the answers and preview the same plan explicitly:
|
|
76
79
|
|
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ericrisco/rsc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.18",
|
|
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,14 @@
|
|
|
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"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"fixedIn": "2.0.18",
|
|
103
|
+
"symptom": "Onboarding silently replaced a skill you wrote yourself that had the same name as one of rsc's (review, plan, debug…)"
|
|
96
104
|
}
|
|
97
105
|
]
|
|
98
106
|
}
|
package/scripts/install-apply.js
CHANGED
|
@@ -422,6 +422,14 @@ export function collisions({ cwd = process.cwd(), target, home, skillIds = [] })
|
|
|
422
422
|
return skillIds.filter((id) => !managed.has(id) && existsSync(paths.skillDir(id)));
|
|
423
423
|
}
|
|
424
424
|
|
|
425
|
+
// The same test as `collisions`, across every target of a plan, as the paths the user would recognise.
|
|
426
|
+
// `onboard` puts these in the plan itself, so the id the user accepts is an id that names them.
|
|
427
|
+
export function ownSkillPaths({ cwd = process.cwd(), targets = [], skillIds = [], home }) {
|
|
428
|
+
const found = targets.flatMap((target) => collisions({ cwd, target, home, skillIds })
|
|
429
|
+
.map((id) => relative(cwd, targetPaths(target, home, cwd).skillDir(id)).split(sep).join('/')));
|
|
430
|
+
return [...new Set(found)].sort();
|
|
431
|
+
}
|
|
432
|
+
|
|
425
433
|
export function listInstalled({ target, home, cwd = process.cwd() }) {
|
|
426
434
|
const paths = targetPaths(target, home, cwd);
|
|
427
435
|
return Object.keys(readState(paths.stateFile).skills);
|
package/scripts/lib/backups.js
CHANGED
|
@@ -40,6 +40,19 @@ export function createBackup({ cwd = process.cwd(), operation, target, paths, cl
|
|
|
40
40
|
return manifest;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Where the newest snapshot kept a path, relative to the project — what to tell someone whose own work
|
|
44
|
+
// was just replaced, so recovering it is a copy they can find rather than a restore they never heard of.
|
|
45
|
+
// A transaction snapshot is the fallback: it is the install's rollback, not the user's copy.
|
|
46
|
+
export function whereKept({ cwd = process.cwd(), relPath }) {
|
|
47
|
+
const backups = listBackups({ cwd });
|
|
48
|
+
const ordered = [...backups.filter((b) => !/transaction/.test(b.operation)), ...backups.filter((b) => /transaction/.test(b.operation))];
|
|
49
|
+
for (const b of ordered) {
|
|
50
|
+
const kept = join(backupsDir(cwd), b.id, 'files', relPath);
|
|
51
|
+
if (existsSync(kept)) return relative(cwd, kept).split(sep).join('/');
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
export function listBackups({ cwd = process.cwd() } = {}) {
|
|
44
57
|
const dir = backupsDir(cwd);
|
|
45
58
|
if (!existsSync(dir)) return [];
|
|
@@ -83,18 +83,69 @@ function recoveryCommand(plan, planId) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export function renderOnboardingDocuments(plan, planId) {
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
`
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/scripts/rsc.js
CHANGED
|
@@ -4,14 +4,14 @@ import { detectTarget, installedTargets, resolveTargets, TARGETS } from '../targ
|
|
|
4
4
|
import { detectRepo } from './detect-repo.js';
|
|
5
5
|
import { rank } from './consult.js';
|
|
6
6
|
import { expandRecommends, toOutcomes, hasOutcome } from './lib/recommend.js';
|
|
7
|
-
import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
|
|
7
|
+
import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purge, collisions, ownSkillPaths } from './install-apply.js';
|
|
8
8
|
import { stackAgentNames } from '../targets/agents.js';
|
|
9
9
|
import { doctor } from './doctor.js';
|
|
10
10
|
import { ask, say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
|
|
11
11
|
import { refreshRegistry, registryStatus } from './lib/registry.js';
|
|
12
12
|
import { audit, writeAuditReport } from './audit.js';
|
|
13
13
|
import { DOMAINS } from './lib/domains.js';
|
|
14
|
-
import { listBackups, restoreBackup } from './lib/backups.js';
|
|
14
|
+
import { listBackups, restoreBackup, whereKept } from './lib/backups.js';
|
|
15
15
|
import { runUpgrade } from './lib/upgrade.js';
|
|
16
16
|
import { diagnose, repair } from './lib/repair.js';
|
|
17
17
|
import { DEFAULT_SKILL_FLOOR, withDefaultSkillFloor } from './lib/default-skill-floor.js';
|
|
@@ -142,6 +142,12 @@ function renderPlan(plan, planId) {
|
|
|
142
142
|
for (const decision of plan.decisions.filter((d) => d.state === 'excluded')) say(` × ${decision.kind}/${decision.id} — ${decision.reason}`);
|
|
143
143
|
say('Managed paths:');
|
|
144
144
|
for (const path of plan.governedPaths) say(` ${path}`);
|
|
145
|
+
if (plan.replacesUserSkills?.length) {
|
|
146
|
+
say('Your own skills this plan replaces:');
|
|
147
|
+
for (const path of plan.replacesUserSkills) say(` ! ${path} — already here and not put by rsc; the catalog skill of the same name takes its place, and yours is kept in .rsc/backups/`);
|
|
148
|
+
say(' To keep yours, rename its folder (for example review → review-own) and generate the plan again.');
|
|
149
|
+
say(' AGENT: name each of these to the user before accepting, and ask whether to replace or rename.');
|
|
150
|
+
}
|
|
145
151
|
if (plan.evidence.parentHarness) say(`Parent harness detected at ${plan.evidence.parentHarness}; it is not inherited by this plan.`);
|
|
146
152
|
const pieces = [
|
|
147
153
|
'npx @ericrisco/rsc@latest onboard',
|
|
@@ -201,6 +207,11 @@ async function runOnboarding(targets) {
|
|
|
201
207
|
return;
|
|
202
208
|
}
|
|
203
209
|
const plan = buildOnboardingPlan(record, scanProject(process.cwd()));
|
|
210
|
+
// #275 — a folder where a catalog skill goes that rsc did not put is the user's own work. It goes
|
|
211
|
+
// INTO the plan, so the id they accept names it: an id made before that folder existed no longer
|
|
212
|
+
// applies, and accepting is never consent to something the plan did not show.
|
|
213
|
+
const replacesUserSkills = ownSkillPaths({ cwd: process.cwd(), targets: plan.policy.targets, skillIds: plan.policy.skills });
|
|
214
|
+
if (replacesUserSkills.length) plan.replacesUserSkills = replacesUserSkills;
|
|
204
215
|
const planId = identifyPlan(plan);
|
|
205
216
|
const accepted = flag('accept-plan');
|
|
206
217
|
if (!accepted) {
|
|
@@ -229,6 +240,10 @@ async function runOnboarding(targets) {
|
|
|
229
240
|
} catch (error) {
|
|
230
241
|
say(`RSC_SKELETON_FAILED ${error.message}`);
|
|
231
242
|
}
|
|
243
|
+
for (const path of plan.replacesUserSkills || []) {
|
|
244
|
+
const kept = whereKept({ cwd: process.cwd(), relPath: path });
|
|
245
|
+
if (kept) say(`Your previous ${path} is kept in ${kept}`);
|
|
246
|
+
}
|
|
232
247
|
emitHarnessReadiness(plan, planId);
|
|
233
248
|
}
|
|
234
249
|
|
|
@@ -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
|
| --- | --- | --- | --- |
|
package/skills/orient/SKILL.md
CHANGED
|
@@ -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.
|