@ericrisco/rsc 2.0.15 → 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 +1 -1
- package/package.json +9 -1
- package/scripts/install-apply.js +7 -3
- package/scripts/lib/onboarding-apply.js +73 -12
- package/scripts/lib/onboarding.js +8 -0
- package/scripts/lib/repair.js +8 -4
- 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/targets/claude.js +25 -15
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.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": {
|
|
@@ -89,6 +89,14 @@
|
|
|
89
89
|
{
|
|
90
90
|
"fixedIn": "2.0.15",
|
|
91
91
|
"symptom": "The 'consider compacting' notice never appears in sessions that edit files through the terminal instead of the Edit tool"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"fixedIn": "2.0.16",
|
|
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"
|
|
92
100
|
}
|
|
93
101
|
]
|
|
94
102
|
}
|
package/scripts/install-apply.js
CHANGED
|
@@ -78,9 +78,12 @@ export function generatedHookFiles({ target, cwd, policy }) {
|
|
|
78
78
|
join(cwd, '.rsc', 'hook-once.mjs'),
|
|
79
79
|
join(cwd, '.rsc', 'worktree-reaper.mjs'),
|
|
80
80
|
];
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// The danger guard is declared on its own terms (#273): present unless the plan says otherwise,
|
|
82
|
+
// whether or not the code guards are.
|
|
83
|
+
const danger = policy?.dangerGuard === false ? [] : [join(cwd, '.rsc', 'danger-guard.mjs')];
|
|
84
|
+
if (policy?.codeHooks === false) return [...lifecycle, ...danger, join(cwd, '.rsc', 'suggest-always-on.md')];
|
|
85
|
+
return [...lifecycle, ...danger,
|
|
86
|
+
join(cwd, '.rsc', 'ship-guard.mjs'),
|
|
84
87
|
join(cwd, '.rsc', 'gitmoji-guard.mjs'), join(cwd, '.rsc', 'userprompt-gate.mjs'),
|
|
85
88
|
join(cwd, '.rsc', 'sello.mjs')];
|
|
86
89
|
}
|
|
@@ -279,6 +282,7 @@ export async function applyInstall({ skillIds = [], agentIds = [], target, home,
|
|
|
279
282
|
baseAgents: policy.baseAgents !== false,
|
|
280
283
|
alwaysOn: policy.alwaysOn !== false,
|
|
281
284
|
codeHooks: policy.codeHooks !== false,
|
|
285
|
+
dangerGuard: policy.dangerGuard !== false,
|
|
282
286
|
gitmojiGuard: policy.gitmojiGuard !== false,
|
|
283
287
|
memory: policy.memory !== false,
|
|
284
288
|
context7: policy.context7 === true,
|
|
@@ -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,
|
|
@@ -230,6 +230,13 @@ export function buildOnboardingPlan(record, evidence) {
|
|
|
230
230
|
const hooks = practisesSdd;
|
|
231
231
|
const agents = baseAgents ? resolveAgentNames(skills, []).sort() : [];
|
|
232
232
|
const gitmojiGuard = hooks && normalized.targets.includes('claude');
|
|
233
|
+
// The danger guard is NOT code machinery, and tying it to `hooks` was the mistake behind #273. The
|
|
234
|
+
// three guards above need a code project with git — a branch to protect, a commit to lint. This one
|
|
235
|
+
// stops recursive deletes, history-rewriting pushes and friends, which are exactly as irreversible
|
|
236
|
+
// in an invoicing workspace, and `init` promises it by technical level: non-technical and mixed get
|
|
237
|
+
// it, whatever the project is. The guard also decides at runtime from the profile, so the plan only
|
|
238
|
+
// chooses whether it is present, never whether it bites.
|
|
239
|
+
const dangerGuard = normalized.technicalLevel !== 'technical';
|
|
233
240
|
// `sdd` is installed everywhere now, but its DECISION is about practice, not presence — so where
|
|
234
241
|
// the chain is not practised the explicit `deferred` entry below must be the only one, or the
|
|
235
242
|
// generic "installed → selected" mapping would shadow it and `reassess` would see nothing to watch.
|
|
@@ -276,6 +283,7 @@ export function buildOnboardingPlan(record, evidence) {
|
|
|
276
283
|
alwaysOn: true,
|
|
277
284
|
codeHooks: practisesSdd,
|
|
278
285
|
gitmojiGuard,
|
|
286
|
+
dangerGuard,
|
|
279
287
|
memory: true,
|
|
280
288
|
context7: false,
|
|
281
289
|
};
|
package/scripts/lib/repair.js
CHANGED
|
@@ -131,22 +131,26 @@ export async function repair({ cwd = process.cwd(), target, home = homedir(), dr
|
|
|
131
131
|
|
|
132
132
|
const paths = targetPaths(target, home, cwd);
|
|
133
133
|
const ids = [...new Set([...Object.keys(readState(paths.stateFile).skills || {}), ...(readManifest(cwd)?.skills || [])])];
|
|
134
|
+
// The accepted plan governs a repair exactly as it governs a sync. Without it, `wireHook` saw `{}`,
|
|
135
|
+
// read `codeHooks !== false` as true and wired every guard the plan had declined — and the next
|
|
136
|
+
// `sync`, which does pass the policy, took them away again (#274). Restoring is not deciding.
|
|
137
|
+
const policy = readManifest(cwd)?.onboarding?.plan?.policy;
|
|
134
138
|
const backup = createBackup({
|
|
135
139
|
cwd, operation: 'repair', target,
|
|
136
|
-
paths: managedPathsForInstall({ skillIds: ids, target, home, cwd }),
|
|
140
|
+
paths: managedPathsForInstall({ skillIds: ids, target, home, cwd, policy }),
|
|
137
141
|
});
|
|
138
142
|
|
|
139
143
|
for (const f of todo) {
|
|
140
144
|
if (f.id === 'nested-layout') rmSync(join(paths.root, 'rsc'), { recursive: true, force: true });
|
|
141
|
-
if (f.id === 'duplicate-hooks') { unwireHook(target, paths); await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' }); }
|
|
142
|
-
if (f.id === 'dangling-links') await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair' });
|
|
145
|
+
if (f.id === 'duplicate-hooks') { unwireHook(target, paths); await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair', policy }); }
|
|
146
|
+
if (f.id === 'dangling-links') await applyInstall({ skillIds: ids, target, home, cwd, operation: 'repair', policy });
|
|
143
147
|
if (f.id === 'no-manifest') recordInManifest({ cwd, target, skillIds: ids });
|
|
144
148
|
if (f.id === 'wrong-target') {
|
|
145
149
|
// Install where the project actually points, THEN take the old wiring down — in that
|
|
146
150
|
// order, so a failure halfway leaves a working harness rather than none. Taking the
|
|
147
151
|
// block out of the old file gives it back byte-identical: in #249 that file was the
|
|
148
152
|
// project's hand-written constitution, and it had already been written into once.
|
|
149
|
-
await applyInstall({ skillIds: ids, target: f.to, home, cwd, operation: 'repair' });
|
|
153
|
+
await applyInstall({ skillIds: ids, target: f.to, home, cwd, operation: 'repair', policy });
|
|
150
154
|
unwireHook(target, paths);
|
|
151
155
|
removeManagedSkills(paths);
|
|
152
156
|
recordInManifest({ cwd, target: f.to, skillIds: ids, dropTarget: target });
|
|
@@ -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.
|
package/targets/claude.js
CHANGED
|
@@ -179,18 +179,6 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
179
179
|
);
|
|
180
180
|
settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: sgCmd }] });
|
|
181
181
|
|
|
182
|
-
// Danger guard: a PreToolUse(Bash) hook that DENIES irreversible foot-gun commands
|
|
183
|
-
// (rm -rf, git push --force, DROP/TRUNCATE, DELETE/UPDATE without WHERE, dd to /dev,
|
|
184
|
-
// curl|bash, …) when the user-profile says the user is NON-technical (default-safe
|
|
185
|
-
// when no profile exists yet; never guards a fully `technical` user). Materialized +
|
|
186
|
-
// node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
|
|
187
|
-
const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
|
|
188
|
-
copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
|
|
189
|
-
const dgCmd = viaBootstrap('guard', at('.rsc', 'danger-guard.mjs'), `"${P}"`);
|
|
190
|
-
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
191
|
-
(e) => !hookWiringOf(e).includes('.rsc/danger-guard.'),
|
|
192
|
-
);
|
|
193
|
-
settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: dgCmd }] });
|
|
194
182
|
|
|
195
183
|
// Gitmoji guard: a PreToolUse(Bash) hook that DENIES a `git commit` whose message
|
|
196
184
|
// carries no gitmoji (gitmoji.dev) in front of the Conventional Commits header. The
|
|
@@ -223,23 +211,45 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
223
211
|
(e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'),
|
|
224
212
|
);
|
|
225
213
|
settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
|
|
226
|
-
written.push(sgDest,
|
|
214
|
+
written.push(sgDest, gmDest, fgDest, join(paths.projectRoot, '.rsc', 'sello.mjs'));
|
|
227
215
|
} else {
|
|
228
216
|
for (const event of ['PreToolUse', 'UserPromptSubmit']) {
|
|
229
217
|
if (!settings.hooks[event]) continue;
|
|
230
218
|
settings.hooks[event] = settings.hooks[event].filter((entry) => {
|
|
231
219
|
const body = hookWiringOf(entry);
|
|
232
|
-
return !body.includes('.rsc/ship-guard.') &&
|
|
220
|
+
return !body.includes('.rsc/ship-guard.') &&
|
|
233
221
|
!body.includes('.rsc/gitmoji-guard.') && !body.includes('.rsc/userprompt-gate.');
|
|
234
222
|
});
|
|
235
223
|
if (!settings.hooks[event].length) delete settings.hooks[event];
|
|
236
224
|
}
|
|
237
|
-
for (const name of ['ship-guard.mjs', '
|
|
225
|
+
for (const name of ['ship-guard.mjs', 'gitmoji-guard.mjs', 'userprompt-gate.mjs', 'sello.mjs']) {
|
|
238
226
|
rmSync(join(paths.projectRoot, '.rsc', name), { force: true });
|
|
239
227
|
}
|
|
240
228
|
written.push(operationsSuggest);
|
|
241
229
|
}
|
|
242
230
|
|
|
231
|
+
// Danger guard: its own decision, not part of the code-hook block above (#273). `init` promises it
|
|
232
|
+
// to non-technical and mixed users whatever the project is, and a plan that predates the field is
|
|
233
|
+
// read as "yes": a non-technical operations install gets the guard it was missing on its next sync,
|
|
234
|
+
// and a technical user gets a guard that stands down at runtime.
|
|
235
|
+
settings.hooks.PreToolUse ||= [];
|
|
236
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter((e) => !hookWiringOf(e).includes('.rsc/danger-guard.'));
|
|
237
|
+
if (policy.dangerGuard !== false) {
|
|
238
|
+
// Danger guard: a PreToolUse(Bash) hook that DENIES irreversible foot-gun commands
|
|
239
|
+
// (rm -rf, git push --force, DROP/TRUNCATE, DELETE/UPDATE without WHERE, dd to /dev,
|
|
240
|
+
// curl|bash, …) when the user-profile says the user is NON-technical (default-safe
|
|
241
|
+
// when no profile exists yet; never guards a fully `technical` user). Materialized +
|
|
242
|
+
// node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
|
|
243
|
+
const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
|
|
244
|
+
copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
|
|
245
|
+
const dgCmd = viaBootstrap('guard', at('.rsc', 'danger-guard.mjs'), `"${P}"`);
|
|
246
|
+
settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: dgCmd }] });
|
|
247
|
+
written.push(dgDest);
|
|
248
|
+
} else {
|
|
249
|
+
rmSync(join(paths.projectRoot, '.rsc', 'danger-guard.mjs'), { force: true });
|
|
250
|
+
}
|
|
251
|
+
if (!settings.hooks.PreToolUse.length) delete settings.hooks.PreToolUse;
|
|
252
|
+
|
|
243
253
|
mkdirSync(dirname(file), { recursive: true });
|
|
244
254
|
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
245
255
|
|