@ericrisco/rsc 3.0.7 → 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 +22 -11
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit.js +11 -0
- package/scripts/doctor.js +63 -11
- package/scripts/install-apply.js +221 -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 +92 -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/ftd-nudge.mjs +137 -0
- package/targets/gitmoji-guard.mjs +14 -1
- package/targets/knowledge-sync.mjs +203 -45
- package/targets/opt-outs.js +1 -0
- package/targets/sello.mjs +9 -2
- package/targets/session-memory-adapter.mjs +27 -3
- 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/worktree-reaper.mjs +109 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { applyInstall, pruneSharedBases, removeTargetInstall } from '../install-apply.js';
|
|
6
6
|
import { targetPaths } from '../../targets/index.js';
|
|
7
7
|
import { targetHasAgents } from '../../targets/agents.js';
|
|
@@ -10,6 +10,7 @@ import { readManifest, writeManifest } from './manifest-file.js';
|
|
|
10
10
|
import { encodeGoal, identifyPlan } from './onboarding.js';
|
|
11
11
|
import { createBackup, restoreBackup } from './backups.js';
|
|
12
12
|
import { RETIRED_SKILLS, replaceRetired } from './retired-skills.js';
|
|
13
|
+
import { LAYER_IGNORE, TEMPLATE_SOURCE, targetName, templateAssets } from './tools-skeleton.js';
|
|
13
14
|
|
|
14
15
|
const sameSet = (a = [], b = []) => JSON.stringify([...a].sort()) === JSON.stringify([...b].sort());
|
|
15
16
|
|
|
@@ -157,12 +158,35 @@ export function mergeProfile(existing, plan, previousRecord) {
|
|
|
157
158
|
return `---\n${front.join('\n')}\n---\n${body.startsWith('\n') ? '' : '\n'}${body}`;
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The profile is one person's dials (knowledge-sync never sends it), so it never belongs in a commit
|
|
163
|
+
* either — yet it sat untracked for good, and every session's agent reported it as «cambios sin
|
|
164
|
+
* commitear» (E2E 2026-10-07). Excluded in THIS clone only (`info/exclude`, not `.gitignore`), and
|
|
165
|
+
* never when the team already tracks it: that is their decision.
|
|
166
|
+
*/
|
|
167
|
+
export function excludePersonalProfile(cwd) {
|
|
168
|
+
const git = (args) => { try { return execFileSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return null; } };
|
|
169
|
+
if (git(['rev-parse', '--is-inside-work-tree']) !== 'true') return false;
|
|
170
|
+
const rel = '02-DOCS/wiki/harness/user-profile.md';
|
|
171
|
+
if (git(['ls-files', '--', rel])) return false;
|
|
172
|
+
const value = git(['rev-parse', '--git-path', 'info/exclude']);
|
|
173
|
+
if (!value) return false;
|
|
174
|
+
const file = resolve(cwd, value);
|
|
175
|
+
const pattern = `/${rel}`;
|
|
176
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
177
|
+
const body = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
178
|
+
if (body.split('\n').includes(pattern)) return true;
|
|
179
|
+
appendFileSync(file, `${body && !body.endsWith('\n') ? '\n' : ''}${pattern}\n`);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
160
183
|
export function writeOnboardingDocuments(cwd, plan, planId, previousRecord = null) {
|
|
161
184
|
const dir = join(cwd, '02-DOCS', 'wiki', 'harness');
|
|
162
185
|
mkdirSync(dir, { recursive: true });
|
|
163
186
|
const docs = renderOnboardingDocuments(plan, planId);
|
|
164
187
|
const profilePath = join(dir, 'user-profile.md');
|
|
165
188
|
writeFileSync(profilePath, mergeProfile(existsSync(profilePath) ? readFileSync(profilePath, 'utf8') : '', plan, previousRecord));
|
|
189
|
+
excludePersonalProfile(cwd);
|
|
166
190
|
writeFileSync(join(dir, 'installation-plan.md'), docs.installation);
|
|
167
191
|
const decisionsPath = join(dir, 'decisions.md');
|
|
168
192
|
if (!existsSync(decisionsPath)) writeFileSync(decisionsPath, docs.decisions);
|
|
@@ -343,34 +367,13 @@ export async function applyAcceptedOnboarding({ cwd = process.cwd(), plan, planI
|
|
|
343
367
|
// Y la retro-compatibilidad no es una lista de exentos (P3): el suelo viaja DENTRO del plan, así
|
|
344
368
|
// que un recibo aceptado por una versión anterior no lo trae y queda exento por construcción.
|
|
345
369
|
|
|
346
|
-
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
347
|
-
const TEMPLATE_SOURCE = join(LIB_DIR, '..', '..', 'skills', 'harness', 'assets', '_TEMPLATE');
|
|
348
370
|
const TEMPLATE_FLOOR = '01-TOOLS/_TEMPLATE/';
|
|
349
371
|
|
|
350
372
|
export const HARNESS_FLOOR_MINIMUM = [TEMPLATE_FLOOR, '02-DOCS/wiki/harness/'];
|
|
351
373
|
export const HARNESS_FLOOR_CONSTITUTION = '02-DOCS/wiki/sdd/constitution.md';
|
|
352
374
|
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
// Un error aquí NO se convierte en lista vacía. Comérselo dejaba el suelo permanentemente
|
|
357
|
-
// insatisfacible, con un mensaje que ofrecía una acción que leía ese mismo asset ausente y que por
|
|
358
|
-
// tanto no podía arreglarlo nunca — y sin nombrar jamás la causa real, que es un paquete roto.
|
|
359
|
-
function templateAssets() {
|
|
360
|
-
return readdirSync(TEMPLATE_SOURCE).sort();
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// npm NUNCA empaqueta un fichero llamado `.gitignore`, así que el asset viaja sin punto y se copia
|
|
364
|
-
// con él. Sin esto, desde el paquete publicado se copiaban 4 de 5 ficheros y el que faltaba era el
|
|
365
|
-
// único que evita comitear el `.env` que el README —copiado por el propio instalador— manda crear.
|
|
366
|
-
const DOTTED = { gitignore: '.gitignore' };
|
|
367
|
-
const targetName = (asset) => DOTTED[asset] ?? asset;
|
|
368
|
-
|
|
369
|
-
// La capa entera, no cada proveedor: el flujo documentado es copiar `_TEMPLATE/` a
|
|
370
|
-
// `01-TOOLS/<PROVEEDOR>/`, y depender de que cada copia se lleve su propia protección es depender
|
|
371
|
-
// de que nadie se salte un paso con credenciales de por medio.
|
|
372
|
-
const LAYER_IGNORE = ['# Escrito por rsc: la capa de herramientas guarda credenciales.',
|
|
373
|
-
'*/.env', '*/.env.*', '!*/.env.example', '*/keys/', '*/out/', ''].join('\n');
|
|
375
|
+
// La plantilla, sus nombres con punto y el `.gitignore` de la capa viven en `tools-skeleton.js`:
|
|
376
|
+
// lo que se copia aquí es exactamente lo que `purge` puede recuperar, y sólo si sigue intacto.
|
|
374
377
|
|
|
375
378
|
/**
|
|
376
379
|
* Crea la parte determinista del esqueleto. Copiar ficheros estáticos es un algoritmo, así que por
|
|
@@ -248,7 +248,14 @@ export function buildOnboardingPlan(record, evidence) {
|
|
|
248
248
|
// in an invoicing workspace, and `init` promises it by technical level: non-technical and mixed get
|
|
249
249
|
// it, whatever the project is. The guard also decides at runtime from the profile, so the plan only
|
|
250
250
|
// chooses whether it is present, never whether it bites.
|
|
251
|
-
|
|
251
|
+
//
|
|
252
|
+
// And since 3.0.8 it is present for EVERYONE. A technical user used to get nothing, so a small
|
|
253
|
+
// project with `main` open had no guard at all (E2E 2026-10-07). The runtime still decides the
|
|
254
|
+
// bite: deny for non-technical, `ask` — only on lost work or rewritten history — for technical.
|
|
255
|
+
const dangerGuard = true;
|
|
256
|
+
// The FTD/SDD lane decision belongs to every software project, small ones included: without it
|
|
257
|
+
// the agent never sees the lanes (E2E 2026-10-07: five sessions, zero feature documents).
|
|
258
|
+
const laneGate = isSoftware;
|
|
252
259
|
// `sdd` is installed everywhere now, but its DECISION is about practice, not presence — so where
|
|
253
260
|
// the chain is not practised the explicit `deferred` entry below must be the only one, or the
|
|
254
261
|
// generic "installed → selected" mapping would shadow it and `reassess` would see nothing to watch.
|
|
@@ -269,9 +276,16 @@ export function buildOnboardingPlan(record, evidence) {
|
|
|
269
276
|
// so the chain is always one request away; this decision is what `reassess` watches, and what the
|
|
270
277
|
// floor reads. Collapsing the two would have deleted reassess's reason to exist as a side effect
|
|
271
278
|
// of an installer change, which is not a decision an installer change gets to make.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
279
|
+
// The reason says both halves, because the plan lists `.claude/skills/sdd` under managed paths
|
|
280
|
+
// and a bare "deferred" next to it read as a contradiction (E2E 2026-10-07). Only the wording
|
|
281
|
+
// changes: the decision stays the one deferred `workflow/sdd` entry that reassess watches.
|
|
282
|
+
const whyDeferred = isSoftware
|
|
283
|
+
? 'the software scope is small, so specification overhead is not justified yet.'
|
|
284
|
+
: 'SDD applies to substantial software work, which is not the declared project purpose.';
|
|
285
|
+
const sddReason = skills.includes('sdd')
|
|
286
|
+
? `The sdd skill is installed, so the chain is one request away; the practice is deferred: ${whyDeferred}`
|
|
287
|
+
: whyDeferred.charAt(0).toUpperCase() + whyDeferred.slice(1);
|
|
288
|
+
if (!practisesSdd) decisions.push(deferred('sdd', 'workflow', sddReason, sddTriggers, softwareTriggers));
|
|
275
289
|
if (baseAgents) {
|
|
276
290
|
for (const id of agents) decisions.push(selected(id, 'agent', 'The accepted substantial software workflow requires this implementation or review role.'));
|
|
277
291
|
} else {
|
|
@@ -296,6 +310,7 @@ export function buildOnboardingPlan(record, evidence) {
|
|
|
296
310
|
codeHooks: practisesSdd,
|
|
297
311
|
gitmojiGuard,
|
|
298
312
|
dangerGuard,
|
|
313
|
+
laneGate,
|
|
299
314
|
memory: true,
|
|
300
315
|
context7: false,
|
|
301
316
|
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
// The `01-TOOLS/` scaffolding rsc writes on onboarding, in ONE place: what `ensureHarnessSkeleton`
|
|
7
|
+
// copies is exactly what `purge` may take back, and it may take back only a file still identical to
|
|
8
|
+
// what was copied. `01-TOOLS/` is the layer the user edits when adding tools, so anything that
|
|
9
|
+
// differs from the template is theirs and stays.
|
|
10
|
+
|
|
11
|
+
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
export const TEMPLATE_SOURCE = join(LIB_DIR, '..', '..', 'skills', 'harness', 'assets', '_TEMPLATE');
|
|
13
|
+
|
|
14
|
+
// Los ficheros que la plantilla trae hoy. Se lee del asset en vez de codificar una lista, para que
|
|
15
|
+
// añadir un fichero a la plantilla no deje el suelo comprobando de menos en silencio.
|
|
16
|
+
//
|
|
17
|
+
// Un error aquí NO se convierte en lista vacía. Comérselo dejaba el suelo permanentemente
|
|
18
|
+
// insatisfacible, con un mensaje que ofrecía una acción que leía ese mismo asset ausente y que por
|
|
19
|
+
// tanto no podía arreglarlo nunca — y sin nombrar jamás la causa real, que es un paquete roto.
|
|
20
|
+
export function templateAssets() {
|
|
21
|
+
return readdirSync(TEMPLATE_SOURCE).sort();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// npm NUNCA empaqueta un fichero llamado `.gitignore`, así que el asset viaja sin punto y se copia
|
|
25
|
+
// con él. Sin esto, desde el paquete publicado se copiaban 4 de 5 ficheros y el que faltaba era el
|
|
26
|
+
// único que evita comitear el `.env` que el README —copiado por el propio instalador— manda crear.
|
|
27
|
+
const DOTTED = { gitignore: '.gitignore' };
|
|
28
|
+
export const targetName = (asset) => DOTTED[asset] ?? asset;
|
|
29
|
+
|
|
30
|
+
// La capa entera, no cada proveedor: el flujo documentado es copiar `_TEMPLATE/` a
|
|
31
|
+
// `01-TOOLS/<PROVEEDOR>/`, y depender de que cada copia se lleve su propia protección es depender
|
|
32
|
+
// de que nadie se salte un paso con credenciales de por medio.
|
|
33
|
+
export const LAYER_IGNORE = ['# Escrito por rsc: la capa de herramientas guarda credenciales.',
|
|
34
|
+
'*/.env', '*/.env.*', '!*/.env.example', '*/keys/', '*/out/', ''].join('\n');
|
|
35
|
+
|
|
36
|
+
const sameBytes = (a, b) => {
|
|
37
|
+
try { return readFileSync(a).equals(Buffer.isBuffer(b) ? b : readFileSync(b)); } catch { return false; }
|
|
38
|
+
};
|
|
39
|
+
const isPlainFile = (p) => { try { return lstatSync(p).isFile(); } catch { return false; } };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Take back the scaffolding rsc wrote, file by file, only where it is still byte-identical to what
|
|
43
|
+
* rsc wrote. Returns `{ removed, kept }` as paths relative to `cwd`, `kept` with a reason. Empty
|
|
44
|
+
* directories left behind are removed by the caller's directory sweep.
|
|
45
|
+
*/
|
|
46
|
+
// Committed means the team has it: deleting it leaves the tree dirty and takes it from everybody on
|
|
47
|
+
// the next push (field test 3.0.8: purge deleted a 01-TOOLS/.gitignore that was already in history).
|
|
48
|
+
function tracked(cwd, rel) {
|
|
49
|
+
try { return Boolean(execFileSync('git', ['ls-files', '--', rel], { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function removeUnmodifiedSkeleton(cwd, { dryRun = false } = {}) {
|
|
53
|
+
const removed = [];
|
|
54
|
+
const kept = [];
|
|
55
|
+
const committed = (rel) => tracked(cwd, rel) && (kept.push({ path: rel, reason: 'committed to git — the team has it; delete it in a commit if you mean to' }), true);
|
|
56
|
+
const templateDir = join(cwd, '01-TOOLS', '_TEMPLATE');
|
|
57
|
+
if (existsSync(templateDir) && !lstatSync(templateDir).isSymbolicLink()) {
|
|
58
|
+
for (const asset of templateAssets()) {
|
|
59
|
+
const rel = `01-TOOLS/_TEMPLATE/${targetName(asset)}`;
|
|
60
|
+
const file = join(cwd, rel);
|
|
61
|
+
if (!existsSync(file) || committed(rel)) continue;
|
|
62
|
+
if (isPlainFile(file) && sameBytes(file, join(TEMPLATE_SOURCE, asset))) {
|
|
63
|
+
if (!dryRun) rmSync(file, { force: true });
|
|
64
|
+
removed.push(rel);
|
|
65
|
+
} else kept.push({ path: rel, reason: 'changed since rsc wrote it — yours now' });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const layer = join(cwd, '01-TOOLS', '.gitignore');
|
|
69
|
+
if (existsSync(layer) && !committed('01-TOOLS/.gitignore')) {
|
|
70
|
+
if (isPlainFile(layer) && sameBytes(layer, Buffer.from(LAYER_IGNORE))) {
|
|
71
|
+
if (!dryRun) rmSync(layer, { force: true });
|
|
72
|
+
removed.push('01-TOOLS/.gitignore');
|
|
73
|
+
} else kept.push({ path: '01-TOOLS/.gitignore', reason: 'changed since rsc wrote it — yours now' });
|
|
74
|
+
}
|
|
75
|
+
return { removed, kept };
|
|
76
|
+
}
|
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 } = {}) {
|
|
@@ -515,7 +580,7 @@ function printContextBudget(b) {
|
|
|
515
580
|
if (b.notApplicable.length) {
|
|
516
581
|
say(` ${b.note}`);
|
|
517
582
|
} else {
|
|
518
|
-
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)`);
|
|
519
584
|
say(` Per user turn : ${kb(b.perTurnBytes)}`);
|
|
520
585
|
}
|
|
521
586
|
say(` Skill descriptions: ${kb(b.descriptionsBytes)} (${b.installedSkills} installed, always in context)`);
|
|
@@ -1006,7 +1071,15 @@ async function main() {
|
|
|
1006
1071
|
// defeated by an argument the sweep itself told the agent to type.
|
|
1007
1072
|
const confirmed = argv.includes('--confirm');
|
|
1008
1073
|
const targets = one ? [one] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
|
|
1009
|
-
|
|
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; }
|
|
1010
1083
|
for (const t of targets) {
|
|
1011
1084
|
const out = W.reapWorktree(root, t, { confirmed });
|
|
1012
1085
|
if (out.removed) {
|
|
@@ -1018,6 +1091,7 @@ async function main() {
|
|
|
1018
1091
|
process.exitCode = 1;
|
|
1019
1092
|
}
|
|
1020
1093
|
}
|
|
1094
|
+
reapBranches();
|
|
1021
1095
|
return;
|
|
1022
1096
|
}
|
|
1023
1097
|
|
|
@@ -1272,7 +1346,14 @@ async function main() {
|
|
|
1272
1346
|
const dry = argv.includes('--dry-run');
|
|
1273
1347
|
// `uninstall --all` is an alias for a full purge.
|
|
1274
1348
|
if (argv.includes('--all')) return void (await runPurge(dry, argv.includes('--with-docs')));
|
|
1275
|
-
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);
|
|
1276
1357
|
// A retired skill an old install still holds is not unknown: the state file proves it is ours.
|
|
1277
1358
|
const held = new Set(listInstalled({ target }));
|
|
1278
1359
|
selected.skills.push(...selected.unknown.filter((id) => Object.hasOwn(RETIRED_SKILLS, id) && held.has(id)));
|
|
@@ -1306,9 +1387,7 @@ async function main() {
|
|
|
1306
1387
|
return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
|
|
1307
1388
|
default:
|
|
1308
1389
|
say(`rsc: unknown command '${cmd}'.`);
|
|
1309
|
-
say(
|
|
1310
|
-
say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
|
|
1311
|
-
say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
|
|
1390
|
+
say(GENERAL_HELP);
|
|
1312
1391
|
process.exitCode = 1;
|
|
1313
1392
|
}
|
|
1314
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;
|