@ericrisco/rsc 1.1.6 → 1.2.1
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 +62 -4
- package/manifest.json +834 -2
- package/package.json +3 -2
- package/schema/ecc-originality.json +16 -0
- package/schema/ecc-originality.u64 +0 -0
- package/scripts/build-manifest.js +43 -3
- package/scripts/doctor.js +47 -1
- package/scripts/install-apply.js +148 -31
- package/scripts/lib/capabilities.js +21 -0
- package/scripts/lib/manifest-file.js +4 -1
- package/scripts/originality-check.js +137 -0
- package/scripts/rsc.js +168 -14
- package/skills/ship/SKILL.md +15 -8
- package/skills/worktrees/SKILL.md +30 -22
- package/targets/agent-catalog.js +220 -0
- package/targets/agent-sources.json +48 -0
- package/targets/agents.js +61 -13
- package/targets/claude.js +3 -0
- package/targets/commands.js +212 -0
- package/targets/memory.js +276 -0
- package/targets/opencode-memory-plugin.js +65 -0
- package/targets/session-memory-adapter.mjs +117 -0
- package/targets/session-memory-core.mjs +477 -0
- package/targets/session-memory.mjs +27 -0
- package/targets/session-start.mjs +31 -0
- package/targets/worktree-reaper.mjs +458 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync, readdirSync, readFileSync, statSync, writeFileSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { stackAgents } from '../targets/agent-catalog.js';
|
|
9
|
+
import { allAgentNames } from '../targets/agents.js';
|
|
10
|
+
import { resolveCommands } from '../targets/commands.js';
|
|
11
|
+
|
|
12
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
|
+
const META_PATH = join(ROOT, 'schema', 'ecc-originality.json');
|
|
14
|
+
const DATA_PATH = join(ROOT, 'schema', 'ecc-originality.u64');
|
|
15
|
+
const NGRAM_SIZE = 8;
|
|
16
|
+
const EXEMPTION_CATEGORIES = new Set(['name', 'error', 'tool-command', 'language-term']);
|
|
17
|
+
|
|
18
|
+
export function normalizeTokens(text) {
|
|
19
|
+
return String(text || '').normalize('NFKC').toLowerCase().match(/[\p{L}\p{N}_+.#/-]+/gu) || [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fingerprint(tokens, offset, size = NGRAM_SIZE) {
|
|
23
|
+
return createHash('sha256').update(tokens.slice(offset, offset + size).join(' ')).digest('hex').slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fingerprintsOf(text, size = NGRAM_SIZE) {
|
|
27
|
+
const tokens = normalizeTokens(text);
|
|
28
|
+
const out = [];
|
|
29
|
+
for (let offset = 0; offset <= tokens.length - size; offset += 1) {
|
|
30
|
+
out.push({ fingerprint: fingerprint(tokens, offset, size), offset });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function walk(directory) {
|
|
36
|
+
if (!existsSync(directory)) throw new Error(`originality: corpus directory missing: ${directory}`);
|
|
37
|
+
return readdirSync(directory).sort().flatMap((name) => {
|
|
38
|
+
const path = join(directory, name);
|
|
39
|
+
return statSync(path).isDirectory() ? walk(path) : [path];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function catalogDocuments() {
|
|
44
|
+
const skillIds = readdirSync(join(ROOT, 'skills')).filter((id) => {
|
|
45
|
+
try { return statSync(join(ROOT, 'skills', id, 'SKILL.md')).isFile(); } catch { return false; }
|
|
46
|
+
});
|
|
47
|
+
const commands = resolveCommands({
|
|
48
|
+
target: 'cursor', skills: skillIds, agents: allAgentNames(), memoryMode: 'full',
|
|
49
|
+
});
|
|
50
|
+
return [
|
|
51
|
+
...stackAgents().map((agent) => ({ id: `agent:${agent.name}`, text: `${agent.desc}\n${agent.body}` })),
|
|
52
|
+
...commands.map((command) => ({ id: `command:${command.name}`, text: `${command.description}\n${command.body}` })),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function fingerprintCorpus(metaPath = META_PATH, dataPath = DATA_PATH) {
|
|
57
|
+
const metadata = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
58
|
+
if (metadata.ngramSize !== NGRAM_SIZE) throw new Error(`originality: unsupported n-gram size ${metadata.ngramSize}`);
|
|
59
|
+
for (const exemption of metadata.exemptions || []) {
|
|
60
|
+
if (!EXEMPTION_CATEGORIES.has(exemption.category) || !/^[0-9a-f]{16}$/.test(exemption.fingerprint || '')) {
|
|
61
|
+
throw new Error('originality: every exemption needs a valid fingerprint and closed category');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const bytes = readFileSync(dataPath);
|
|
65
|
+
if (bytes.length % 8 !== 0 || bytes.length / 8 !== metadata.fingerprintCount) {
|
|
66
|
+
throw new Error('originality: fingerprint corpus length does not match its receipt');
|
|
67
|
+
}
|
|
68
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
69
|
+
if (digest !== metadata.dataSha256) throw new Error('originality: fingerprint corpus failed its integrity receipt');
|
|
70
|
+
const fingerprints = new Set();
|
|
71
|
+
for (let offset = 0; offset < bytes.length; offset += 8) fingerprints.add(bytes.subarray(offset, offset + 8).toString('hex'));
|
|
72
|
+
return { metadata, fingerprints };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function checkCatalogOriginality({ corpus = fingerprintCorpus(), documents = catalogDocuments() } = {}) {
|
|
76
|
+
const exempt = new Set((corpus.metadata.exemptions || []).map((entry) => entry.fingerprint));
|
|
77
|
+
const matches = [];
|
|
78
|
+
for (const document of documents) {
|
|
79
|
+
for (const candidate of fingerprintsOf(document.text, corpus.metadata.ngramSize)) {
|
|
80
|
+
if (corpus.fingerprints.has(candidate.fingerprint) && !exempt.has(candidate.fingerprint)) {
|
|
81
|
+
matches.push({ document: document.id, tokenOffset: candidate.offset, fingerprint: candidate.fingerprint });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
ngramSize: corpus.metadata.ngramSize,
|
|
87
|
+
corpusRevision: corpus.metadata.revision,
|
|
88
|
+
documents: documents.length,
|
|
89
|
+
exemptions: exempt.size,
|
|
90
|
+
matches,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function refresh(corpusRoot) {
|
|
95
|
+
const files = ['agents', 'commands'].flatMap((directory) => walk(join(corpusRoot, directory)));
|
|
96
|
+
const hashes = new Set();
|
|
97
|
+
for (const path of files) {
|
|
98
|
+
for (const candidate of fingerprintsOf(readFileSync(path, 'utf8'))) hashes.add(candidate.fingerprint);
|
|
99
|
+
}
|
|
100
|
+
const sorted = [...hashes].sort();
|
|
101
|
+
const bytes = Buffer.alloc(sorted.length * 8);
|
|
102
|
+
sorted.forEach((hash, index) => Buffer.from(hash, 'hex').copy(bytes, index * 8));
|
|
103
|
+
writeFileSync(DATA_PATH, bytes);
|
|
104
|
+
const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: corpusRoot, encoding: 'utf8' }).trim();
|
|
105
|
+
const metadata = {
|
|
106
|
+
schemaVersion: 1,
|
|
107
|
+
source: 'https://github.com/affaan-m/everything-claude-code',
|
|
108
|
+
revision,
|
|
109
|
+
generatedAt: new Date().toISOString().slice(0, 10),
|
|
110
|
+
included: ['agents/**', 'commands/**'],
|
|
111
|
+
ngramSize: NGRAM_SIZE,
|
|
112
|
+
normalization: 'NFKC lowercase; Unicode letters/numbers plus _+.#/-; whitespace joined',
|
|
113
|
+
fingerprintFormat: 'first 64 bits of SHA-256, sorted big-endian; collisions fail closed',
|
|
114
|
+
fingerprintCount: sorted.length,
|
|
115
|
+
dataSha256: createHash('sha256').update(bytes).digest('hex'),
|
|
116
|
+
exemptions: [],
|
|
117
|
+
};
|
|
118
|
+
writeFileSync(META_PATH, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
119
|
+
return metadata;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function main() {
|
|
123
|
+
if (process.argv[2] === '--refresh') {
|
|
124
|
+
if (!process.argv[3]) throw new Error('Usage: node scripts/originality-check.js --refresh <ecc-checkout>');
|
|
125
|
+
const metadata = refresh(process.argv[3]);
|
|
126
|
+
console.log(`originality corpus refreshed (${metadata.fingerprintCount} fingerprints at ${metadata.revision})`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const result = checkCatalogOriginality();
|
|
130
|
+
if (result.matches.length) {
|
|
131
|
+
for (const match of result.matches) console.error(`${match.document}: shared ${result.ngramSize}-token fingerprint ${match.fingerprint} at token ${match.tokenOffset}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
console.log(`originality OK (${result.documents} documents vs ${fingerprintCorpus().metadata.fingerprintCount} corpus fingerprints; ${result.exemptions} exemptions)`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (import.meta.url === `file://${process.argv[1]}`) main();
|
package/scripts/rsc.js
CHANGED
|
@@ -4,7 +4,8 @@ import { detectTarget, resolveTargets, TARGETS } from '../targets/index.js';
|
|
|
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, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
|
|
7
|
+
import { applyInstall, listInstalled, listInstalledAgents, listInstalledCommands, uninstall, syncInstalled, purge, collisions } from './install-apply.js';
|
|
8
|
+
import { stackAgentNames } from '../targets/agents.js';
|
|
8
9
|
import { doctor } from './doctor.js';
|
|
9
10
|
import { say, select, pickFrom, banner, confirm, isInteractive } from './lib/ui.js';
|
|
10
11
|
import { refreshRegistry, registryStatus } from './lib/registry.js';
|
|
@@ -20,6 +21,54 @@ const cmd = argv[0];
|
|
|
20
21
|
|
|
21
22
|
const targetLabel = (id) => TARGETS.find((t) => t.id === id)?.label || id;
|
|
22
23
|
|
|
24
|
+
function requestedIds(start = 1) {
|
|
25
|
+
const valueFlags = new Set(['--target', '--profile', '--without']);
|
|
26
|
+
const ids = [];
|
|
27
|
+
for (let i = start; i < argv.length; i++) {
|
|
28
|
+
if (valueFlags.has(argv[i])) { i++; continue; }
|
|
29
|
+
if (argv[i].startsWith('--')) continue;
|
|
30
|
+
ids.push(argv[i]);
|
|
31
|
+
}
|
|
32
|
+
return ids;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function editDistance(a, b) {
|
|
36
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
37
|
+
for (let i = 1; i <= a.length; i++) {
|
|
38
|
+
let diagonal = prev[0];
|
|
39
|
+
prev[0] = i;
|
|
40
|
+
for (let j = 1; j <= b.length; j++) {
|
|
41
|
+
const above = prev[j];
|
|
42
|
+
prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
43
|
+
diagonal = above;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return prev[b.length];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function classifyRequested(ids) {
|
|
50
|
+
const skills = new Set(loadManifest().skills.map((skill) => skill.id));
|
|
51
|
+
const agents = new Set(stackAgentNames());
|
|
52
|
+
const out = { skills: [], agents: [], unknown: [] };
|
|
53
|
+
for (const id of ids) {
|
|
54
|
+
if (skills.has(id)) out.skills.push(id);
|
|
55
|
+
else if (agents.has(id)) out.agents.push(id);
|
|
56
|
+
else out.unknown.push(id);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function reportUnknown(ids) {
|
|
62
|
+
if (!ids.length) return false;
|
|
63
|
+
const candidates = [...loadManifest().skills.map((skill) => skill.id), ...stackAgentNames()];
|
|
64
|
+
for (const id of ids) {
|
|
65
|
+
const close = [...candidates].sort((a, b) => editDistance(id, a) - editDistance(id, b) || a.localeCompare(b)).slice(0, 3);
|
|
66
|
+
console.error(`rsc: unknown skill or agent '${id}'. Closest: ${close.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
23
72
|
// Commands that act ON one assistant. Only these stop when two are installed and no
|
|
24
73
|
// flag says which: `purge` sweeps every target by design, and the catalog/consult
|
|
25
74
|
// side barely touches one, so blocking them would be a regression, not a safeguard.
|
|
@@ -113,6 +162,8 @@ function printNextSteps(targets, ids) {
|
|
|
113
162
|
say('\n Add something by hand anytime: npx @ericrisco/rsc add <skill>');
|
|
114
163
|
say(' Browse the catalog / get picks: npx @ericrisco/rsc consult "whatever you need"');
|
|
115
164
|
say('────────────────────────────────────────────────────────');
|
|
165
|
+
if (targets.includes('codex')) say(' Codex: review and trust the project lifecycle hook once with `/hooks`; until then memory is reported as requiring trust.');
|
|
166
|
+
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.');
|
|
116
167
|
printAgentHandoff();
|
|
117
168
|
}
|
|
118
169
|
|
|
@@ -268,15 +319,12 @@ async function main() {
|
|
|
268
319
|
case undefined:
|
|
269
320
|
return wizard(f ? targets : null);
|
|
270
321
|
case 'add': {
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
requested.push(argv[i]);
|
|
276
|
-
}
|
|
277
|
-
const ids = withDefaultSkillFloor(requested);
|
|
322
|
+
const requested = requestedIds();
|
|
323
|
+
const selected = classifyRequested(requested);
|
|
324
|
+
if (reportUnknown(selected.unknown)) return;
|
|
325
|
+
const ids = withDefaultSkillFloor(selected.skills);
|
|
278
326
|
if (!argv.includes('--force') && !(await guardCollisions(targets, ids))) return;
|
|
279
|
-
for (const t of targets) await applyInstall({ skillIds: ids, target: t });
|
|
327
|
+
for (const t of targets) await applyInstall({ skillIds: ids, agentIds: selected.agents, target: t });
|
|
280
328
|
say(`✅ Installed for ${targets.join(', ')}: ${requested.join(', ')}`);
|
|
281
329
|
return void say(' ↻ Reload/restart your assistant so the new skill activates.');
|
|
282
330
|
}
|
|
@@ -328,14 +376,66 @@ async function main() {
|
|
|
328
376
|
else say('\n(no harness wiki here — printed above only; run `harness` to keep a written record)');
|
|
329
377
|
return;
|
|
330
378
|
}
|
|
331
|
-
case 'list':
|
|
332
|
-
|
|
379
|
+
case 'list': {
|
|
380
|
+
const skills = listInstalled({ target }).map((id) => `skill\t${id}`);
|
|
381
|
+
const agents = listInstalledAgents({ target }).map((agent) => `agent\t${agent.id}\t${agent.source}${agent.skills.length ? `:${agent.skills.join(',')}` : ''}`);
|
|
382
|
+
const commands = listInstalledCommands({ target }).map((command) => `command\t${command.id}\t${command.path}`);
|
|
383
|
+
return void say([...skills, ...agents, ...commands].join('\n') || '(nothing installed)');
|
|
384
|
+
}
|
|
333
385
|
case 'doctor': {
|
|
334
386
|
const report = doctor({ target });
|
|
335
387
|
if (argv.includes('--json')) return void say(JSON.stringify(report, null, 2));
|
|
336
388
|
printContextBudget(report.contextBudget);
|
|
337
389
|
return void say(JSON.stringify({ ...report, contextBudget: undefined }, null, 2));
|
|
338
390
|
}
|
|
391
|
+
case 'memory': {
|
|
392
|
+
const sub = argv[1] || 'status';
|
|
393
|
+
const root = process.cwd();
|
|
394
|
+
if (sub === 'on' || sub === 'off') {
|
|
395
|
+
const { readManifest, writeManifest } = await import('./lib/manifest-file.js');
|
|
396
|
+
const current = readManifest(root) || { version: 1, targets: targets || [], skills: [], agents: [], ownSkills: [], optOuts: [] };
|
|
397
|
+
writeManifest(root, { ...current, memory: sub === 'off' ? false : { enabled: true } });
|
|
398
|
+
for (const t of targets) await syncInstalled({ target: t, cwd: root });
|
|
399
|
+
say(`rsc memory ${sub}: ${sub === 'on' ? 'enabled' : 'disabled'} for this project (${targets.join(', ') || 'declaration only'}).`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const M = await import('../targets/session-memory-core.mjs');
|
|
403
|
+
if (sub === 'status') {
|
|
404
|
+
const reports = targets.map((t) => doctor({ target: t, cwd: root }).memory);
|
|
405
|
+
return void say(JSON.stringify(reports.length === 1 ? reports[0] : reports, null, 2));
|
|
406
|
+
}
|
|
407
|
+
if (sub === 'resume') {
|
|
408
|
+
const result = M.resume({ cwd: root });
|
|
409
|
+
return void say(argv.includes('--json') ? JSON.stringify(result, null, 2) : (result.context || '(no local continuation for this branch/worktree)'));
|
|
410
|
+
}
|
|
411
|
+
if (sub === 'save') {
|
|
412
|
+
const result = M.capture({
|
|
413
|
+
cwd: root,
|
|
414
|
+
sessionId: typeof flag('session') === 'string' ? flag('session') : `manual-${Date.now()}`,
|
|
415
|
+
target: target || 'manual',
|
|
416
|
+
event: 'save',
|
|
417
|
+
force: true,
|
|
418
|
+
});
|
|
419
|
+
return void say(JSON.stringify(result, null, 2));
|
|
420
|
+
}
|
|
421
|
+
if (sub === 'learn') {
|
|
422
|
+
const value = (name) => { const v = flag(name); return typeof v === 'string' && !v.startsWith('--') ? v : undefined; };
|
|
423
|
+
const result = M.learn({
|
|
424
|
+
cwd: root,
|
|
425
|
+
text: value('text'),
|
|
426
|
+
evidence: value('evidence'),
|
|
427
|
+
scope: value('scope') || 'project',
|
|
428
|
+
confidence: value('confidence'),
|
|
429
|
+
approved: argv.includes('--approve'),
|
|
430
|
+
});
|
|
431
|
+
if (!result.saved) process.exitCode = 1;
|
|
432
|
+
return void say(JSON.stringify(result, null, 2));
|
|
433
|
+
}
|
|
434
|
+
if (sub === 'metrics') return void say(JSON.stringify(M.metricsSummary({ cwd: root }), null, 2));
|
|
435
|
+
say('Use: npx @ericrisco/rsc memory on|off|status|save [--session id]|resume [--json]|learn --text "…" --evidence "…" --scope project|global --confidence 0..1 --approve|metrics');
|
|
436
|
+
process.exitCode = 1;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
339
439
|
case 'sync': {
|
|
340
440
|
const dry = argv.includes('--dry-run');
|
|
341
441
|
for (const t of targets) {
|
|
@@ -387,6 +487,52 @@ async function main() {
|
|
|
387
487
|
say('Use: npx @ericrisco/rsc registry refresh | registry status');
|
|
388
488
|
return;
|
|
389
489
|
}
|
|
490
|
+
case 'worktrees': {
|
|
491
|
+
// The deterministic half of "the cleanup is the default". `ship` and the `worktrees` skill call
|
|
492
|
+
// this instead of judging by eye, which is what turns a rule that was only ever written down
|
|
493
|
+
// into one that actually runs.
|
|
494
|
+
const W = await import('../targets/worktree-reaper.mjs');
|
|
495
|
+
// From inside a worktree, home is the main checkout — you cannot remove the floor you stand on.
|
|
496
|
+
const root = W.resolveMainRoot(process.cwd());
|
|
497
|
+
const sub = argv[1];
|
|
498
|
+
|
|
499
|
+
if (!W.isCleanupEnabled(root)) {
|
|
500
|
+
say(`worktree cleanup is off for this project (.rsc/${W.OPT_OUT}). Delete that file to turn it back on.`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const candidates = W.classifyWorktrees(root).filter((c) => c.verdict !== 'skip');
|
|
505
|
+
|
|
506
|
+
if (sub === 'reap') {
|
|
507
|
+
const one = argv[2] && !argv[2].startsWith('--') ? argv[2] : undefined;
|
|
508
|
+
// Naming a path SELECTS it; it does not accept the risk of removing it. Conflating the two
|
|
509
|
+
// meant the strongest refusal in the module — "holds files that are in no commit" — was
|
|
510
|
+
// defeated by an argument the sweep itself told the agent to type.
|
|
511
|
+
const confirmed = argv.includes('--confirm');
|
|
512
|
+
const targets = one ? [one] : candidates.filter((c) => c.verdict === 'safe').map((c) => c.path);
|
|
513
|
+
if (!targets.length) { say('nothing to clean up.'); return; }
|
|
514
|
+
for (const t of targets) {
|
|
515
|
+
const out = W.reapWorktree(root, t, { confirmed });
|
|
516
|
+
if (out.removed) {
|
|
517
|
+
say(`✅ removed ${out.path}`);
|
|
518
|
+
if (out.branchKept) say(` branch ${out.branch} kept — git will not delete it safely, and while it exists the commits are recoverable.`);
|
|
519
|
+
} else {
|
|
520
|
+
say(`⏸ kept ${t} — ${out.reason}`);
|
|
521
|
+
if (one && !confirmed) say(' If that is acceptable and you want it gone anyway: add --confirm');
|
|
522
|
+
process.exitCode = 1;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!candidates.length) { say('No worktrees to clean up.'); return; }
|
|
529
|
+
say(`${candidates.length} worktree(s) whose work has landed:`);
|
|
530
|
+
for (const c of candidates) say(W.describe(c));
|
|
531
|
+
say('');
|
|
532
|
+
say('Remove the safe ones: npx @ericrisco/rsc worktrees reap');
|
|
533
|
+
say('Remove one by name: npx @ericrisco/rsc worktrees reap <path> [--confirm]');
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
390
536
|
case 'capabilities': {
|
|
391
537
|
// "What do I already have that solves this?" — the deterministic step the
|
|
392
538
|
// automation-gap rule requires BEFORE anyone proposes creating a skill or an
|
|
@@ -446,6 +592,13 @@ async function main() {
|
|
|
446
592
|
} else {
|
|
447
593
|
say(`# ${caps.target} has no file-based agents — the agent option does not apply here`);
|
|
448
594
|
}
|
|
595
|
+
if (caps.commandsSupported) {
|
|
596
|
+
for (const command of caps.commands) say(`command\t${command.id}\tproject\t${command.path}`);
|
|
597
|
+
if (!caps.commands.length) say('# no managed command files found (this target supports them)');
|
|
598
|
+
} else {
|
|
599
|
+
say(`# ${caps.target} has no supported project command surface`);
|
|
600
|
+
}
|
|
601
|
+
say(`memory\t${caps.memory.mode}\t${caps.memory.status}\t${caps.memory.reason}`);
|
|
449
602
|
}
|
|
450
603
|
if (!full) say('# catalog shown as ids; add --full for descriptions, or use `catalog --available` to match by meaning');
|
|
451
604
|
return;
|
|
@@ -623,8 +776,9 @@ async function main() {
|
|
|
623
776
|
const dry = argv.includes('--dry-run');
|
|
624
777
|
// `uninstall --all` is an alias for a full purge.
|
|
625
778
|
if (argv.includes('--all')) return void (await runPurge(dry, argv.includes('--with-docs')));
|
|
626
|
-
const
|
|
627
|
-
|
|
779
|
+
const selected = classifyRequested(requestedIds());
|
|
780
|
+
if (reportUnknown(selected.unknown)) return;
|
|
781
|
+
const removed = await uninstall({ skillIds: selected.skills, agentIds: selected.agents, target, dryRun: dry });
|
|
628
782
|
return void say((dry ? 'Would remove:\n' : 'Removed:\n') + (removed.join('\n') || '(nothing)'));
|
|
629
783
|
}
|
|
630
784
|
case 'repair': {
|
|
@@ -652,7 +806,7 @@ async function main() {
|
|
|
652
806
|
return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
|
|
653
807
|
default:
|
|
654
808
|
say(`rsc: unknown command '${cmd}'.`);
|
|
655
|
-
say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | repair | uninstall <id> | purge');
|
|
809
|
+
say('Use: npx @ericrisco/rsc | 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');
|
|
656
810
|
say('Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)');
|
|
657
811
|
say(' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.');
|
|
658
812
|
process.exitCode = 1;
|
package/skills/ship/SKILL.md
CHANGED
|
@@ -116,12 +116,17 @@ Stacked PR / feature-track support still fits inside the three landing options:
|
|
|
116
116
|
git switch main && git pull --ff-only
|
|
117
117
|
git merge --no-ff feature/<slug> -m "feat: <what shipped> (<spec-slug>)" # no AI trailer
|
|
118
118
|
git push origin main
|
|
119
|
-
|
|
119
|
+
npx @ericrisco/rsc worktrees reap <path-of-this-feature-worktree> # by name: this one, not all of them
|
|
120
|
+
git branch -d feature/<slug> 2>/dev/null || true # no worktree involved? then the branch alone
|
|
120
121
|
git push origin --delete feature/<slug> 2>/dev/null || true
|
|
121
122
|
```
|
|
122
123
|
|
|
123
124
|
Use `--no-ff` so the feature is one legible merge commit tied to the spec. Confirm the trunk still builds after the merge if the repo has a local gate (defer the actual run to `verify`).
|
|
124
125
|
|
|
126
|
+
**Name the path.** Bare `worktrees reap` retires *every* worktree that currently qualifies, which is not what landing one branch means — with `parallel` running two streams, it is how shipping A deletes B. Pass the path of the worktree this branch lived in; capture it before you switch, since you are usually standing in it (`WT=$(pwd)`).
|
|
127
|
+
|
|
128
|
+
Naming a path selects it, it does not accept the risk of removing it: a worktree holding anything unsaved is still refused, with the reason. Add `--confirm` only after the user has seen that reason and said yes. Run it from anywhere — it finds the main checkout itself.
|
|
129
|
+
|
|
125
130
|
### Option 2 — pull request
|
|
126
131
|
|
|
127
132
|
Write the **commit(s)** clean, push, then open the PR with `gh`. The PR body links the spec/plan and lists what shipped — **and carries no AI attribution.**
|
|
@@ -158,6 +163,8 @@ Implements `02-DOCS/wiki/sdd/specs/<slug>.md`. <the user-facing reason>
|
|
|
158
163
|
|
|
159
164
|
Then either let the gate run (team/CI) or self-merge once green: `gh pr merge --squash --delete-branch` (or `--merge` to preserve the history). Squash when the branch history is noisy; preserve when each commit is meaningful.
|
|
160
165
|
|
|
166
|
+
Once it is merged, pull the trunk and reap the one you landed — `git switch main && git pull --ff-only && npx @ericrisco/rsc worktrees reap "$WT"`. A squashed pull request is exactly the case the reaper judges by content rather than by commit identity, so it is recognised as landed; the local branch is kept, because git will not delete a squashed branch safely and while it exists the work is recoverable.
|
|
167
|
+
|
|
161
168
|
For stacked PRs, create each PR against the previous branch or against a feature-track branch, with bodies that name their dependency:
|
|
162
169
|
|
|
163
170
|
```markdown
|
|
@@ -172,13 +179,13 @@ Never stack to hide review risk. Stack because each slice is independently revie
|
|
|
172
179
|
- **Park:** leave the branch, push it so it's not lost (`git push -u origin feature/<slug>`), and log *why it's parked* to `02-DOCS/wiki/sdd/decisions.md`. Do not merge.
|
|
173
180
|
- **Discard:** deletion is **destructive and unrecoverable** once the branch is gone from both sides, so it takes an explicit confirmation that quotes the branch name (the literal `yes, delete feature/<slug>`) before `git branch -D`. Anything ambiguous means keep it. Log the discard and the reason so the dead-end is remembered, not re-attempted.
|
|
174
181
|
|
|
175
|
-
**
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
**Park and discard do NOT reap.** The cleanup default acts only on work that is already in the trunk;
|
|
183
|
+
a parked branch is the opposite of that, and `worktrees reap` refuses it by design. Leave the worktree
|
|
184
|
+
where it is — the next `ship` that lands the branch will retire it. Discarding a worktree along with
|
|
185
|
+
unmerged work stays what it always was: explicit, confirmed with the quoted branch name, and logged.
|
|
186
|
+
|
|
187
|
+
If a native `EnterWorktree`-style tool created the workspace, exit through that tool rather than the
|
|
188
|
+
reaper — it owns its own lifecycle and its tracking has to stay consistent.
|
|
182
189
|
|
|
183
190
|
## Commit message discipline
|
|
184
191
|
|
|
@@ -109,39 +109,47 @@ git switch -c feat/<slug> origin/<default-branch>
|
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
```bash
|
|
112
|
-
# when the work is
|
|
113
|
-
|
|
114
|
-
git branch -d feat/<slug> # -d (safe) not -D, unless the user confirms discarding
|
|
112
|
+
# when the work has landed — the cleanup is the default, and it is a command, not a judgement call
|
|
113
|
+
npx @ericrisco/rsc worktrees reap
|
|
115
114
|
```
|
|
116
115
|
|
|
117
116
|
Either way, the contract is identical: **after this step, the cwd is an isolated branch off a clean
|
|
118
117
|
base, and the default-branch checkout is exactly as it was.** Confirm that out loud (at the dial's
|
|
119
118
|
level) before handing to `implement`.
|
|
120
119
|
|
|
121
|
-
###
|
|
120
|
+
### Cleanup is the default, and it is not yours to judge
|
|
122
121
|
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
A worktree whose work is already in the trunk is retired **by default** — you do not decide that by
|
|
123
|
+
reading the situation, because "is this safe to delete?" is a deterministic question and a wrong
|
|
124
|
+
answer costs a directory that cannot be brought back. Ask the command:
|
|
125
125
|
|
|
126
126
|
```bash
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
git rev-parse --show-superproject-working-tree # non-empty ⇒ this is a submodule; STOP
|
|
131
|
-
# 3) cd to the MAIN working tree before removing (you cannot remove the worktree you stand in)
|
|
132
|
-
cd "$(git rev-parse --git-common-dir)/.."
|
|
133
|
-
git worktree remove <path>
|
|
134
|
-
git worktree prune # self-heal stale administrative entries
|
|
127
|
+
npx @ericrisco/rsc worktrees # every landed worktree, with a verdict and a reason
|
|
128
|
+
npx @ericrisco/rsc worktrees reap # retire the ones that are safe with nothing to lose
|
|
129
|
+
npx @ericrisco/rsc worktrees reap <path> # retire one the user has just confirmed
|
|
135
130
|
```
|
|
136
131
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
- **
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
132
|
+
Three things have to hold before a worktree is retired without asking, and each fails towards
|
|
133
|
+
**keeping** it:
|
|
134
|
+
|
|
135
|
+
- **rsc created it** — location *and* branch shape both match. Only one matches → it might be the
|
|
136
|
+
user's, so it is asked about, never removed silently. Neither → foreign; not touched, not offered.
|
|
137
|
+
- **The branch adds nothing the trunk does not have** — asked about *content*, so a squash-merged
|
|
138
|
+
pull request counts as landed. Its branch is kept even so: git will not delete it safely, and while
|
|
139
|
+
it exists the commits survive a wrong call.
|
|
140
|
+
- **Nothing inside would be lost** — not just "no pending changes": untracked and ignored files count,
|
|
141
|
+
because a worktree that git calls clean can still hold the only copy of a `.env` or a local
|
|
142
|
+
database. Installed dependencies and build output are the one carve-out.
|
|
143
|
+
|
|
144
|
+
Anything else is reported with the reason and the way out. A refusal you cannot act on is not safety.
|
|
145
|
+
|
|
146
|
+
**The native tool owns its own lifecycle.** If you isolated via a native `EnterWorktree`-style tool,
|
|
147
|
+
exit through that tool's `remove`/`keep`; the reaper is for worktrees git alone knows about.
|
|
148
|
+
|
|
149
|
+
**When the command is unavailable** (no rsc in this project), the manual equivalent is: confirm it is
|
|
150
|
+
a linked worktree (`git rev-parse --git-dir` ≠ `--git-common-dir`), rule out a submodule
|
|
151
|
+
(`git rev-parse --show-superproject-working-tree` must be empty), `cd` to the main working tree, then
|
|
152
|
+
`git worktree remove` and `git worktree prune`. Never `-D` a branch to make the cleanup succeed.
|
|
145
153
|
|
|
146
154
|
## Model tier — `light` (opt-in routing)
|
|
147
155
|
|