@ericrisco/rsc 1.0.4 → 1.0.6
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 +2 -2
- package/package.json +1 -1
- package/scripts/doctor.js +3 -0
- package/scripts/eval-lint.sh +52 -1
- package/scripts/install-apply.js +27 -1
- package/scripts/lib/capabilities.js +189 -0
- package/scripts/rsc.js +45 -1
- package/scripts/skill-rubric.md +1 -1
- package/skills/author-skill/references/eval-authoring.md +3 -3
- package/skills/building-agents/evals/cases.yaml +1 -1
- package/skills/course-storytelling/evals/cases.yaml +3 -3
- package/skills/skill-scout/SKILL.md +39 -3
- package/skills/suggest/SKILL.md +6 -0
- package/targets/sello.mjs +6 -0
package/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.0.
|
|
2
|
+
"version": "1.0.6",
|
|
3
3
|
"counts": {
|
|
4
4
|
"skills": 258
|
|
5
5
|
},
|
|
@@ -4348,7 +4348,7 @@
|
|
|
4348
4348
|
},
|
|
4349
4349
|
{
|
|
4350
4350
|
"id": "skill-scout",
|
|
4351
|
-
"description": "Use when a specialized skill probably exists but is not loaded, when a task feels harder than it should because generic reasoning is doing a domain skill's job,
|
|
4351
|
+
"description": "Use when a specialized skill probably exists but is not loaded, when a task feels harder than it should because generic reasoning is doing a domain skill's job, when the work just delivered was a repeatable procedure worth turning into a skill or an agent, or when auditing coverage for holes. Resolves against what already exists — installed skills, catalog skills and agent files — then names the gap and emits the install command. NOT routing among skills you already have (that is `suggest`), NOT writing the artifact when none exists (that is `author-skill` for skills, `building-agents` for agents).",
|
|
4352
4352
|
"tags": [
|
|
4353
4353
|
"skill-discovery",
|
|
4354
4354
|
"capability-gap",
|
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -7,6 +7,7 @@ import { loadManifest } from './lib/manifest.js';
|
|
|
7
7
|
import { listBackups } from './lib/backups.js';
|
|
8
8
|
import { SDD_GATE_TEXT } from '../targets/hook-once.mjs';
|
|
9
9
|
import { isEnabled, checkSello, readSello, countFindings, readEffectiveConfig, validateRiskConfig } from '../targets/sello.mjs';
|
|
10
|
+
import { countGaps } from './lib/capabilities.js';
|
|
10
11
|
|
|
11
12
|
// The sello's health, surfaced where the user already looks (spec: non-blocking
|
|
12
13
|
// findings live in the project and are SUMMARIZED here, never nagged about).
|
|
@@ -56,6 +57,8 @@ export function doctor({ target, home, cwd }) {
|
|
|
56
57
|
},
|
|
57
58
|
contextBudget: contextBudget({ target, home, cwd }),
|
|
58
59
|
sello: selloStatus(root),
|
|
60
|
+
// Counted, never interpreted — by spec, the gap log's reader is the user.
|
|
61
|
+
automationGaps: countGaps(root),
|
|
59
62
|
};
|
|
60
63
|
for (const [id, e] of Object.entries(state.skills)) {
|
|
61
64
|
for (const f of e.files) if (!existsSync(f)) report.missing.push(`${id}:${f}`);
|
package/scripts/eval-lint.sh
CHANGED
|
@@ -7,6 +7,16 @@ set -euo pipefail
|
|
|
7
7
|
# >= 5 should_trigger entries
|
|
8
8
|
# >= 4 should_not_trigger entries
|
|
9
9
|
# >= 1 capability scenario
|
|
10
|
+
# every should_not_trigger route_to resolves (see below)
|
|
11
|
+
#
|
|
12
|
+
# route_to is the one that used to be decorative: the rubric listed "route_to names a
|
|
13
|
+
# real skill" as a deterministic gate and nothing checked it, so evals accumulated
|
|
14
|
+
# routes to skills that were never authored. Each value must now be one of:
|
|
15
|
+
# - a real catalog skill id (a directory under skills/ with a SKILL.md)
|
|
16
|
+
# - "none" — deliberately nothing to route to
|
|
17
|
+
# - "external:<name>" — a skill outside this catalog, on purpose. Some skills
|
|
18
|
+
# legitimately defer to tools that ship elsewhere; the prefix makes that a
|
|
19
|
+
# visible, greppable claim instead of a typo indistinguishable from drift.
|
|
10
20
|
#
|
|
11
21
|
# Uses python3 for real YAML parsing when available. If python3 is missing,
|
|
12
22
|
# falls back to a basic structural grep check and SKIPs the deep validation
|
|
@@ -36,7 +46,7 @@ warned_skip=0
|
|
|
36
46
|
validate_py() {
|
|
37
47
|
cy="$1"
|
|
38
48
|
CASES_YAML="$cy" MIN_TRIGGER="$MIN_TRIGGER" MIN_NOT_TRIGGER="$MIN_NOT_TRIGGER" \
|
|
39
|
-
MIN_CAPABILITY="$MIN_CAPABILITY" python3 - <<'PYEOF'
|
|
49
|
+
MIN_CAPABILITY="$MIN_CAPABILITY" VALID_IDS="$VALID_IDS" python3 - <<'PYEOF'
|
|
40
50
|
import os, sys
|
|
41
51
|
try:
|
|
42
52
|
import yaml
|
|
@@ -72,7 +82,39 @@ st = count("should_trigger")
|
|
|
72
82
|
sn = count("should_not_trigger")
|
|
73
83
|
cap = count("capability")
|
|
74
84
|
|
|
85
|
+
# --- route_to resolution -------------------------------------------------------
|
|
86
|
+
# Accepts a real catalog id, "none", or "external:<name>". A value may be a list,
|
|
87
|
+
# or a string holding several targets separated by comma / | / " or ".
|
|
88
|
+
valid = set(filter(None, os.environ.get("VALID_IDS", "").split(",")))
|
|
89
|
+
NOTHING = {"none", "n/a", "-", ""}
|
|
90
|
+
bad_routes = []
|
|
91
|
+
|
|
92
|
+
def targets(value):
|
|
93
|
+
items = value if isinstance(value, list) else [value]
|
|
94
|
+
out = []
|
|
95
|
+
for item in items:
|
|
96
|
+
for part in str(item).replace("|", ",").replace(" or ", ",").split(","):
|
|
97
|
+
out.append(part.strip().strip("`"))
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
for case in (data.get("should_not_trigger") or []):
|
|
101
|
+
if not isinstance(case, dict) or case.get("route_to") is None:
|
|
102
|
+
continue
|
|
103
|
+
for t in targets(case["route_to"]):
|
|
104
|
+
low = t.lower()
|
|
105
|
+
if low in NOTHING:
|
|
106
|
+
continue
|
|
107
|
+
if low.startswith("external:"):
|
|
108
|
+
if not low[len("external:"):].strip():
|
|
109
|
+
bad_routes.append('"external:" with no name')
|
|
110
|
+
continue
|
|
111
|
+
if valid and t not in valid:
|
|
112
|
+
bad_routes.append(t)
|
|
113
|
+
|
|
75
114
|
reasons = []
|
|
115
|
+
if bad_routes:
|
|
116
|
+
uniq = sorted(set(bad_routes))
|
|
117
|
+
reasons.append("route_to does not resolve: %s (use a real skill id, \"none\", or \"external:<name>\")" % ", ".join(uniq))
|
|
76
118
|
if st < 0:
|
|
77
119
|
reasons.append("should_trigger is not a list")
|
|
78
120
|
elif st < mt:
|
|
@@ -112,8 +154,17 @@ validate_grep() {
|
|
|
112
154
|
|
|
113
155
|
echo "eval-lint: scanning $SKILLS_DIR"
|
|
114
156
|
echo "minimums: should_trigger>=$MIN_TRIGGER should_not_trigger>=$MIN_NOT_TRIGGER capability>=$MIN_CAPABILITY"
|
|
157
|
+
echo "route_to must resolve: a catalog skill id, \"none\", or \"external:<name>\""
|
|
115
158
|
echo
|
|
116
159
|
|
|
160
|
+
# The set of real skill ids, built once from disk — the same surface the router sees.
|
|
161
|
+
VALID_IDS=""
|
|
162
|
+
for sp in "$SKILLS_DIR"/*; do
|
|
163
|
+
[ -f "$sp/SKILL.md" ] || continue
|
|
164
|
+
VALID_IDS="$VALID_IDS,$(basename "$sp")"
|
|
165
|
+
done
|
|
166
|
+
VALID_IDS="${VALID_IDS#,}"
|
|
167
|
+
|
|
117
168
|
# Iterate skills deterministically; bash 3.2-safe (no mapfile).
|
|
118
169
|
for skill_path in "$SKILLS_DIR"/*; do
|
|
119
170
|
[ -d "$skill_path" ] || continue
|
package/scripts/install-apply.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } 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';
|
|
@@ -112,9 +112,35 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
|
|
|
112
112
|
writeState(paths.stateFile, state);
|
|
113
113
|
mkdirSync(dirname(versionFile(cwd)), { recursive: true });
|
|
114
114
|
writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
|
|
115
|
+
ignoreLocalState(cwd);
|
|
115
116
|
return { ...state, backup };
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
// `.rsc/` holds local machine state — hook scripts, install markers, the sello's
|
|
120
|
+
// seals, the automation-gap log. None of it belongs in a user's repository, and the
|
|
121
|
+
// gap log in particular is prose about what happened in their project: a routine
|
|
122
|
+
// `git add -A` would publish it. Nothing used to keep that out, so add the entry.
|
|
123
|
+
//
|
|
124
|
+
// Additive and idempotent by construction: append one line to an existing
|
|
125
|
+
// .gitignore, never rewrite or reorder it, and never create the file in a directory
|
|
126
|
+
// that is not a git repository.
|
|
127
|
+
export function ignoreLocalState(cwd = process.cwd()) {
|
|
128
|
+
if (!existsSync(join(cwd, '.git'))) return null;
|
|
129
|
+
const gi = join(cwd, '.gitignore');
|
|
130
|
+
let text = '';
|
|
131
|
+
try { text = existsSync(gi) ? readFileSync(gi, 'utf8') : ''; } catch { return null; }
|
|
132
|
+
const already = text.split('\n').some((l) => {
|
|
133
|
+
const s = l.trim().replace(/\/$/, '');
|
|
134
|
+
return s === '.rsc' || s === '/.rsc';
|
|
135
|
+
});
|
|
136
|
+
if (already) return null;
|
|
137
|
+
const prefix = text === '' ? '' : (text.endsWith('\n') ? '' : '\n');
|
|
138
|
+
try {
|
|
139
|
+
appendFileSync(gi, `${prefix}\n# rsc local state (hooks, seals, logs) — machine-local, never committed\n.rsc/\n`);
|
|
140
|
+
return gi;
|
|
141
|
+
} catch { return null; }
|
|
142
|
+
}
|
|
143
|
+
|
|
118
144
|
export function listInstalled({ target, home, cwd = process.cwd() }) {
|
|
119
145
|
const paths = targetPaths(target, home, cwd);
|
|
120
146
|
return Object.keys(readState(paths.stateFile).skills);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// capabilities.js — "what do I already have that solves this?", answered by a
|
|
2
|
+
// command instead of by the model's memory.
|
|
3
|
+
//
|
|
4
|
+
// The automation-gap rule says: never propose BUILDING a skill or an agent before
|
|
5
|
+
// checking whether one already exists. A rule like that is only real if something
|
|
6
|
+
// enumerates the existing set — otherwise it is a decorative gate, the defect class
|
|
7
|
+
// this repo has paid for repeatedly. This file is that something.
|
|
8
|
+
//
|
|
9
|
+
// Three sources, one shape:
|
|
10
|
+
// installed — skill artifacts ON DISK, project and user scope, each tagged
|
|
11
|
+
// available — catalog skills not installed anywhere (ids; --full adds descriptions)
|
|
12
|
+
// agents — agent files on disk, project and user scope, each tagged
|
|
13
|
+
//
|
|
14
|
+
// Both skills and agents are read from DISK, never from the install state file: a
|
|
15
|
+
// state entry whose files were deleted would otherwise answer "covered — use it"
|
|
16
|
+
// for something that is not there, and that answer has no recovery path.
|
|
17
|
+
//
|
|
18
|
+
// 8 of 17 targets have file-based agents; on the other 9 the agent is advisory and
|
|
19
|
+
// there is nothing to enumerate. That is `agentsSupported: false`, never an error —
|
|
20
|
+
// the caller drops the agent branch instead of failing.
|
|
21
|
+
|
|
22
|
+
import { existsSync, readdirSync, statSync, appendFileSync, readFileSync, mkdirSync } from 'node:fs';
|
|
23
|
+
import { join, dirname, basename, resolve } from 'node:path';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { loadManifest } from './manifest.js';
|
|
26
|
+
import { targetPaths } from '../../targets/index.js';
|
|
27
|
+
import { developerAgentPath, targetHasAgents } from '../../targets/agents.js';
|
|
28
|
+
|
|
29
|
+
// AGENT_TARGETS is not exported, so derive dir + extension from the one path helper
|
|
30
|
+
// that owns it — duplicating that table is how two copies drift apart. The extension
|
|
31
|
+
// is taken as everything after the agent's name, because `extname()` cannot recover a
|
|
32
|
+
// COMPOUND extension: copilot writes `developer.agent.md`, where extname says '.md',
|
|
33
|
+
// which both mis-names the agent and degrades the filter to every markdown file.
|
|
34
|
+
const AGENT_NAME = 'developer';
|
|
35
|
+
function agentSpecFor(target, root) {
|
|
36
|
+
const p = developerAgentPath(target, root);
|
|
37
|
+
if (!p) return null;
|
|
38
|
+
const base = basename(p);
|
|
39
|
+
const i = base.indexOf(AGENT_NAME);
|
|
40
|
+
const ext = i === 0 ? base.slice(AGENT_NAME.length) : base.slice(base.indexOf('.'));
|
|
41
|
+
return { dir: dirname(p), ext };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Real files only. Directories (a dir named `foo.md` is not an agent), dangling
|
|
45
|
+
// symlinks and dotfiles are not capabilities.
|
|
46
|
+
function entriesWithExt(dir, ext) {
|
|
47
|
+
if (!dir || !existsSync(dir)) return [];
|
|
48
|
+
try {
|
|
49
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
50
|
+
.filter((e) => !e.name.startsWith('.') && e.name.endsWith(ext) && e.name.length > ext.length)
|
|
51
|
+
.filter((e) => {
|
|
52
|
+
if (e.isFile()) return true;
|
|
53
|
+
if (!e.isSymbolicLink()) return false;
|
|
54
|
+
try { return statSync(join(dir, e.name)).isFile(); } catch { return false; } // dangling
|
|
55
|
+
})
|
|
56
|
+
.map((e) => e.name)
|
|
57
|
+
.sort();
|
|
58
|
+
} catch { return []; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Project scope first, then user scope — unless they are the same directory (a
|
|
62
|
+
// project that IS the home dir, plausible for the non-code harnesses), where one
|
|
63
|
+
// artifact must not be reported twice.
|
|
64
|
+
function bothScopes(cwd, home, forRoot) {
|
|
65
|
+
const project = forRoot(cwd);
|
|
66
|
+
const user = forRoot(home);
|
|
67
|
+
const same = project?.dir && user?.dir && resolve(project.dir) === resolve(user.dir);
|
|
68
|
+
return same ? [[project, 'project']] : [[project, 'project'], [user, 'user']];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listAgents({ target, home, cwd = process.cwd() } = {}) {
|
|
72
|
+
if (!targetHasAgents(target)) return { supported: false, agents: [] };
|
|
73
|
+
const agents = [];
|
|
74
|
+
for (const [spec, scope] of bothScopes(cwd, home || homedir(), (r) => agentSpecFor(target, r))) {
|
|
75
|
+
if (!spec) continue;
|
|
76
|
+
for (const name of entriesWithExt(spec.dir, spec.ext)) {
|
|
77
|
+
agents.push({ id: name.slice(0, -spec.ext.length), scope, path: join(spec.dir, name) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { supported: true, agents };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Installed skills, read from disk in both scopes. `skillExt` targets (cursor) store
|
|
84
|
+
// each skill as a single file; the rest use a directory per skill.
|
|
85
|
+
export function listSkills({ target, home, cwd = process.cwd() } = {}) {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const [paths, scope] of bothScopes(cwd, home || homedir(), (r) => {
|
|
88
|
+
try { const p = targetPaths(target, r, r); return { dir: p.root, probe: p.skillDir('__probe__') }; }
|
|
89
|
+
catch { return null; }
|
|
90
|
+
})) {
|
|
91
|
+
if (!paths?.dir || !existsSync(paths.dir)) continue;
|
|
92
|
+
const ext = basename(paths.probe).replace('__probe__', ''); // '' for dir-per-skill targets
|
|
93
|
+
try {
|
|
94
|
+
for (const e of readdirSync(paths.dir, { withFileTypes: true })) {
|
|
95
|
+
if (e.name.startsWith('.')) continue;
|
|
96
|
+
if (ext) { if (e.isFile() && e.name.endsWith(ext)) out.push({ id: e.name.slice(0, -ext.length), scope }); }
|
|
97
|
+
else if (e.isDirectory() && existsSync(join(paths.dir, e.name, 'SKILL.md'))) out.push({ id: e.name, scope });
|
|
98
|
+
}
|
|
99
|
+
} catch { /* unreadable scope → contributes nothing */ }
|
|
100
|
+
}
|
|
101
|
+
return out.sort((a, b) => a.id.localeCompare(b.id) || a.scope.localeCompare(b.scope));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Keep the discriminator: a description's value is its `NOT x (that is y)` boundary,
|
|
105
|
+
// so never cut it off. Trim the middle instead when the whole thing is long.
|
|
106
|
+
export function shortDesc(d, limit = 220) {
|
|
107
|
+
const s = String(d || '').replace(/\s+/g, ' ').trim();
|
|
108
|
+
if (s.length <= limit) return s;
|
|
109
|
+
const not = s.search(/\bNOT\b/);
|
|
110
|
+
if (not > 0) {
|
|
111
|
+
const tail = s.slice(not);
|
|
112
|
+
const head = s.slice(0, Math.max(0, limit - tail.length - 2));
|
|
113
|
+
if (head) return `${head.trim()}… ${tail}`;
|
|
114
|
+
}
|
|
115
|
+
return `${s.slice(0, limit - 1)}…`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// `full: true` adds catalog descriptions. Off by default because the catalog's
|
|
119
|
+
// descriptions are ~40 KB and `rsc catalog --available` already serves meaning-based
|
|
120
|
+
// matching — paying for the same bytes twice is the cost this harness keeps cutting.
|
|
121
|
+
export function capabilities({ target, home, cwd = process.cwd(), full = false } = {}) {
|
|
122
|
+
const manifest = loadManifest();
|
|
123
|
+
const byId = new Map(manifest.skills.map((s) => [s.id, s]));
|
|
124
|
+
const skills = listSkills({ target, home, cwd });
|
|
125
|
+
const installedIds = new Set(skills.map((s) => s.id));
|
|
126
|
+
const { supported, agents } = listAgents({ target, home, cwd });
|
|
127
|
+
return {
|
|
128
|
+
target,
|
|
129
|
+
agentsSupported: supported,
|
|
130
|
+
installed: skills.map((s) => ({ ...s, description: shortDesc(byId.get(s.id)?.description) })),
|
|
131
|
+
available: manifest.skills
|
|
132
|
+
.filter((s) => !installedIds.has(s.id))
|
|
133
|
+
.map((s) => (full ? { id: s.id, description: shortDesc(s.description) } : { id: s.id }))
|
|
134
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
135
|
+
agents,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- the gap log ---------------------------------------------------------------
|
|
140
|
+
//
|
|
141
|
+
// Written through this function, never by a skill composing markdown by hand — a
|
|
142
|
+
// free-form writer drifts in format within a few sessions and then nothing can read
|
|
143
|
+
// it back. Nothing DOES read it back (by spec): its reader is the user, and doctor
|
|
144
|
+
// only counts entries.
|
|
145
|
+
//
|
|
146
|
+
// PRIVACY: `procedure` is the assistant's own description of what it observed doing.
|
|
147
|
+
// The user's literal request must never be passed here. This function enforces SHAPE
|
|
148
|
+
// ONLY — it cannot tell a description from a paraphrase, and it does not pretend to.
|
|
149
|
+
// The rule lives in skills/skill-scout/SKILL.md, where it is held by whoever writes.
|
|
150
|
+
|
|
151
|
+
export const GAP_VERDICTS = [
|
|
152
|
+
'covered-installed', 'covered-catalog', 'covered-agent',
|
|
153
|
+
'proposed-accepted', 'proposed-declined',
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
export function gapLogPath(cwd = process.cwd()) {
|
|
157
|
+
return join(cwd, '.rsc', 'automation-gaps.md');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Any whitespace run collapses, not just \n: a lone \r is a CommonMark line ending,
|
|
161
|
+
// so it would render as an extra (forged, dated) entry while `cat` hid it.
|
|
162
|
+
const oneLine = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
163
|
+
|
|
164
|
+
export function appendGap({ procedure, verdict, cwd = process.cwd(), now }) {
|
|
165
|
+
const text = oneLine(procedure);
|
|
166
|
+
if (!text) throw new Error('gap-log: a procedure description is required. Recover: pass --procedure "<what you observed doing>" — your own description of the work, never the user\'s words.');
|
|
167
|
+
if (!GAP_VERDICTS.includes(verdict)) {
|
|
168
|
+
throw new Error(`gap-log: verdict must be one of ${GAP_VERDICTS.join(', ')}. Recover: re-run with a valid --verdict.`);
|
|
169
|
+
}
|
|
170
|
+
const p = gapLogPath(cwd);
|
|
171
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
172
|
+
let needsHeader = true;
|
|
173
|
+
try { needsHeader = statSync(p).size === 0; } catch { needsHeader = true; }
|
|
174
|
+
const header = needsHeader
|
|
175
|
+
? '# Huecos de automatización\n\nProcedimientos que el asistente observó y qué pasó con ellos. Lo escribe `rsc capabilities gap-log`.\nDebe contener solo la descripción que el asistente hizo de su propio trabajo, no peticiones del usuario — es una regla que sostiene quien escribe, no algo que el código pueda comprobar. Fichero local: no lo commitees.\n\n'
|
|
176
|
+
: '';
|
|
177
|
+
const d = now || new Date();
|
|
178
|
+
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
179
|
+
appendFileSync(p, `${header}- ${day} · ${text} · **${verdict}**\n`);
|
|
180
|
+
return p;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function countGaps(cwd = process.cwd()) {
|
|
184
|
+
try {
|
|
185
|
+
const p = gapLogPath(cwd);
|
|
186
|
+
if (!existsSync(p)) return 0;
|
|
187
|
+
return readFileSync(p, 'utf8').split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2} · /.test(l)).length;
|
|
188
|
+
} catch { return 0; } // a malformed log must never take down `doctor`
|
|
189
|
+
}
|
package/scripts/rsc.js
CHANGED
|
@@ -327,6 +327,49 @@ async function main() {
|
|
|
327
327
|
say('Use: npx @ericrisco/rsc registry refresh | registry status');
|
|
328
328
|
return;
|
|
329
329
|
}
|
|
330
|
+
case 'capabilities': {
|
|
331
|
+
// "What do I already have that solves this?" — the deterministic step the
|
|
332
|
+
// automation-gap rule requires BEFORE anyone proposes creating a skill or an
|
|
333
|
+
// agent. Without this command that rule would rest on the model's memory.
|
|
334
|
+
const { capabilities, appendGap, countGaps } = await import('./lib/capabilities.js');
|
|
335
|
+
const { resolveRoot } = await import('../targets/sello.mjs');
|
|
336
|
+
// A string flag must not swallow the next FLAG as its value (`flag()` returns
|
|
337
|
+
// the following token blindly), or `--procedure --verdict x` records "--verdict".
|
|
338
|
+
const str = (name) => {
|
|
339
|
+
const v = flag(name);
|
|
340
|
+
return typeof v === 'string' && !v.startsWith('--') ? v : undefined;
|
|
341
|
+
};
|
|
342
|
+
// Subcommand by presence, not by position: `capabilities --target claude gap-log`
|
|
343
|
+
// used to fall through and silently record nothing.
|
|
344
|
+
if (argv.includes('gap-log')) {
|
|
345
|
+
// The writer and doctor must agree on the root, or a run from a subdirectory
|
|
346
|
+
// writes a log nothing counts.
|
|
347
|
+
const root = resolveRoot(process.cwd());
|
|
348
|
+
try {
|
|
349
|
+
const p = appendGap({ procedure: str('procedure'), verdict: str('verdict'), cwd: root });
|
|
350
|
+
say(`📝 recorded (${countGaps(root)} total): ${p}`);
|
|
351
|
+
} catch (e) { say(e.message); process.exitCode = 1; }
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const full = argv.includes('--full');
|
|
355
|
+
const reports = targets.map((t) => capabilities({ target: t, full }));
|
|
356
|
+
if (argv.includes('--json')) {
|
|
357
|
+
return void say(JSON.stringify(reports.length === 1 ? reports[0] : reports, null, 2));
|
|
358
|
+
}
|
|
359
|
+
for (const caps of reports) {
|
|
360
|
+
if (reports.length > 1) say(`# target: ${caps.target}`);
|
|
361
|
+
for (const s of caps.installed) say(`skill\t${s.id}\tinstalled-${s.scope}\t${s.description}`);
|
|
362
|
+
for (const s of caps.available) say(`skill\t${s.id}\tavailable${s.description ? `\t${s.description}` : ''}`);
|
|
363
|
+
if (caps.agentsSupported) {
|
|
364
|
+
for (const a of caps.agents) say(`agent\t${a.id}\t${a.scope}\t${a.path}`);
|
|
365
|
+
if (!caps.agents.length) say('# no agent files found (this target supports them)');
|
|
366
|
+
} else {
|
|
367
|
+
say(`# ${caps.target} has no file-based agents — the agent option does not apply here`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!full) say('# catalog shown as ids; add --full for descriptions, or use `catalog --available` to match by meaning');
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
330
373
|
case 'sello': {
|
|
331
374
|
// Deterministic transitions of the sello (review receipt). The `review` skill
|
|
332
375
|
// orchestrates the lenses; every state change and every check runs HERE, token-free.
|
|
@@ -508,7 +551,8 @@ async function main() {
|
|
|
508
551
|
return void (await runPurge(argv.includes('--dry-run'), argv.includes('--with-docs')));
|
|
509
552
|
default:
|
|
510
553
|
say(`rsc: unknown command '${cmd}'.`);
|
|
511
|
-
say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | audit | registry refresh | doctor | sync | sello <on|off|status|…> | backups | restore <id|latest> | upgrade | uninstall <id> | purge');
|
|
554
|
+
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 | uninstall <id> | purge');
|
|
555
|
+
process.exitCode = 1;
|
|
512
556
|
}
|
|
513
557
|
}
|
|
514
558
|
|
package/scripts/skill-rubric.md
CHANGED
|
@@ -16,7 +16,7 @@ dishonest 9.
|
|
|
16
16
|
| 3 | **Body craft & progressive disclosure** | 0.15 | **The smallest body that routes correctly** — ≤400 lines is a ceiling, not a target, and there is no minimum: a skill that does its job in 60 lines scores higher than the same skill padded to 200. Every `references/` file is pointed to inline from this body (an unlinked reference is never loaded, so it is dead weight). rsc voice, every fence language-tagged, a checklist/decision table only where the flow branches, an anti-patterns table present. |
|
|
17
17
|
| 4 | **Correctness, grounding & freshness** | **0.25** | Every load-bearing claim is accurate and **current (2025–2026)**: real library/API versions, no hallucinated flags/endpoints. The spec cites **authoritative sources with dates**; nothing rests on stale or invented facts. This is the heaviest dimension by design. |
|
|
18
18
|
| 5 | **Actionability** | 0.15 | Concrete and runnable: real numbers, paths, commands, decision rules — not adjectives. A practitioner could act from it without leaving the skill. |
|
|
19
|
-
| 6 | **Evals quality** | 0.10 | ≥5 `should_trigger` (incl. non-obvious), ≥4 `should_not_trigger` each with a `route_to` that
|
|
19
|
+
| 6 | **Evals quality** | 0.10 | ≥5 `should_trigger` (incl. non-obvious), ≥4 `should_not_trigger` each with a `route_to` that resolves — a **real** catalog id, `none`, or `external:<name>` (enforced by `eval-lint`), ≥1 `capability` with a meaningful `must_include` rubric. The cases are genuine, not filler. |
|
|
20
20
|
| 7 | **Originality & safety** | 0.10 | rsc voice, not a clone of any external catalog (no borrowed urgency blocks / rationalization tables / `*-reviewer-prompt.md` conventions / verbatim flowcharts). `verify.sh` present iff there is a checkable artifact. No destructive or unsafe advice presented without guardrails. |
|
|
21
21
|
|
|
22
22
|
**Weighted score** = Σ(subscore × weight), rounded to 1 decimal. **Ship gate: ≥ 8.5.**
|
|
@@ -17,7 +17,7 @@ Everything lives in `evals/cases.yaml` (the cases) and `evals/README.md` (how to
|
|
|
17
17
|
|
|
18
18
|
`scripts/eval-lint.sh` parses every `cases.yaml` and fails the build only on the **structural minimums**: that `evals/cases.yaml` exists, that `should_trigger`, `should_not_trigger`, and `capability` are present as lists, and that their item counts meet **≥ 5 / ≥ 4 / ≥ 1**. (Without python3+PyYAML it degrades to a presence-only key check.) Run it before shipping to catch a missing or undersized section.
|
|
19
19
|
|
|
20
|
-
It
|
|
20
|
+
It also resolves every `route_to`: each value must be a real catalog skill id, `none`, or `external:<name>` for a skill that deliberately lives outside this catalog — a stale route now fails the build. What it still does **not** read: whether each `should_not_trigger` actually carries a `route_to`, whether your `should_trigger` set includes a genuinely non-obvious or non-English phrasing, and whether each `capability` scenario has a real `must_include` rubric of gradeable points. Those are **author and review responsibilities**: verify them by hand (and in the self-audit / code-review pass) before shipping. A green eval-lint means the shape is right, not that the cases are good.
|
|
21
21
|
|
|
22
22
|
## cases.yaml structure
|
|
23
23
|
|
|
@@ -33,7 +33,7 @@ should_trigger:
|
|
|
33
33
|
|
|
34
34
|
should_not_trigger:
|
|
35
35
|
- prompt: "A near-miss that looks close but belongs elsewhere."
|
|
36
|
-
route_to: "sibling-id" # a
|
|
36
|
+
route_to: "sibling-id" # a real catalog id, "none", or "external:<name>" — checked by eval-lint
|
|
37
37
|
why: "Why it is NOT this skill and why the sibling owns it."
|
|
38
38
|
# … ≥ 4 total
|
|
39
39
|
|
|
@@ -53,7 +53,7 @@ capability:
|
|
|
53
53
|
|
|
54
54
|
## Writing good `should_not_trigger` cases
|
|
55
55
|
|
|
56
|
-
These are where descriptions get sharpened. Each near-miss should be genuinely tempting — adjacent in topic but owned by a sibling. The `route_to` must name a skill that **exists in this repo
|
|
56
|
+
These are where descriptions get sharpened. Each near-miss should be genuinely tempting — adjacent in topic but owned by a sibling. The `route_to` must name a skill that **exists in this repo** — `eval-lint` fails the build otherwise. If the right owner genuinely ships outside this catalog, say so with `external:<name>` rather than inventing an id: the prefix makes the claim visible instead of indistinguishable from drift. Pick the siblings most likely to be confused with this one and write a case that disambiguates each.
|
|
57
57
|
|
|
58
58
|
For `author-skill`, the natural confusables are `specify`/`plan` (building a feature, not a skill), `building-agents` (agent loops), `harness` (generic docs/wiki), and `init` (bootstrapping). Route each near-miss to whichever it truly belongs to.
|
|
59
59
|
|
|
@@ -24,7 +24,7 @@ should_not_trigger:
|
|
|
24
24
|
route_to: "none"
|
|
25
25
|
why: "One-shot throwaway prompt with no tools/eval/production path — the skill says just call the SDK directly."
|
|
26
26
|
- prompt: "Tune Anthropic prompt caching and extended thinking in our anthropic-only client.py to cut latency on a file that only imports the anthropic SDK."
|
|
27
|
-
route_to: "claude-api"
|
|
27
|
+
route_to: "external:claude-api"
|
|
28
28
|
why: "Single-vendor Anthropic-SDK internals tuning in a file that only imports anthropic defers to the dedicated Anthropic skill; this skill stays multi-provider."
|
|
29
29
|
- prompt: "Write the pgvector schema and an HNSW-indexed nearest-neighbour SQL query, plus tuning advice — just the database side, no LLM calls."
|
|
30
30
|
route_to: "postgresdb"
|
|
@@ -22,14 +22,14 @@ should_not_trigger:
|
|
|
22
22
|
route_to: "marketing"
|
|
23
23
|
why: "Sales/landing copy for the course is marketing's domain. The skill teaches; it never bolts a pitch on, and explicitly defers selling words to marketing."
|
|
24
24
|
- prompt: "Audit module 14 of my course for gaps, redundancy, anachronisms, and outdated stack references, and give me a written report."
|
|
25
|
-
route_to: "review-content"
|
|
25
|
+
route_to: "external:review-content"
|
|
26
26
|
why: "Diagnostic content-audit pass with a written report. The skill says run review-content first and bring findings here; it rebuilds, it does not audit."
|
|
27
27
|
- prompt: "Design a color system, typography scale, and diagram style for my course slides."
|
|
28
28
|
route_to: "design"
|
|
29
29
|
why: "Pure visual system / pixels. The skill defers the visual layer to design; it owns narrative, not aesthetics."
|
|
30
30
|
- prompt: "Fact-check whether the historical claims in my lesson about the origins of TCP are actually accurate, with sources."
|
|
31
|
-
route_to: "
|
|
32
|
-
why: "Pure fact-checking/research of subject matter, not reframing teaching. The skill forbids inventing proof and defers verification to deep-research."
|
|
31
|
+
route_to: "research-ops"
|
|
32
|
+
why: "Pure fact-checking/research of subject matter, not reframing teaching. The skill forbids inventing proof and defers verification to a research pass — `research-ops` in this catalog, a deep-research tool where the environment provides one."
|
|
33
33
|
|
|
34
34
|
capability:
|
|
35
35
|
- scenario: "A learner profile already exists and is complete in 02-DOCS/wiki/teaching/. The user gives a flat, definition-only lesson on 'idempotency' for a mid-level backend course and asks to make it land."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: skill-scout
|
|
3
|
-
description: "Use when a specialized skill probably exists but is not loaded, when a task feels harder than it should because generic reasoning is doing a domain skill's job,
|
|
3
|
+
description: "Use when a specialized skill probably exists but is not loaded, when a task feels harder than it should because generic reasoning is doing a domain skill's job, when the work just delivered was a repeatable procedure worth turning into a skill or an agent, or when auditing coverage for holes. Resolves against what already exists — installed skills, catalog skills and agent files — then names the gap and emits the install command. NOT routing among skills you already have (that is `suggest`), NOT writing the artifact when none exists (that is `author-skill` for skills, `building-agents` for agents)."
|
|
4
4
|
tags: [skill-discovery, capability-gap, meta, recommendations, coverage-audit]
|
|
5
5
|
recommends: [suggest, author-skill, context-budget, continuous-learning, knowledge-ops]
|
|
6
6
|
origin: risco
|
|
@@ -8,9 +8,9 @@ origin: risco
|
|
|
8
8
|
|
|
9
9
|
# skill-scout — find the skill you don't have
|
|
10
10
|
|
|
11
|
-
You are the **gap detector** for the skills catalog. Before a task starts
|
|
11
|
+
You are the **gap detector** for the skills catalog. Before a task starts, in the middle of one that is dragging, or right after delivering work that turned out to be a procedure — you answer exactly one question: *is there a skill that should be handling this, and is it loaded?* You do **not** do the work. You name what to load, then get out of the way.
|
|
12
12
|
|
|
13
|
-
Every run ends in one of
|
|
13
|
+
Every run ends in one of the verdicts in the table below:
|
|
14
14
|
|
|
15
15
|
- **INSTALLED-elsewhere** — the skill exists in the catalog and on disk somewhere (user scope, another project, a plugin) but is not active *here*. → emit a scope-fix / install command.
|
|
16
16
|
- **MISSING** — the skill exists in the catalog but is not installed at all. → emit an install command and a one-line why.
|
|
@@ -26,11 +26,46 @@ Why this matters: skills load by **progressive disclosure**. At session start Cl
|
|
|
26
26
|
| Clear match, exists at user scope but not in this project (or vice versa) | INSTALLED-elsewhere | Emit the scope-fix (copy to the missing scope) + why. |
|
|
27
27
|
| 2–3 plausible candidates, none dominant | AMBIGUOUS | Name the top 2 with a one-line distinction; ask which fits. Do not guess. |
|
|
28
28
|
| Present skill already covers this task | NO GAP | Say so and hand to `../suggest/SKILL.md` (routing among present skills) — that is its job, not yours. |
|
|
29
|
+
| An installed **agent** already does this work | AGENT-COVERS | Name it, use it (delegate), propose nothing. Agents are listed by `npx @ericrisco/rsc capabilities`; they are invisible to the catalog, so a scan that skips them proposes duplicates. |
|
|
29
30
|
| Catalog match present but its description is too vague to ever fire | WEAK-DESCRIPTION | Treat as a gap; route to `../author-skill/SKILL.md` to fix the frontmatter. |
|
|
30
31
|
| No catalog id fits at all | NONEXISTENT | Route to `../author-skill/SKILL.md`. Do not fabricate an id. |
|
|
31
32
|
|
|
32
33
|
The branch you must never collapse: **MISSING vs NONEXISTENT.** Confusing them either sends the user to write a skill that already exists, or has them wait for an install command for a skill that was never authored.
|
|
33
34
|
|
|
35
|
+
## The automation gap — the trigger that fires *after* the work
|
|
36
|
+
|
|
37
|
+
Everything above is preventive: you scan *before* generic reasoning does a specialist's job. There is a second moment, and it is the opposite one — **the work is already done and delivered**, and only then you notice its shape:
|
|
38
|
+
|
|
39
|
+
> The last thing you did by hand was a **procedure**: several mostly-deterministic steps, a recognizable result, repeatable by someone else from a description, and plausibly needed again.
|
|
40
|
+
|
|
41
|
+
Then, in this order and never skipping ahead:
|
|
42
|
+
|
|
43
|
+
1. **Deliver first.** This never delays, conditions or replaces the work asked for. Raising it beforehand turns a simple request into a conversation about harness architecture.
|
|
44
|
+
2. **Enumerate what exists** — `npx @ericrisco/rsc capabilities` lists installed skills (with their scope), **agent files** and the catalog ids in one pass. Read it; do not recall it. Add `--full` for catalog descriptions, or use `catalog --available` when you need to match by meaning — the default stays cheap on purpose. If the command cannot run, **propose nothing** and say the check did not happen: never propose building on a check that did not occur.
|
|
45
|
+
3. **Something covers it** → use it (install with the usual one-word confirm if it is catalog-only), and **propose nothing**. Proposing to build what already exists is the worst outcome available here — it costs attention and it spends the credibility of every later suggestion.
|
|
46
|
+
4. **Nothing covers it** → one sentence at the end of the work: the procedure you saw, and whether it fits as a skill or an agent. Then stop; the user decides. Their "no" ends it for this procedure for the rest of the session.
|
|
47
|
+
5. **Record it either way** — `npx @ericrisco/rsc capabilities gap-log --procedure "<what YOU observed doing>" --verdict <covered-installed|covered-catalog|covered-agent|proposed-accepted|proposed-declined>`.
|
|
48
|
+
|
|
49
|
+
**Scale the sentence to the dial**, never whether it appears. Read the accompaniment level in `02-DOCS/wiki/harness/user-profile.md`: at L0/L1 it is one dry line ("this looked like a procedure — worth a skill?"); at L2/L3 add why it qualified and what the skill-vs-agent difference means for them. A profile that asked for brevity still gets the observation, just not the essay.
|
|
50
|
+
|
|
51
|
+
**The privacy boundary is yours to hold**, not the command's — it validates shape and nothing else, and it cannot tell your description from a paraphrase of the request. `--procedure` carries *your* account of the work you did. Never the user's words. The test: if the line could be reconstructed from what they typed, it does not belong in the log.
|
|
52
|
+
|
|
53
|
+
**Which log:** this one (`.rsc/automation-gaps.md`, verdicts `covered-*`/`proposed-*`) is for the after-the-work automation gap. The coverage-audit log below (`skill-gaps.jsonl`, verdicts `MISSING`/`NONEXISTENT`) is for a deliberate catalog audit. One event, one log — never both.
|
|
54
|
+
|
|
55
|
+
**Silence is a correct and common outcome.** A one-off, an exploration, a one-liner, or work that failed is not a procedure — a procedure is extracted from something that worked. There is no switch to turn this off, which is exactly why the bar is high: propose on everything and the feature becomes noise a user cannot escape.
|
|
56
|
+
|
|
57
|
+
### Skill or agent
|
|
58
|
+
|
|
59
|
+
Decided by the nature of the work, never its size:
|
|
60
|
+
|
|
61
|
+
| | Fits a **skill** | Fits an **agent** |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| Shape | Knowledge + procedure that must **fire at the right moment**, in the conversation, user present | Work you want to **delegate**: its own context, possibly in parallel, possibly another model |
|
|
64
|
+
| Tell | "Whenever X comes up, do it this way" | "Go do X and come back with the result" |
|
|
65
|
+
| Build with | `../author-skill/SKILL.md` | `../building-agents/SKILL.md` |
|
|
66
|
+
|
|
67
|
+
When both fit, name the cheaper one to try and say the other is possible. When it is genuinely ambiguous, ask instead of choosing. On a target with no file-based agents (`capabilities` says so), the agent option does not exist — do not offer it.
|
|
68
|
+
|
|
34
69
|
## Symptoms that the task should have a skill
|
|
35
70
|
|
|
36
71
|
Watch for these. Any one is enough to run a scan:
|
|
@@ -129,5 +164,6 @@ One JSON object per line, append-only. `recommended_id` must be a real catalog i
|
|
|
129
164
|
|
|
130
165
|
- `../suggest/SKILL.md` — routing among the skills you already have. suggest is routing; you are procurement. If the right skill is already present, it's suggest's job, not yours.
|
|
131
166
|
- `../author-skill/SKILL.md` — building a new skill when none exists. You own the *exists / missing / nonexistent* decision; the moment the verdict is NONEXISTENT (or WEAK-DESCRIPTION), hand off here.
|
|
167
|
+
- `../building-agents/SKILL.md` — designing the agent when the gap is delegated work rather than in-conversation procedure (see *Skill or agent* above).
|
|
132
168
|
- `context-budget` — when the loaded set is too heavy for the window. That is token budgeting of what *is* loaded; orthogonal to finding what is *absent*. (Not yet on disk; do not link until it ships.)
|
|
133
169
|
- `continuous-learning` / `knowledge-ops` — when the pattern is a recurring *learning* to capture, not a missing skill to install. (Not yet on disk.)
|
package/skills/suggest/SKILL.md
CHANGED
|
@@ -71,6 +71,12 @@ and never to interrupt a flow with a nice-to-have. Never recommend something alr
|
|
|
71
71
|
nothing for natural-language or non-English intent. Never let it decide, and never read its silence
|
|
72
72
|
as "no skill exists" — the catalog plus your judgment is the source of truth.
|
|
73
73
|
|
|
74
|
+
### Automation gap — after the work
|
|
75
|
+
|
|
76
|
+
Delivered work a repeatable **procedure**? Before proposing to *build* anything, run
|
|
77
|
+
`npx @ericrisco/rsc capabilities`: covered by a skill or agent → use it, say nothing.
|
|
78
|
+
Not covered → one line, skill or agent. Rules: `skill-scout`.
|
|
79
|
+
|
|
74
80
|
---
|
|
75
81
|
|
|
76
82
|
## 3. First contact
|
package/targets/sello.mjs
CHANGED
|
@@ -35,8 +35,14 @@ import { spawnSync } from 'node:child_process';
|
|
|
35
35
|
// sello would mutate the change being sealed, so `freeze` → `approve` could never
|
|
36
36
|
// converge (a permanent, unrecoverable delivery lockout). Excluded whether the
|
|
37
37
|
// project tracks .rsc/ or not.
|
|
38
|
+
// Every tool-written journal under .rsc/ belongs here, not just the sello's own:
|
|
39
|
+
// `.rsc/` is a non-lowerable tier-2 risk class, so ANY file the harness writes
|
|
40
|
+
// automatically would escalate risk and invalidate an approved seal on every write.
|
|
41
|
+
// The automation-gap log is exactly that — it is appended after every piece of work,
|
|
42
|
+
// with no switch to stop it, so leaving it out made the lockout permanent.
|
|
38
43
|
export const SELLO_STATE_PATHS = [
|
|
39
44
|
'.rsc/sello.json', '.rsc/sello-config.json', '.rsc/sello-findings.md', '.rsc/sello-log.jsonl',
|
|
45
|
+
'.rsc/automation-gaps.md',
|
|
40
46
|
];
|
|
41
47
|
|
|
42
48
|
export function selloPaths(root) {
|