@ericrisco/rsc 1.0.22 → 1.0.24
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/knowledge-doctor.js +51 -9
- package/scripts/lib/knowledge-doctor.js +152 -10
- 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);
|
|
@@ -19,9 +19,16 @@ import { diagnose, CLASSES } from './lib/knowledge-doctor.js';
|
|
|
19
19
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
20
|
const WIKI = join(REPO, '02-DOCS', 'wiki');
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
// withFileTypes, NOT statSync — this matches scripts/drift-check.js:167, the precedent the plan cited
|
|
23
|
+
// as exact and then diverged from. statSync FOLLOWS symlinks, so a symlink under the wiki let the walk
|
|
24
|
+
// read outside the declared zone (reporting the logical path, hiding the escape), a dangling symlink
|
|
25
|
+
// crashed the whole report with ENOENT, and a symlink cycle crashed it with ELOOP. dirent.isDirectory()
|
|
26
|
+
// does none of that: a symlink is neither a file nor a directory here, so it is skipped.
|
|
27
|
+
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
28
|
+
const p = join(dir, e.name);
|
|
29
|
+
if (e.isDirectory()) return walk(p);
|
|
30
|
+
if (e.isFile() && p.endsWith('.md')) return [p];
|
|
31
|
+
return []; // symlinks, sockets, anything else: not our business, and not a crash
|
|
25
32
|
});
|
|
26
33
|
|
|
27
34
|
// Injected so the pure library never touches git. Cached: the same ref is asked about repeatedly.
|
|
@@ -48,19 +55,49 @@ function main() {
|
|
|
48
55
|
const indexPath = join(WIKI, 'index.md');
|
|
49
56
|
const indexText = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : null;
|
|
50
57
|
|
|
58
|
+
// AC7 says an unreadable document is a candidate with its reason, never a silent skip. The first
|
|
59
|
+
// version read "unreadable" as "frontmatter unparseable" only, so a chmod-000 file or a dangling
|
|
60
|
+
// symlink threw out of readFileSync and took the WHOLE report with it — worse than a silent skip.
|
|
61
|
+
const unreadable = [];
|
|
51
62
|
const docs = files.map((p) => {
|
|
52
63
|
const rel = relative(WIKI, p);
|
|
64
|
+
let text;
|
|
65
|
+
try {
|
|
66
|
+
text = readFileSync(p, 'utf8');
|
|
67
|
+
} catch (e) {
|
|
68
|
+
unreadable.push({
|
|
69
|
+
path: relative(REPO, p), line: 1, cls: 'fuera-de-sitio', uncertain: true,
|
|
70
|
+
signal: `no se pudo leer el fichero (${e.code || e.message})`,
|
|
71
|
+
expected: 'un documento legible', found: e.code || 'error de lectura',
|
|
72
|
+
});
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
53
75
|
return {
|
|
54
76
|
path: relative(REPO, p),
|
|
55
77
|
rel,
|
|
56
78
|
dir: dirname(rel) === '.' ? '' : dirname(rel),
|
|
57
|
-
text
|
|
79
|
+
text,
|
|
58
80
|
// Only artifacts the chain consumes are expected in the Knowledge map; an article is not.
|
|
59
81
|
indexable: rel.startsWith('sdd/specs/') || rel.startsWith('sdd/verifications/'),
|
|
60
82
|
};
|
|
61
|
-
});
|
|
83
|
+
}).filter(Boolean);
|
|
84
|
+
|
|
85
|
+
// Project artifacts outside the wiki: skill ids and script basenames. The library cannot read the
|
|
86
|
+
// repo (it is pure by design), so the caller that can, does.
|
|
87
|
+
const extraSlugs = [];
|
|
88
|
+
for (const [dir, strip] of [['skills', null], ['scripts', /\.(js|mjs|sh)$/], ['scripts/lib', /\.(js|mjs)$/]]) {
|
|
89
|
+
try {
|
|
90
|
+
for (const e of readdirSync(join(REPO, dir), { withFileTypes: true })) {
|
|
91
|
+
if (strip && e.isFile()) extraSlugs.push(e.name.replace(strip, ''));
|
|
92
|
+
else if (!strip && e.isDirectory()) extraSlugs.push(e.name);
|
|
93
|
+
}
|
|
94
|
+
} catch { /* a missing directory is not an error here */ }
|
|
95
|
+
}
|
|
62
96
|
|
|
63
|
-
const report = diagnose({ docs, indexText, refExists });
|
|
97
|
+
const report = diagnose({ docs, indexText, refExists, extraSlugs });
|
|
98
|
+
// Unreadable files are candidates too, and they sort first: a document nobody can open is the one
|
|
99
|
+
// whose staleness nobody can check.
|
|
100
|
+
if (unreadable.length) report.candidates = [...unreadable, ...report.candidates];
|
|
64
101
|
if (asJson) { console.log(JSON.stringify(report, null, 2)); process.exit(0); }
|
|
65
102
|
|
|
66
103
|
const byClass = (c) => report.candidates.filter((x) => x.cls === c);
|
|
@@ -69,10 +106,15 @@ function main() {
|
|
|
69
106
|
console.log('Sin candidatos. Nada que triar.\n');
|
|
70
107
|
} else {
|
|
71
108
|
console.log(`${report.candidates.length} candidato(s), ordenados por coste de equivocarse:\n`);
|
|
109
|
+
// Filenames are interpolated into markdown, and a filename may contain newlines and backticks. A
|
|
110
|
+
// crafted name rendered a structurally valid FORGED candidate while the tally below still read 0 —
|
|
111
|
+
// and the consumer of this report (knowledge-ops) archives and overwrites files. Neutralise the
|
|
112
|
+
// three characters that can break out of the line.
|
|
113
|
+
const safe = (s) => String(s).replace(/[\r\n]+/g, '⏎').replace(/`/g, "'");
|
|
72
114
|
for (const c of report.candidates) {
|
|
73
|
-
console.log(`- **${c.cls}** · \`${c.path}:${c.line}\`${c.uncertain ? ' · _incierto_' : ''}`);
|
|
74
|
-
console.log(` - señal: ${c.signal}`);
|
|
75
|
-
console.log(` - esperado: ${c.expected} · encontrado: ${c.found}`);
|
|
115
|
+
console.log(`- **${c.cls}** · \`${safe(c.path)}:${c.line}\`${c.uncertain ? ' · _incierto_' : ''}`);
|
|
116
|
+
console.log(` - señal: ${safe(c.signal)}`);
|
|
117
|
+
console.log(` - esperado: ${safe(c.expected)} · encontrado: ${safe(c.found)}`);
|
|
76
118
|
}
|
|
77
119
|
console.log('');
|
|
78
120
|
for (const [label, cls] of [['se contradice', CLASSES.CONTRADICTS], ['ya no aplica', CLASSES.STALE], ['fuera de sitio', CLASSES.MISPLACED]]) {
|
|
@@ -27,7 +27,11 @@ export const CLASSES = { STALE: 'ya-no-aplica', MISPLACED: 'fuera-de-sitio', CON
|
|
|
27
27
|
|
|
28
28
|
// Out of scope, named so their absence is not read as cleanliness.
|
|
29
29
|
export const NOT_LOOKED_AT = [
|
|
30
|
-
|
|
30
|
+
// This entry described the PRE-FIX dead-code behaviour and survived the fix that removed it: a stale
|
|
31
|
+
// claim inside the tool built to find stale claims. Rewritten to name the mechanism that actually
|
|
32
|
+
// ships — the 75% dominant-share threshold — and its real blind spot.
|
|
33
|
+
'Un tipo con pocos documentos: una carpeta solo se reconoce como el hogar de un `type:` cuando concentra el 75% de sus documentos, así que un tipo con 3 o menos ejemplares no tiene hogar y su ubicación nunca se juzga. Deliberado — la alternativa es marcar trabajo correcto (`article` vive legítimamente en tres carpetas).',
|
|
34
|
+
'Documentos alcanzados por symlink: el recorrido usa `withFileTypes` y salta los enlaces, así que un `.md` enlazado dentro del wiki no se audita. Es el precio de impedir que el recorrido se escape de `02-DOCS/` siguiendo un enlace.',
|
|
31
35
|
'Contradicción general entre documentos (exige comparación semántica; fuera por clarify 18-08).',
|
|
32
36
|
'Obsolescencia semántica de prosa: si un párrafo sobre arquitectura sigue siendo cierto.',
|
|
33
37
|
'La memoria del proyecto: drift-check ya resuelve sus rutas vía memoryDir(); su auditoría semántica queda fuera.',
|
|
@@ -58,9 +62,43 @@ export function parseFrontmatter(text) {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/** Does this document declare itself the single source of a fact? Matched on normalized prose. */
|
|
65
|
+
/**
|
|
66
|
+
* Does this document declare ITSELF the single source of a fact?
|
|
67
|
+
*
|
|
68
|
+
* The first version matched `fuente única` / `single source of` anywhere in the file, and an adversarial
|
|
69
|
+
* review measured what that admits: on a 51-document wiki, **7 documents passed and only 1 actually
|
|
70
|
+
* declared single-sourcehood**. The other six were meta-documents merely DISCUSSING the concept — this
|
|
71
|
+
* detector's own spec, its own plan, and three verification records — and five of them had an unrelated
|
|
72
|
+
* first table sitting there as a denominator. One ordinary edit adding "probado 9 veces" to any of their
|
|
73
|
+
* descriptions would have fabricated a contradiction against a table with nothing to do with the number.
|
|
74
|
+
* That is over-firing on correct work, the direction P7 and the v1.0.17 rule both name as the dangerous
|
|
75
|
+
* one, and the whole narrow scope agreed in clarify rested on this gate being narrow.
|
|
76
|
+
*
|
|
77
|
+
* So the phrase must now be SELF-REFERENTIAL: the document has to point at itself ("esta tabla", "this
|
|
78
|
+
* table", "aquí") as the place the fact lives. Talking about single sources in the abstract no longer
|
|
79
|
+
* qualifies.
|
|
80
|
+
*/
|
|
61
81
|
export function declaresSingleSource(text) {
|
|
62
82
|
const p = normalizeProse(text);
|
|
63
|
-
|
|
83
|
+
// A locator ("en esta tabla", "in this table", "aquí") within a short window of the exclusivity
|
|
84
|
+
// claim. The window keeps it one sentence: a doc that says "esta tabla" in one paragraph and
|
|
85
|
+
// "fuente única" three pages later is not declaring anything.
|
|
86
|
+
const claim = /(en esta (tabla|lista)|in this (table|list)|aqu[ií] y en ning|here and nowhere)/i;
|
|
87
|
+
const exclusive = /(en ning[uú]n otro sitio|and nowhere else|nowhere else|fuente [uú]nica de|single source of truth for)/i;
|
|
88
|
+
if (!claim.test(p) || !exclusive.test(p)) return false;
|
|
89
|
+
// And they must be near each other, or two unrelated sentences pass by coincidence.
|
|
90
|
+
const ci = p.search(claim);
|
|
91
|
+
const ei = p.search(exclusive);
|
|
92
|
+
if (Math.abs(ci - ei) > 160) return false;
|
|
93
|
+
// A QUOTED phrase is a citation, not a declaration. Measured: two documents still passed after the
|
|
94
|
+
// narrowing above, and both were quoting this repo's own pattern registry — a spec and a verification
|
|
95
|
+
// record. A document declares in its own voice; one discussing the idea quotes someone else's. Both
|
|
96
|
+
// are exactly the kind of document whose description naturally gains "probado 9 veces", which is how
|
|
97
|
+
// the fabricated contradiction would have arrived.
|
|
98
|
+
const around = p.slice(Math.max(0, ei - 60), ei + 60);
|
|
99
|
+
const quoted = /["«“][^"»”]{0,120}$/.test(p.slice(Math.max(0, ei - 120), ei))
|
|
100
|
+
&& /^[^"«“]{0,120}["»”]/.test(p.slice(ei));
|
|
101
|
+
return !quoted || /\*\*/.test(around) === false ? !quoted : false;
|
|
64
102
|
}
|
|
65
103
|
|
|
66
104
|
const WORD_NUMBERS = {
|
|
@@ -72,9 +110,14 @@ const WORD_NUMBERS = {
|
|
|
72
110
|
* A count asserted in the FRONTMATTER only. Body prose is not a usable signal: the live case's own
|
|
73
111
|
* document contains "7 veces" inside a table cell describing something else entirely.
|
|
74
112
|
*/
|
|
113
|
+
export const MAX_REFS_PER_DOC = 32;
|
|
114
|
+
// A frontmatter value longer than this is not a title or a description. Bound before matching: the
|
|
115
|
+
// digit-run pattern is quadratic, measured at 21.5s on an 160k-character value.
|
|
116
|
+
const MAX_FM_VALUE = 2000;
|
|
117
|
+
|
|
75
118
|
export function statedCountInFrontmatter(fields) {
|
|
76
119
|
for (const key of ['description', 'title']) {
|
|
77
|
-
const v = String(fields[key] || '');
|
|
120
|
+
const v = String(fields[key] || '').slice(0, MAX_FM_VALUE);
|
|
78
121
|
const digits = /(\d+)\s+(?:veces|times|apariciones|occurrences)/i.exec(v);
|
|
79
122
|
if (digits) return { value: Number(digits[1]), source: key, literal: digits[0] };
|
|
80
123
|
const words = new RegExp(`\\b(${Object.keys(WORD_NUMBERS).join('|')})\\s+(?:veces|times)`, 'i').exec(v);
|
|
@@ -83,9 +126,22 @@ export function statedCountInFrontmatter(fields) {
|
|
|
83
126
|
return null;
|
|
84
127
|
}
|
|
85
128
|
|
|
86
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Blank out fenced code blocks, keeping the line count intact so reported line numbers stay true.
|
|
131
|
+
* Without this, an illustrative table inside ``` fences counted as the canonical one: a real 3-row
|
|
132
|
+
* table read as 1 row, and the report printed two wrong numbers as its evidence.
|
|
133
|
+
*/
|
|
134
|
+
export function stripFences(body) {
|
|
135
|
+
let inFence = false;
|
|
136
|
+
return String(body || '').split('\n').map((l) => {
|
|
137
|
+
if (/^\s*(```|~~~)/.test(l)) { inFence = !inFence; return ''; }
|
|
138
|
+
return inFence ? '' : l;
|
|
139
|
+
}).join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Data rows of the first markdown table (header and separator excluded), fences excluded. */
|
|
87
143
|
export function firstTableRowCount(body) {
|
|
88
|
-
const lines =
|
|
144
|
+
const lines = stripFences(body).split('\n');
|
|
89
145
|
let i = lines.findIndex((l) => /^\s*\|.*\|/.test(l) && /\|/.test(l));
|
|
90
146
|
if (i === -1) return null;
|
|
91
147
|
if (!/^\s*\|[\s:|-]+\|/.test(lines[i + 1] || '')) return null; // header must be followed by a separator
|
|
@@ -107,9 +163,29 @@ export function findStaleClaims({ path, text, refExists }) {
|
|
|
107
163
|
if (!status) return [];
|
|
108
164
|
const out = [];
|
|
109
165
|
const refs = new Set();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
for
|
|
166
|
+
// A short hex run is only a commit if it is NOT all digits: `20260818` is a compact date, and the
|
|
167
|
+
// old pattern reported it as a missing commit. Verified: `status: implementada el 20260818` produced
|
|
168
|
+
// a candidate for a ref that never existed as a ref.
|
|
169
|
+
for (const m of status.matchAll(/\b([0-9a-f]{7,8})\b/g)) {
|
|
170
|
+
if (/^\d+$/.test(m[1])) continue;
|
|
171
|
+
refs.add(m[1]);
|
|
172
|
+
}
|
|
173
|
+
// A version is only a tag claim when the text presents it as one. `requiere node 18.0.0 y ajv 8.18.0`
|
|
174
|
+
// used to yield two candidates for tags `v18.0.0` and `v8.18.0` — dependency versions read as releases.
|
|
175
|
+
for (const m of status.matchAll(/\b(?:v|versión |version |publicada en |released in |released as )(\d+\.\d+\.\d+)\b/gi)) {
|
|
176
|
+
refs.add(`v${m[1]}`);
|
|
177
|
+
}
|
|
178
|
+
// Cap the spawns a single document can cause: one status: with 3000 distinct refs spawned 6002 git
|
|
179
|
+
// processes and took 76s. A document citing more than this is not making a traceable claim.
|
|
180
|
+
const capped = [...refs].slice(0, MAX_REFS_PER_DOC);
|
|
181
|
+
if (refs.size > MAX_REFS_PER_DOC) {
|
|
182
|
+
out.push({
|
|
183
|
+
path, line: lineOf(text, 'status:'), cls: CLASSES.STALE, uncertain: true,
|
|
184
|
+
signal: `status: cita ${refs.size} referencias; solo se comprobaron las primeras ${MAX_REFS_PER_DOC}`,
|
|
185
|
+
expected: `un status: con referencias acotadas`, found: `${refs.size} referencias`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
for (const ref of capped) {
|
|
113
189
|
if (refExists(ref)) continue;
|
|
114
190
|
out.push({
|
|
115
191
|
path, line: lineOf(text, 'status:'), cls: CLASSES.STALE,
|
|
@@ -120,6 +196,21 @@ export function findStaleClaims({ path, text, refExists }) {
|
|
|
120
196
|
return out;
|
|
121
197
|
}
|
|
122
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Is there an actual index ROW for this path — a list item whose markdown link targets it?
|
|
201
|
+
*
|
|
202
|
+
* The first version was `indexText.includes(doc.rel)`, a substring test, so a path merely MENTIONED in
|
|
203
|
+
* prose silenced the check: "Borramos sdd/specs/x.md porque ya no aplica" counted as indexed. That is
|
|
204
|
+
* verbatim the mistake this repo recorded as having shipped twice in one day — "the path appears in the
|
|
205
|
+
* string" is not "the row exists" — and it fails in the under-firing direction, which is the quiet one.
|
|
206
|
+
*/
|
|
207
|
+
export function hasIndexRow(indexText, rel) {
|
|
208
|
+
if (!rel) return false;
|
|
209
|
+
const escaped = rel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
210
|
+
const row = new RegExp(`^\\s*[-*]\\s.*\\]\\([^)]*${escaped}\\)`, 'm');
|
|
211
|
+
return row.test(String(indexText || ''));
|
|
212
|
+
}
|
|
213
|
+
|
|
123
214
|
/**
|
|
124
215
|
* Class 2 — out of place. Conventions are DERIVED from the wiki on every run, never hardcoded:
|
|
125
216
|
* a hardcoded table is a second copy of the truth and drifts from it.
|
|
@@ -214,7 +305,7 @@ export function findMisplaced({ doc, conventions, indexText }) {
|
|
|
214
305
|
});
|
|
215
306
|
}
|
|
216
307
|
}
|
|
217
|
-
if (indexText != null && doc.indexable && !indexText
|
|
308
|
+
if (indexText != null && doc.indexable && !hasIndexRow(indexText, doc.rel)) {
|
|
218
309
|
out.push({
|
|
219
310
|
path: doc.path, line: 1, cls: CLASSES.MISPLACED,
|
|
220
311
|
signal: 'artefacto sin fila en el Knowledge map',
|
|
@@ -224,6 +315,44 @@ export function findMisplaced({ doc, conventions, indexText }) {
|
|
|
224
315
|
return out;
|
|
225
316
|
}
|
|
226
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Class 2b — an entry in a canonical log that does not describe what that log says it contains.
|
|
320
|
+
*
|
|
321
|
+
* AC2 of the spec, and it was NOT implemented in the shipped version — found by two independent
|
|
322
|
+
* refuter lenses. It is not a nice-to-have: one of the three incidents that motivated the whole spec
|
|
323
|
+
* was four entries written into `decisions.md` by eval agents, about features nobody was building.
|
|
324
|
+
* Worse than absent, the disclosure list did not name it, so the report presented the canonical log as
|
|
325
|
+
* scanned and clean — affirming a check nobody performed.
|
|
326
|
+
*
|
|
327
|
+
* The signal has to be narrow or this becomes the noisiest detector in the tool. A canonical log
|
|
328
|
+
* declares its own subject in its opening prose ("Procedimientos que el asistente observó",
|
|
329
|
+
* "decisiones de alcance"). An entry is a candidate when its heading names a SLUG that exists nowhere
|
|
330
|
+
* in the project — because a decision about `magic-link-login` when no such spec, plan or artifact
|
|
331
|
+
* exists is either about someone else's work or about work nobody is doing.
|
|
332
|
+
*/
|
|
333
|
+
export function findLogIntruders({ path, text, knownSlugs }) {
|
|
334
|
+
if (!/decisions\.md$|automation-gaps\.md$/.test(path)) return [];
|
|
335
|
+
const out = [];
|
|
336
|
+
const lines = String(text || '').split('\n');
|
|
337
|
+
for (let i = 0; i < lines.length; i++) {
|
|
338
|
+
const h = /^##\s+\d{4}-\d{2}-\d{2}\s*[—·-]\s*(.+)$/.exec(lines[i]);
|
|
339
|
+
if (!h) continue;
|
|
340
|
+
// A slug-shaped token in the heading: lowercase words joined by hyphens, at least two parts.
|
|
341
|
+
const slugs = (h[1].match(/`?\b([a-z][a-z0-9]+(?:-[a-z0-9]+)+)\b`?/g) || [])
|
|
342
|
+
.map((s) => s.replace(/`/g, ''));
|
|
343
|
+
const orphans = slugs.filter((s) => !knownSlugs.has(s));
|
|
344
|
+
// Only flag when EVERY slug in the heading is unknown: a heading mixing known and unknown terms is
|
|
345
|
+
// ordinary prose, not an entry about work that does not exist.
|
|
346
|
+
if (!slugs.length || orphans.length !== slugs.length) continue;
|
|
347
|
+
out.push({
|
|
348
|
+
path, line: i + 1, cls: CLASSES.MISPLACED, uncertain: true,
|
|
349
|
+
signal: `entrada cuyo asunto (${orphans.map((s) => `\`${s}\``).join(', ')}) no corresponde a ningún artefacto del proyecto`,
|
|
350
|
+
expected: 'una entrada sobre trabajo que existe', found: 'asunto sin artefacto',
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
227
356
|
/** Class 3 — a frontmatter count contradicting the table the document declares its single source. */
|
|
228
357
|
export function findContradictions({ path, text }) {
|
|
229
358
|
if (!declaresSingleSource(text)) return [];
|
|
@@ -253,13 +382,26 @@ export function rank(candidates) {
|
|
|
253
382
|
}
|
|
254
383
|
|
|
255
384
|
/** The whole pass, over already-read documents. Pure: no fs, no git — both are injected. */
|
|
256
|
-
export function diagnose({ docs, indexText, refExists }) {
|
|
385
|
+
export function diagnose({ docs, indexText, refExists, extraSlugs = [] }) {
|
|
257
386
|
const cands = [];
|
|
258
387
|
const conventions = deriveConventions(docs);
|
|
388
|
+
// Slugs the project actually has, derived from the documents themselves (P3: the content is the
|
|
389
|
+
// ledger). A log entry whose subject is not in here is about work that does not exist.
|
|
390
|
+
// extraSlugs comes from the CLI, which knows the repo: skill ids and script basenames are project
|
|
391
|
+
// artifacts too. Deriving only from wiki filenames flagged `drift-check` — a real script — as work
|
|
392
|
+
// that does not exist.
|
|
393
|
+
const knownSlugs = new Set(extraSlugs);
|
|
394
|
+
for (const doc of docs) {
|
|
395
|
+
const base = (doc.rel || doc.path).split('/').pop().replace(/\.md$/, '').replace(/\.plan$/, '');
|
|
396
|
+
knownSlugs.add(base);
|
|
397
|
+
const fmSlug = parseFrontmatter(doc.text).fields.slug;
|
|
398
|
+
if (fmSlug) knownSlugs.add(fmSlug);
|
|
399
|
+
}
|
|
259
400
|
for (const doc of docs) {
|
|
260
401
|
cands.push(...findStaleClaims({ path: doc.path, text: doc.text, refExists }));
|
|
261
402
|
cands.push(...findMisplaced({ doc, conventions, indexText }));
|
|
262
403
|
cands.push(...findContradictions({ path: doc.path, text: doc.text }));
|
|
404
|
+
cands.push(...findLogIntruders({ path: doc.path, text: doc.text, knownSlugs }));
|
|
263
405
|
}
|
|
264
406
|
return { candidates: rank(cands), scanned: docs.length, notLookedAt: NOT_LOOKED_AT };
|
|
265
407
|
}
|
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 };
|