@ericrisco/rsc 1.0.15 → 1.0.17

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.15",
2
+ "version": "1.0.17",
3
3
  "counts": {
4
4
  "skills": 264
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,13 +29,89 @@ export const ROLE_MARKERS = {
29
29
  loader: 'return its full text as',
30
30
  };
31
31
 
32
- // Write verbs that indicate a transcript actually WROTE a path rather than merely naming it. A
33
- // mention is not a mutation: the grader quotes repo paths constantly and is not violating anything.
34
- const WRITE_SIGNALS = [
35
- 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
36
- 'cat >', 'tee ', '>>', 'mv ', 'cp ', 'rm ',
32
+ // Tools that write. Checked against a tool call's actual target path, never against nearby text.
33
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
34
+
35
+ // Shell constructs that write TO a path. `cat 02-DOCS/x` is a read and must not match; `cat > 02-DOCS/x`
36
+ // must. Each pattern is anchored on the redirect/verb so the protected path has to be its TARGET.
37
+ const SHELL_WRITE_TO = (p) => [
38
+ new RegExp(`>>?\\s*\\S*${p}`), // > path, >> path
39
+ new RegExp(`\\btee\\s+(-\\S+\\s+)*\\S*${p}`),
40
+ new RegExp(`\\b(mv|cp|rsync)\\s+[^|;&]*\\s\\S*${p}`),
41
+ new RegExp(`\\brm\\s+(-\\S+\\s+)*\\S*${p}`),
42
+ new RegExp(`\\b(mkdir|touch)\\s+(-\\S+\\s+)*\\S*${p}`),
37
43
  ];
38
44
 
45
+ /**
46
+ * Structurally extract the tool calls from a transcript. This is the fix for the defect the first
47
+ * real run exposed: the previous version looked for a write VERB within ~400 chars of a protected
48
+ * path in the raw text. In a treatment transcript the injected SKILL.md itself names
49
+ * 02-DOCS/wiki/... paths, and tool names appear in every JSON envelope, so the proximity window
50
+ * matched almost always. It blocked a run in which nothing had been written — verified against the
51
+ * filesystem, which held no new files at all. An over-blocking gate is still a broken gate: it would
52
+ * have made every treatment run BLOCKED and wedged skill-harden permanently.
53
+ */
54
+ export function extractToolCalls(transcriptText) {
55
+ const calls = [];
56
+ for (const line of String(transcriptText || '').split('\n')) {
57
+ if (!line.trim()) continue;
58
+ let o;
59
+ try { o = JSON.parse(line); } catch { continue; }
60
+ const msg = o && typeof o.message === 'object' && o.message ? o.message : o;
61
+ const content = msg && msg.content;
62
+ if (!Array.isArray(content)) continue;
63
+ for (const b of content) {
64
+ if (b && b.type === 'tool_use') calls.push({ name: b.name, input: b.input || {} });
65
+ }
66
+ }
67
+ return calls;
68
+ }
69
+
70
+ // The per-arm sandbox. A write under here is the CORRECT behaviour, however its path reads.
71
+ export const SANDBOX_MARKER = '.rsc/eval-sandbox/';
72
+
73
+ /**
74
+ * Did this tool call write to a path under `protected`?
75
+ *
76
+ * Two false positives were fixed here, both the same mistake — "the path appears in the string" is
77
+ * not "the write targets that location":
78
+ * 1. proximity matching in raw transcript text (the injected SKILL.md names 02-DOCS paths);
79
+ * 2. substring matching against a sandbox path that MIRRORS the wiki layout inside itself, e.g.
80
+ * .rsc/eval-sandbox/specify/treatment-1/02-DOCS/wiki/sdd/specs/x.md — a correct write that
81
+ * contains "02-DOCS/wiki/" as a substring.
82
+ * Both were caught only by checking the filesystem, which held no new files either time. The lesson
83
+ * is in the shape of the check, not in the patterns: anchor on the sandbox first, always.
84
+ */
85
+ export function callWritesTo(call, protectedPath) {
86
+ if (!call) return false;
87
+ if (WRITE_TOOLS.has(call.name)) {
88
+ const target = String(call.input.file_path || call.input.path || call.input.notebook_path || '');
89
+ if (target.includes(SANDBOX_MARKER)) return false;
90
+ return target.includes(protectedPath);
91
+ }
92
+ if (call.name === 'Bash') {
93
+ const cmd = String(call.input.command || '');
94
+ if (!cmd.includes(protectedPath)) return false;
95
+ const esc = protectedPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
96
+ const sandboxEsc = SANDBOX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
+ return SHELL_WRITE_TO(esc).some((re) => {
98
+ const m = re.exec(cmd);
99
+ if (!m) return false;
100
+ // The matched write target itself must not sit inside the sandbox. Checking the whole command
101
+ // would let one sandboxed write excuse a real one in the same line.
102
+ return !new RegExp(`${sandboxEsc}[^\\s'"]*${esc}`).test(m[0]);
103
+ });
104
+ }
105
+ return false;
106
+ }
107
+
108
+ /** Did this tool call READ the given path? A path inside a tool input, not prose mentioning it. */
109
+ export function callReads(call, path) {
110
+ if (!call) return false;
111
+ const input = JSON.stringify(call.input || {});
112
+ return input.includes(path);
113
+ }
114
+
39
115
  // Paths an eval arm may never write. 02-DOCS is the project's brain and is untracked (P9), so a
40
116
  // stray write there has no undo.
41
117
  export const PROTECTED_PATHS = ['02-DOCS/wiki/'];
@@ -73,35 +149,29 @@ export function findViolations({ skillId, agents }) {
73
149
  const rubricPath = `skills/${skillId}/evals/cases.yaml`;
74
150
 
75
151
  for (const a of agents || []) {
76
- const text = String(a.text || '');
152
+ // Judged on TOOL CALLS, not on text. A transcript naming a path proves nothing: the treatment's
153
+ // own injected skill body names 02-DOCS paths, and the grader quotes repo paths constantly.
154
+ const calls = a.calls || extractToolCalls(a.text);
155
+
77
156
  if (a.role === 'baseline') {
78
- // Graded separately: reading the skill removes the control; reading the rubric is reading the
79
- // answer key, which also inflates the baseline's own score.
80
- const skillHits = countOf(text, skillPath);
157
+ // Only the control arm is judged on reads — the treatment is supposed to have the skill and
158
+ // the loader reads both files by design.
159
+ const skillHits = calls.filter((c) => callReads(c, skillPath)).length;
81
160
  if (skillHits > 0) {
82
161
  violations.push({ kind: 'baseline-read-skill', agent: a.name, pattern: skillPath, count: skillHits });
83
162
  }
84
- const rubricHits = countOf(text, rubricPath);
163
+ // Reading the rubric is reading the answer key: it also inflates the baseline's own score.
164
+ const rubricHits = calls.filter((c) => callReads(c, rubricPath)).length;
85
165
  if (rubricHits > 0) {
86
166
  violations.push({ kind: 'baseline-read-rubric', agent: a.name, pattern: rubricPath, count: rubricHits });
87
167
  }
88
168
  }
169
+
89
170
  if (a.role === 'baseline' || a.role === 'treatment') {
90
171
  for (const p of PROTECTED_PATHS) {
91
- if (!text.includes(p)) continue;
92
- // Require a write verb near the path, or a mention becomes a violation and the grader's
93
- // quotations would block every run.
94
- const wrote = WRITE_SIGNALS.some((verb) => {
95
- let i = text.indexOf(p);
96
- while (i !== -1) {
97
- const window = text.slice(Math.max(0, i - 400), i + 400);
98
- if (window.includes(verb)) return true;
99
- i = text.indexOf(p, i + p.length);
100
- }
101
- return false;
102
- });
103
- if (wrote) {
104
- violations.push({ kind: 'wrote-protected-path', agent: a.name, pattern: p, count: countOf(text, p) });
172
+ const hits = calls.filter((c) => callWritesTo(c, p)).length;
173
+ if (hits > 0) {
174
+ violations.push({ kind: 'wrote-protected-path', agent: a.name, pattern: p, count: hits });
105
175
  }
106
176
  }
107
177
  }
@@ -118,7 +188,7 @@ export function readAgents(dir) {
118
188
  .filter((f) => /^agent-.*\.jsonl$/.test(f))
119
189
  .map((f) => {
120
190
  const text = readFileSync(join(dir, f), 'utf8');
121
- return { name: f, role: classifyAgent(text), text };
191
+ return { name: f, role: classifyAgent(text), text, calls: extractToolCalls(text) };
122
192
  });
123
193
  }
124
194
 
@@ -85,7 +85,7 @@ Rules for RUN:
85
85
  - Run from the directory the stack skill documents (Go runs from the module root; others from the subproject root). Don't guess paths.
86
86
  - If no `scripts/verify.sh` exists for a touched stack, that's a gap — say the gate is incomplete and recommend the user add the stack skill, rather than hand-rolling a one-off check that drifts from the real gate.
87
87
 
88
- #### A home-grown gate must prove it can fail
88
+ #### A home-grown gate must prove it can fail — and that it can pass
89
89
 
90
90
  The dangerous way a checker breaks is not a crash — it is **fail-open**: nothing errors, the layer prints pass, and it prints pass forever. That failure can only ever produce green, so no failing run will ever surface it. The rule follows: **before you trust a home-grown gate's pass, watch it fail** on a known-bad input. It is the RED principle applied to the checker.
91
91
 
@@ -94,6 +94,14 @@ The dangerous way a checker breaks is not a crash — it is **fail-open**: nothi
94
94
  - **State the limit where you state the pass.** Watching a gate fail once proves that *one* known-bad case reaches its failure path. It does **not** prove the gate recognizes every violation of the rule it claims to enforce — a gate can fail closed perfectly and still guard a spelling rather than a behavior. Record the control, and record what it does not buy.
95
95
  - **Our own known fail-open:** the stack `verify.sh` scripts SKIP a missing tool instead of failing. That is a documented gap, not a pass — so it goes in the record's *Capas no ejecutadas* under `HERRAMIENTA-AUSENTE`, never left implied.
96
96
 
97
+ **Then run the other control: a known-GOOD input, and watch it pass.** Both directions or neither — a gate proven only against bad input is half-tested, and the untested half is the one that fires on every run.
98
+
99
+ **Over-blocking is not the safe side.** It feels like caution and it is not: a gate that fires on correct work gets muted, worked around, or wedges the pipeline that depends on it, which is the same damage as a gate that checks nothing with the sign reversed. And it is *harder* to notice, because the failure arrives dressed as diligence.
100
+
101
+ Where this bites hardest is a check that matches text rather than structure — **"the path appears in the string" is not "the write targets that location"**. Our own integrity gate learned this twice in one day: it flagged a transcript that merely *named* a protected path (the skill body it had been handed named it), then flagged a sandbox directory whose path *contained* the protected one as a substring. Twelve mutants had proven it could fail; not one had asked whether it could pass, so the defect shipped and surfaced on first real use with the filesystem provably untouched. → `02-DOCS/wiki/harness/puertas-y-mecanismos.md`
102
+
103
+ So when you write the negative control, write the positive one beside it, and prefer matching **structure** (a parsed tool call, a resolved path, an exit code) over matching text that happens to contain the thing you care about.
104
+
97
105
  ### 3 — WALK the done-checks and acceptance criteria
98
106
 
99
107
  The stack gate proves the code is *clean and tested*. It does **not** prove the feature does what the spec asked. Walk both lists explicitly: