@ericrisco/rsc 1.0.21 → 1.0.23
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/install-apply.js +21 -11
- package/scripts/lib/capabilities.js +34 -2
- package/scripts/lib/repetition-report.js +47 -0
- package/scripts/lib/repetition.js +186 -0
- package/scripts/rsc.js +20 -0
- package/targets/agents.js +118 -26
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/install-apply.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
3
|
+
import { join, dirname, basename } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { planInstall } from './install-plan.js';
|
|
6
6
|
import { targetPaths, writeSkill, wireHook, unwireHook, baseDir, TARGET_IDS } from '../targets/index.js';
|
|
7
|
-
import { targetHasAgents,
|
|
7
|
+
import { targetHasAgents, writeAgents, removeAgents, agentPath, agentNames } from '../targets/agents.js';
|
|
8
8
|
import { readState, writeState } from './lib/state.js';
|
|
9
9
|
import { createBackup } from './lib/backups.js';
|
|
10
10
|
|
|
@@ -70,7 +70,7 @@ function managedPathsForInstall({ skillIds, target, home, cwd }) {
|
|
|
70
70
|
const paths = targetPaths(target, home, cwd);
|
|
71
71
|
const plan = planInstall({ skillIds, target, home, cwd });
|
|
72
72
|
const out = [paths.stateFile, versionFile(cwd), baseVersionsFile(cwd)];
|
|
73
|
-
if (targetHasAgents(target)) out.push(
|
|
73
|
+
if (targetHasAgents(target)) out.push(...agentNames().map((n) => agentPath(target, cwd, n)), join(cwd, '.rsc', 'developer.json'));
|
|
74
74
|
for (const step of plan) {
|
|
75
75
|
if (step.kind === 'skill') {
|
|
76
76
|
out.push(step.to, baseDir(step.id, cwd));
|
|
@@ -103,11 +103,18 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
writeBaseVersions(cwd, baseVersions);
|
|
106
|
-
// Install the
|
|
107
|
-
// Code, Cursor, OpenCode, Gemini, Copilot, Junie, Kiro, Codex)
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
106
|
+
// Install the catalog's subagents for targets that support file-based agents (Claude
|
|
107
|
+
// Code, Cursor, OpenCode, Gemini, Copilot, Junie, Kiro, Codex): `developer` plus the three
|
|
108
|
+
// adversarial refuter lenses `review` dispatches at tier 2. They run at the balanced tier
|
|
109
|
+
// by default (never light/Haiku); the tier lives in .rsc/developer.json, set by `init` at
|
|
110
|
+
// onboarding and honored on every (re)install/sync.
|
|
111
|
+
//
|
|
112
|
+
// The recorded names come from what was WRITTEN, not from a hardcoded list: a state entry
|
|
113
|
+
// naming an agent whose file never landed answers "you have it" for something absent.
|
|
114
|
+
if (targetHasAgents(target)) {
|
|
115
|
+
const written = writeAgents(target, cwd);
|
|
116
|
+
state.agents = written.map((f) => basename(f).split('.')[0]);
|
|
117
|
+
}
|
|
111
118
|
state.version = CLI_VERSION;
|
|
112
119
|
writeState(paths.stateFile, state);
|
|
113
120
|
mkdirSync(dirname(versionFile(cwd)), { recursive: true });
|
|
@@ -214,9 +221,12 @@ export async function purge({ home, cwd = process.cwd(), withDocs = false, dryRu
|
|
|
214
221
|
}
|
|
215
222
|
// unwireHook mutates files, so only run it for real (dry runs skip it).
|
|
216
223
|
if (!dryRun) removed.push(...unwireHook(target, paths));
|
|
217
|
-
// Remove the
|
|
218
|
-
|
|
219
|
-
|
|
224
|
+
// Remove the subagents this catalog installed — and only those. An uninstaller that takes
|
|
225
|
+
// an agent the user wrote by hand is worse than one that leaves residue.
|
|
226
|
+
for (const n of agentNames()) {
|
|
227
|
+
const agentFile = agentPath(target, cwd, n);
|
|
228
|
+
if (agentFile) drop(agentFile);
|
|
229
|
+
}
|
|
220
230
|
}
|
|
221
231
|
drop(join(cwd, '.rsc'), true);
|
|
222
232
|
if (withDocs) drop(join(cwd, '02-DOCS'), true);
|
|
@@ -148,20 +148,39 @@ export function capabilities({ target, home, cwd = process.cwd(), full = false }
|
|
|
148
148
|
// ONLY — it cannot tell a description from a paraphrase, and it does not pretend to.
|
|
149
149
|
// The rule lives in skills/skill-scout/SKILL.md, where it is held by whoever writes.
|
|
150
150
|
|
|
151
|
+
// `proposed-capability` added 2026-08-18: none of the original five could express "this needs an
|
|
152
|
+
// executable capability, not a skill", which is the majority of what the repetition detector actually
|
|
153
|
+
// finds. Without it the honest case had to be recorded as one of the other five, and all five would
|
|
154
|
+
// have been false. Additive — the existing five keep validating unchanged.
|
|
151
155
|
export const GAP_VERDICTS = [
|
|
152
156
|
'covered-installed', 'covered-catalog', 'covered-agent',
|
|
153
|
-
'proposed-accepted', 'proposed-declined',
|
|
157
|
+
'proposed-accepted', 'proposed-declined', 'proposed-capability',
|
|
154
158
|
];
|
|
155
159
|
|
|
156
160
|
export function gapLogPath(cwd = process.cwd()) {
|
|
157
161
|
return join(cwd, '.rsc', 'automation-gaps.md');
|
|
158
162
|
}
|
|
159
163
|
|
|
164
|
+
/**
|
|
165
|
+
* The user-level log. A procedure repeated across three projects is STRONGER evidence that it should
|
|
166
|
+
* stop being manual, not weaker — and the per-repo log makes that pattern invisible by construction
|
|
167
|
+
* (clarify 2026-08-18). Nothing "wins" when the two disagree: they are summed, de-duplicated by
|
|
168
|
+
* date+procedure+verdict, so double-writing cannot double-count.
|
|
169
|
+
*/
|
|
170
|
+
export function globalGapLogPath(home = homedir()) {
|
|
171
|
+
return join(home, '.rsc', 'automation-gaps.md');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Repo name as it appears in a log line, so the offer can say WHERE it repeated. */
|
|
175
|
+
export function repoNameOf(cwd = process.cwd()) {
|
|
176
|
+
return basename(resolve(cwd));
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
// Any whitespace run collapses, not just \n: a lone \r is a CommonMark line ending,
|
|
161
180
|
// so it would render as an extra (forged, dated) entry while `cat` hid it.
|
|
162
181
|
const oneLine = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
163
182
|
|
|
164
|
-
export function appendGap({ procedure, verdict, cwd = process.cwd(), now }) {
|
|
183
|
+
export function appendGap({ procedure, verdict, cwd = process.cwd(), now, home = homedir() }) {
|
|
165
184
|
const text = oneLine(procedure);
|
|
166
185
|
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
186
|
if (!GAP_VERDICTS.includes(verdict)) {
|
|
@@ -177,6 +196,19 @@ export function appendGap({ procedure, verdict, cwd = process.cwd(), now }) {
|
|
|
177
196
|
const d = now || new Date();
|
|
178
197
|
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
179
198
|
appendFileSync(p, `${header}- ${day} · ${text} · **${verdict}**\n`);
|
|
199
|
+
|
|
200
|
+
// Also record globally, tagged with the repo. Wrapped: a home directory that cannot be written must
|
|
201
|
+
// never take down the local record, which is the one the current repo depends on.
|
|
202
|
+
try {
|
|
203
|
+
const g = globalGapLogPath(home);
|
|
204
|
+
mkdirSync(dirname(g), { recursive: true });
|
|
205
|
+
let gHeader = true;
|
|
206
|
+
try { gHeader = statSync(g).size === 0; } catch { gHeader = true; }
|
|
207
|
+
const gh = gHeader
|
|
208
|
+
? '# Huecos de automatización (global)\n\nAcumulado de todos los repos, para que un procedimiento repetido en varios proyectos sea visible.\nLo escribe `rsc capabilities gap-log`. Cada línea lleva `[repo:<nombre>]`. Fichero local: no lo commitees.\n\n'
|
|
209
|
+
: '';
|
|
210
|
+
appendFileSync(g, `${gh}- ${day} · ${text} · [repo:${repoNameOf(cwd)}] · **${verdict}**\n`);
|
|
211
|
+
} catch { /* the local log is what matters; a global failure is not fatal */ }
|
|
180
212
|
return p;
|
|
181
213
|
}
|
|
182
214
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// repetition-report.js — the I/O half of the repetition detector.
|
|
2
|
+
//
|
|
3
|
+
// Kept apart from repetition.js on purpose: the deciding is pure and heavily tested, the reading of two
|
|
4
|
+
// files is not. Mixing them would make the interesting logic need fixtures on disk.
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { gapLogPath, globalGapLogPath, capabilities } from './capabilities.js';
|
|
9
|
+
import { parseGapLog, groupProcedures, decideOffer, mergeLogs, THRESHOLD } from './repetition.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read local + global logs, group, decide. FAILS CLOSED: an unreadable log yields `blocked`, never an
|
|
13
|
+
* invented count. countGaps() already sets the precedent of not taking `doctor` down over a malformed
|
|
14
|
+
* log; the difference here is that the degradation is announced instead of swallowed.
|
|
15
|
+
*/
|
|
16
|
+
export function repetitionReport({ cwd = process.cwd(), home = homedir(), target = 'claude' } = {}) {
|
|
17
|
+
const read = (p, label) => {
|
|
18
|
+
if (!existsSync(p)) return { entries: [], missing: true };
|
|
19
|
+
try { return { entries: parseGapLog(readFileSync(p, 'utf8')), missing: false }; }
|
|
20
|
+
catch (e) { return { error: `no se pudo leer el registro ${label} (${p}): ${e.message}` }; }
|
|
21
|
+
};
|
|
22
|
+
const local = read(gapLogPath(cwd), 'local');
|
|
23
|
+
const global = read(globalGapLogPath(home), 'global');
|
|
24
|
+
if (local.error || global.error) {
|
|
25
|
+
return { blocked: true, reason: local.error || global.error, offer: null, uncertain: [], entries: 0 };
|
|
26
|
+
}
|
|
27
|
+
const merged = mergeLogs(local.entries, global.entries);
|
|
28
|
+
if (!merged.length) {
|
|
29
|
+
return { blocked: false, offer: null, reason: 'no hay entradas registradas todavía', uncertain: [], entries: 0 };
|
|
30
|
+
}
|
|
31
|
+
const { groups, uncertain } = groupProcedures(merged);
|
|
32
|
+
|
|
33
|
+
// "Already covered" is answered by the command that reads the disk, never by memory — the decision
|
|
34
|
+
// capabilities.js already made and for the right reason.
|
|
35
|
+
let covered = false;
|
|
36
|
+
try {
|
|
37
|
+
const caps = capabilities({ target, cwd });
|
|
38
|
+
const top = [...groups].sort((a, b) => b.entries.length - a.entries.length)[0];
|
|
39
|
+
if (top) {
|
|
40
|
+
const words = top.key.toLowerCase();
|
|
41
|
+
covered = caps.installed.some((s) => words.includes(s.id)) || (caps.agents || []).some((a) => words.includes(a.id));
|
|
42
|
+
}
|
|
43
|
+
} catch { /* enumeration failing must not fabricate "covered" — leave it false */ }
|
|
44
|
+
|
|
45
|
+
const { offer, reason } = decideOffer({ groups, threshold: THRESHOLD, covered });
|
|
46
|
+
return { blocked: false, offer, reason, uncertain, entries: merged.length, groups: groups.length };
|
|
47
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// repetition.js — has this procedure been done before, and does it deserve to stop being manual?
|
|
2
|
+
//
|
|
3
|
+
// The always-on rule already asks "did you just deliver a repeatable PROCEDURE?" — a judgment about ONE
|
|
4
|
+
// episode, made by the same agent that just performed it, at the moment it is most convinced its work
|
|
5
|
+
// was special. Grepping the always-on layer for repeat|repetit|twice|recurr returns nothing: nothing
|
|
6
|
+
// looks backwards.
|
|
7
|
+
//
|
|
8
|
+
// The registry that would allow it already exists. `rsc capabilities gap-log` writes one line per
|
|
9
|
+
// observed procedure, and the only thing that consumes it is countGaps(), which returns a TOTAL for
|
|
10
|
+
// `doctor` to display. The ledger is written and nobody interrogates it — the passive form of the same
|
|
11
|
+
// P2 defect.
|
|
12
|
+
//
|
|
13
|
+
// The evidence is the session that wrote this: four manual mutation runs (8, 17, 12, 6 mutants) and
|
|
14
|
+
// five identical score-then-rescore passes, none of which ever triggered the rule, because each episode
|
|
15
|
+
// looked like one-off work on its own.
|
|
16
|
+
//
|
|
17
|
+
// Two decisions from clarify (2026-08-18): the count is GLOBAL with a per-repo breakdown (a procedure
|
|
18
|
+
// repeated across projects is stronger signal, not weaker), and the threshold is THREE — a reasoned
|
|
19
|
+
// bet, not a measurement, and it says so.
|
|
20
|
+
|
|
21
|
+
import { tokenize } from './text-rank.js';
|
|
22
|
+
|
|
23
|
+
export const THRESHOLD = 3;
|
|
24
|
+
|
|
25
|
+
// Numerals and structural filler carry no information about WHICH procedure this is — "eight mutants"
|
|
26
|
+
// and "twelve mutants" are the same work. Dropping them is what made the margin measurable at all.
|
|
27
|
+
const FILLER = /^(uno|dos|tres|cuatro|cinco|seis|siete|ocho|nueve|diez|once|doce|trece|catorce|quince|dieciseis|diecisiet|dieciocho|diecinuev|veint|cada|por|sobr|un|una|one|two|three|four|five|six|seven|eight|nine|ten|each)$/;
|
|
28
|
+
|
|
29
|
+
// CALIBRATED, NOT GUESSED. Three metrics were measured on real descriptions from the session that
|
|
30
|
+
// motivated this (3 same-procedure pairs, 12 different-procedure pairs):
|
|
31
|
+
//
|
|
32
|
+
// metric same different margin
|
|
33
|
+
// jaccard 0.17–0.30 ≤0.09 0.08
|
|
34
|
+
// overlap 0.29–0.50 ≤0.20 0.09
|
|
35
|
+
// dice 0.29–0.46 ≤0.17 0.12 ← widest, so this is the one
|
|
36
|
+
//
|
|
37
|
+
// The first version used Jaccard at 0.6 and grouped nothing: the real case came out as four groups of
|
|
38
|
+
// one. Guessing a threshold for a similarity metric is how you ship a detector that never fires.
|
|
39
|
+
//
|
|
40
|
+
// HONEST LIMIT: n=15 pairs from one session. This is calibration, not validation. If it groups wrongly
|
|
41
|
+
// in practice the number moves, and the measurement above is the record of how it was chosen.
|
|
42
|
+
export const GROUP_AT = 0.25;
|
|
43
|
+
// The band where the answer is "maybe": it does NOT group, and it records what it refused to assume.
|
|
44
|
+
// Grouping wrongly inflates the count and fires an offer about something that never repeated — the more
|
|
45
|
+
// expensive of the two mistakes (P7: a system that nags in the harmless 80% gets switched off).
|
|
46
|
+
export const UNCERTAIN_AT = 0.17;
|
|
47
|
+
|
|
48
|
+
const LOG_LINE = /^- (\d{4}-\d{2}-\d{2}) · (.+?) · \*\*([a-z-]+)\*\*\s*$/;
|
|
49
|
+
const REPO_TAG = /\s*·?\s*\[repo:([^\]]+)\]\s*$/;
|
|
50
|
+
|
|
51
|
+
/** Parse a gap log. Malformed lines are skipped, not guessed at; the caller decides what silence means. */
|
|
52
|
+
export function parseGapLog(text) {
|
|
53
|
+
const out = [];
|
|
54
|
+
const lines = String(text || '').split('\n');
|
|
55
|
+
for (let i = 0; i < lines.length; i++) {
|
|
56
|
+
const m = LOG_LINE.exec(lines[i]);
|
|
57
|
+
if (!m) continue;
|
|
58
|
+
let procedure = m[2];
|
|
59
|
+
let repo = null;
|
|
60
|
+
const tag = REPO_TAG.exec(procedure);
|
|
61
|
+
if (tag) { repo = tag[1]; procedure = procedure.slice(0, tag.index).trim(); }
|
|
62
|
+
out.push({ date: m[1], procedure, verdict: m[3], repo, line: i + 1 });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Significant tokens: text-rank's (accent-folded, stopworded, stemmed) minus numerals and filler. */
|
|
68
|
+
export function terms(text) {
|
|
69
|
+
return new Set(tokenize(String(text || '')).filter((tk) => !FILLER.test(tk)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Dice coefficient over significant tokens: 2|A∩B| / (|A|+|B|). Not cosine — that weights by IDF over a
|
|
74
|
+
* corpus and here there are just two sentences. Not Jaccard — measured narrower on real data (see the
|
|
75
|
+
* table above).
|
|
76
|
+
*/
|
|
77
|
+
export function similarity(a, b) {
|
|
78
|
+
const A = terms(a);
|
|
79
|
+
const B = terms(b);
|
|
80
|
+
if (!A.size || !B.size) return 0;
|
|
81
|
+
let inter = 0;
|
|
82
|
+
for (const tk of A) if (B.has(tk)) inter++;
|
|
83
|
+
return (2 * inter) / (A.size + B.size);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Group entries describing the same procedure. Deterministic: first entry of a group is its key, and a
|
|
88
|
+
* candidate joins only if it clears GROUP_AT against that key. Anything in the uncertain band is left
|
|
89
|
+
* ungrouped and recorded, so a human can see what the detector refused to assume.
|
|
90
|
+
*/
|
|
91
|
+
export function groupProcedures(entries, threshold = GROUP_AT) {
|
|
92
|
+
const groups = [];
|
|
93
|
+
const uncertain = [];
|
|
94
|
+
for (const e of entries) {
|
|
95
|
+
let best = null;
|
|
96
|
+
let bestScore = 0;
|
|
97
|
+
for (const g of groups) {
|
|
98
|
+
const s = similarity(e.procedure, g.key);
|
|
99
|
+
if (s > bestScore) { bestScore = s; best = g; }
|
|
100
|
+
}
|
|
101
|
+
if (best && bestScore >= threshold) {
|
|
102
|
+
best.entries.push(e);
|
|
103
|
+
if (e.repo) best.repos.set(e.repo, (best.repos.get(e.repo) || 0) + 1);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (best && bestScore >= UNCERTAIN_AT) {
|
|
107
|
+
uncertain.push({ procedure: e.procedure, nearest: best.key, score: Number(bestScore.toFixed(2)) });
|
|
108
|
+
}
|
|
109
|
+
groups.push({
|
|
110
|
+
key: e.procedure,
|
|
111
|
+
entries: [e],
|
|
112
|
+
repos: new Map(e.repo ? [[e.repo, 1]] : []),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return { groups, uncertain };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const CAPABILITY_VERBS = /\b(plantar|plant|correr|ejecutar|run|contar|count|restaurar|restore|medir|measure|extraer|extract|puntuar|score|comparar|compare|generar|generate)\b/i;
|
|
119
|
+
const AGENT_VERBS = /\b(revisar|review|refutar|refute|auditar|audit|verificar|verify|juzgar|judge|a ciegas|blind)\b/i;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Which shape does this procedure want? Order matters: the first match wins, and capability comes
|
|
123
|
+
* first because it is the majority case observed and the one with no verdict until now. Writing a
|
|
124
|
+
* paragraph for something that needs a script is the failure the generalization gate already named.
|
|
125
|
+
*/
|
|
126
|
+
export function recommendKind(procedure) {
|
|
127
|
+
const p = String(procedure || '');
|
|
128
|
+
if (CAPABILITY_VERBS.test(p)) {
|
|
129
|
+
return {
|
|
130
|
+
kind: 'capability',
|
|
131
|
+
why: 'describe algo que hay que ejecutar y comparar; una skill escribiría un párrafo donde hace falta un script o un hook',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (AGENT_VERBS.test(p)) {
|
|
135
|
+
return {
|
|
136
|
+
kind: 'agent',
|
|
137
|
+
why: 'describe mirar o juzgar sin arrastrar el contexto de quien lo pide, que es para lo que sirve un contexto fresco',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { kind: 'skill', why: 'describe guía y disciplina sobre lo que ya se está haciendo en la conversación' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const DECLINED = 'proposed-declined';
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Should anything be said? Silence is the default and the common case.
|
|
147
|
+
*
|
|
148
|
+
* Never offers when: below threshold, already covered, or previously declined. A "no" that has to be
|
|
149
|
+
* repeated every week is what makes someone switch the whole harness off.
|
|
150
|
+
*/
|
|
151
|
+
export function decideOffer({ groups, threshold = THRESHOLD, covered = false }) {
|
|
152
|
+
if (covered) return { offer: null, reason: 'ya cubierto por una skill o agente instalado' };
|
|
153
|
+
const ranked = [...groups].sort((a, b) => b.entries.length - a.entries.length);
|
|
154
|
+
for (const g of ranked) {
|
|
155
|
+
if (g.entries.length < threshold) continue;
|
|
156
|
+
// A decline anywhere in the group retires it permanently.
|
|
157
|
+
if (g.entries.some((e) => e.verdict === DECLINED)) continue;
|
|
158
|
+
const rec = recommendKind(g.key);
|
|
159
|
+
return {
|
|
160
|
+
offer: {
|
|
161
|
+
procedure: g.key,
|
|
162
|
+
seen: g.entries.length,
|
|
163
|
+
dates: g.entries.map((e) => e.date),
|
|
164
|
+
repos: [...g.repos.entries()].map(([repo, n]) => ({ repo, n })),
|
|
165
|
+
kind: rec.kind,
|
|
166
|
+
why: rec.why,
|
|
167
|
+
threshold,
|
|
168
|
+
},
|
|
169
|
+
reason: null,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { offer: null, reason: `ningún procedimiento alcanza el umbral de ${threshold}` };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Merge local + global entries, de-duplicated by date+procedure+verdict, so double-writing cannot double-count. */
|
|
176
|
+
export function mergeLogs(localEntries, globalEntries) {
|
|
177
|
+
const seen = new Set();
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const e of [...localEntries, ...globalEntries]) {
|
|
180
|
+
const k = `${e.date}|${e.procedure}|${e.verdict}`;
|
|
181
|
+
if (seen.has(k)) continue;
|
|
182
|
+
seen.add(k);
|
|
183
|
+
out.push(e);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
package/scripts/rsc.js
CHANGED
|
@@ -352,6 +352,26 @@ async function main() {
|
|
|
352
352
|
} catch (e) { say(e.message); process.exitCode = 1; }
|
|
353
353
|
return;
|
|
354
354
|
}
|
|
355
|
+
// `capabilities repetition` — read the ledger nobody was interrogating. It lives here and not in
|
|
356
|
+
// a new command because the enumeration and the writing already live here, and splitting them
|
|
357
|
+
// across two places is the parallel bookkeeping P3 forbids.
|
|
358
|
+
if (argv.includes('repetition')) {
|
|
359
|
+
const root = resolveRoot(process.cwd());
|
|
360
|
+
const { repetitionReport } = await import('./lib/repetition-report.js');
|
|
361
|
+
const rep = repetitionReport({ cwd: root });
|
|
362
|
+
if (argv.includes('--json')) return void say(JSON.stringify(rep, null, 2));
|
|
363
|
+
if (rep.blocked) { say(`⚠️ ${rep.reason}`); process.exitCode = 1; return; }
|
|
364
|
+
if (!rep.offer) { say(`# nada que ofrecer — ${rep.reason} (${rep.entries} entradas leídas)`); return; }
|
|
365
|
+
const o = rep.offer;
|
|
366
|
+
const where = o.repos.length ? ` · ${o.repos.map((r) => `${r.repo}×${r.n}`).join(', ')}` : '';
|
|
367
|
+
say(`🔁 «${o.procedure}» visto ${o.seen} veces (${o.dates.join(', ')})${where}`);
|
|
368
|
+
say(` → ${o.kind}: ${o.why}`);
|
|
369
|
+
say(` registra la decisión con: rsc capabilities gap-log --procedure "..." --verdict proposed-${o.kind === 'capability' ? 'capability' : 'accepted'}|proposed-declined`);
|
|
370
|
+
if (rep.uncertain.length) {
|
|
371
|
+
say(` (${rep.uncertain.length} parecido(s) que NO se agruparon por debajo del umbral — deliberado)`);
|
|
372
|
+
}
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
355
375
|
const full = argv.includes('--full');
|
|
356
376
|
const reports = targets.map((t) => capabilities({ target: t, full }));
|
|
357
377
|
if (argv.includes('--json')) {
|
package/targets/agents.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
// rsc
|
|
2
|
-
// file-based subagents with a per-agent model.
|
|
1
|
+
// rsc agent registry — the agents installed with the harness for every target that supports
|
|
2
|
+
// file-based subagents with a per-agent model.
|
|
3
|
+
//
|
|
4
|
+
// This file shipped ONE agent with its name, description and body as module constants. Adding a
|
|
5
|
+
// second was therefore not "copy a block": it was turning the file into a registry without breaking
|
|
6
|
+
// the `developer` installs already deployed to users. The registry is the work; the refuters are its
|
|
7
|
+
// first client. See 02-DOCS/wiki/sdd/specs/refuter-agent.md. The agent runs at the `balanced` tier
|
|
3
8
|
// (never `light`/Haiku): Sonnet for Anthropic-backed tools, the provider's mid model
|
|
4
9
|
// elsewhere. The chosen tier (balanced default, or heavy) lives in `.rsc/developer.json`,
|
|
5
10
|
// written by `init` at onboarding and read here so re-syncs honor it.
|
|
@@ -33,9 +38,31 @@ const AGENT_TARGETS = {
|
|
|
33
38
|
export const AGENT_TARGET_IDS = Object.keys(AGENT_TARGETS);
|
|
34
39
|
export function targetHasAgents(target) { return Boolean(AGENT_TARGETS[target]); }
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
// The contract every refuter shares, in ONE place. Three lens files each carry the lens inside them
|
|
42
|
+
// (decided in clarify 2026-08-18: a lens passed as a parameter reintroduces the
|
|
43
|
+
// does-anyone-remember dependency this whole spec exists to remove) — but P5 says length is a cost,
|
|
44
|
+
// so the contract they share is composed, not written three times.
|
|
45
|
+
const REFUTER_CONTRACT = `Your mandate is to **refute readiness**, not confirm it. A reviewer looking for confirmation finds confirmation; the asymmetry is the point.
|
|
46
|
+
|
|
47
|
+
**You get exactly four inputs, and nothing else:**
|
|
48
|
+
1. The task contract — the original request **plus every scope change a human explicitly approved since**. Without the approved changes, a legitimate scope revision reads as a spec gap and you will report a confident false positive.
|
|
49
|
+
2. The approved spec.
|
|
50
|
+
3. The exact source state (commit SHA, or a tree hash when git is absent). A verdict attaches to the state you saw, not to the project.
|
|
51
|
+
4. The entry point — the one command that reruns the checks.
|
|
52
|
+
|
|
53
|
+
**You do NOT get** the builder's conversation, reasoning, defences, or draft verdict. If a claim needs the builder's justification to stand, it is not proven.
|
|
54
|
+
|
|
55
|
+
**Blind first, compare second.** Record what you attacked and what you found BEFORE you are shown the builder's conclusions. Only then may you compare and add findings; the blind record is append-only after that, never rewritten. Skip this and your fresh context is spent confirming their framing, which is the one thing it was bought to avoid.
|
|
56
|
+
|
|
57
|
+
**The attack list is the deliverable, not just the findings.** "Nothing found" without saying where you looked is indistinguishable from not having looked.
|
|
58
|
+
|
|
59
|
+
**A finding blocks only if it is caused by this change, is severe, and carries evidence** — a repro or a concrete failure scenario. A suspicion without one is a question, and questions do not block. You fix nothing: findings return through the normal loop, and a SPEC gap goes to the human, never to the builder to self-amend.`;
|
|
60
|
+
|
|
61
|
+
const AGENTS = [
|
|
62
|
+
{
|
|
63
|
+
name: 'developer',
|
|
64
|
+
desc: 'Implementation worker: turns an approved spec+plan into working, tested code under strict TDD (red->green->refactor), one task at a time. The rsc SDD fan-out/implementation hand.',
|
|
65
|
+
body: `You are the **developer** subagent for this project — the hands of the rsc SDD chain. You execute a planned, approved task into working, tested code. You do NOT design features.
|
|
39
66
|
|
|
40
67
|
- Work **test-first**: smallest failing test (RED), least code to pass it (GREEN), then refactor on green. A test that never failed proves nothing.
|
|
41
68
|
- One task at a time; keep the diff to that task's scope — no "while I'm here".
|
|
@@ -43,7 +70,54 @@ const BODY = `You are the **developer** subagent for this project — the hands
|
|
|
43
70
|
- If there is no approved spec + plan for non-trivial feature work, STOP and route to \`specify\` — do not write feature code.
|
|
44
71
|
- Log non-obvious decisions to \`02-DOCS/wiki/sdd/decisions.md\`. Report your diff + test output at the end.
|
|
45
72
|
|
|
46
|
-
Full discipline lives in the \`implement\` skill
|
|
73
|
+
Full discipline lives in the \`implement\` skill.`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'refuter-correctness',
|
|
77
|
+
desc: 'Adversarial reviewer, correctness lens: attacks a green diff for boundary and error-path defects, and demands that any home-grown gate prove it can both fail and pass. Fresh context, mandate to refute.',
|
|
78
|
+
body: `You are the **correctness** refuter for this project — one of three adversarial lenses ${'`review`'} dispatches at tier 2. What matters is not the number of lenses but their **diversity**: the worst defect this panel ever found was found by the privacy lens, which was not looking for it.
|
|
79
|
+
|
|
80
|
+
${REFUTER_CONTRACT}
|
|
81
|
+
|
|
82
|
+
**Your lens — correctness on the boundaries, not the happy path:** off-by-one, null/empty/zero, error paths swallowed, races, the wrong operator, a value that is correct in one function and unchecked in its twin.
|
|
83
|
+
|
|
84
|
+
**And the lens this panel was missing:** when the change adds or touches a **gate, checker or guard**, ask it in BOTH directions.
|
|
85
|
+
- Can it fail? Feed it a known-bad input and watch it fail. A gate nobody has seen fail is not a gate.
|
|
86
|
+
- **Can it pass?** Feed it a known-good input and watch it pass. Over-blocking is not the safe side — a gate that fires on correct work gets muted, worked around, or wedges the pipeline that depends on it, and it is *harder* to notice because it arrives dressed as diligence.
|
|
87
|
+
- Watch for a check that matches **text** where it should match **structure**: "the path appears in the string" is not "the write targets that location". That exact mistake shipped twice in one day here.`,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'refuter-security',
|
|
91
|
+
desc: 'Adversarial reviewer, security and privacy lens: hunts untrusted input reaching a sink, authz gaps, leaked secrets and data escaping where it should not. Fresh context, mandate to refute.',
|
|
92
|
+
body: `You are the **security and privacy** refuter for this project — one of three adversarial lenses ${'`review`'} dispatches at tier 2. Your value is that you are not looking where the others look: the worst defect this panel ever found was found by this lens, chasing something else entirely.
|
|
93
|
+
|
|
94
|
+
${REFUTER_CONTRACT}
|
|
95
|
+
|
|
96
|
+
**Your lens:** untrusted input reaching a sink (injection, SSRF, path traversal), authorization gaps (authenticated is not authorized), secrets in the diff or in config, and **data leaving where it should not** — a log line, an error message, a file written outside its zone, a payload sent to a third party.
|
|
97
|
+
|
|
98
|
+
**Follow the trail rather than the checklist.** When something looks merely untidy — a file in an odd place, a path that repeats — ask who else cares about that location before dismissing it. That is how this lens found the worst one.`,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'refuter-tests',
|
|
102
|
+
desc: 'Adversarial reviewer, tests-as-evidence lens: tries to make the suite pass wrongly, invents mutants the builder did not choose, and checks the spec-to-test mapping in both directions. Fresh context, mandate to refute.',
|
|
103
|
+
body: `You are the **tests-as-evidence** refuter for this project — one of three adversarial lenses ${'`review`'} dispatches at tier 2.
|
|
104
|
+
|
|
105
|
+
${REFUTER_CONTRACT}
|
|
106
|
+
|
|
107
|
+
**Your lens — try to make the suite pass wrongly:** implementation keyed to test inputs, mocks swallowing the logic under test, assertions that cannot fail, coverage that touches lines without asserting anything.
|
|
108
|
+
|
|
109
|
+
**Invent mutants the builder did not choose.** Their mutant list encodes their blind spots. Watch for tests that pin less than they claim — a boundary pinned in one function and not in its twin, a magnitude left free while its boundary is fixed, an assertion satisfied by a caller that never arrived. **Before reporting a surviving mutant, prove it diverges:** construct a concrete input where mutant and original disagree. A survivor you cannot make disagree is an equivalent mutant, and reporting it sends someone to write a test that asserts non-behaviour.
|
|
110
|
+
|
|
111
|
+
**Check the mapping both ways:** every acceptance criterion needs a falsification procedure that can be made to fail, and every test should trace to something someone asked for.`,
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
// Back-compat: the tier file and its reader are named for `developer` because that is what they
|
|
116
|
+
// configure. The refuters run at the same tier — a deliberate, reversible default: nobody has measured
|
|
117
|
+
// whether a heavier model finds more here, and silently tripling tier-2 cost on an unmeasured hunch is
|
|
118
|
+
// the wrong way to find out.
|
|
119
|
+
const byName = (name) => AGENTS.find((a) => a.name === name);
|
|
120
|
+
export const agentNames = () => AGENTS.map((a) => a.name);
|
|
47
121
|
|
|
48
122
|
// `.rsc/developer.json` — the chosen tier (balanced default; never light). `init` writes
|
|
49
123
|
// it on the onboarding answer; the installer reads it so every (re)install/sync matches.
|
|
@@ -60,39 +134,57 @@ export function writeDeveloperTier(cwd, tier) {
|
|
|
60
134
|
return t;
|
|
61
135
|
}
|
|
62
136
|
|
|
63
|
-
function renderMd(spec, model) {
|
|
64
|
-
const fm = ['---', `name: ${
|
|
137
|
+
function renderMd(spec, model, agent) {
|
|
138
|
+
const fm = ['---', `name: ${agent.name}`, `description: "${agent.desc}"`, `model: ${model}`];
|
|
65
139
|
if (spec.mode) fm.push(`mode: ${spec.mode}`);
|
|
66
140
|
fm.push('---', '');
|
|
67
|
-
return `${fm.join('\n')}${
|
|
141
|
+
return `${fm.join('\n')}${agent.body}\n`;
|
|
68
142
|
}
|
|
69
|
-
const renderJson = (model) => `${JSON.stringify({ name:
|
|
70
|
-
function renderToml(model) {
|
|
143
|
+
const renderJson = (model, agent) => `${JSON.stringify({ name: agent.name, description: agent.desc, model, prompt: agent.body }, null, 2)}\n`;
|
|
144
|
+
function renderToml(model, agent) {
|
|
71
145
|
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
72
146
|
// body as a TOML multiline LITERAL string ('''…''') — no escape processing.
|
|
73
|
-
return `name = "${
|
|
147
|
+
return `name = "${agent.name}"\ndescription = "${esc(agent.desc)}"\nmodel = "${model}"\ndeveloper_instructions = '''\n${agent.body}\n'''\n`;
|
|
74
148
|
}
|
|
75
149
|
|
|
76
|
-
export function
|
|
150
|
+
export function agentPath(target, cwd, name = 'developer') {
|
|
77
151
|
const spec = AGENT_TARGETS[target];
|
|
78
|
-
return spec ? join(cwd, ...spec.dir.split('/'), `${
|
|
152
|
+
return spec ? join(cwd, ...spec.dir.split('/'), `${name}${spec.ext}`) : null;
|
|
79
153
|
}
|
|
80
154
|
|
|
81
|
-
export function
|
|
155
|
+
export function writeAgents(target, cwd, tier = readDeveloperTier(cwd)) {
|
|
82
156
|
const spec = AGENT_TARGETS[target];
|
|
83
157
|
if (!spec) return [];
|
|
84
158
|
const model = spec.model(tier);
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
159
|
+
const written = [];
|
|
160
|
+
for (const agent of AGENTS) {
|
|
161
|
+
const content = spec.format === 'json' ? renderJson(model, agent)
|
|
162
|
+
: spec.format === 'toml' ? renderToml(model, agent)
|
|
163
|
+
: renderMd(spec, model, agent);
|
|
164
|
+
const path = agentPath(target, cwd, agent.name);
|
|
165
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
166
|
+
writeFileSync(path, content);
|
|
167
|
+
written.push(path);
|
|
168
|
+
}
|
|
169
|
+
return written;
|
|
92
170
|
}
|
|
93
171
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Remove only the agents this catalog ships. An uninstaller that takes an agent the user wrote by hand
|
|
174
|
+
* is worse than one that leaves residue.
|
|
175
|
+
*/
|
|
176
|
+
export function removeAgents(target, cwd) {
|
|
177
|
+
const removed = [];
|
|
178
|
+
for (const agent of AGENTS) {
|
|
179
|
+
const path = agentPath(target, cwd, agent.name);
|
|
180
|
+
if (path && existsSync(path)) { rmSync(path, { force: true }); removed.push(path); }
|
|
181
|
+
}
|
|
182
|
+
return removed;
|
|
98
183
|
}
|
|
184
|
+
|
|
185
|
+
// ── back-compat aliases. Kept because `developer` is already installed in user repos and in
|
|
186
|
+
// capabilities.js's spec derivation; renaming their imports is not worth breaking a deployed install.
|
|
187
|
+
export const developerAgentPath = (target, cwd) => agentPath(target, cwd, 'developer');
|
|
188
|
+
export const writeDeveloperAgent = (target, cwd, tier = readDeveloperTier(cwd)) => writeAgents(target, cwd, tier);
|
|
189
|
+
export const removeDeveloperAgent = (target, cwd) => removeAgents(target, cwd);
|
|
190
|
+
export { byName as agentByName };
|