@ericrisco/rsc 3.0.20 → 3.0.21
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/doctor.js +1 -0
- package/scripts/install-apply.js +3 -0
- package/scripts/rsc.js +32 -2
- package/skills/ads/SKILL.md +9 -0
- package/skills/brand-voice/SKILL.md +9 -0
- package/skills/content-engine/SKILL.md +9 -0
- package/skills/landing-copy/SKILL.md +9 -0
- package/skills/linkedin-content/SKILL.md +9 -0
- package/skills/marketing/SKILL.md +9 -0
- package/targets/claude.js +14 -0
- package/targets/clone-bootstrap.mjs +1 -1
- package/targets/copy-guard.mjs +85 -0
- package/targets/copy-rules.mjs +144 -0
- package/targets/opt-outs.js +1 -0
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/doctor.js
CHANGED
|
@@ -398,6 +398,7 @@ export const RSC_HOOKS = [
|
|
|
398
398
|
{ id: 'ship-guard', label: 'ship-guard', needle: '.rsc/ship-guard.', injects: null },
|
|
399
399
|
{ id: 'danger-guard', label: 'danger-guard', needle: '.rsc/danger-guard.', injects: null },
|
|
400
400
|
{ id: 'gitmoji-guard', label: 'gitmoji-guard', needle: '.rsc/gitmoji-guard.', injects: null },
|
|
401
|
+
{ id: 'copy-guard', label: 'copy-guard', needle: '.rsc/copy-guard.', injects: null },
|
|
401
402
|
// The bash-era form, still retired by unwireHook. If uninstall knows how to remove it, the report
|
|
402
403
|
// has to know how to count it.
|
|
403
404
|
{ id: 'legacy-cat-form', label: 'legacy suggest hook', needle: 'skills/rsc/suggest', injects: 'sessionStart' },
|
package/scripts/install-apply.js
CHANGED
|
@@ -88,6 +88,9 @@ export function generatedHookFiles({ target, cwd, policy }) {
|
|
|
88
88
|
// team-safe-default: the 3.0 start-up duties and the trunk policy (also read by branch-guard).
|
|
89
89
|
join(cwd, '.rsc', 'team-safe-start.mjs'),
|
|
90
90
|
join(cwd, '.rsc', 'trunk-policy.mjs'),
|
|
91
|
+
// The copy guard and the rules it reads: wired for every Claude project.
|
|
92
|
+
join(cwd, '.rsc', 'copy-guard.mjs'),
|
|
93
|
+
join(cwd, '.rsc', 'copy-rules.mjs'),
|
|
91
94
|
];
|
|
92
95
|
// The danger guard is declared on its own terms (#273): present unless the plan says otherwise,
|
|
93
96
|
// whether or not the code guards are.
|
package/scripts/rsc.js
CHANGED
|
@@ -46,7 +46,7 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
|
|
|
46
46
|
process.exit(0);
|
|
47
47
|
}
|
|
48
48
|
const GENERAL_HELP = [
|
|
49
|
-
'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | agent-model <target> <model|inherit|status> | agents [status|reset <name|--all>] | memory <on|off|status|save|resume|learn|metrics> | review consolidate [reports…] | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
|
|
49
|
+
'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | agent-model <target> <model|inherit|status> | agents [status|reset <name|--all>] | memory <on|off|status|save|resume|learn|metrics> | review consolidate [reports…] | copy check <file> | copy rules | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
|
|
50
50
|
'Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)',
|
|
51
51
|
' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.',
|
|
52
52
|
].join('\n');
|
|
@@ -77,7 +77,7 @@ const PURGE_HELP = [
|
|
|
77
77
|
const GLOBAL_VALUE_FLAGS = new Set([
|
|
78
78
|
'--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--workflow', '--accept-plan',
|
|
79
79
|
]);
|
|
80
|
-
const COMMANDS = new Set(['onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'git-permissions', 'agent-model', 'agents', 'main', 'isolation', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'review', 'sello', 'repair', 'uninstall', 'purge']);
|
|
80
|
+
const COMMANDS = new Set(['copy', 'onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'git-permissions', 'agent-model', 'agents', 'main', 'isolation', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'review', 'sello', 'repair', 'uninstall', 'purge']);
|
|
81
81
|
function positionalTokens(input) {
|
|
82
82
|
const out = [];
|
|
83
83
|
for (let i = 0; i < input.length; i++) {
|
|
@@ -1181,6 +1181,36 @@ async function main() {
|
|
|
1181
1181
|
say('Use: npx @ericrisco/rsc registry refresh | registry status');
|
|
1182
1182
|
return;
|
|
1183
1183
|
}
|
|
1184
|
+
case 'copy': {
|
|
1185
|
+
// The phrases this project forbids in what it publishes (targets/copy-rules.mjs). `check` is the
|
|
1186
|
+
// gate the copy skills run before delivering text; the Claude Stop hook runs the same check.
|
|
1187
|
+
const USE = 'Use: npx @ericrisco/rsc copy check <file|-> [--json] | copy rules [--json]\n Rules come from 02-DOCS/wiki/: a "Never use" / "Frases prohibidas" section, a "Never use: a, b" line, or a sentence that prohibits quoted phrases.';
|
|
1188
|
+
const sub = argv[1];
|
|
1189
|
+
if (sub !== 'check' && sub !== 'rules') { say(USE); if (sub) process.exitCode = 1; return; }
|
|
1190
|
+
const { readFileSync } = await import('node:fs');
|
|
1191
|
+
const C = await import('../targets/copy-rules.mjs');
|
|
1192
|
+
const rules = C.loadRules(process.cwd());
|
|
1193
|
+
if (sub === 'rules') {
|
|
1194
|
+
if (argv.includes('--json')) say(JSON.stringify(rules, null, 2));
|
|
1195
|
+
else say(rules.length ? rules.map((r) => `${r.phrase} (${r.source}:${r.line})`).join('\n') : 'No banned phrases declared in 02-DOCS/wiki/.');
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
const file = argv.slice(2).find((a) => !a.startsWith('--'));
|
|
1199
|
+
let text = '';
|
|
1200
|
+
try {
|
|
1201
|
+
if (file && file !== '-') text = readFileSync(file, 'utf8');
|
|
1202
|
+
else if (!process.stdin.isTTY) text = readFileSync(0, 'utf8');
|
|
1203
|
+
else { say(USE); process.exitCode = 1; return; }
|
|
1204
|
+
} catch (e) { say(`rsc: cannot read ${file || 'stdin'} (${e.code || e.message}).`); process.exitCode = 1; return; }
|
|
1205
|
+
const hits = C.findViolations(C.proseOnly(text), rules);
|
|
1206
|
+
const where = file && file !== '-' ? file : 'stdin';
|
|
1207
|
+
if (argv.includes('--json')) say(JSON.stringify({ rules: rules.length, hits }, null, 2));
|
|
1208
|
+
else if (!rules.length) say('No banned phrases declared in 02-DOCS/wiki/: nothing to check.');
|
|
1209
|
+
else if (!hits.length) say(`RSC_COPY_CLEAN · ${rules.length} rule(s) checked.`);
|
|
1210
|
+
else say(['RSC_COPY_VIOLATION · rewrite without these (a negation still counts):', ...hits.map((h) => ` ${where}:${h.line} "${h.phrase}" found as "${h.match}" (rule in ${h.source})`)].join('\n'));
|
|
1211
|
+
if (hits.length) process.exitCode = 1;
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1184
1214
|
case 'review': {
|
|
1185
1215
|
// `review consolidate` — the deterministic half of the refuter panel (P1): the three lenses
|
|
1186
1216
|
// often report one defect three ways, so grouping happens here, by location and evidence, and
|
package/skills/ads/SKILL.md
CHANGED
|
@@ -140,6 +140,15 @@ this gate before judging any campaign:
|
|
|
140
140
|
Hand the reporting/dashboards to the `analytics` / `dashboard` skills — you set up
|
|
141
141
|
the signal; they build the read-out.
|
|
142
142
|
|
|
143
|
+
## Banned phrases gate (before you deliver)
|
|
144
|
+
|
|
145
|
+
The project's forbidden phrases are enforced, not suggested. Before handing over copy meant to be
|
|
146
|
+
published, write it to a scratch file and run `npx @ericrisco/rsc copy check <file>`. On
|
|
147
|
+
`RSC_COPY_VIOLATION`, rewrite and check again. A negation still counts ("this is not X" uses X), so
|
|
148
|
+
say what it is instead. When the person states a new ban, record it in
|
|
149
|
+
`02-DOCS/wiki/brand/voice-guide.md` under `## Never use`, one phrase per bullet (`word*` also covers
|
|
150
|
+
its derivatives). In Claude Code a Stop hook runs the same check on your final answer.
|
|
151
|
+
|
|
143
152
|
## Anti-patterns
|
|
144
153
|
|
|
145
154
|
| Anti-pattern | Why it fails | Do instead |
|
|
@@ -139,6 +139,15 @@ To score a sample against the guide, run three passes:
|
|
|
139
139
|
|
|
140
140
|
Off-brand reads like everyone else: abstract nouns, hedged claims, AI tells, energy faked with punctuation instead of verbs. The fix is always a rewrite toward a rule, never "make it pop."
|
|
141
141
|
|
|
142
|
+
## Banned phrases gate (before you deliver)
|
|
143
|
+
|
|
144
|
+
The project's forbidden phrases are enforced, not suggested. Before handing over copy meant to be
|
|
145
|
+
published, write it to a scratch file and run `npx @ericrisco/rsc copy check <file>`. On
|
|
146
|
+
`RSC_COPY_VIOLATION`, rewrite and check again. A negation still counts ("this is not X" uses X), so
|
|
147
|
+
say what it is instead. When the person states a new ban, record it in
|
|
148
|
+
`02-DOCS/wiki/brand/voice-guide.md` under `## Never use`, one phrase per bullet (`word*` also covers
|
|
149
|
+
its derivatives). In Claude Code a Stop hook runs the same check on your final answer.
|
|
150
|
+
|
|
142
151
|
## Anti-patterns
|
|
143
152
|
|
|
144
153
|
| Anti-pattern | Why it fails | Do this instead |
|
|
@@ -132,6 +132,15 @@ The derivative menu, a worked 1→10 example, and per-channel Bad→Good rewrite
|
|
|
132
132
|
|
|
133
133
|
The tell: if the ask is **the system that decides what gets made, when, and how it moves**, it is here. If the ask is **producing one artifact** or **the act of distributing**, it is a sibling.
|
|
134
134
|
|
|
135
|
+
## Banned phrases gate (before you deliver)
|
|
136
|
+
|
|
137
|
+
The project's forbidden phrases are enforced, not suggested. Before handing over copy meant to be
|
|
138
|
+
published, write it to a scratch file and run `npx @ericrisco/rsc copy check <file>`. On
|
|
139
|
+
`RSC_COPY_VIOLATION`, rewrite and check again. A negation still counts ("this is not X" uses X), so
|
|
140
|
+
say what it is instead. When the person states a new ban, record it in
|
|
141
|
+
`02-DOCS/wiki/brand/voice-guide.md` under `## Never use`, one phrase per bullet (`word*` also covers
|
|
142
|
+
its derivatives). In Claude Code a Stop hook runs the same check on your final answer.
|
|
143
|
+
|
|
135
144
|
## Anti-patterns
|
|
136
145
|
|
|
137
146
|
| Anti-pattern | Why it fails | Do instead |
|
|
@@ -121,6 +121,15 @@ These are skeletons to fill with the buyer's actual language, not fill-in templa
|
|
|
121
121
|
|
|
122
122
|
Common stack: **PAS/AIDA hook → FAB offer → proof → CTA.** How much you agitate the problem before the offer depends on traffic temperature (cold needs more, warm goes straight to the offer). Full explanations with fresh examples and the awareness-stage note → `references/frameworks.md`.
|
|
123
123
|
|
|
124
|
+
## Banned phrases gate (before you deliver)
|
|
125
|
+
|
|
126
|
+
The project's forbidden phrases are enforced, not suggested. Before handing over copy meant to be
|
|
127
|
+
published, write it to a scratch file and run `npx @ericrisco/rsc copy check <file>`. On
|
|
128
|
+
`RSC_COPY_VIOLATION`, rewrite and check again. A negation still counts ("this is not X" uses X), so
|
|
129
|
+
say what it is instead. When the person states a new ban, record it in
|
|
130
|
+
`02-DOCS/wiki/brand/voice-guide.md` under `## Never use`, one phrase per bullet (`word*` also covers
|
|
131
|
+
its derivatives). In Claude Code a Stop hook runs the same check on your final answer.
|
|
132
|
+
|
|
124
133
|
## Anti-patterns → fix
|
|
125
134
|
|
|
126
135
|
| Rationalization | Reality / Fix |
|
|
@@ -138,6 +138,15 @@ Full templates with char budgets for all three formats live in `references/hooks
|
|
|
138
138
|
|
|
139
139
|
If the project has an `02-DOCS/` post log, read it before drafting: pull prior posts and their measured outcomes (impressions, dwell, comments, saves) and bias the new draft toward the hook patterns, formats, and CTA types that actually performed *for this account* — not generic best practice. Then log the new post back with its hook, format, and CTA type so the next draft is sharper. The front-matter schema (date, format, hook, cta_type, impressions, dwell_s, comments, saves) is in `references/hooks-and-formats.md`.
|
|
140
140
|
|
|
141
|
+
## Banned phrases gate (before you deliver)
|
|
142
|
+
|
|
143
|
+
The project's forbidden phrases are enforced, not suggested. Before handing over copy meant to be
|
|
144
|
+
published, write it to a scratch file and run `npx @ericrisco/rsc copy check <file>`. On
|
|
145
|
+
`RSC_COPY_VIOLATION`, rewrite and check again. A negation still counts ("this is not X" uses X), so
|
|
146
|
+
say what it is instead. When the person states a new ban, record it in
|
|
147
|
+
`02-DOCS/wiki/brand/voice-guide.md` under `## Never use`, one phrase per bullet (`word*` also covers
|
|
148
|
+
its derivatives). In Claude Code a Stop hook runs the same check on your final answer.
|
|
149
|
+
|
|
141
150
|
## Anti-patterns
|
|
142
151
|
|
|
143
152
|
| Anti-pattern | Why it kills the post | Fix |
|
|
@@ -147,6 +147,15 @@ Good — "We rebuilt deploys around one CLI step. Here's what changed: 22 min
|
|
|
147
147
|
|
|
148
148
|
The brand study's do/don't word lists EXTEND this list per project — always apply both.
|
|
149
149
|
|
|
150
|
+
## Banned phrases gate (before you deliver)
|
|
151
|
+
|
|
152
|
+
The project's forbidden phrases are enforced, not suggested. Before handing over copy meant to be
|
|
153
|
+
published, write it to a scratch file and run `npx @ericrisco/rsc copy check <file>`. On
|
|
154
|
+
`RSC_COPY_VIOLATION`, rewrite and check again. A negation still counts ("this is not X" uses X), so
|
|
155
|
+
say what it is instead. When the person states a new ban, record it in
|
|
156
|
+
`02-DOCS/wiki/brand/voice-guide.md` under `## Never use`, one phrase per bullet (`word*` also covers
|
|
157
|
+
its derivatives). In Claude Code a Stop hook runs the same check on your final answer.
|
|
158
|
+
|
|
150
159
|
## Anti-patterns
|
|
151
160
|
|
|
152
161
|
| Anti-pattern | Why it fails / the fix |
|
package/targets/claude.js
CHANGED
|
@@ -277,6 +277,20 @@ export function wireHook(paths, sourceMd, policy = {}) {
|
|
|
277
277
|
} else rmSync(join(paths.projectRoot, '.rsc', 'ftd-nudge.mjs'), { force: true });
|
|
278
278
|
if (!settings.hooks.PostToolUse.length) delete settings.hooks.PostToolUse;
|
|
279
279
|
|
|
280
|
+
// Copy guard: a Stop hook that refuses a final answer carrying a phrase the project forbids in what
|
|
281
|
+
// it publishes. Every wired project gets it because brand rules are not a software concern; it is
|
|
282
|
+
// silent and costs one directory read where no rules are declared. Opt-out: .rsc/.no-copy-guard.
|
|
283
|
+
settings.hooks.Stop ||= [];
|
|
284
|
+
settings.hooks.Stop = settings.hooks.Stop.filter((e) => !hookWiringOf(e).includes('.rsc/copy-guard.'));
|
|
285
|
+
{
|
|
286
|
+
const cgDest = join(paths.projectRoot, '.rsc', 'copy-guard.mjs');
|
|
287
|
+
copyFileSync(join(HERE, 'copy-rules.mjs'), join(paths.projectRoot, '.rsc', 'copy-rules.mjs'));
|
|
288
|
+
copyFileSync(join(HERE, 'copy-guard.mjs'), cgDest);
|
|
289
|
+
const cgCmd = viaBootstrap('quiet', at('.rsc', 'copy-guard.mjs'), `"${P}"`);
|
|
290
|
+
settings.hooks.Stop.push({ hooks: [{ type: 'command', command: cgCmd, timeout: 10 }] });
|
|
291
|
+
written.push(cgDest, join(paths.projectRoot, '.rsc', 'copy-rules.mjs'));
|
|
292
|
+
}
|
|
293
|
+
|
|
280
294
|
// Danger guard: its own decision, not part of the code-hook block above (#273). `init` promises it
|
|
281
295
|
// to non-technical and mixed users whatever the project is, and a plan that predates the field is
|
|
282
296
|
// read as "yes": a non-technical operations install gets the guard it was missing on its next sync,
|
|
@@ -43,7 +43,7 @@ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/i;
|
|
|
43
43
|
* nothing checks is just drift with a head start.
|
|
44
44
|
*/
|
|
45
45
|
export const PROJECT_OPT_OUTS = [
|
|
46
|
-
'audit', 'auto-update', 'claudemd-check', 'danger-guard', 'feature-gate', 'ftd-close', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
|
|
46
|
+
'audit', 'auto-update', 'claudemd-check', 'copy-guard', 'danger-guard', 'feature-gate', 'ftd-close', 'ftd-nudge', 'gitmoji', 'knowledge-sync', 'ship-guard', 'trunk-guard', 'trunk-open', 'worktree-cleanup', 'worktree-isolation',
|
|
47
47
|
];
|
|
48
48
|
|
|
49
49
|
/**
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copy guard — a Claude Code Stop hook that refuses to let a final answer carry a phrase the project
|
|
2
|
+
// has forbidden (rules: targets/copy-rules.mjs).
|
|
3
|
+
//
|
|
4
|
+
// Why a hook and not another instruction: the instruction already existed, and the model read it,
|
|
5
|
+
// quoted it, and still wrote "no es una red de captación" because it decided a negation was allowed.
|
|
6
|
+
// The Stop hook runs after the model has finished, on the text the reader is about to get, so the
|
|
7
|
+
// decision is no longer the model's. It blocks once per turn with the exact phrases and where the
|
|
8
|
+
// rule lives; the model rewrites. A second stop in the same turn is let through (stop_hook_active),
|
|
9
|
+
// so a rule that cannot be satisfied never traps the session in a loop.
|
|
10
|
+
//
|
|
11
|
+
// Silent and free when the project declares no banned phrases, which is almost every project.
|
|
12
|
+
// Fails open on any error. Opt out with .rsc/.no-copy-guard.
|
|
13
|
+
import { existsSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
14
|
+
import { join, resolve } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { loadRules, findViolations, proseOnly } from './copy-rules.mjs';
|
|
17
|
+
|
|
18
|
+
const TAIL_BYTES = 2_000_000;
|
|
19
|
+
|
|
20
|
+
/** The text of the last assistant turn, read from the end of the transcript. */
|
|
21
|
+
export function lastAssistantText(transcriptPath) {
|
|
22
|
+
let raw = '';
|
|
23
|
+
try {
|
|
24
|
+
const size = statSync(transcriptPath).size;
|
|
25
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
26
|
+
const fd = openSync(transcriptPath, 'r');
|
|
27
|
+
const buf = Buffer.alloc(size - start);
|
|
28
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
29
|
+
closeSync(fd);
|
|
30
|
+
raw = buf.toString('utf8');
|
|
31
|
+
} catch { return ''; }
|
|
32
|
+
const entries = raw.split('\n').map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
33
|
+
const texts = [];
|
|
34
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
35
|
+
const e = entries[i];
|
|
36
|
+
const msg = e.message || {};
|
|
37
|
+
const content = msg.content;
|
|
38
|
+
if (e.type === 'user' || msg.role === 'user') {
|
|
39
|
+
// A tool result belongs to the assistant's own turn; a real prompt ends the search.
|
|
40
|
+
const isToolResult = Array.isArray(content) && content.length && content.every((c) => c && c.type === 'tool_result');
|
|
41
|
+
if (!isToolResult) break;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (e.type === 'assistant' || msg.role === 'assistant') {
|
|
45
|
+
if (typeof content === 'string') texts.unshift(content);
|
|
46
|
+
else if (Array.isArray(content)) texts.unshift(content.filter((c) => c && c.type === 'text').map((c) => c.text).join('\n'));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return texts.join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The hook's decision, pure enough to test: a block object, or null. */
|
|
53
|
+
export function decide(root, input) {
|
|
54
|
+
if (existsSync(join(root, '.rsc', '.no-copy-guard'))) return null;
|
|
55
|
+
if (input.stop_hook_active) return null;
|
|
56
|
+
const rules = loadRules(root);
|
|
57
|
+
if (!rules.length) return null;
|
|
58
|
+
const text = typeof input.last_assistant_message === 'string' && input.last_assistant_message
|
|
59
|
+
? input.last_assistant_message
|
|
60
|
+
: input.transcript_path ? lastAssistantText(input.transcript_path) : '';
|
|
61
|
+
if (!text) return null;
|
|
62
|
+
const hits = findViolations(proseOnly(text), rules);
|
|
63
|
+
if (!hits.length) return null;
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const list = hits.filter((h) => !seen.has(h.phrase) && seen.add(h.phrase))
|
|
66
|
+
.map((h) => `- "${h.phrase}" (found as "${h.match}", line ${h.line}; rule in ${h.source})`).join('\n');
|
|
67
|
+
return {
|
|
68
|
+
decision: 'block',
|
|
69
|
+
reason: `rsc copy guard: your answer uses phrases this project forbids in what it publishes:\n${list}\n`
|
|
70
|
+
+ 'Rewrite the answer without them. A negation still counts ("this is not X" uses X): say what it is instead. '
|
|
71
|
+
+ 'Reply in the person\'s language. If you need to name a banned phrase when talking about the rule, put it inside `backticks`; '
|
|
72
|
+
+ 'text in backticks is not checked. If the person explicitly asked for the phrase, say so in one line and keep it.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const self = fileURLToPath(import.meta.url);
|
|
77
|
+
if (process.argv[1] && resolve(process.argv[1]) === self) {
|
|
78
|
+
try {
|
|
79
|
+
const root = process.argv[2] || process.cwd();
|
|
80
|
+
const input = JSON.parse(readFileSync(0, 'utf8') || '{}');
|
|
81
|
+
const out = decide(root, input);
|
|
82
|
+
if (out) process.stdout.write(JSON.stringify(out));
|
|
83
|
+
} catch { /* fail open */ }
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Project copy rules: the phrases a project has forbidden in anything it publishes.
|
|
2
|
+
//
|
|
3
|
+
// Until this file existed those rules were prose the model read and interpreted. A student's project
|
|
4
|
+
// banned recruitment wording in decisions.md, and the agent still delivered "this is not a recruitment
|
|
5
|
+
// network": it judged that negating a banned word was allowed. A rule the model can reinterpret is a
|
|
6
|
+
// preference. This module turns the project's own wording into patterns a script can check, so the
|
|
7
|
+
// copy guard (targets/copy-guard.mjs) and `rsc copy check` enforce it without asking the model.
|
|
8
|
+
//
|
|
9
|
+
// Where rules come from, all under 02-DOCS/wiki/ (raw/ is unprocessed input, not policy):
|
|
10
|
+
// 1. a heading that names a ban ("## Never use", "## Frases prohibidas") → its bullet items;
|
|
11
|
+
// 2. a "Never use: a, b, c" line → its comma-separated items;
|
|
12
|
+
// 3. a sentence that prohibits ("Queda prohibido …", "Never say …") → its quoted phrases.
|
|
13
|
+
// Only node builtins: the file is copied next to the hook and must run with nothing installed.
|
|
14
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
15
|
+
import { join, relative } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const BAN = String.raw`(?:prohib\p{L}*|forbid\p{L}*|banned|ban list|never (?:use|say|write)|nunca (?:uses|usar|digas|decir|escribas)|no (?:uses|digas|escribas)|do not (?:use|say|write)|don'?t (?:use|say|write)|vetad\p{L}*|blocklist|denylist)`;
|
|
18
|
+
const BAN_HEADING = new RegExp(BAN, 'iu');
|
|
19
|
+
const BAN_SENTENCE = new RegExp(BAN, 'iu');
|
|
20
|
+
const BAN_COLON = new RegExp(String.raw`^\s*(?:[-*+]\s+)?\**\s*(?:[\p{L} ]{0,20}\s)?${BAN}[^:]{0,20}:\**\s*(.+)$`, 'iu');
|
|
21
|
+
const QUOTED = /"([^"\n]{1,80})"|“([^”\n]{1,80})”|«([^»\n]{1,80})»|`([^`\n]{1,80})`/gu;
|
|
22
|
+
|
|
23
|
+
export const normalize = (s) => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
24
|
+
|
|
25
|
+
// A trailing * is the explicit wildcard, so only a markdown bold pair is stripped from the end.
|
|
26
|
+
const clean = (s) => s.replace(/^[\s"“«`'*_]+/u, '').replace(/\*\*$/u, '').replace(/[\s"”»`'_.;:,]+$/u, '').trim();
|
|
27
|
+
|
|
28
|
+
function quotedIn(text) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const m of text.matchAll(QUOTED)) out.push(m[1] ?? m[2] ?? m[3] ?? m[4]);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listItems(text) {
|
|
35
|
+
const q = quotedIn(text);
|
|
36
|
+
if (q.length) return q;
|
|
37
|
+
return text.split(/[,;]|\s+(?:y|and|o|or)\s+/u).map(clean).filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Banned phrases declared in one markdown document. */
|
|
41
|
+
export function extractRules(markdown, source = '') {
|
|
42
|
+
const found = [];
|
|
43
|
+
const add = (phrase, line) => {
|
|
44
|
+
const p = clean(phrase);
|
|
45
|
+
// A ban is a word or a short phrase. Anything longer is a sentence that happened to be quoted.
|
|
46
|
+
if (p && p.split(/\s+/).length <= 6 && /\p{L}/u.test(p)) found.push({ phrase: p, source, line });
|
|
47
|
+
};
|
|
48
|
+
const lines = String(markdown).split('\n');
|
|
49
|
+
let inFence = false, banSection = false, para = [], paraStart = 0;
|
|
50
|
+
const flush = () => {
|
|
51
|
+
if (!para.length) return;
|
|
52
|
+
const text = para.join(' ');
|
|
53
|
+
for (const sentence of text.split(/(?<=[.!?])\s+(?=[\p{Lu}¿¡"“«])/u)) {
|
|
54
|
+
if (BAN_SENTENCE.test(sentence)) for (const q of quotedIn(sentence)) add(q, paraStart + 1);
|
|
55
|
+
}
|
|
56
|
+
para = [];
|
|
57
|
+
};
|
|
58
|
+
lines.forEach((raw, i) => {
|
|
59
|
+
if (/^\s*```/.test(raw)) { flush(); inFence = !inFence; return; }
|
|
60
|
+
if (inFence) return;
|
|
61
|
+
const heading = raw.match(/^#{1,6}\s+(.*)$/);
|
|
62
|
+
if (heading) { flush(); banSection = BAN_HEADING.test(heading[1]); return; }
|
|
63
|
+
if (!raw.trim()) { flush(); return; }
|
|
64
|
+
const colon = raw.match(BAN_COLON);
|
|
65
|
+
if (colon && !quotedIn(raw.slice(0, raw.indexOf(':'))).length) { flush(); for (const item of listItems(colon[1])) add(item, i + 1); return; }
|
|
66
|
+
const bullet = raw.match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/);
|
|
67
|
+
if (bullet && banSection) { flush(); for (const item of listItems(bullet[1])) add(item, i + 1); return; }
|
|
68
|
+
if (bullet) { flush(); para = [bullet[1]]; paraStart = i; flush(); return; }
|
|
69
|
+
if (!para.length) paraStart = i;
|
|
70
|
+
para.push(raw.trim());
|
|
71
|
+
});
|
|
72
|
+
flush();
|
|
73
|
+
return found;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every rule in the project's wiki, de-duplicated by its normalized phrase. */
|
|
77
|
+
export function loadRules(root, { maxFiles = 3000 } = {}) {
|
|
78
|
+
const base = join(root, '02-DOCS', 'wiki');
|
|
79
|
+
const files = [];
|
|
80
|
+
const walk = (dir) => {
|
|
81
|
+
let entries = [];
|
|
82
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
83
|
+
for (const name of entries) {
|
|
84
|
+
if (files.length >= maxFiles || name.startsWith('.')) continue;
|
|
85
|
+
const full = join(dir, name);
|
|
86
|
+
let st; try { st = statSync(full); } catch { continue; }
|
|
87
|
+
if (st.isDirectory()) walk(full);
|
|
88
|
+
else if (name.endsWith('.md')) files.push(full);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
walk(base);
|
|
92
|
+
const seen = new Map();
|
|
93
|
+
for (const f of files) {
|
|
94
|
+
let body = '';
|
|
95
|
+
try { body = readFileSync(f, 'utf8'); } catch { continue; }
|
|
96
|
+
for (const r of extractRules(body, relative(root, f).split('\\').join('/'))) {
|
|
97
|
+
const key = normalize(r.phrase);
|
|
98
|
+
if (!seen.has(key)) seen.set(key, r);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...seen.values()];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The pattern for one phrase. A trailing * widens it explicitly. A single Spanish infinitive is the
|
|
108
|
+
* one implicit widening, because that is how such bans are written and how they leak: "captar"
|
|
109
|
+
* forbidden, "captación" delivered. Only forms that continue the stem with the verb's own vowels
|
|
110
|
+
* count, so "captar" catches "capta", "captación", "captando" and leaves "captura" alone.
|
|
111
|
+
*/
|
|
112
|
+
export function patternOf(phrase) {
|
|
113
|
+
let p = normalize(phrase).trim();
|
|
114
|
+
let tail = '(?![\\p{L}\\p{N}])';
|
|
115
|
+
if (p.endsWith('*')) { p = p.slice(0, -1); tail = '[\\p{L}\\p{N}]*'; }
|
|
116
|
+
else if (/^\p{L}{3,}(ar|er|ir)$/u.test(p)) {
|
|
117
|
+
const vowels = p.endsWith('ar') ? '[aeo]' : '[aeio]';
|
|
118
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${esc(p.slice(0, -2))}${vowels}\\p{L}*`, 'gu');
|
|
119
|
+
}
|
|
120
|
+
const body = p.split(/\s+/).map(esc).join('\\s+');
|
|
121
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${body}${tail}`, 'gu');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Text with code removed: an agent may cite a rule inside backticks without it counting as copy. */
|
|
125
|
+
export function proseOnly(text) {
|
|
126
|
+
return String(text)
|
|
127
|
+
.replace(/```[\s\S]*?(?:```|$)/g, (m) => m.replace(/[^\n]/g, ' '))
|
|
128
|
+
.replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Each place the text uses a banned phrase: { phrase, source, line, match }. */
|
|
132
|
+
export function findViolations(text, rules) {
|
|
133
|
+
const hits = [];
|
|
134
|
+
const compiled = rules.map((r) => ({ r, re: patternOf(r.phrase) }));
|
|
135
|
+
String(text).split('\n').forEach((raw, i) => {
|
|
136
|
+
const line = normalize(raw);
|
|
137
|
+
for (const { r, re } of compiled) {
|
|
138
|
+
re.lastIndex = 0;
|
|
139
|
+
const m = re.exec(line);
|
|
140
|
+
if (m) hits.push({ phrase: r.phrase, source: r.source, line: i + 1, match: m[0] });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return hits;
|
|
144
|
+
}
|
package/targets/opt-outs.js
CHANGED
|
@@ -20,6 +20,7 @@ export const PROJECT_OPT_OUTS = [
|
|
|
20
20
|
'audit', // cadence of this project's skill audit
|
|
21
21
|
'auto-update', // installing new rsc releases alone: one machine ahead moves the team's catalogVersion
|
|
22
22
|
'claudemd-check', // the root CLAUDE.md context budget
|
|
23
|
+
'copy-guard', // the Stop hook that enforces the project's banned phrases in delivered copy
|
|
23
24
|
'danger-guard', // foot-gun denial, keyed to the committed user profile
|
|
24
25
|
'feature-gate', // per-turn re-injection of the SDD gate
|
|
25
26
|
'ftd-close', // a landed branch's feature documents marked done by the post-merge hook
|