@ericrisco/rsc 3.0.6 → 3.0.8
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 +61 -17
- package/manifest.json +1 -1
- package/package.json +3 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +63 -11
- package/scripts/install-apply.js +224 -18
- package/scripts/lib/onboarding-apply.js +27 -24
- package/scripts/lib/onboarding.js +19 -4
- package/scripts/lib/tools-skeleton.js +76 -0
- package/scripts/rsc.js +93 -13
- package/skills/suggest/SKILL.md +5 -4
- package/targets/branch-guard.mjs +94 -26
- package/targets/claude.js +36 -6
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/danger-guard.mjs +80 -14
- package/targets/deepseek-bridge.js +86 -0
- package/targets/deepseek-bridge.mjs +90 -0
- package/targets/ftd-nudge.mjs +137 -0
- package/targets/gitmoji-guard.mjs +14 -1
- package/targets/index.js +4 -0
- package/targets/knowledge-sync.mjs +203 -45
- package/targets/knowledge-wiring.js +2 -0
- package/targets/memory.js +7 -0
- package/targets/opt-outs.js +1 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +28 -4
- package/targets/session-memory-core.mjs +211 -36
- package/targets/session-start.mjs +40 -5
- package/targets/shell-unwrap.mjs +145 -0
- package/targets/ship-guard.mjs +40 -9
- package/targets/team-safe-start.mjs +76 -10
- package/targets/update-wiring.js +2 -1
- package/targets/worktree-reaper.mjs +109 -3
package/scripts/rsc.js
CHANGED
|
@@ -4,12 +4,13 @@ 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,
|
|
7
|
+
import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purgeReport, collisions, ownSkillPaths } from './install-apply.js';
|
|
8
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
8
9
|
import { stackAgentNames } from '../targets/agents.js';
|
|
9
10
|
import { doctor } from './doctor.js';
|
|
10
11
|
import { ask, say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
|
|
11
12
|
import { refreshRegistry, registryStatus } from './lib/registry.js';
|
|
12
|
-
import { audit, writeAuditReport } from './audit.js';
|
|
13
|
+
import { audit, writeAuditReport, recordAuditBaseline } from './audit.js';
|
|
13
14
|
import { DOMAINS } from './lib/domains.js';
|
|
14
15
|
import { listBackups, restoreBackup, whereKept } from './lib/backups.js';
|
|
15
16
|
import { runUpgrade } from './lib/upgrade.js';
|
|
@@ -18,6 +19,7 @@ import { DEFAULT_SKILL_FLOOR, withDefaultSkillFloor } from './lib/default-skill-
|
|
|
18
19
|
import { RETIRED_SKILLS, replaceRetired } from './lib/retired-skills.js';
|
|
19
20
|
import { readManifest, writeManifest } from './lib/manifest-file.js';
|
|
20
21
|
import { versionReport } from './lib/versions.js';
|
|
22
|
+
import { templateAssets, targetName } from './lib/tools-skeleton.js';
|
|
21
23
|
import {
|
|
22
24
|
normalizeOnboarding, missingOnboardingFields, scanProject,
|
|
23
25
|
buildOnboardingPlan, decodeGoal, encodeGoal, identifyPlan, recommendDeferredComponents,
|
|
@@ -39,6 +41,33 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
|
|
|
39
41
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
40
42
|
process.exit(0);
|
|
41
43
|
}
|
|
44
|
+
const GENERAL_HELP = [
|
|
45
|
+
'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | memory <on|off|status|save|resume|learn|metrics> | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
|
|
46
|
+
'Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)',
|
|
47
|
+
' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.',
|
|
48
|
+
].join('\n');
|
|
49
|
+
const UNINSTALL_HELP = [
|
|
50
|
+
'Use: npx @ericrisco/rsc uninstall <id...> [--dry-run] [--target <assistant>]',
|
|
51
|
+
' Removes the named skills or agents from this project.',
|
|
52
|
+
' To remove everything rsc installed here, run: npx @ericrisco/rsc purge (keeps 02-DOCS/ unless --with-docs)',
|
|
53
|
+
].join('\n');
|
|
54
|
+
const PURGE_HELP = [
|
|
55
|
+
'Use: npx @ericrisco/rsc purge [--dry-run] [--with-docs]',
|
|
56
|
+
' Removes everything rsc installed in this project and reports what was removed, cleaned and kept.',
|
|
57
|
+
' --dry-run shows the report without touching anything. 02-DOCS/ is kept unless --with-docs.',
|
|
58
|
+
].join('\n');
|
|
59
|
+
|
|
60
|
+
// Asking for help must never RUN the command. `uninstall --help` used to be read as an empty id list
|
|
61
|
+
// and report "Removed: (nothing)"; `purge --help` would have purged. Answered before anything else.
|
|
62
|
+
{
|
|
63
|
+
const helpAsked = rawArgv.some((a) => a === '--help' || a === '-h');
|
|
64
|
+
if (rawArgv[0] === 'help' || helpAsked) {
|
|
65
|
+
const about = rawArgv[0] === 'help' ? rawArgv[1] : rawArgv.find((a) => !a.startsWith('-'));
|
|
66
|
+
const text = about === 'uninstall' ? UNINSTALL_HELP : about === 'purge' ? PURGE_HELP : GENERAL_HELP;
|
|
67
|
+
process.stdout.write(`${text}\n`);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
42
71
|
// `--accompaniment` stays listed although the dial is retired: old scripts and agents still pass it,
|
|
43
72
|
// and its value must keep being skipped rather than read as a command or a skill id.
|
|
44
73
|
const GLOBAL_VALUE_FLAGS = new Set([
|
|
@@ -159,6 +188,12 @@ function renderPlan(plan, planId) {
|
|
|
159
188
|
for (const decision of plan.decisions.filter((d) => d.state === 'excluded')) say(` × ${decision.kind}/${decision.id} — ${decision.reason}`);
|
|
160
189
|
say('Managed paths:');
|
|
161
190
|
for (const path of plan.governedPaths) say(` ${path}`);
|
|
191
|
+
// The scaffolding is written once and then belongs to the person, so it is not a managed (digested)
|
|
192
|
+
// path — but it IS written by this plan, and a plan that does not show it is not the whole plan.
|
|
193
|
+
if ((plan.floorPaths || []).includes('01-TOOLS/_TEMPLATE/')) {
|
|
194
|
+
say('Scaffolded once (yours to edit; never overwritten; purge removes only untouched files):');
|
|
195
|
+
for (const path of ['01-TOOLS/.gitignore', ...templateAssets().map((a) => `01-TOOLS/_TEMPLATE/${targetName(a)}`)].sort()) say(` ${path}`);
|
|
196
|
+
}
|
|
162
197
|
if (plan.replacesUserSkills?.length) {
|
|
163
198
|
say('Your own skills this plan replaces:');
|
|
164
199
|
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/`);
|
|
@@ -166,8 +201,11 @@ function renderPlan(plan, planId) {
|
|
|
166
201
|
say(' AGENT: name each of these to the user before accepting, and ask whether to replace or rename.');
|
|
167
202
|
}
|
|
168
203
|
if (plan.evidence.parentHarness) say(`Parent harness detected at ${plan.evidence.parentHarness}; it is not inherited by this plan.`);
|
|
204
|
+
// Pinned to the version that computed this plan. The id is a hash of the plan, and the plan
|
|
205
|
+
// depends on the catalog of the running version: `@latest` would accept it with whatever version
|
|
206
|
+
// npm resolves next, which answers RSC_PLAN_CHANGED the day a release lands in between.
|
|
169
207
|
const pieces = [
|
|
170
|
-
|
|
208
|
+
`npx @ericrisco/rsc@${versionReport(process.cwd()).cli} onboard`,
|
|
171
209
|
`--technical-level ${plan.record.technicalLevel}`,
|
|
172
210
|
`--project-kind ${plan.record.projectKind}`,
|
|
173
211
|
`--goal-base64 ${encodeGoal(plan.record.goal)}`,
|
|
@@ -260,6 +298,9 @@ async function runOnboarding(targets) {
|
|
|
260
298
|
} catch (error) {
|
|
261
299
|
say(`RSC_SKELETON_FAILED ${error.message}`);
|
|
262
300
|
}
|
|
301
|
+
// The audit cadence starts at install. Without a baseline the very first session after onboarding
|
|
302
|
+
// announced "a skill audit is due" about a set chosen minutes earlier. Never overwrites a real run.
|
|
303
|
+
try { recordAuditBaseline(process.cwd()); } catch { /* a missing baseline only means an early nudge */ }
|
|
263
304
|
for (const path of plan.replacesUserSkills || []) {
|
|
264
305
|
const kept = whereKept({ cwd: process.cwd(), relPath: path });
|
|
265
306
|
if (kept) say(`Your previous ${path} is kept in ${kept}`);
|
|
@@ -370,10 +411,34 @@ function flag(name) {
|
|
|
370
411
|
// Remove everything rsc installed in this project (skills, hooks, .rsc/), across
|
|
371
412
|
// every assistant. Keeps 02-DOCS/ unless --with-docs. `purge` / `uninstall --all`.
|
|
372
413
|
async function runPurge(dryRun, withDocs) {
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
414
|
+
const cwd = process.cwd();
|
|
415
|
+
const report = await purgeReport({ cwd, withDocs, dryRun });
|
|
416
|
+
// Relative to the project: the report is read by a person, and every path is inside it.
|
|
417
|
+
const shown = (p) => {
|
|
418
|
+
const rel = relative(cwd, p);
|
|
419
|
+
return !rel || rel.startsWith('..') || isAbsolute(rel) ? p : rel.split(sep).join('/');
|
|
420
|
+
};
|
|
421
|
+
say(`${dryRun ? 'Would remove' : 'Removed'} ${report.removed.length} path(s):`);
|
|
422
|
+
for (const r of report.removed.map(shown).sort()) say(` - ${r}`);
|
|
423
|
+
if (report.cleaned.length) {
|
|
424
|
+
say(`\n${dryRun ? 'Would clean' : 'Cleaned'} ${report.cleaned.length} file(s) — rsc's entries ${dryRun ? 'would be' : 'were'} taken out; ${dryRun ? 'each is deleted if nothing of yours remains' : 'the rest of the file is yours and stays'}:`);
|
|
425
|
+
for (const c of report.cleaned.map(shown).sort()) say(` - ${c}`);
|
|
426
|
+
}
|
|
427
|
+
if (report.kept.length) {
|
|
428
|
+
say(`\nKept ${report.kept.length}:`);
|
|
429
|
+
for (const k of report.kept) say(` - ${k.path} — ${k.reason}`);
|
|
430
|
+
}
|
|
431
|
+
// On a clone most of what purge deletes is committed. Deleting it here is local; committing the
|
|
432
|
+
// deletions uninstalls rsc for the whole team (team simulation D15). Said before anyone commits.
|
|
433
|
+
if (report.tracked?.length) {
|
|
434
|
+
const quote = (p) => (/^[\w./@+-]+$/.test(p) ? p : `'${p.replace(/'/g, `'\\''`)}'`);
|
|
435
|
+
say(`\nWARNING: ${report.tracked.length} of these file(s) are committed in git and ${dryRun ? 'would show' : 'now show'} as deleted or changed:`);
|
|
436
|
+
for (const f of report.tracked.slice(0, 12)) say(` - ${f}`);
|
|
437
|
+
if (report.tracked.length > 12) say(` … and ${report.tracked.length - 12} more`);
|
|
438
|
+
say('Committing these changes uninstalls rsc for the whole team, on their next pull. To remove rsc only on this machine, do not commit them.');
|
|
439
|
+
if (!dryRun) say(`To undo locally: git restore -- ${report.tracked.map(quote).join(' ')}`);
|
|
440
|
+
else say('To undo after a real purge: git restore -- <those files> (purge prints the exact command).');
|
|
441
|
+
}
|
|
377
442
|
}
|
|
378
443
|
|
|
379
444
|
async function recommendIds(query, { labeledOnly = false } = {}) {
|
|
@@ -450,6 +515,7 @@ function printNextSteps(targets, ids) {
|
|
|
450
515
|
say(' Browse the catalog / get picks: npx @ericrisco/rsc consult "whatever you need"');
|
|
451
516
|
say('────────────────────────────────────────────────────────');
|
|
452
517
|
if (targets.includes('codex')) say(' Codex: review and trust the project lifecycle hook once with `/hooks`; until then memory is reported as requiring trust.');
|
|
518
|
+
if (targets.includes('deepseek')) say(' DeepSeek Harness: hooks run through rsc\'s bridge in ~/.dsh/cordis.patch.yml (one block, every profile); restart dsh once to load it.');
|
|
453
519
|
if (targets.includes('cursor')) say(' Cursor: startup memory is assisted because its sessionStart hook is fire-and-forget; the installed always-on rule performs the read-before-action fallback.');
|
|
454
520
|
printAgentHandoff();
|
|
455
521
|
}
|
|
@@ -514,7 +580,7 @@ function printContextBudget(b) {
|
|
|
514
580
|
if (b.notApplicable.length) {
|
|
515
581
|
say(` ${b.note}`);
|
|
516
582
|
} else {
|
|
517
|
-
say(` Per session start : ${kb(b.sessionStartBytes)} (always-on body, per wired scope)`);
|
|
583
|
+
say(` Per session start : ${kb(b.sessionStartBytes)} (always-on body, per wired scope; one-time and conditional banners not counted)`);
|
|
518
584
|
say(` Per user turn : ${kb(b.perTurnBytes)}`);
|
|
519
585
|
}
|
|
520
586
|
say(` Skill descriptions: ${kb(b.descriptionsBytes)} (${b.installedSkills} installed, always in context)`);
|
|
@@ -1005,7 +1071,15 @@ async function main() {
|
|
|
1005
1071
|
// defeated by an argument the sweep itself told the agent to type.
|
|
1006
1072
|
const confirmed = argv.includes('--confirm');
|
|
1007
1073
|
const targets = one ? [one] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
|
|
1008
|
-
|
|
1074
|
+
// The bulk reap also deletes plain local branches that have landed (`branch -d`, never -D):
|
|
1075
|
+
// the worktree is not the only thing a merge leaves behind (E2E defect 14).
|
|
1076
|
+
const reapBranches = () => {
|
|
1077
|
+
if (one) return 0;
|
|
1078
|
+
const { deleted } = W.reapMergedBranches(root);
|
|
1079
|
+
for (const b of deleted) say(`✅ deleted merged branch ${b}`);
|
|
1080
|
+
return deleted.length;
|
|
1081
|
+
};
|
|
1082
|
+
if (!targets.length) { if (!reapBranches()) say('nothing to clean up.'); return; }
|
|
1009
1083
|
for (const t of targets) {
|
|
1010
1084
|
const out = W.reapWorktree(root, t, { confirmed });
|
|
1011
1085
|
if (out.removed) {
|
|
@@ -1017,6 +1091,7 @@ async function main() {
|
|
|
1017
1091
|
process.exitCode = 1;
|
|
1018
1092
|
}
|
|
1019
1093
|
}
|
|
1094
|
+
reapBranches();
|
|
1020
1095
|
return;
|
|
1021
1096
|
}
|
|
1022
1097
|
|
|
@@ -1271,7 +1346,14 @@ async function main() {
|
|
|
1271
1346
|
const dry = argv.includes('--dry-run');
|
|
1272
1347
|
// `uninstall --all` is an alias for a full purge.
|
|
1273
1348
|
if (argv.includes('--all')) return void (await runPurge(dry, argv.includes('--with-docs')));
|
|
1274
|
-
const
|
|
1349
|
+
const ids = requestedIds();
|
|
1350
|
+
// No id is not "nothing to remove": it is a command that was not given what it needs.
|
|
1351
|
+
if (!ids.length) {
|
|
1352
|
+
console.error(`rsc: uninstall needs at least one skill or agent id.\n${UNINSTALL_HELP}`);
|
|
1353
|
+
process.exitCode = 1;
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const selected = classifyRequested(ids);
|
|
1275
1357
|
// A retired skill an old install still holds is not unknown: the state file proves it is ours.
|
|
1276
1358
|
const held = new Set(listInstalled({ target }));
|
|
1277
1359
|
selected.skills.push(...selected.unknown.filter((id) => Object.hasOwn(RETIRED_SKILLS, id) && held.has(id)));
|
|
@@ -1305,9 +1387,7 @@ async function main() {
|
|
|
1305
1387
|
return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
|
|
1306
1388
|
default:
|
|
1307
1389
|
say(`rsc: unknown command '${cmd}'.`);
|
|
1308
|
-
say(
|
|
1309
|
-
say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
|
|
1310
|
-
say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
|
|
1390
|
+
say(GENERAL_HELP);
|
|
1311
1391
|
process.exitCode = 1;
|
|
1312
1392
|
}
|
|
1313
1393
|
}
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -106,12 +106,13 @@ Offer once per session. Never mention any of it when the harness is healthy.
|
|
|
106
106
|
|
|
107
107
|
Before handling the first request of a session, check the workspace:
|
|
108
108
|
|
|
109
|
-
- No `02-DOCS/wiki/harness/user-profile.md` **and** no `.rsc/.no-harness` →
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
- No `02-DOCS/wiki/harness/user-profile.md` **and** no `.rsc/.no-harness` → invoke `init` first
|
|
110
|
+
(one question: technical terms or analogies) before the task.
|
|
111
|
+
- A clone (`.rsc.json` committed) is not first contact: ask that once, in one line, then continue
|
|
112
|
+
with their task; with `.rsc/.profile-offered`, never again.
|
|
112
113
|
- The user declines a harness here ("sin harness", "solo código") → create an empty
|
|
113
114
|
`.rsc/.no-harness`, confirm in one line, and never auto-start `init` in this repo again.
|
|
114
|
-
-
|
|
115
|
+
- With a profile, this gate is inert. Never re-onboard.
|
|
115
116
|
|
|
116
117
|
## Explain without assuming
|
|
117
118
|
|
package/targets/branch-guard.mjs
CHANGED
|
@@ -33,6 +33,29 @@ export const COMMITS_HERE = new RegExp(String.raw`\bgit\s+${GIT_OPTS}(?:commit|m
|
|
|
33
33
|
// A branch move: switch, or checkout of something that is not a path (`checkout -- f`, `checkout HEAD -- f`).
|
|
34
34
|
export const MOVES_BRANCH = new RegExp(String.raw`\bgit\s+${GIT_OPTS}(?:switch(?![\w-])|checkout(?![\w-])(?![^;&|\n]*\s--(?:\s|$)))`);
|
|
35
35
|
|
|
36
|
+
// A move of a branch REF without a commit: `git update-ref <ref> …` or a forced `git branch`
|
|
37
|
+
// (-f/--force, -M, -C). Which ref it moves is read by refMoveTarget().
|
|
38
|
+
export const MOVES_REF = new RegExp(String.raw`\bgit\s+${GIT_OPTS}(?:update-ref(?![\w-])|branch\s+(?:\S+\s+)*?(?:-[A-Za-z]*[fMC][A-Za-z]*|--force)(?=\s|$))`);
|
|
39
|
+
|
|
40
|
+
// A push whose DESTINATION is named: `git push origin HEAD:main`, `feat/x:refs/heads/main`, `--mirror`.
|
|
41
|
+
export const PUSHES = new RegExp(String.raw`\bgit\s+${GIT_OPTS}push(?![\w-])`);
|
|
42
|
+
|
|
43
|
+
/** True when this push segment writes to branch `trunk` on the remote by an explicit refspec. */
|
|
44
|
+
export function pushesTo(seg, trunk) {
|
|
45
|
+
const t = tokens(seg.replace(/^.*?\bgit\s+/, '').replace(/^(?:(?:-C|-c)\s+\S+\s+|--\S+\s+)*/, ''));
|
|
46
|
+
if (t.shift() !== 'push') return false;
|
|
47
|
+
const args = [];
|
|
48
|
+
for (let i = 0; i < t.length; i++) {
|
|
49
|
+
if (t[i] === '--mirror') return true; // rewrites every ref on the remote, the default branch included
|
|
50
|
+
if (/^(-o|--push-option|--repo|--receive-pack|--exec)$/.test(t[i])) { i++; continue; }
|
|
51
|
+
if (!t[i].startsWith('-')) args.push(t[i]);
|
|
52
|
+
}
|
|
53
|
+
return args.slice(1).some((spec) => {
|
|
54
|
+
const dst = spec.replace(/^\+/, '').split(':')[1];
|
|
55
|
+
return dst !== undefined && dst.replace(/^refs\/heads\//, '') === trunk;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
export const unquoted = (command) => command.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""');
|
|
37
60
|
|
|
38
61
|
/** Where the git command actually runs: `git -C <dir>`, or a leading `cd <dir> &&`, else the cwd. */
|
|
@@ -54,27 +77,10 @@ const ago = (iso) => {
|
|
|
54
77
|
return min <= 1 ? 'just now' : `${min} min ago`;
|
|
55
78
|
};
|
|
56
79
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
export function segments(command) {
|
|
62
|
-
const out = [];
|
|
63
|
-
let cur = '';
|
|
64
|
-
let q = null;
|
|
65
|
-
for (let i = 0; i < command.length; i++) {
|
|
66
|
-
const c = command[i];
|
|
67
|
-
if (q) { cur += c; if (c === '\\' && q === '"') cur += command[++i] ?? ''; else if (c === q) q = null; continue; }
|
|
68
|
-
if (c === '"' || c === "'") { q = c; cur += c; continue; }
|
|
69
|
-
if (c === ';' || c === '\n' || c === '|' || c === '&') {
|
|
70
|
-
if ((c === '&' || c === '|') && command[i + 1] === c) i++;
|
|
71
|
-
out.push(cur); cur = ''; continue;
|
|
72
|
-
}
|
|
73
|
-
cur += c;
|
|
74
|
-
}
|
|
75
|
-
out.push(cur);
|
|
76
|
-
return out.filter((s) => s.trim());
|
|
77
|
-
}
|
|
80
|
+
// The quote-aware split, the heredoc strip and the shell unwrapping (`bash -c "…"`, `eval`, `env`…)
|
|
81
|
+
// live in shell-unwrap.mjs, shared with the other guards (E2E defect 15). It is a sibling import
|
|
82
|
+
// like trunk-policy.mjs; without it the guard cannot read a command and fails open.
|
|
83
|
+
const SH = await import(new URL('./shell-unwrap.mjs', import.meta.url)).catch(() => null);
|
|
78
84
|
const tokens = (s) => s.trim().split(/\s+/).filter(Boolean);
|
|
79
85
|
|
|
80
86
|
/** What a branch-moving segment moves to, or null if it is a path checkout after all (`checkout .`). */
|
|
@@ -89,6 +95,31 @@ function moveTarget(seg, dir) {
|
|
|
89
95
|
return { name, created: false };
|
|
90
96
|
}
|
|
91
97
|
|
|
98
|
+
/** The branch a ref-moving segment points somewhere else (`update-ref refs/heads/x`, `branch -f x`), or null. */
|
|
99
|
+
export function refMoveTarget(seg) {
|
|
100
|
+
const t = tokens(seg.replace(/^.*?\bgit\s+/, '').replace(/^(?:(?:-C|-c)\s+\S+\s+|--\S+\s+)*/, ''));
|
|
101
|
+
const verb = t.shift();
|
|
102
|
+
if (verb === 'update-ref') {
|
|
103
|
+
const args = [];
|
|
104
|
+
for (let i = 0; i < t.length; i++) {
|
|
105
|
+
if (t[i] === '-m') { i++; continue; }
|
|
106
|
+
if (t[i] === '--stdin') return null; // refs come from stdin: unreadable here
|
|
107
|
+
if (!t[i].startsWith('-')) args.push(t[i]);
|
|
108
|
+
}
|
|
109
|
+
const m = (args[0] || '').match(/^refs\/heads\/(.+)$/);
|
|
110
|
+
return m ? m[1] : null;
|
|
111
|
+
}
|
|
112
|
+
if (verb !== 'branch') return null;
|
|
113
|
+
const flags = t.filter((x) => x.startsWith('-'));
|
|
114
|
+
const names = t.filter((x) => !x.startsWith('-'));
|
|
115
|
+
const short = flags.filter((f) => /^-[A-Za-z]+$/.test(f)).join('');
|
|
116
|
+
const renames = /[mMcC]/.test(short) || flags.some((f) => /^--(move|copy)$/.test(f));
|
|
117
|
+
const forced = /[fMC]/.test(short) || flags.includes('--force');
|
|
118
|
+
if (!forced || /[dD]/.test(short)) return null;
|
|
119
|
+
// `branch -M [old] new` / `-C [old] new`: the last name is the one overwritten; `-f name [start]`: the first.
|
|
120
|
+
return (renames ? names[names.length - 1] : names[0]) || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
92
123
|
/**
|
|
93
124
|
* Rule A and rule B, judged segment by segment: a chain is followed the way the shell runs it, so
|
|
94
125
|
* `git switch -c feat/x && git commit` is a commit on feat/x (allowed) and `git switch main && git
|
|
@@ -96,12 +127,14 @@ function moveTarget(seg, dir) {
|
|
|
96
127
|
*/
|
|
97
128
|
export async function evaluate({ root, command, cwd, sessionId }) {
|
|
98
129
|
if (typeof command !== 'string' || !command) return null;
|
|
130
|
+
if (!SH) return null;
|
|
99
131
|
// Words inside quotes or heredocs are text, not commands: `grep -rn "git commit" docs/` is a search.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
const raw = segments(withoutHeredocs(command));
|
|
132
|
+
// Except where a wrapper hands the string to a shell: `bash -c "git commit"` runs the commit, so
|
|
133
|
+
// the segments judged are the ones the shell would really run (shell-unwrap.mjs).
|
|
134
|
+
const raw = SH.expand(command);
|
|
104
135
|
const segs = raw.map(unquoted);
|
|
136
|
+
if (!segs.some((s) => COMMITS_HERE.test(s) || MOVES_BRANCH.test(s) || MOVES_REF.test(s) || PUSHES.test(s))) return null;
|
|
137
|
+
const self = (rel) => new URL(rel, import.meta.url);
|
|
105
138
|
let dir = cwd || root;
|
|
106
139
|
const branchAt = new Map(); // toplevel → branch the chain has moved it to
|
|
107
140
|
|
|
@@ -111,7 +144,9 @@ export async function evaluate({ root, command, cwd, sessionId }) {
|
|
|
111
144
|
if (cd) { const d = cd[1].replace(/^["']|["']$/g, ''); dir = isAbsolute(d) ? d : resolve(dir, d); continue; }
|
|
112
145
|
const commits = COMMITS_HERE.test(seg);
|
|
113
146
|
const moves = MOVES_BRANCH.test(seg);
|
|
114
|
-
|
|
147
|
+
const movesRef = MOVES_REF.test(seg);
|
|
148
|
+
const pushes = PUSHES.test(seg);
|
|
149
|
+
if (!commits && !moves && !movesRef && !pushes) continue;
|
|
115
150
|
const at = effectiveDir(raw[i] || seg, dir);
|
|
116
151
|
const git = gitAt(at);
|
|
117
152
|
const top = git('rev-parse', '--show-toplevel');
|
|
@@ -129,6 +164,32 @@ export async function evaluate({ root, command, cwd, sessionId }) {
|
|
|
129
164
|
}
|
|
130
165
|
}
|
|
131
166
|
|
|
167
|
+
// Rule A, the other way onto the default branch: pointing it somewhere else without a commit.
|
|
168
|
+
if (movesRef && !existsSync(join(root, '.rsc', '.no-trunk-guard'))) {
|
|
169
|
+
try {
|
|
170
|
+
const { trunkPolicy, defaultBranchName } = await import(self('./trunk-policy.mjs'));
|
|
171
|
+
const trunk = defaultBranchName(here);
|
|
172
|
+
if (trunk && refMoveTarget(seg) === trunk) {
|
|
173
|
+
const policy = trunkPolicy(here);
|
|
174
|
+
if (policy.closed) return refDenial({ trunk, policy });
|
|
175
|
+
}
|
|
176
|
+
} catch { /* policy module missing → nothing to enforce */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// And by pushing straight into it: `git push origin HEAD:main` from a feature branch. Branch
|
|
180
|
+
// protection on the server would refuse it, but a repo without one would take it (second team
|
|
181
|
+
// simulation) — so the closed trunk is enforced here, where the agent runs it.
|
|
182
|
+
if (pushes && !existsSync(join(root, '.rsc', '.no-trunk-guard'))) {
|
|
183
|
+
try {
|
|
184
|
+
const { trunkPolicy, defaultBranchName } = await import(self('./trunk-policy.mjs'));
|
|
185
|
+
const trunk = defaultBranchName(here);
|
|
186
|
+
if (trunk && pushesTo(seg, trunk)) {
|
|
187
|
+
const policy = trunkPolicy(here);
|
|
188
|
+
if (policy.closed) return refDenial({ trunk, policy, how: '`git push <remote> <branch>:' + trunk + '`' });
|
|
189
|
+
}
|
|
190
|
+
} catch { /* policy module missing → nothing to enforce */ }
|
|
191
|
+
}
|
|
192
|
+
|
|
132
193
|
if (commits && !existsSync(join(root, '.rsc', '.no-trunk-guard'))) {
|
|
133
194
|
try {
|
|
134
195
|
const { trunkPolicy, defaultBranchName } = await import(self('./trunk-policy.mjs'));
|
|
@@ -164,6 +225,13 @@ async function trunkDenial({ root, here, sessionId, self, trunk, policy }) {
|
|
|
164
225
|
'Unlock → only on their explicit answer, `npx @ericrisco/rsc main unlock` (a project decision, saved in .rsc.json), then commit again.';
|
|
165
226
|
}
|
|
166
227
|
|
|
228
|
+
function refDenial({ trunk, policy, how = '`git update-ref` and a forced `git branch`' }) {
|
|
229
|
+
return `This project keeps its default branch "${trunk}" closed for the agent (it looks complex or in production: ${policy.signals.join(', ')}), so "${trunk}" was not moved — ` +
|
|
230
|
+
`${how} land${how.includes(' and ') ? '' : 's'} work on it with no review. ` +
|
|
231
|
+
`Work reaches "${trunk}" through a pull request: push the branch and open one (the \`ship\` skill). ` +
|
|
232
|
+
`Do not choose for the person: if they want "${trunk}" moved by hand, ask them in one line; only on their explicit answer, \`npx @ericrisco/rsc main unlock\` (a project decision, saved in .rsc.json).`;
|
|
233
|
+
}
|
|
234
|
+
|
|
167
235
|
async function isolationDenial({ root, here, sessionId, self }) {
|
|
168
236
|
const others = await othersHere({ root, here, sessionId, self });
|
|
169
237
|
if (!others.length) return null;
|
package/targets/claude.js
CHANGED
|
@@ -115,6 +115,9 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
115
115
|
// Shared by session-start + userprompt-gate. Hooks are materialized file by file, so a module
|
|
116
116
|
// they import must be copied as their SIBLING — a subdirectory import would break once copied.
|
|
117
117
|
copyFileSync(join(HERE, 'hook-once.mjs'), join(paths.projectRoot, '.rsc', 'hook-once.mjs'));
|
|
118
|
+
// Shared by every PreToolUse guard (and the sello): what a Bash command really runs, `bash -c` and
|
|
119
|
+
// `eval` unwrapped. A sibling for the same reason, and here because the danger guard needs it too.
|
|
120
|
+
copyFileSync(join(HERE, 'shell-unwrap.mjs'), join(paths.projectRoot, '.rsc', 'shell-unwrap.mjs'));
|
|
118
121
|
// Same reason: session-start imports the reaper for the landed-worktree sweep, so it travels as a
|
|
119
122
|
// sibling too. Missing it does not break startup — the sweep is wrapped — it just goes silent.
|
|
120
123
|
copyFileSync(join(HERE, 'worktree-reaper.mjs'), join(paths.projectRoot, '.rsc', 'worktree-reaper.mjs'));
|
|
@@ -132,7 +135,12 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
132
135
|
|
|
133
136
|
let suggestRel = at(relative(paths.projectRoot, paths.skillDir('suggest')).split(sep).join('/'), 'SKILL.md');
|
|
134
137
|
const operationsSuggest = join(paths.projectRoot, '.rsc', 'suggest-always-on.md');
|
|
135
|
-
|
|
138
|
+
// The lane decision (FTD or SDD) belongs to every SOFTWARE project, small ones included. Tying it to
|
|
139
|
+
// the code gates was the cause behind the E2E of 2026-10-07: a small project got a three-line stub
|
|
140
|
+
// with no lanes in it, no per-turn gate, and so never wrote a feature document. Plans from before
|
|
141
|
+
// the field keep their old meaning (`laneGate` follows `codeHooks`) until onboard runs again.
|
|
142
|
+
const laneGate = policy.laneGate ?? policy.codeHooks !== false;
|
|
143
|
+
if (policy.codeHooks === false && !laneGate) {
|
|
136
144
|
writeFileSync(operationsSuggest, '# rsc-suggest — always-on operations layer\n\nRead `02-DOCS/wiki/harness/user-profile.md` before acting. Use `orient` to keep the user situated and `suggest` to offer a missing skill only when the current task needs it. Close with the configured orientation block.\n');
|
|
137
145
|
suggestRel = at('.rsc', 'suggest-always-on.md');
|
|
138
146
|
} else if (existsSync(operationsSuggest)) rmSync(operationsSuggest, { force: true });
|
|
@@ -237,16 +245,38 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
237
245
|
settings.hooks[event] = settings.hooks[event].filter((entry) => {
|
|
238
246
|
const body = hookWiringOf(entry);
|
|
239
247
|
return !body.includes('.rsc/ship-guard.') && !body.includes('.rsc/branch-guard.') &&
|
|
240
|
-
!body.includes('.rsc/gitmoji-guard.') && !body.includes('.rsc/userprompt-gate.');
|
|
248
|
+
!body.includes('.rsc/gitmoji-guard.') && (laneGate || !body.includes('.rsc/userprompt-gate.'));
|
|
241
249
|
});
|
|
242
250
|
if (!settings.hooks[event].length) delete settings.hooks[event];
|
|
243
251
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
252
|
+
const gone = ['ship-guard.mjs', 'branch-guard.mjs', 'gitmoji-guard.mjs', 'sello.mjs', ...(laneGate ? [] : ['trunk-policy.mjs', 'userprompt-gate.mjs'])];
|
|
253
|
+
for (const name of gone) rmSync(join(paths.projectRoot, '.rsc', name), { force: true });
|
|
254
|
+
if (laneGate) {
|
|
255
|
+
// Same per-turn gate as a governed project: the lane, and where code changes go here.
|
|
256
|
+
const fgDest = join(paths.projectRoot, '.rsc', 'userprompt-gate.mjs');
|
|
257
|
+
copyFileSync(join(HERE, 'userprompt-gate.mjs'), fgDest);
|
|
258
|
+
copyFileSync(join(HERE, 'trunk-policy.mjs'), join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
|
|
259
|
+
const fgCmd = viaBootstrap('quiet', at('.rsc', 'userprompt-gate.mjs'), `"${P}"`);
|
|
260
|
+
settings.hooks.UserPromptSubmit ||= [];
|
|
261
|
+
settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter((e) => !hookWiringOf(e).includes('.rsc/userprompt-gate.'));
|
|
262
|
+
settings.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: fgCmd }] });
|
|
263
|
+
written.push(fgDest, join(paths.projectRoot, '.rsc', 'trunk-policy.mjs'));
|
|
264
|
+
} else written.push(operationsSuggest);
|
|
248
265
|
}
|
|
249
266
|
|
|
267
|
+
// FTD nudge: once per session, right after the first code edit with no feature document touched.
|
|
268
|
+
// Wherever the lane decision is installed; context only, never blocks; .rsc/.no-ftd-nudge.
|
|
269
|
+
settings.hooks.PostToolUse ||= [];
|
|
270
|
+
settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((e) => !hookWiringOf(e).includes('.rsc/ftd-nudge.'));
|
|
271
|
+
if (laneGate) {
|
|
272
|
+
const fnDest = join(paths.projectRoot, '.rsc', 'ftd-nudge.mjs');
|
|
273
|
+
copyFileSync(join(HERE, 'ftd-nudge.mjs'), fnDest);
|
|
274
|
+
const fnCmd = viaBootstrap('quiet', at('.rsc', 'ftd-nudge.mjs'), `"${P}"`);
|
|
275
|
+
settings.hooks.PostToolUse.push({ matcher: 'Edit|Write|MultiEdit|NotebookEdit', hooks: [{ type: 'command', command: fnCmd }] });
|
|
276
|
+
written.push(fnDest);
|
|
277
|
+
} else rmSync(join(paths.projectRoot, '.rsc', 'ftd-nudge.mjs'), { force: true });
|
|
278
|
+
if (!settings.hooks.PostToolUse.length) delete settings.hooks.PostToolUse;
|
|
279
|
+
|
|
250
280
|
// Danger guard: its own decision, not part of the code-hook block above (#273). `init` promises it
|
|
251
281
|
// to non-technical and mixed users whatever the project is, and a plan that predates the field is
|
|
252
282
|
// read as "yes": a non-technical operations install gets the guard it was missing on its next sync,
|
|
@@ -43,7 +43,7 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
|
|
|
43
43
|
* nothing checks is just drift with a head start.
|
|
44
44
|
*/
|
|
45
45
|
export const PROJECT_OPT_OUTS = [
|
|
46
|
-
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
|
|
46
|
+
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
|
|
47
47
|
];
|
|
48
48
|
|
|
49
49
|
/**
|