@ericrisco/rsc 3.0.20 → 3.0.22

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": "3.0.20",
2
+ "version": "3.0.22",
3
3
  "counts": {
4
4
  "skills": 271,
5
5
  "agents": 34,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.0.20",
3
+ "version": "3.0.22",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
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' },
@@ -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
@@ -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,164 @@
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
+ // A rule is a DECLARATION, not a mention. 3.0.21 read any sentence that contained "prohibido" and any
18
+ // backticked token near it, and on a real wiki that turned file names, table cells and words like
19
+ // "add" or "review" into banned phrases: documentation ABOUT rules became rules. So the keyword has to
20
+ // open the line or sentence, code spans are never phrases in prose, tables are skipped, and process
21
+ // records (ftd/, sdd/) are not policy.
22
+ const BAN = String.raw`(?:prohib\p{L}*|vetad\p{L}*|forbidden|banned|never (?:use|say|write)|nunca (?:uses|usar|digas|decir|escribas)|no (?:uses|digas|escribas)|do not (?:use|say|write)|don'?t (?:use|say|write)|blocklist|denylist)`;
23
+ const LEAD = String.raw`^[\s>*_¡¿("“«-]*(?:(?:queda|quedan|esta|está|estan|están|it is|it's|is|are|strictly|totalmente|terminantemente|absolutamente|words|phrases|terms|palabras|frases|términos|terminos|expresiones)\s+){0,2}`;
24
+ const DECLARES = new RegExp(LEAD + BAN, 'iu');
25
+ const BAN_HEADING = new RegExp(BAN, 'iu');
26
+ const BAN_COLON = new RegExp(String.raw`^\s*(?:[-*+]\s+)?\**\s*(?:(?:queda|quedan|esta|está|it is|words|phrases|terms|palabras|frases|términos|terminos|expresiones)\s+){0,2}${BAN}[\p{L} ]{0,25}:\**\s*(.*)$`, 'iu');
27
+ const PROSE_QUOTES = /"([^"\n]{1,80})"|“([^”\n]{1,80})”|«([^»\n]{1,80})»/gu;
28
+ const LIST_QUOTES = /"([^"\n]{1,80})"|“([^”\n]{1,80})”|«([^»\n]{1,80})»|`([^`\n]{1,80})`/gu;
29
+ const STOP = new Set(['y', 'o', 'e', 'u', 'a', 'de', 'la', 'el', 'en', 'and', 'or', 'the', 'of', 'to', 'in']);
30
+ const SKIP_DIRS = new Set(['ftd', 'sdd', 'raw']);
31
+ // The guard checks TEXT. A visual-identity ban ("no illustrations of people") read as copy would forbid
32
+ // the word "people" in every answer, so pages and sections about visuals are not copy policy.
33
+ const VISUAL = /visual|design|dise[nñ]o|colou?r|logo|imagen|image|ilustraci|tipograf|typograph|paleta|palette/iu;
34
+
35
+ export const normalize = (s) => String(s).normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
36
+
37
+ // A trailing * is the explicit wildcard, so only a markdown bold pair is stripped from the end.
38
+ const clean = (s) => s.replace(/\*\*/gu, '').replace(/^[\s"“«`'_]+/u, '').replace(/^(?:y|and|o|or)\s+/iu, '').replace(/[\s"”»`'_.;:,]+$/u, '').trim();
39
+
40
+ function quotedIn(text, re) {
41
+ const out = [];
42
+ for (const m of text.matchAll(re)) out.push(m[1] ?? m[2] ?? m[3] ?? m[4]);
43
+ return out;
44
+ }
45
+
46
+ function listItems(text) {
47
+ const q = quotedIn(text, LIST_QUOTES);
48
+ if (q.length) return q;
49
+ // Commas only: "cerebros, robots o personas" split on "o" would ban the word "personas".
50
+ return text.split(/[,;]/u).map(clean).filter(Boolean);
51
+ }
52
+
53
+ /** Banned phrases declared in one markdown document. */
54
+ export function extractRules(markdown, source = '') {
55
+ const found = [];
56
+ const add = (phrase, line) => {
57
+ const p = clean(phrase);
58
+ const letters = normalize(p).replace(/[^\p{L}]/gu, '');
59
+ // A ban is a word or a short phrase: not a sentence that happened to be quoted, not a stray "y".
60
+ if (p && p.split(/\s+/).length <= 6 && letters.length >= 2 && !STOP.has(normalize(p))) found.push({ phrase: p, source, line });
61
+ };
62
+ const lines = String(markdown).split('\n');
63
+ let inFence = false, banSection = false, visualSection = false, para = [], paraStart = 0, colonList = null;
64
+ const flush = () => {
65
+ if (colonList) { for (const item of listItems(colonList.text)) add(item, colonList.line); colonList = null; }
66
+ if (!para.length) return;
67
+ const text = para.join(' ');
68
+ for (const sentence of text.split(/(?<=[.!?])\s+(?=[\p{Lu}¿¡"“«*])/u)) {
69
+ if (DECLARES.test(sentence)) for (const q of quotedIn(sentence, PROSE_QUOTES)) add(q, paraStart + 1);
70
+ }
71
+ para = [];
72
+ };
73
+ lines.forEach((raw, i) => {
74
+ if (/^\s*```/.test(raw)) { flush(); inFence = !inFence; return; }
75
+ if (inFence) return;
76
+ if (/^\s*\|/.test(raw)) { flush(); return; }
77
+ const heading = raw.match(/^#{1,6}\s+(.*)$/);
78
+ if (heading) { flush(); visualSection = VISUAL.test(heading[1]); banSection = !visualSection && BAN_HEADING.test(heading[1]) && heading[1].split(/\s+/).length <= 6; return; }
79
+ if (visualSection) return;
80
+ if (!raw.trim()) { flush(); return; }
81
+ const bullet = raw.match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/);
82
+ // A "Vetado: a, b," list may wrap onto the next lines; it runs until a blank line or a new item.
83
+ if (colonList && !bullet) { colonList.text += ' ' + raw.trim(); const end = colonList.text.search(/[.!?](?=\s+\p{Lu}|\s*$)/u); if (end >= 0) { colonList.text = colonList.text.slice(0, end); flush(); } return; }
84
+ const colon = raw.match(BAN_COLON);
85
+ if (colon) { flush(); colonList = { text: colon[1], line: i + 1 }; const end = colonList.text.search(/[.!?](?=\s+\p{Lu}|\s*$)/u); if (end >= 0) { colonList.text = colonList.text.slice(0, end); flush(); } return; }
86
+ if (bullet && banSection) { flush(); for (const item of listItems(bullet[1])) add(item, i + 1); return; }
87
+ if (bullet) { flush(); para = [bullet[1]]; paraStart = i; flush(); return; }
88
+ if (!para.length) paraStart = i;
89
+ para.push(raw.trim());
90
+ });
91
+ flush();
92
+ return found;
93
+ }
94
+
95
+ /** Every rule in the project's wiki, de-duplicated by its normalized phrase. */
96
+ export function loadRules(root, { maxFiles = 3000 } = {}) {
97
+ const base = join(root, '02-DOCS', 'wiki');
98
+ const files = [];
99
+ const walk = (dir) => {
100
+ let entries = [];
101
+ try { entries = readdirSync(dir); } catch { return; }
102
+ for (const name of entries) {
103
+ if (files.length >= maxFiles || name.startsWith('.')) continue;
104
+ const full = join(dir, name);
105
+ let st; try { st = statSync(full); } catch { continue; }
106
+ // Archived pages (_archive/, archive/) are history, not the rules in force.
107
+ if (st.isDirectory()) { if ((dir !== base || !SKIP_DIRS.has(name)) && !name.startsWith('_') && !/^archive/i.test(name)) walk(full); }
108
+ else if (name.endsWith('.md') && !VISUAL.test(name)) files.push(full);
109
+ }
110
+ };
111
+ walk(base);
112
+ const seen = new Map();
113
+ for (const f of files) {
114
+ let body = '';
115
+ try { body = readFileSync(f, 'utf8'); } catch { continue; }
116
+ for (const r of extractRules(body, relative(root, f).split('\\').join('/'))) {
117
+ const key = normalize(r.phrase);
118
+ if (!seen.has(key)) seen.set(key, r);
119
+ }
120
+ }
121
+ return [...seen.values()];
122
+ }
123
+
124
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125
+
126
+ /**
127
+ * The pattern for one phrase. A trailing * widens it explicitly. A single Spanish infinitive is the
128
+ * one implicit widening, because that is how such bans are written and how they leak: "captar"
129
+ * forbidden, "captación" delivered. Only forms that continue the stem with the verb's own vowels
130
+ * count, so "captar" catches "capta", "captación", "captando" and leaves "captura" alone.
131
+ */
132
+ export function patternOf(phrase) {
133
+ let p = normalize(phrase).trim();
134
+ let tail = '(?![\\p{L}\\p{N}])';
135
+ if (p.endsWith('*')) { p = p.slice(0, -1); tail = '[\\p{L}\\p{N}]*'; }
136
+ else if (/^\p{L}{3,}(ar|er|ir)$/u.test(p)) {
137
+ const vowels = p.endsWith('ar') ? '[aeo]' : '[aeio]';
138
+ return new RegExp(`(?<![\\p{L}\\p{N}])${esc(p.slice(0, -2))}${vowels}\\p{L}*`, 'gu');
139
+ }
140
+ const body = p.split(/\s+/).map(esc).join('\\s+');
141
+ return new RegExp(`(?<![\\p{L}\\p{N}])${body}${tail}`, 'gu');
142
+ }
143
+
144
+ /** Text with code removed: an agent may cite a rule inside backticks without it counting as copy. */
145
+ export function proseOnly(text) {
146
+ return String(text)
147
+ .replace(/```[\s\S]*?(?:```|$)/g, (m) => m.replace(/[^\n]/g, ' '))
148
+ .replace(/`[^`\n]*`/g, (m) => ' '.repeat(m.length));
149
+ }
150
+
151
+ /** Each place the text uses a banned phrase: { phrase, source, line, match }. */
152
+ export function findViolations(text, rules) {
153
+ const hits = [];
154
+ const compiled = rules.map((r) => ({ r, re: patternOf(r.phrase) }));
155
+ String(text).split('\n').forEach((raw, i) => {
156
+ const line = normalize(raw);
157
+ for (const { r, re } of compiled) {
158
+ re.lastIndex = 0;
159
+ const m = re.exec(line);
160
+ if (m) hits.push({ phrase: r.phrase, source: r.source, line: i + 1, match: m[0] });
161
+ }
162
+ });
163
+ return hits;
164
+ }
@@ -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