@ericrisco/rsc 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/audit.js +224 -0
- package/scripts/install-apply.js +25 -6
- package/scripts/rsc.js +13 -1
- package/skills/init/SKILL.md +23 -0
- package/skills/ship/SKILL.md +10 -0
- package/targets/claude.js +28 -1
- package/targets/danger-guard.mjs +87 -0
- package/targets/session-start.mjs +97 -0
- package/targets/ship-guard.mjs +82 -0
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/audit.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// audit.js — inventory installed skills (project + machine), surface possible
|
|
2
|
+
// overlap and over-install (bloat). Advisory only: every finding is a "review
|
|
3
|
+
// this", never a hard error. Run on demand (`rsc audit`), at `init`, and nudged
|
|
4
|
+
// periodically by the SessionStart hook when the last run is stale.
|
|
5
|
+
//
|
|
6
|
+
// Signals it leans on, all already in the repo:
|
|
7
|
+
// - manifest.json → the catalog (id, description, tags) — what a skill is
|
|
8
|
+
// - .rsc/skills/<id> → the project's single source of truth for installed skills
|
|
9
|
+
// - ~/.claude/skills → machine/user-scope skills
|
|
10
|
+
// - detectRepo() → coarse stack signals, to judge "no footprint here"
|
|
11
|
+
// - DOMAINS → the catalog grouped by intent, to judge overlap/heaviness
|
|
12
|
+
import { existsSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { loadManifest } from './lib/manifest.js';
|
|
17
|
+
import { detectRepo } from './detect-repo.js';
|
|
18
|
+
import { DOMAINS } from './lib/domains.js';
|
|
19
|
+
|
|
20
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
|
|
22
|
+
// The control plane + SDD pipeline always coexist by design — never flag them as
|
|
23
|
+
// overlap or bloat. A pipeline of phases is not "too many skills".
|
|
24
|
+
const FLOOR_DOMAINS = ['Core & control plane', 'Spec-Driven Development'];
|
|
25
|
+
// Domains whose skills imply a concrete stack in the repo. Only these get the
|
|
26
|
+
// "no footprint detected" check — content/business skills can't be judged from code.
|
|
27
|
+
const CODE_DOMAINS = [
|
|
28
|
+
'Languages',
|
|
29
|
+
'Frameworks & app stacks',
|
|
30
|
+
'Databases & data layer',
|
|
31
|
+
'Ship & operate — platforms',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// detectRepo() is coarse (a handful of stack tags). Expand each detected signal to
|
|
35
|
+
// the sibling skills it implies, so "Next.js detected" doesn't flag react/vercel as
|
|
36
|
+
// orphans. Anything outside the expanded set in a CODE_DOMAIN is advisory bloat.
|
|
37
|
+
const STACK_SIBLINGS = {
|
|
38
|
+
nextjs: ['nextjs', 'react', 'typescript', 'nodejs', 'vercel', 'design', 'tailwind'],
|
|
39
|
+
design: ['design', 'nextjs', 'react'],
|
|
40
|
+
fastapi: ['fastapi', 'python', 'sql'],
|
|
41
|
+
go: ['go'],
|
|
42
|
+
postgresdb: ['postgresdb', 'sql', 'prisma-orm', 'drizzle-orm', 'db-migrations', 'supabase', 'neon'],
|
|
43
|
+
deployment: ['deployment', 'docker', 'github-actions', 'vercel', 'netlify', 'railway', 'render', 'fly-io'],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const OVERLAP_SHARED_TAGS = 3; // pair in same domain sharing ≥ this many tags → "similar ground"
|
|
47
|
+
const HEAVY_DOMAIN_COUNT = 5; // > this many installed in one (non-floor) domain → "heavy"
|
|
48
|
+
const STALE_DAYS = 14; // periodic nudge cadence (used by the SessionStart hook)
|
|
49
|
+
|
|
50
|
+
function domainOf(id) {
|
|
51
|
+
const d = DOMAINS.find((dom) => dom.ids.includes(id));
|
|
52
|
+
return d ? d.title : 'Uncategorized';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function subdirs(dir) {
|
|
56
|
+
try {
|
|
57
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
58
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
59
|
+
.map((e) => e.name);
|
|
60
|
+
} catch { return []; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Installed in THIS project = whatever has a real base under .rsc/skills/<id>,
|
|
64
|
+
// intersected with the catalog (ignore stray dirs). This is target-agnostic: the
|
|
65
|
+
// shared base is the single source of truth regardless of which assistants link it.
|
|
66
|
+
export function installedProject(cwd, catalogIds) {
|
|
67
|
+
const set = new Set(catalogIds);
|
|
68
|
+
return subdirs(join(cwd, '.rsc', 'skills')).filter((id) => set.has(id)).sort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Installed on the MACHINE = user-scope Claude skills (~/.claude/skills/<id>),
|
|
72
|
+
// intersected with the catalog. Best-effort; other assistants' user scopes vary.
|
|
73
|
+
export function installedMachine(home, catalogIds) {
|
|
74
|
+
const set = new Set(catalogIds);
|
|
75
|
+
return subdirs(join(home, '.claude', 'skills')).filter((id) => set.has(id)).sort();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findOverlaps(ids, tagsById) {
|
|
79
|
+
const out = [];
|
|
80
|
+
const content = ids.filter((id) => !FLOOR_DOMAINS.includes(domainOf(id)));
|
|
81
|
+
for (let i = 0; i < content.length; i++) {
|
|
82
|
+
for (let j = i + 1; j < content.length; j++) {
|
|
83
|
+
const a = content[i];
|
|
84
|
+
const b = content[j];
|
|
85
|
+
if (domainOf(a) !== domainOf(b)) continue;
|
|
86
|
+
const shared = (tagsById[a] || []).filter((t) => (tagsById[b] || []).includes(t));
|
|
87
|
+
if (shared.length >= OVERLAP_SHARED_TAGS) {
|
|
88
|
+
out.push({ a, b, domain: domainOf(a), sharedTags: shared });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function findHeavyDomains(ids) {
|
|
96
|
+
const byDomain = {};
|
|
97
|
+
for (const id of ids) {
|
|
98
|
+
const d = domainOf(id);
|
|
99
|
+
if (FLOOR_DOMAINS.includes(d)) continue;
|
|
100
|
+
(byDomain[d] ||= []).push(id);
|
|
101
|
+
}
|
|
102
|
+
return Object.entries(byDomain)
|
|
103
|
+
.filter(([, list]) => list.length > HEAVY_DOMAIN_COUNT)
|
|
104
|
+
.map(([domain, list]) => ({ domain, count: list.length, ids: list.sort() }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findNoFootprint(ids, tagsById, detected) {
|
|
108
|
+
if (!detected.length) return []; // can't judge a non-code / empty repo — stay silent
|
|
109
|
+
const covered = new Set(detected.flatMap((sig) => STACK_SIBLINGS[sig] || [sig]));
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const id of ids) {
|
|
112
|
+
const dom = domainOf(id);
|
|
113
|
+
if (!CODE_DOMAINS.includes(dom)) continue;
|
|
114
|
+
const tags = tagsById[id] || [];
|
|
115
|
+
const hasFootprint = covered.has(id) || tags.some((t) => covered.has(t));
|
|
116
|
+
if (!hasFootprint) {
|
|
117
|
+
out.push({ id, domain: dom, reason: `no detected footprint (repo looks like: ${detected.join(', ')})` });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function audit({
|
|
124
|
+
cwd = process.cwd(),
|
|
125
|
+
home = homedir(),
|
|
126
|
+
manifest = loadManifest(),
|
|
127
|
+
date = new Date().toISOString().slice(0, 10),
|
|
128
|
+
} = {}) {
|
|
129
|
+
const catalogIds = manifest.skills.map((s) => s.id);
|
|
130
|
+
const tagsById = Object.fromEntries(manifest.skills.map((s) => [s.id, s.tags || []]));
|
|
131
|
+
|
|
132
|
+
const project = installedProject(cwd, catalogIds);
|
|
133
|
+
const machine = installedMachine(home, catalogIds);
|
|
134
|
+
const detected = detectRepo(cwd);
|
|
135
|
+
|
|
136
|
+
const overlaps = findOverlaps(project, tagsById);
|
|
137
|
+
const heavyDomains = findHeavyDomains(project);
|
|
138
|
+
const noFootprint = findNoFootprint(project, tagsById, detected);
|
|
139
|
+
|
|
140
|
+
const byDomain = {};
|
|
141
|
+
for (const id of project) (byDomain[domainOf(id)] ||= []).push(id);
|
|
142
|
+
|
|
143
|
+
const findings = overlaps.length + heavyDomains.length + noFootprint.length;
|
|
144
|
+
const headline = findings === 0
|
|
145
|
+
? `${project.length} skills installed — nothing to flag.`
|
|
146
|
+
: `${project.length} installed · ${overlaps.length} possible overlap, ${heavyDomains.length} heavy domain(s), ${noFootprint.length} with no footprint.`;
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
date,
|
|
150
|
+
project: { root: cwd, installed: project, byDomain },
|
|
151
|
+
machine: { root: join(home, '.claude', 'skills'), installed: machine },
|
|
152
|
+
detectedStacks: detected,
|
|
153
|
+
overlaps,
|
|
154
|
+
heavyDomains,
|
|
155
|
+
noFootprint,
|
|
156
|
+
summary: {
|
|
157
|
+
projectCount: project.length,
|
|
158
|
+
machineCount: machine.length,
|
|
159
|
+
overlapCount: overlaps.length,
|
|
160
|
+
heavyCount: heavyDomains.length,
|
|
161
|
+
noFootprintCount: noFootprint.length,
|
|
162
|
+
clean: findings === 0,
|
|
163
|
+
headline,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function renderAuditMarkdown(report) {
|
|
169
|
+
const L = [];
|
|
170
|
+
L.push(`# Skill audit — ${report.date}`, '');
|
|
171
|
+
L.push(report.summary.headline, '');
|
|
172
|
+
L.push(`- Project skills: **${report.summary.projectCount}** (\`${report.project.root}\`)`);
|
|
173
|
+
L.push(`- Machine skills: **${report.summary.machineCount}** (\`${report.machine.root}\`)`);
|
|
174
|
+
L.push(`- Detected stacks: ${report.detectedStacks.length ? report.detectedStacks.join(', ') : '(none detected)'}`, '');
|
|
175
|
+
|
|
176
|
+
if (report.overlaps.length) {
|
|
177
|
+
L.push('## Possible overlap (review — not necessarily wrong)', '');
|
|
178
|
+
for (const o of report.overlaps) {
|
|
179
|
+
L.push(`- \`${o.a}\` ↔ \`${o.b}\` — same domain *${o.domain}*, share: ${o.sharedTags.join(', ')}`);
|
|
180
|
+
}
|
|
181
|
+
L.push('');
|
|
182
|
+
}
|
|
183
|
+
if (report.heavyDomains.length) {
|
|
184
|
+
L.push('## Heavy domains (more than usual for one project)', '');
|
|
185
|
+
for (const h of report.heavyDomains) {
|
|
186
|
+
L.push(`- **${h.domain}** — ${h.count} installed: ${h.ids.map((i) => `\`${i}\``).join(', ')}`);
|
|
187
|
+
}
|
|
188
|
+
L.push('');
|
|
189
|
+
}
|
|
190
|
+
if (report.noFootprint.length) {
|
|
191
|
+
L.push('## Installed but no footprint detected (verify)', '');
|
|
192
|
+
for (const n of report.noFootprint) {
|
|
193
|
+
L.push(`- \`${n.id}\` (${n.domain}) — ${n.reason}`);
|
|
194
|
+
}
|
|
195
|
+
L.push('');
|
|
196
|
+
}
|
|
197
|
+
if (report.summary.clean) L.push('Nothing to flag. The installed set fits the project.', '');
|
|
198
|
+
|
|
199
|
+
L.push('---', `_Advisory only. Trim with \`npx @ericrisco/rsc uninstall <id>\`. Re-run with \`npx @ericrisco/rsc audit\`._`);
|
|
200
|
+
return L.join('\n') + '\n';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Persist a stamp the SessionStart hook reads to decide if a periodic audit is due.
|
|
204
|
+
export function stampAudit(cwd, date = new Date().toISOString()) {
|
|
205
|
+
const dir = join(cwd, '.rsc');
|
|
206
|
+
mkdirSync(dir, { recursive: true });
|
|
207
|
+
writeFileSync(join(dir, 'audit.json'), JSON.stringify({ lastRun: date }, null, 2) + '\n');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Write the report into the harness wiki when one exists; always stamp .rsc/audit.json.
|
|
211
|
+
export function writeAuditReport(report, cwd = process.cwd()) {
|
|
212
|
+
const written = [];
|
|
213
|
+
const wikiHarness = join(cwd, '02-DOCS', 'wiki', 'harness');
|
|
214
|
+
if (existsSync(join(cwd, '02-DOCS', 'wiki'))) {
|
|
215
|
+
mkdirSync(wikiHarness, { recursive: true });
|
|
216
|
+
const file = join(wikiHarness, `skill-audit-${report.date}.md`);
|
|
217
|
+
writeFileSync(file, renderAuditMarkdown(report));
|
|
218
|
+
written.push(file);
|
|
219
|
+
}
|
|
220
|
+
stampAudit(cwd, `${report.date}T00:00:00.000Z`);
|
|
221
|
+
return written;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export { STALE_DAYS, ROOT };
|
package/scripts/install-apply.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { rmSync, existsSync, cpSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join, dirname } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { planInstall } from './install-plan.js';
|
|
@@ -7,11 +7,24 @@ import { targetPaths, writeSkill, wireHook, baseDir } from '../targets/index.js'
|
|
|
7
7
|
import { readState, writeState } from './lib/state.js';
|
|
8
8
|
|
|
9
9
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const CLI_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
|
10
11
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
12
|
+
// `.rsc/.version` records the CLI version the shared bases (.rsc/skills/) were
|
|
13
|
+
// materialized at — the single, target-agnostic source of truth for "installed
|
|
14
|
+
// skills version" (read by the SessionStart update check too).
|
|
15
|
+
const versionFile = (cwd) => join(cwd, '.rsc', '.version');
|
|
16
|
+
function readBaseVersion(cwd) {
|
|
17
|
+
try { return readFileSync(versionFile(cwd), 'utf8').trim(); } catch { return undefined; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Materialize the real skill files into the project-local base. Normally copied
|
|
21
|
+
// once and reused; when `refresh` is set (a different CLI version than the base was
|
|
22
|
+
// materialized at) the base is re-copied so a reinstall actually updates content.
|
|
23
|
+
// Skills are read-only catalog (user customization lives in 02-DOCS/CLAUDE.md), so
|
|
24
|
+
// overwriting on a version change is safe.
|
|
25
|
+
function ensureBase(id, cwd, refresh) {
|
|
14
26
|
const dest = baseDir(id, cwd);
|
|
27
|
+
if (refresh && existsSync(dest)) rmSync(dest, { recursive: true, force: true });
|
|
15
28
|
if (!existsSync(dest)) {
|
|
16
29
|
mkdirSync(dirname(dest), { recursive: true });
|
|
17
30
|
cpSync(join(ROOT, 'skills', id), dest, { recursive: true });
|
|
@@ -23,16 +36,22 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
|
|
|
23
36
|
const paths = targetPaths(target, home, cwd);
|
|
24
37
|
const plan = planInstall({ skillIds, target, home, cwd });
|
|
25
38
|
const state = readState(paths.stateFile);
|
|
39
|
+
// Refresh bases when installing a different version than they were materialized at
|
|
40
|
+
// (or a pre-versioning install where the marker is absent). Same version → no-op.
|
|
41
|
+
const refresh = readBaseVersion(cwd) !== CLI_VERSION;
|
|
26
42
|
for (const step of plan) {
|
|
27
43
|
if (step.kind === 'skill') {
|
|
28
|
-
const base = ensureBase(step.id, cwd);
|
|
44
|
+
const base = ensureBase(step.id, cwd, refresh);
|
|
29
45
|
const files = await writeSkill(target, step.id, base, step.to);
|
|
30
46
|
state.skills[step.id] = { files, base };
|
|
31
47
|
} else if (step.kind === 'hook') {
|
|
32
|
-
await wireHook(target, paths, join(ensureBase('suggest', cwd), 'SKILL.md'));
|
|
48
|
+
await wireHook(target, paths, join(ensureBase('suggest', cwd, refresh), 'SKILL.md'));
|
|
33
49
|
}
|
|
34
50
|
}
|
|
51
|
+
state.version = CLI_VERSION;
|
|
35
52
|
writeState(paths.stateFile, state);
|
|
53
|
+
mkdirSync(dirname(versionFile(cwd)), { recursive: true });
|
|
54
|
+
writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
|
|
36
55
|
return state;
|
|
37
56
|
}
|
|
38
57
|
|
package/scripts/rsc.js
CHANGED
|
@@ -8,6 +8,7 @@ import { applyInstall, listInstalled, uninstall } from './install-apply.js';
|
|
|
8
8
|
import { doctor } from './doctor.js';
|
|
9
9
|
import { say, select, pickFrom, banner, confirm } from './lib/ui.js';
|
|
10
10
|
import { refreshRegistry, registryStatus } from './lib/registry.js';
|
|
11
|
+
import { audit, writeAuditReport } from './audit.js';
|
|
11
12
|
import { DOMAINS } from './lib/domains.js';
|
|
12
13
|
|
|
13
14
|
const argv = process.argv.slice(2);
|
|
@@ -175,6 +176,17 @@ async function main() {
|
|
|
175
176
|
for (const o of toOutcomes(ids)) say(`${o.id}\t${o.label}`);
|
|
176
177
|
return;
|
|
177
178
|
}
|
|
179
|
+
case 'audit': {
|
|
180
|
+
const report = audit();
|
|
181
|
+
const written = writeAuditReport(report);
|
|
182
|
+
say(report.summary.headline);
|
|
183
|
+
for (const o of report.overlaps) say(` ~ overlap: ${o.a} ↔ ${o.b} (${o.sharedTags.join(', ')})`);
|
|
184
|
+
for (const h of report.heavyDomains) say(` ! heavy: ${h.domain} — ${h.count} skills`);
|
|
185
|
+
for (const n of report.noFootprint) say(` ? no footprint: ${n.id} (${n.reason})`);
|
|
186
|
+
if (written.length) say(`\nReport: ${written[0]}`);
|
|
187
|
+
else say('\n(no harness wiki here — printed above only; run `harness` to keep a written record)');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
178
190
|
case 'list':
|
|
179
191
|
return void say(listInstalled({ target }).join('\n') || '(nothing installed)');
|
|
180
192
|
case 'doctor':
|
|
@@ -201,7 +213,7 @@ async function main() {
|
|
|
201
213
|
}
|
|
202
214
|
default:
|
|
203
215
|
say(`rsc: unknown command '${cmd}'.`);
|
|
204
|
-
say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | registry refresh | doctor | uninstall <id>');
|
|
216
|
+
say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | audit | registry refresh | doctor | uninstall <id>');
|
|
205
217
|
}
|
|
206
218
|
}
|
|
207
219
|
|
package/skills/init/SKILL.md
CHANGED
|
@@ -137,6 +137,29 @@ npx @ericrisco/rsc add <skill> [<skill> ...]
|
|
|
137
137
|
|
|
138
138
|
Install only skills their answers justify — same discipline as "no speculative tools". Full skill map, sample printouts per scenario, and the requirements-first decision pattern → `references/recommend-skills.md`.
|
|
139
139
|
|
|
140
|
+
### Phase 3.5 — GROUND THE PROJECT (git · live docs · skill audit)
|
|
141
|
+
|
|
142
|
+
Three quick, enforced setup checks once the skills are installed. The SessionStart hook nudges each
|
|
143
|
+
of these too; doing them here means the user starts clean.
|
|
144
|
+
|
|
145
|
+
1. **Version control is required.** If the workspace has no `.git/`, offer `git init` (recommended —
|
|
146
|
+
the SDD chain and the ship guard assume git). If the user declines, write an empty `.rsc/.no-git`
|
|
147
|
+
so the decision is persisted and neither you nor the hook asks again. Log the decision.
|
|
148
|
+
2. **Offer Context7 (live library docs).** For a software project, offer to wire the Context7 MCP
|
|
149
|
+
once: `claude mcp add --transport http context7 https://mcp.context7.com/mcp`. If they don't want
|
|
150
|
+
it, write `.rsc/.no-context7`. (It gives version-correct docs instead of guessing from memory.)
|
|
151
|
+
3. **Run a skill audit.** After installing, run `npx @ericrisco/rsc audit`. It inventories the skills
|
|
152
|
+
installed for this project and on the machine, and flags possible overlap or skills with no
|
|
153
|
+
footprint here — so the project starts with the right set, not a pile. It re-runs on a cadence via
|
|
154
|
+
the SessionStart nudge. Summarize the result at the user's accompaniment level.
|
|
155
|
+
4. **Danger guard follows `technical_level`.** When you record `technical_level: non-technical` (or
|
|
156
|
+
`mixed`, or while no profile exists yet), a `PreToolUse` guard (`.rsc/danger-guard.mjs`) **blocks**
|
|
157
|
+
irreversible foot-gun commands (`rm -rf`, `git push --force`, `git reset --hard`, `DROP`/`TRUNCATE`,
|
|
158
|
+
`DELETE`/`UPDATE` with no `WHERE`, `dd` to a device, `curl | bash`, …) and asks for a safer
|
|
159
|
+
alternative. A fully `technical` user is never guarded. It only turns off if the **user explicitly
|
|
160
|
+
asks** to allow dangerous commands — then write `.rsc/.no-danger-guard`. Mention this protection
|
|
161
|
+
exists when you set a non-technical level so the user is not surprised by a block.
|
|
162
|
+
|
|
140
163
|
### Phase 4 — HANDOFF
|
|
141
164
|
|
|
142
165
|
Tell the user to install `harness` (`npx @ericrisco/rsc add harness`) and then run the **`harness`** skill to actually scaffold the `01-TOOLS/` + `02-DOCS/` workspace. `init` stops here — it has set the profile, recorded the discovery, and recommended the skills. `harness` reads the profile and builds the structure.
|
package/skills/ship/SKILL.md
CHANGED
|
@@ -81,6 +81,16 @@ git diff main...HEAD | grep -icE 'api[_-]?key|secret|password|token|BEGIN .*PRIV
|
|
|
81
81
|
| sed 's/^/secret-hits: /'
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
### Automated guard (PreToolUse) — you cannot quietly abandon a feature
|
|
85
|
+
|
|
86
|
+
When rsc is installed for Claude Code, a `PreToolUse` hook (`.rsc/ship-guard.mjs`) enforces this
|
|
87
|
+
phase at the one deterministic moment it matters: it **denies** any Bash command that switches to
|
|
88
|
+
`main`/`master` or merges while the current feature branch has **uncommitted changes** or **commits
|
|
89
|
+
that were never pushed**. The denial reason names the branch and routes you here. The guard is
|
|
90
|
+
local-only (no network), **fail-open** (any ambiguity allows the command), and can be disabled per
|
|
91
|
+
project with `.rsc/.no-ship-guard`. It guarantees the commit → push step; opening the PR is still
|
|
92
|
+
this skill's job (and its hard rule). If the guard blocks you, do not work around it — run ship.
|
|
93
|
+
|
|
84
94
|
## The three landing options — always present exactly three
|
|
85
95
|
|
|
86
96
|
This mirrors the harness "siempre 3 opciones" pattern. Gather the one fact that changes the answer (does this repo use PRs / require review on `main`?), then present **exactly three** with an honest recommendation matched to the workflow and the accompaniment level.
|
package/targets/claude.js
CHANGED
|
@@ -57,7 +57,34 @@ export function wireHook(paths) {
|
|
|
57
57
|
settings.hooks[event].push({ hooks: [{ type: 'command', command: wlCmd }] });
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// Ship guard: a PreToolUse(Bash) hook that DENIES switching to / merging the trunk
|
|
61
|
+
// while the current feature branch has uncommitted or unpushed work — forcing the
|
|
62
|
+
// commit → push → PR close (the `ship` skill). Materialized + node-run (Windows-safe),
|
|
63
|
+
// registered idempotently, fail-open, opt-out via .rsc/.no-ship-guard. Other
|
|
64
|
+
// (non-rsc) PreToolUse hooks are preserved.
|
|
65
|
+
const sgDest = join(paths.projectRoot, '.rsc', 'ship-guard.mjs');
|
|
66
|
+
copyFileSync(join(HERE, 'ship-guard.mjs'), sgDest);
|
|
67
|
+
const sgCmd = `node "${sgDest}" "${paths.projectRoot}"`;
|
|
68
|
+
settings.hooks.PreToolUse ||= [];
|
|
69
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
70
|
+
(e) => !JSON.stringify(e).includes('.rsc/ship-guard.'),
|
|
71
|
+
);
|
|
72
|
+
settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: sgCmd }] });
|
|
73
|
+
|
|
74
|
+
// Danger guard: a PreToolUse(Bash) hook that DENIES irreversible foot-gun commands
|
|
75
|
+
// (rm -rf, git push --force, DROP/TRUNCATE, DELETE/UPDATE without WHERE, dd to /dev,
|
|
76
|
+
// curl|bash, …) when the user-profile says the user is NON-technical (default-safe
|
|
77
|
+
// when no profile exists yet; never guards a fully `technical` user). Materialized +
|
|
78
|
+
// node-run (Windows-safe), idempotent, fail-open, opt-out via .rsc/.no-danger-guard.
|
|
79
|
+
const dgDest = join(paths.projectRoot, '.rsc', 'danger-guard.mjs');
|
|
80
|
+
copyFileSync(join(HERE, 'danger-guard.mjs'), dgDest);
|
|
81
|
+
const dgCmd = `node "${dgDest}" "${paths.projectRoot}"`;
|
|
82
|
+
settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
|
|
83
|
+
(e) => !JSON.stringify(e).includes('.rsc/danger-guard.'),
|
|
84
|
+
);
|
|
85
|
+
settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: dgCmd }] });
|
|
86
|
+
|
|
60
87
|
mkdirSync(dirname(file), { recursive: true });
|
|
61
88
|
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
62
|
-
return [file, scriptDest, wlDest];
|
|
89
|
+
return [file, scriptDest, wlDest, sgDest, dgDest];
|
|
63
90
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rsc Danger guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash)
|
|
3
|
+
// as `node ...` so it runs on every platform including Windows.
|
|
4
|
+
// argv[2] = absolute project root stdin = PreToolUse hook JSON
|
|
5
|
+
//
|
|
6
|
+
// For a NON-TECHNICAL user (per 02-DOCS/wiki/harness/user-profile.md → technical_level),
|
|
7
|
+
// it DENIES irreversible, foot-gun Bash commands and tells the agent to find a safer,
|
|
8
|
+
// scoped alternative. A fully `technical` user is never guarded. Default-safe: if there
|
|
9
|
+
// is no profile yet, the harness convention is "assume non-technical", so the guard is ON.
|
|
10
|
+
//
|
|
11
|
+
// Disable per project with .rsc/.no-danger-guard — but only when the USER explicitly asks
|
|
12
|
+
// for it (the deny message says so). Fail-open on any internal error (never brick a shell).
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const root = process.argv[2] || process.cwd();
|
|
17
|
+
|
|
18
|
+
function allow() { process.exit(0); }
|
|
19
|
+
function deny(why) {
|
|
20
|
+
process.stdout.write(JSON.stringify({
|
|
21
|
+
hookSpecificOutput: {
|
|
22
|
+
hookEventName: 'PreToolUse',
|
|
23
|
+
permissionDecision: 'deny',
|
|
24
|
+
permissionDecisionReason:
|
|
25
|
+
`BLOCKED for a non-technical user — this command ${why}. ` +
|
|
26
|
+
`Do NOT run it: explain the risk in plain language and propose a safer, scoped alternative ` +
|
|
27
|
+
`(name exact paths, add a WHERE clause, back up first, etc.). ` +
|
|
28
|
+
`Only if the USER explicitly insists on allowing dangerous commands here, create .rsc/.no-danger-guard to disable this guard.`,
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (existsSync(join(root, '.rsc', '.no-danger-guard'))) allow();
|
|
35
|
+
|
|
36
|
+
// technical_level === 'technical' → not guarded. non-technical / mixed / missing → guarded.
|
|
37
|
+
function technicalLevel() {
|
|
38
|
+
try {
|
|
39
|
+
const txt = readFileSync(join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md'), 'utf8');
|
|
40
|
+
const m = txt.match(/technical_level:\s*([a-z-]+)/i);
|
|
41
|
+
return m ? m[1].toLowerCase() : null;
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
if (technicalLevel() === 'technical') allow();
|
|
45
|
+
|
|
46
|
+
// Only Bash commands can be dangerous here.
|
|
47
|
+
let input = {};
|
|
48
|
+
try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
|
|
49
|
+
if ((input.tool_name || input.toolName) !== 'Bash') allow();
|
|
50
|
+
const cmd = input.tool_input?.command || input.toolInput?.command || '';
|
|
51
|
+
if (typeof cmd !== 'string' || !cmd) allow();
|
|
52
|
+
|
|
53
|
+
// --- the dangerous-command list ---------------------------------------------
|
|
54
|
+
// High-signal, low-false-positive. Each rule: what it catches and the plain why.
|
|
55
|
+
const noWhere = (verbRe) => verbRe.test(cmd) && !/\bwhere\b/i.test(cmd);
|
|
56
|
+
|
|
57
|
+
function isRmRecursiveForce() {
|
|
58
|
+
if (!/\brm\b/.test(cmd)) return false;
|
|
59
|
+
const hasR = /(-[a-z]*r[a-z]*|--recursive)\b/i.test(cmd);
|
|
60
|
+
const hasF = /(-[a-z]*f[a-z]*|--force)\b/i.test(cmd);
|
|
61
|
+
return hasR && hasF; // -rf / -fr / -r -f / --recursive --force
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const RULES = [
|
|
65
|
+
{ id: 'rm-rf', why: 'deletes whole files/folders irreversibly (rm with -r and -f)', match: isRmRecursiveForce },
|
|
66
|
+
{ id: 'find-delete', why: 'mass-deletes matched files (find … -delete / -exec rm)', match: () => /\bfind\b[^|;&]*(-delete\b|-exec\s+rm\b)/i.test(cmd) },
|
|
67
|
+
{ id: 'dd-disk', why: 'overwrites a raw disk device and can destroy the whole drive (dd of=/dev/…)', match: () => /\bdd\b[^|;&]*\bof=\/dev\//i.test(cmd) },
|
|
68
|
+
{ id: 'mkfs', why: 'formats a filesystem, erasing everything on it (mkfs)', match: () => /\bmkfs(\.\w+)?\b/i.test(cmd) },
|
|
69
|
+
{ id: 'curl-pipe-shell', why: 'pipes a downloaded script straight into a shell (curl|bash) — runs untrusted code', match: () => /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i.test(cmd) },
|
|
70
|
+
|
|
71
|
+
{ id: 'git-push-force', why: 'force-pushes and can overwrite shared history for everyone (git push --force)', match: () => /\bgit\s+push\b[^|;&]*(--force(?!-with-lease)\b|\s-f\b)/i.test(cmd) },
|
|
72
|
+
{ id: 'git-reset-hard', why: 'throws away all uncommitted work with no undo (git reset --hard)', match: () => /\bgit\s+reset\b[^|;&]*--hard\b/i.test(cmd) },
|
|
73
|
+
{ id: 'git-clean', why: 'permanently deletes untracked files (git clean -f)', match: () => /\bgit\s+clean\b[^|;&]*-[a-z]*f/i.test(cmd) },
|
|
74
|
+
{ id: 'git-branch-D', why: 'force-deletes a branch even if its work was never merged (git branch -D)', match: () => /\bgit\s+branch\b[^|;&]*\s-D\b/.test(cmd) },
|
|
75
|
+
|
|
76
|
+
{ id: 'sql-drop', why: 'drops an entire database/schema/table (DROP …)', match: () => /\bdrop\s+(database|schema|table)\b/i.test(cmd) },
|
|
77
|
+
{ id: 'sql-truncate', why: 'empties an entire table (TRUNCATE)', match: () => /\btruncate\s+(table\s+)?\S/i.test(cmd) },
|
|
78
|
+
{ id: 'sql-delete-no-where', why: 'deletes EVERY row — a DELETE with no WHERE clause', match: () => noWhere(/\bdelete\s+from\s+\S/i) },
|
|
79
|
+
{ id: 'sql-update-no-where', why: 'rewrites EVERY row — an UPDATE with no WHERE clause', match: () => noWhere(/\bupdate\s+\S+\s+set\b/i) },
|
|
80
|
+
{ id: 'mongo-wipe', why: 'drops a collection/database or deletes all documents (drop()/dropDatabase/deleteMany({}))', match: () => /\.drop\(\s*\)|dropdatabase\s*\(|deletemany\(\s*\{\s*\}\s*\)|\.remove\(\s*\{\s*\}\s*\)/i.test(cmd) },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
for (const rule of RULES) {
|
|
84
|
+
try { if (rule.match()) deny(rule.why); } catch { /* a rule erroring must never block */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
allow();
|
|
@@ -8,13 +8,34 @@
|
|
|
8
8
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
|
|
11
|
+
const STALE_AUDIT_DAYS = 14; // periodic skill-audit cadence (kept in sync with scripts/audit.js)
|
|
12
|
+
|
|
11
13
|
const suggestMd = process.argv[2];
|
|
12
14
|
const root = process.argv[3] || process.cwd();
|
|
13
15
|
|
|
16
|
+
const has = (...p) => existsSync(join(root, ...p));
|
|
17
|
+
|
|
18
|
+
// Is a Context7 MCP server configured for this project (.mcp.json → mcpServers.context7)?
|
|
19
|
+
function hasContext7() {
|
|
20
|
+
try {
|
|
21
|
+
const mcp = JSON.parse(readFileSync(join(root, '.mcp.json'), 'utf8'));
|
|
22
|
+
return Boolean(mcp.mcpServers && mcp.mcpServers.context7);
|
|
23
|
+
} catch { return false; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Has a skill audit run within the cadence? Missing stamp → never audited → due.
|
|
27
|
+
function auditDue() {
|
|
28
|
+
try {
|
|
29
|
+
const { lastRun } = JSON.parse(readFileSync(join(root, '.rsc', 'audit.json'), 'utf8'));
|
|
30
|
+
return (Date.now() - new Date(lastRun).getTime()) / 86400000 >= STALE_AUDIT_DAYS;
|
|
31
|
+
} catch { return true; }
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
try { process.stdout.write(readFileSync(suggestMd, 'utf8')); } catch { /* missing → emit nothing */ }
|
|
15
35
|
|
|
16
36
|
const profile = join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md');
|
|
17
37
|
const optout = join(root, '.rsc', '.no-harness');
|
|
38
|
+
const profileExists = existsSync(profile);
|
|
18
39
|
if (!existsSync(profile) && !existsSync(optout)) {
|
|
19
40
|
process.stdout.write(`
|
|
20
41
|
===== rsc onboarding =====
|
|
@@ -47,3 +68,79 @@ Originals are copied, never moved; deleting an emptied folder needs explicit con
|
|
|
47
68
|
`);
|
|
48
69
|
}
|
|
49
70
|
}
|
|
71
|
+
|
|
72
|
+
// Git required: version control is the substrate the SDD chain + ship guard assume.
|
|
73
|
+
// If the project is not under git, push for it once per session. Persist the user's
|
|
74
|
+
// "no" as .rsc/.no-git so the decision lives in a file and we stop re-asking.
|
|
75
|
+
if (!has('.git') && !has('.rsc', '.no-git')) {
|
|
76
|
+
process.stdout.write(`
|
|
77
|
+
===== rsc git required =====
|
|
78
|
+
This project is NOT under version control (no .git/).
|
|
79
|
+
The SDD flow and the ship guard assume git. ACTION: offer to run \`git init\` now
|
|
80
|
+
(recommended). If the user declines, create .rsc/.no-git so I stop asking.
|
|
81
|
+
============================
|
|
82
|
+
`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Context7 MCP: only for active rsc projects (a profile exists). If the live-docs MCP
|
|
86
|
+
// isn't wired, nudge to add it once. Opt out per project with .rsc/.no-context7.
|
|
87
|
+
if (profileExists && !hasContext7() && !has('.rsc', '.no-context7')) {
|
|
88
|
+
process.stdout.write(`
|
|
89
|
+
===== rsc context7 MCP =====
|
|
90
|
+
Context7 (live, version-correct library docs) is not configured for this project.
|
|
91
|
+
ACTION: install it once — \`claude mcp add --transport http context7 https://mcp.context7.com/mcp\`
|
|
92
|
+
then reload the session. If you don't want it here: create .rsc/.no-context7.
|
|
93
|
+
============================
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Periodic skill audit: for active rsc projects, nudge to re-audit when the last run
|
|
98
|
+
// is stale (or never ran). Running \`rsc audit\` stamps .rsc/audit.json and silences
|
|
99
|
+
// this for the cadence. Opt out with .rsc/.no-audit.
|
|
100
|
+
if (profileExists && auditDue() && !has('.rsc', '.no-audit')) {
|
|
101
|
+
process.stdout.write(`
|
|
102
|
+
===== rsc skill audit =====
|
|
103
|
+
A skill audit is due (runs at most every ${STALE_AUDIT_DAYS} days). It flags overlapping
|
|
104
|
+
skills and skills with no footprint in this project.
|
|
105
|
+
ACTION: run \`npx @ericrisco/rsc audit\`. Opt out with .rsc/.no-audit.
|
|
106
|
+
===========================
|
|
107
|
+
`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Update check: compare the installed version (.rsc/.version, written at install)
|
|
111
|
+
// against the latest published on npm, and nudge the agent to offer an update.
|
|
112
|
+
// Fail-silent (offline / missing baseline / parse error → nothing). Disable with
|
|
113
|
+
// RSC_NO_UPDATE_CHECK=1. RSC_LATEST overrides the npm lookup (tests / mirrors).
|
|
114
|
+
function isNewer(a, b) {
|
|
115
|
+
const pa = String(a).split('.').map(Number);
|
|
116
|
+
const pb = String(b).split('.').map(Number);
|
|
117
|
+
for (let i = 0; i < 3; i++) {
|
|
118
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return true;
|
|
119
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return false;
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!process.env.RSC_NO_UPDATE_CHECK) {
|
|
125
|
+
try {
|
|
126
|
+
const installed = readFileSync(join(root, '.rsc', '.version'), 'utf8').trim();
|
|
127
|
+
let latest = process.env.RSC_LATEST;
|
|
128
|
+
if (!latest) {
|
|
129
|
+
const ctrl = new AbortController();
|
|
130
|
+
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
131
|
+
const res = await fetch('https://registry.npmjs.org/@ericrisco%2frsc/latest', { signal: ctrl.signal });
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
latest = (await res.json()).version;
|
|
134
|
+
}
|
|
135
|
+
if (installed && latest && isNewer(latest, installed)) {
|
|
136
|
+
process.stdout.write(`
|
|
137
|
+
===== rsc update available =====
|
|
138
|
+
rsc ${latest} is out — you have ${installed}.
|
|
139
|
+
ACTION: tell the user a new version is available and, if they say yes, run:
|
|
140
|
+
npx @ericrisco/rsc@latest
|
|
141
|
+
(That reinstalls and refreshes the skill content to the latest.)
|
|
142
|
+
================================
|
|
143
|
+
`);
|
|
144
|
+
}
|
|
145
|
+
} catch { /* offline / no baseline / parse error → stay silent */ }
|
|
146
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rsc Ship guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash)
|
|
3
|
+
// as `node ...` so it runs on every platform including Windows.
|
|
4
|
+
// argv[2] = absolute project root stdin = PreToolUse hook JSON
|
|
5
|
+
//
|
|
6
|
+
// Enforces the "close the feature before you leave it" rule at the one deterministic
|
|
7
|
+
// moment it matters: when a Bash command tries to switch to the trunk (main/master)
|
|
8
|
+
// or merge into it. If the current feature branch has uncommitted changes or commits
|
|
9
|
+
// that were never pushed, the guard DENIES the command and tells the agent to run
|
|
10
|
+
// `ship` (commit → push → PR). Opening the PR itself is `ship`'s job and the skill's
|
|
11
|
+
// hard rule; this hook guarantees you cannot quietly abandon unsaved/unpushed work.
|
|
12
|
+
//
|
|
13
|
+
// Design: precise (only fires on a trunk switch/merge), local-only (no network, no gh),
|
|
14
|
+
// and FAIL-OPEN — any ambiguity (detached HEAD, no repo, git error) allows the command.
|
|
15
|
+
// Opt out per project with .rsc/.no-ship-guard.
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
|
|
20
|
+
const root = process.argv[2] || process.cwd();
|
|
21
|
+
|
|
22
|
+
function allow() { process.exit(0); }
|
|
23
|
+
function deny(reason) {
|
|
24
|
+
process.stdout.write(JSON.stringify({
|
|
25
|
+
hookSpecificOutput: {
|
|
26
|
+
hookEventName: 'PreToolUse',
|
|
27
|
+
permissionDecision: 'deny',
|
|
28
|
+
permissionDecisionReason: reason,
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Opt-out and "not a git repo" both mean: nothing to enforce.
|
|
35
|
+
if (existsSync(join(root, '.rsc', '.no-ship-guard'))) allow();
|
|
36
|
+
if (!existsSync(join(root, '.git'))) allow();
|
|
37
|
+
|
|
38
|
+
// Read the tool call. Only Bash commands can move branches.
|
|
39
|
+
let input = {};
|
|
40
|
+
try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
|
|
41
|
+
if ((input.tool_name || input.toolName) !== 'Bash') allow();
|
|
42
|
+
const command = input.tool_input?.command || input.toolInput?.command || '';
|
|
43
|
+
if (typeof command !== 'string' || !command) allow();
|
|
44
|
+
|
|
45
|
+
// Does this command try to land on / move to the trunk?
|
|
46
|
+
const TRUNK = /\bgit\s+(?:checkout|switch)\s+(?:-{1,2}\S+\s+)*(?:main|master)\b/;
|
|
47
|
+
const MERGE = /\bgit\s+merge\b/;
|
|
48
|
+
if (!TRUNK.test(command) && !MERGE.test(command)) allow();
|
|
49
|
+
|
|
50
|
+
const git = (...args) => {
|
|
51
|
+
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
52
|
+
return r.status === 0 ? (r.stdout || '').trim() : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const branch = git('rev-parse', '--abbrev-ref', 'HEAD');
|
|
56
|
+
// Not on a feature branch (already trunk, detached, or git failed) → nothing to guard.
|
|
57
|
+
if (!branch || branch === 'HEAD' || branch === 'main' || branch === 'master') allow();
|
|
58
|
+
|
|
59
|
+
const tail = '\n(If this is intentional and you accept the risk, create .rsc/.no-ship-guard to disable this guard.)';
|
|
60
|
+
|
|
61
|
+
// 1) Uncommitted work would be carried off the feature branch.
|
|
62
|
+
const dirty = git('status', '--porcelain');
|
|
63
|
+
if (dirty && dirty.length > 0) {
|
|
64
|
+
deny(`You're leaving feature branch "${branch}" with uncommitted changes. Commit them first — run the \`ship\` skill (commit → push → PR), don't abandon the diff.${tail}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2) Commits exist but were never pushed.
|
|
68
|
+
const upstream = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}');
|
|
69
|
+
if (upstream) {
|
|
70
|
+
const ahead = git('rev-list', '--count', `${upstream}..HEAD`);
|
|
71
|
+
if (ahead && Number(ahead) > 0) {
|
|
72
|
+
deny(`Feature branch "${branch}" has ${ahead} commit(s) not pushed to ${upstream}. Push them and open the PR — run the \`ship\` skill — before switching to the trunk.${tail}`);
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
// No upstream at all: if the branch carries commits beyond the trunk, it was never pushed.
|
|
76
|
+
const aheadOfTrunk = git('rev-list', '--count', 'main..HEAD') ?? git('rev-list', '--count', 'master..HEAD');
|
|
77
|
+
if (aheadOfTrunk && Number(aheadOfTrunk) > 0) {
|
|
78
|
+
deny(`Feature branch "${branch}" was never pushed (no upstream, ${aheadOfTrunk} commit(s) ahead of the trunk). Push it and open a PR — run the \`ship\` skill — before leaving it.${tail}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
allow();
|