@ericrisco/rsc 0.1.42 → 0.1.43

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": "0.1.42",
2
+ "version": "0.1.43",
3
3
  "counts": {
4
4
  "skills": 257
5
5
  },
@@ -4120,7 +4120,7 @@
4120
4120
  },
4121
4121
  {
4122
4122
  "id": "sdd",
4123
- "description": "Use when you want a disciplined, spec-driven path from a feature idea to shipped, verified software — the rsc-sdd dispatcher / front door. It states the SDD method, reads the accompaniment dial from 02-DOCS, and routes to the right phase skill: constitution -> specify -> clarify -> plan -> tasks -> analyze -> implement -> verify -> review -> ship, with debug / worktrees / parallel callable on demand. Use it to START a feature, when unsure which SDD phase you are in, or to govern the whole flow. Triggers: 'spec-driven development', 'sdd', 'build this feature properly', 'start a new feature', 'I have an idea, take it to production', 'which phase am I in', 'run the sdd flow', 'desarrollo dirigido por especificación', 'monta esta feature bien', 'munta aquesta feature bé', 'de la idea a producción', 'de la idea a producció', 'fem un SDD per X', 'quina fase soc', 'per-phase model routing', 'use a cheaper model for implementation', 'qué modelo por fase'. NOT itself a single phase (it dispatches), NOT the workspace harness (harness), NOT a stack build skill.",
4123
+ "description": "Use when you want a disciplined, spec-driven path from a feature idea to shipped, verified software — the SDD dispatcher and front door, in any language. States the method, reads the accompaniment dial from 02-DOCS, and routes to the right phase: constitution -> specify -> clarify -> plan -> tasks -> analyze -> implement -> verify -> review -> ship, with debug / worktrees / parallel on demand. Use it to start a feature, to govern the whole flow, or when unsure which phase you are in. NOT a single phase itself (it dispatches), NOT the workspace harness (that is `harness`), NOT a stack build skill.",
4124
4124
  "tags": [
4125
4125
  "sdd",
4126
4126
  "spec",
@@ -4425,7 +4425,7 @@
4425
4425
  },
4426
4426
  {
4427
4427
  "id": "specify",
4428
- "description": "Use when a feature, change, or product idea is still fuzzy and needs brainstorming into an APPROVED spec BEFORE any planning or code — the brainstorming front door of SDD. Turns a one-line intent into a WHAT/WHY spec (problem, goals, users, scope, behaviour, acceptance) with zero implementation detail, via one-question-at-a-time dialogue, 2-3 proposed approaches with a recommendation, and a design the user approves before anything gets built. Triggers: 'write a spec for…', 'spec this out', 'brainstorm this feature', 'especifica esta feature', 'm'agradaria afegir/posar X', 'voldria…', 'vull afegir/fer X', 'es podria afegir…', 'estaria bé que…', 'i si…?', 'I want to add X', 'tengo una idea', 'se me ha ocurrido', '¿y si…?', 'wouldn't it be nice if…', 'let's think this through', 'what should this feature do', 'draft a PRD', or any moment someone jumps to HOW before WHAT is agreed. NOT the technical plan (that's `plan`), NOT the de-risking ambiguity sweep (that's `clarify`), NOT project-wide principles (that's `constitution`).",
4428
+ "description": "Use when a feature, change, or product idea is still fuzzy and must become an approved WHAT/WHY spec before any planning or code — the brainstorming front door of SDD. Turns a one-line intent into problem, goals, users, scope, behaviour and acceptance criteria, via one-question-at-a-time dialogue and 2-3 approaches with a recommendation, carrying zero implementation detail. Fires whenever someone jumps to HOW before WHAT is agreed, in any language. NOT the technical plan (that is `plan`), NOT the de-risking ambiguity sweep (that is `clarify`), NOT project-wide principles (that is `constitution`).",
4429
4429
  "tags": [
4430
4430
  "sdd",
4431
4431
  "spec",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,9 @@
32
32
  "targets",
33
33
  "schema",
34
34
  "skills",
35
- "manifest.json"
35
+ "manifest.json",
36
+ "!**/__pycache__/**",
37
+ "!**/*.pyc"
36
38
  ],
37
39
  "keywords": [
38
40
  "agent-skills",
@@ -4,7 +4,7 @@
4
4
  "required": ["name", "description", "tags"],
5
5
  "properties": {
6
6
  "name": { "type": "string", "pattern": "^[a-z0-9-]+$" },
7
- "description": { "type": "string", "minLength": 10 },
7
+ "description": { "type": "string", "minLength": 10, "maxLength": 1024 },
8
8
  "tags": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
9
9
  "recommends": { "type": "array", "items": { "type": "string" } },
10
10
  "profiles": { "type": "array", "items": { "enum": ["minimal", "core", "full"] } }
@@ -47,6 +47,22 @@ export function validateFrontmatter() {
47
47
  return errors;
48
48
  }
49
49
 
50
+ // A skill's `description` is in context on every turn it is installed, invoked or not, so the
51
+ // catalog's total description weight is the one number that scales with the catalog itself.
52
+ // The hard failure stays at the schema limit; this reports the soft ceiling the rubric asks for,
53
+ // because turning 257 skills red the day the rubric changed would help nobody.
54
+ export const DESCRIPTION_CEILING_CHARS = 350;
55
+
56
+ export function descriptionWeight() {
57
+ const rows = skillDirs().map((id) => {
58
+ const fm = parseFrontmatter(readFileSync(join(SKILLS, id, 'SKILL.md'), 'utf8'));
59
+ return { id, chars: (fm.description || '').length };
60
+ });
61
+ const total = rows.reduce((s, r) => s + r.chars, 0);
62
+ const over = rows.filter((r) => r.chars > DESCRIPTION_CEILING_CHARS).sort((a, b) => b.chars - a.chars);
63
+ return { total, count: rows.length, mean: Math.round(total / (rows.length || 1)), over };
64
+ }
65
+
50
66
  function main() {
51
67
  const arg = process.argv[2];
52
68
  const out = join(ROOT, 'manifest.json');
@@ -54,6 +70,14 @@ function main() {
54
70
  const errs = validateFrontmatter();
55
71
  if (errs.length) { console.error(errs.join('\n')); process.exit(1); }
56
72
  console.log('frontmatter OK');
73
+ const w = descriptionWeight();
74
+ console.log(
75
+ `description weight: ${(w.total / 1024).toFixed(1)} KB across ${w.count} skills `
76
+ + `(mean ${w.mean} chars) · ${w.over.length} over the ${DESCRIPTION_CEILING_CHARS}-char ceiling`,
77
+ );
78
+ if (w.over.length) {
79
+ console.log(` heaviest: ${w.over.slice(0, 5).map((r) => `${r.id} (${r.chars})`).join(', ')}`);
80
+ }
57
81
  return;
58
82
  }
59
83
  const manifest = buildManifest();
package/scripts/doctor.js CHANGED
@@ -1,9 +1,11 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { targetPaths } from '../targets/index.js';
3
+ import { homedir } from 'node:os';
4
+ import { targetPaths, TARGET_IDS } from '../targets/index.js';
4
5
  import { readState } from './lib/state.js';
5
6
  import { loadManifest } from './lib/manifest.js';
6
7
  import { listBackups } from './lib/backups.js';
8
+ import { SDD_GATE_TEXT } from '../targets/hook-once.mjs';
7
9
 
8
10
  export function doctor({ target, home, cwd }) {
9
11
  const root = cwd || process.cwd();
@@ -22,9 +24,139 @@ export function doctor({ target, home, cwd }) {
22
24
  count: backups.length,
23
25
  latest: backups[0]?.id || null,
24
26
  },
27
+ contextBudget: contextBudget({ target, home, cwd }),
25
28
  };
26
29
  for (const [id, e] of Object.entries(state.skills)) {
27
30
  for (const f of e.files) if (!existsSync(f)) report.missing.push(`${id}:${f}`);
28
31
  }
29
32
  return report;
30
33
  }
34
+
35
+ // ---------------------------------------------------------------------------------------
36
+ // Context budget — what the harness costs in tokens before the user has typed anything.
37
+ //
38
+ // Until this existed, the only way to learn that a harness was injecting the same 11KB block
39
+ // twice per session was to read the hook wiring by hand. `audit` judges bloat by NUMBER of
40
+ // installed skills, which is orthogonal: five heavy skills cost more than twenty light ones.
41
+ // This measures weight, names the biggest contributors, and never writes anything.
42
+ // ---------------------------------------------------------------------------------------
43
+
44
+ // Targets whose "always-on" surface is a markdown file the assistant reads on its own terms
45
+ // (the AGENTS.md family, cursor rules). There is no per-session or per-turn injection to
46
+ // measure, and reporting 0 would read as "this costs you nothing" — a different, wrong claim.
47
+ const HOOKLESS_TARGETS = new Set(TARGET_IDS.filter((t) => t !== 'claude'));
48
+
49
+ const bytesOf = (p) => { try { return statSync(p).size; } catch { return 0; } };
50
+
51
+ // The always-on body a wired scope injects: the hook command embeds the exact SKILL.md path,
52
+ // so read it from there rather than guessing the layout.
53
+ function alwaysOnBytesFor(scopeRoot, settingsRaw) {
54
+ const fromCommand = /"([^"]*suggest[^"]*SKILL\.md)"/.exec(settingsRaw || '');
55
+ if (fromCommand && bytesOf(fromCommand[1])) return bytesOf(fromCommand[1]);
56
+ return bytesOf(join(scopeRoot, '.rsc', 'skills', 'suggest', 'SKILL.md'));
57
+ }
58
+
59
+ function readScope(scopeRoot, label) {
60
+ const settingsPath = join(scopeRoot, '.claude', 'settings.json');
61
+ if (!existsSync(settingsPath)) {
62
+ return { label, root: scopeRoot, wired: false, status: 'ok', alwaysOnBytes: 0, perTurnBytes: 0, version: null };
63
+ }
64
+ let raw;
65
+ try {
66
+ raw = readFileSync(settingsPath, 'utf8');
67
+ JSON.parse(raw); // a scope we cannot parse is reported, not guessed at
68
+ } catch {
69
+ return { label, root: scopeRoot, wired: true, status: 'unknown', alwaysOnBytes: 0, perTurnBytes: 0, version: null };
70
+ }
71
+ const wired = raw.includes('.rsc/') || raw.includes('.rsc\\\\');
72
+ let version = null;
73
+ try { version = readFileSync(join(scopeRoot, '.rsc', '.version'), 'utf8').trim() || null; } catch { /* unknown */ }
74
+ return {
75
+ label,
76
+ root: scopeRoot,
77
+ wired,
78
+ status: 'ok',
79
+ version,
80
+ alwaysOnBytes: wired ? alwaysOnBytesFor(scopeRoot, raw) : 0,
81
+ perTurnBytes: wired && raw.includes('userprompt-gate') ? Buffer.byteLength(SDD_GATE_TEXT) : 0,
82
+ };
83
+ }
84
+
85
+ // Frontmatter `description` of an installed skill — the part of a skill that is ALWAYS in
86
+ // context, whether or not the skill is ever invoked. Cheap to parse, and the figure that
87
+ // matters most as the catalog grows.
88
+ function descriptionBytes(skillDir) {
89
+ try {
90
+ const text = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
91
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text);
92
+ if (!fm) return 0;
93
+ const d = /description:\s*([\s\S]*?)(?=\n[a-z_]+:|$)/.exec(fm[1]);
94
+ return d ? Buffer.byteLength(d[1].trim()) : 0;
95
+ } catch { return 0; }
96
+ }
97
+
98
+ export function contextBudget({ target, home = homedir(), cwd = process.cwd() } = {}) {
99
+ const paths = targetPaths(target, home, cwd);
100
+ const state = readState(paths.stateFile);
101
+ const installed = Object.keys(state.skills || {});
102
+ const hookless = HOOKLESS_TARGETS.has(target);
103
+
104
+ // Both scopes rsc can be wired in. They are independent installs: this is exactly the pair
105
+ // that silently doubles every injected block.
106
+ const scopes = hookless ? [] : [
107
+ readScope(cwd, 'project'),
108
+ ...(join(home) === join(cwd) ? [] : [readScope(home, 'user')]),
109
+ ];
110
+ const wired = scopes.filter((s) => s.wired);
111
+
112
+ const perSkill = installed.map((id) => {
113
+ const base = state.skills[id]?.base || paths.skillDir(id);
114
+ return { id, bytes: bytesOf(join(base, 'SKILL.md')), descriptionBytes: descriptionBytes(base) };
115
+ });
116
+ const descriptionsBytes = perSkill.reduce((s, k) => s + k.descriptionBytes, 0);
117
+
118
+ const findings = [];
119
+ if (wired.length > 1) {
120
+ const doubled = wired.reduce((s, k) => s + k.alwaysOnBytes + k.perTurnBytes, 0)
121
+ - (wired[0].alwaysOnBytes + wired[0].perTurnBytes);
122
+ findings.push({
123
+ id: 'duplicate-wiring',
124
+ severity: 'high',
125
+ summary: `rsc is wired in ${wired.length} scopes (${wired.map((s) => s.label).join(' + ')}), so every `
126
+ + `injected block lands ${wired.length} times — about ${doubled} wasted bytes per session.`,
127
+ action: 'Keep ONE scope. Remove the other with `npx @ericrisco/rsc uninstall --all` run from that '
128
+ + 'root, or update both to a version that de-duplicates at runtime.',
129
+ });
130
+ }
131
+ for (const s of scopes) {
132
+ if (s.status === 'unknown') {
133
+ findings.push({
134
+ id: 'unreadable-scope',
135
+ severity: 'low',
136
+ summary: `The ${s.label} scope at ${s.root} has a settings.json that cannot be parsed, so its weight is unknown.`,
137
+ action: 'Fix or remove that settings.json, then re-run `rsc doctor`.',
138
+ });
139
+ }
140
+ }
141
+
142
+ const byBytes = (a, b) => b.bytes - a.bytes;
143
+ return {
144
+ scopes,
145
+ notApplicable: hookless ? ['sessionStart', 'perTurn'] : [],
146
+ sessionStartBytes: wired.reduce((s, k) => s + k.alwaysOnBytes, 0),
147
+ perTurnBytes: wired.reduce((s, k) => s + k.perTurnBytes, 0),
148
+ descriptionsBytes,
149
+ installedSkills: installed.length,
150
+ topContributors: {
151
+ bodies: [...perSkill].sort(byBytes).slice(0, 5).map(({ id, bytes }) => ({ id, bytes })),
152
+ descriptions: [...perSkill]
153
+ .sort((a, b) => b.descriptionBytes - a.descriptionBytes)
154
+ .slice(0, 5)
155
+ .map(({ id, descriptionBytes: bytes }) => ({ id, bytes })),
156
+ },
157
+ findings,
158
+ note: hookless
159
+ ? 'This target has no hook injection; only skill weight applies.'
160
+ : 'sessionStart counts the always-on body per wired scope; conditional banners are excluded.',
161
+ };
162
+ }
@@ -49,6 +49,10 @@ function ensureBase(id, cwd, baseVersions) {
49
49
  return dest;
50
50
  }
51
51
 
52
+ // Every file wireHook() materializes, so they are covered by the pre-write backup and
53
+ // reported by a --dry-run. Keep in sync with targets/claude.js wireHook(): a file it
54
+ // writes but this omits is silently absent from the snapshot, so `rsc restore` cannot
55
+ // bring it back.
52
56
  function generatedHookFiles({ target, cwd }) {
53
57
  if (target !== 'claude') return [];
54
58
  return [
@@ -56,6 +60,8 @@ function generatedHookFiles({ target, cwd }) {
56
60
  join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
57
61
  join(cwd, '.rsc', 'ship-guard.mjs'),
58
62
  join(cwd, '.rsc', 'danger-guard.mjs'),
63
+ join(cwd, '.rsc', 'userprompt-gate.mjs'),
64
+ join(cwd, '.rsc', 'hook-once.mjs'),
59
65
  ];
60
66
  }
61
67
 
package/scripts/rsc.js CHANGED
@@ -122,6 +122,35 @@ function printAgentHandoff() {
122
122
  say('════════════════════════════════════════════════');
123
123
  }
124
124
 
125
+ // What the harness costs in context before the user types anything. Printed ahead of the raw
126
+ // health JSON because it is the part a human acts on: the figures say whether the harness is
127
+ // paying its way, and the findings say what to do about it.
128
+ const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
129
+
130
+ function printContextBudget(b) {
131
+ if (!b) return;
132
+ say('\n════════════════ CONTEXT BUDGET ════════════════');
133
+ if (b.notApplicable.length) {
134
+ say(` ${b.note}`);
135
+ } else {
136
+ say(` Per session start : ${kb(b.sessionStartBytes)} (always-on body, per wired scope)`);
137
+ say(` Per user turn : ${kb(b.perTurnBytes)}`);
138
+ }
139
+ say(` Skill descriptions: ${kb(b.descriptionsBytes)} (${b.installedSkills} installed, always in context)`);
140
+ if (b.topContributors.bodies.length) {
141
+ const top = b.topContributors.bodies.map((t) => `${t.id} ${kb(t.bytes)}`).join(' · ');
142
+ say(` Heaviest bodies : ${top}`);
143
+ }
144
+ for (const s of b.scopes.filter((x) => x.wired)) {
145
+ say(` Scope ${s.label.padEnd(8)}: ${s.root}${s.version ? ` (${s.version})` : ''}${s.status === 'unknown' ? ' — UNREADABLE' : ''}`);
146
+ }
147
+ for (const f of b.findings) {
148
+ say(`\n [${f.severity.toUpperCase()}] ${f.summary}`);
149
+ say(` → ${f.action}`);
150
+ }
151
+ say('════════════════════════════════════════════════\n');
152
+ }
153
+
125
154
  async function wizard() {
126
155
  const m = loadManifest();
127
156
  await banner();
@@ -241,8 +270,12 @@ async function main() {
241
270
  }
242
271
  case 'list':
243
272
  return void say(listInstalled({ target }).join('\n') || '(nothing installed)');
244
- case 'doctor':
245
- return void say(JSON.stringify(doctor({ target }), null, 2));
273
+ case 'doctor': {
274
+ const report = doctor({ target });
275
+ if (argv.includes('--json')) return void say(JSON.stringify(report, null, 2));
276
+ printContextBudget(report.contextBudget);
277
+ return void say(JSON.stringify({ ...report, contextBudget: undefined }, null, 2));
278
+ }
246
279
  case 'sync': {
247
280
  const dry = argv.includes('--dry-run');
248
281
  for (const t of targets) {
@@ -11,9 +11,9 @@ dishonest 9.
11
11
 
12
12
  | # | Dimension | Weight | What a 10 looks like |
13
13
  |---|---|---|---|
14
- | 1 | **Triggering & description** | 0.15 | Third-person, `Use when …` lead, concrete `Triggers:` incl. ≥1 non-obvious phrasing and ≥1 Spanish/Catalan phrasing, explicit `NOT … (that is <sibling>)` boundary, ≤1024 chars, parses as single-line quoted YAML. |
14
+ | 1 | **Triggering & description** | 0.15 | Third-person, `Use when …` lead, an explicit `NOT … (that is <sibling>)` boundary, and nothing else it can do without. Judged on **discriminative power, not coverage**: could a reader pick this skill over its nearest sibling from the description alone? Aim ≤350 chars; ≤1024 is the hard limit. Parses as single-line quoted YAML. |
15
15
  | 2 | **Scope & boundary** | 0.10 | One job. Body never drifts into a sibling's territory; it delegates by name. Near-miss prompts route to the correct existing sibling. |
16
- | 3 | **Body craft & progressive disclosure** | 0.15 | 120–400 lines, references pointed to inline (not orphaned), rsc voice, every fence language-tagged, a checklist/decision table only where the flow branches, an anti-patterns table present. |
16
+ | 3 | **Body craft & progressive disclosure** | 0.15 | **The smallest body that routes correctly** — ≤400 lines is a ceiling, not a target, and there is no minimum: a skill that does its job in 60 lines scores higher than the same skill padded to 200. Every `references/` file is pointed to inline from this body (an unlinked reference is never loaded, so it is dead weight). rsc voice, every fence language-tagged, a checklist/decision table only where the flow branches, an anti-patterns table present. |
17
17
  | 4 | **Correctness, grounding & freshness** | **0.25** | Every load-bearing claim is accurate and **current (2025–2026)**: real library/API versions, no hallucinated flags/endpoints. The spec cites **authoritative sources with dates**; nothing rests on stale or invented facts. This is the heaviest dimension by design. |
18
18
  | 5 | **Actionability** | 0.15 | Concrete and runnable: real numbers, paths, commands, decision rules — not adjectives. A practitioner could act from it without leaving the skill. |
19
19
  | 6 | **Evals quality** | 0.10 | ≥5 `should_trigger` (incl. non-obvious), ≥4 `should_not_trigger` each with a `route_to` that names a **real** sibling, ≥1 `capability` with a meaningful `must_include` rubric. The cases are genuine, not filler. |
@@ -34,6 +34,7 @@ dishonest 9.
34
34
  ## How the reviewer must work (anti-cheat)
35
35
 
36
36
  - **Independent pass.** Review the artifact as written; do not assume the author's intent filled a gap.
37
- - **Evidence, not vibes.** For dimension 1 quote the char count; for 3 quote the SKILL.md line count; for 4 quote at least two source titles **with their dates** from the spec and name any claim you could not verify; for 6 count the entries.
37
+ - **Evidence, not vibes.** For dimension 1 quote the char count **and name the sibling the description distinguishes this skill from**; for 3 quote the SKILL.md line count **and name anything that could be cut without losing routing**; for 4 quote at least two source titles **with their dates** from the spec and name any claim you could not verify; for 6 count the entries.
38
+ - **Length is a cost, never a credit.** Do not reward a longer description or body for being thorough. Every line is paid for in context on every turn the skill is installed, whether or not it is invoked. If two versions route the same, the shorter one scores higher.
38
39
  - **Freshness is mandatory.** If the skill names a version, API, or pricing that is plausibly out of date, dimension 4 is capped at 6 until a current source is cited. "Looks right" is not a source.
39
40
  - **Fix loop.** Below 8.5, return a concrete `mustFix` list; the author applies it and re-scores. Up to two fix rounds. If still < 8.5, **record the real score and flag it** — never round up to pass.
@@ -102,6 +102,7 @@ harness/
102
102
  │ ├── audit-report-template.md ← exact format of the audit report shown to user
103
103
  │ ├── wiki-protocol.md ← embedded wiki protocol + Inbox/Worklog Sweep + Continuous Improvement
104
104
  │ ├── ingest-formats.md ← multiformat Fetch (PDF, image, CSV, JSON, html…)
105
+ │ ├── ingest-ignore-defaults.md ← what the sweep skips by default (the .rscignore baseline)
105
106
  │ ├── inbox-readme-template.md ← the inbox/README.md drop-zone contract
106
107
  │ ├── wiki-raw-template.md ← format for raw/<topic>/*.md
107
108
  │ ├── wiki-worklog-template.md ← format for raw/worklog/*.md (work-driven capture)
@@ -52,3 +52,9 @@ When the user says "explícame más / menos", "no me expliques tanto", or "ensé
52
52
  - Ask before deciding at any real fork; do not decide alone when the user can choose.
53
53
  - Never invent project state — situate the user from what is actually built (read the Knowledge map / repo if unsure).
54
54
  - Defer the "install a missing skill?" prompt to `suggest`.
55
+
56
+ ## See Also
57
+
58
+ - `references/orientation-contract.md` — the full brújula contract: block anatomy, what each of the
59
+ four beats must contain, and how the dial changes their depth. Read it when a turn ends ambiguously
60
+ or when calibrating a new dial level.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sdd
3
- description: "Use when you want a disciplined, spec-driven path from a feature idea to shipped, verified software — the rsc-sdd dispatcher / front door. It states the SDD method, reads the accompaniment dial from 02-DOCS, and routes to the right phase skill: constitution -> specify -> clarify -> plan -> tasks -> analyze -> implement -> verify -> review -> ship, with debug / worktrees / parallel callable on demand. Use it to START a feature, when unsure which SDD phase you are in, or to govern the whole flow. Triggers: 'spec-driven development', 'sdd', 'build this feature properly', 'start a new feature', 'I have an idea, take it to production', 'which phase am I in', 'run the sdd flow', 'desarrollo dirigido por especificación', 'monta esta feature bien', 'munta aquesta feature bé', 'de la idea a producción', 'de la idea a producció', 'fem un SDD per X', 'quina fase soc', 'per-phase model routing', 'use a cheaper model for implementation', 'qué modelo por fase'. NOT itself a single phase (it dispatches), NOT the workspace harness (harness), NOT a stack build skill."
3
+ description: "Use when you want a disciplined, spec-driven path from a feature idea to shipped, verified software — the SDD dispatcher and front door, in any language. States the method, reads the accompaniment dial from 02-DOCS, and routes to the right phase: constitution -> specify -> clarify -> plan -> tasks -> analyze -> implement -> verify -> review -> ship, with debug / worktrees / parallel on demand. Use it to start a feature, to govern the whole flow, or when unsure which phase you are in. NOT a single phase itself (it dispatches), NOT the workspace harness (that is `harness`), NOT a stack build skill."
4
4
  tags: [sdd, spec, workflow, plan]
5
5
  recommends: [sdd-init, constitution, specify]
6
6
  profiles: [core, full]
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: specify
3
- description: "Use when a feature, change, or product idea is still fuzzy and needs brainstorming into an APPROVED spec BEFORE any planning or code — the brainstorming front door of SDD. Turns a one-line intent into a WHAT/WHY spec (problem, goals, users, scope, behaviour, acceptance) with zero implementation detail, via one-question-at-a-time dialogue, 2-3 proposed approaches with a recommendation, and a design the user approves before anything gets built. Triggers: 'write a spec for…', 'spec this out', 'brainstorm this feature', 'especifica esta feature', 'm'agradaria afegir/posar X', 'voldria…', 'vull afegir/fer X', 'es podria afegir…', 'estaria bé que…', 'i si…?', 'I want to add X', 'tengo una idea', 'se me ha ocurrido', '¿y si…?', 'wouldn't it be nice if…', 'let's think this through', 'what should this feature do', 'draft a PRD', or any moment someone jumps to HOW before WHAT is agreed. NOT the technical plan (that's `plan`), NOT the de-risking ambiguity sweep (that's `clarify`), NOT project-wide principles (that's `constitution`)."
3
+ description: "Use when a feature, change, or product idea is still fuzzy and must become an approved WHAT/WHY spec before any planning or code — the brainstorming front door of SDD. Turns a one-line intent into problem, goals, users, scope, behaviour and acceptance criteria, via one-question-at-a-time dialogue and 2-3 approaches with a recommendation, carrying zero implementation detail. Fires whenever someone jumps to HOW before WHAT is agreed, in any language. NOT the technical plan (that is `plan`), NOT the de-risking ambiguity sweep (that is `clarify`), NOT project-wide principles (that is `constitution`)."
4
4
  tags: [sdd, spec, requirements]
5
5
  recommends: [clarify, plan]
6
6
  profiles: [core, full]
@@ -6,112 +6,86 @@ recommends: []
6
6
  profiles: [minimal, core, full]
7
7
  ---
8
8
 
9
- # rsc-suggest — detect & install the skill the task needs
9
+ # rsc-suggest — the always-on layer
10
10
 
11
- You are always loaded — your body is injected into context at the start of **every** session, and again after every compaction, so you are the one piece guaranteed to be present *before any other skill is matched*. That makes you the **always-on layer**. You do two jobs, in this order:
11
+ Your body is injected at the start of **every** session and again after every compaction, so you
12
+ are the one piece guaranteed to be present before any other skill is matched. Two jobs, in order:
12
13
 
13
- 1. **Route new-feature intent into SDD before any code is written** — the new-feature gate below. This is your first duty; it runs before everything else.
14
- 2. **Keep the session equipped** — detect when the task needs an rsc skill that is **not installed**, name it, and install it on confirm.
14
+ 1. **Route feature intent into SDD** before any code is written.
15
+ 2. **Keep the session equipped** — spot the skill the task needs but the user does not have.
16
+
17
+ Everything below is what only this layer can do. The method behind each rule lives in the skill that
18
+ owns it; this is the pointer, not the manual.
15
19
 
16
20
  ---
17
21
 
18
- ## The new-feature gate (always-on — runs before any other skill)
22
+ ## 1. Routing: feature intent goes through SDD first
19
23
 
20
- This is the **highest-precedence rule in the session.** It fires on **every** user turn, in **any language**, and runs **before you plan, answer, or invoke ANY other skill — no matter how many skills are installed or how strongly one of them matches the request.** That covers the stack skills (`nextjs`, `react`, `fastapi`, `flutter`, `go`, `postgresdb`, `building-agents`, `design`) **and any other skill that would build, create, scaffold, generate, or produce the feature** (e.g. `chatbot`, `course-builder`, `marketing`, automation/connector skills). A skill matching the request does **not** override this gate — it runs *inside* the SDD chain, after the plan is approved. There is no skill with priority over this check.
24
+ The moment someone wants something to **exist or behave differently** — build, add, change,
25
+ integrate, "it should also…", "¿y si…?", in any language — route the turn to `specify` before any
26
+ code is written. No skill outranks this. The stack and builder skills that match the same request
27
+ (`nextjs`, `react`, `fastapi`, `flutter`, `go`, `postgresdb`, `building-agents`, `design`,
28
+ `chatbot`, `course-builder`, `marketing`…) run **inside** the chain, after the plan is approved —
29
+ matching strongly is not a reason to skip ahead.
21
30
 
22
- **The rule (non-negotiable).** The moment the user wants to **build, add, or change a feature**, you MUST route it into SDD via `specify` **first**. **No feature code is written — by ANY skill — until a spec AND a plan exist and the user has approved them.** A stack skill that matched the same request does **not** get to skip this: it builds only *after* the plan is approved. (Exception: if the user engaged **SDD autopilot**, that single up-front consent IS the approval for the whole run — auto-advance through the phases without re-asking; see `../sdd/SKILL.md`.)
31
+ Two exceptions, and say out loud when you take one:
23
32
 
24
- **Where does this turn go? When unsure, choose `specify` — the safe default.**
33
+ - a genuinely one-line, low-risk change (typo, copy tweak, config bump, non-breaking bump) — just do it;
34
+ - a bug fix restoring intended behaviour — that is `debug`, then resume.
25
35
 
26
- | The turn is… | Route to |
27
- | --- | --- |
28
- | A new feature / capability / integration / UI, a behaviour change, a data-model or architecture change, "it should also…", anything non-trivial | **`specify`** → `clarify` → `plan` → `tasks` → `implement` (which fans work out to subagents via `parallel`) |
29
- | A genuinely one-line / low-risk change: typo, copy tweak, config bump, rename, non-breaking dependency bump | Just do it inline, then verify — and **say out loud** you are skipping the chain |
30
- | A bug fix that restores intended behaviour | **`debug`**, then resume |
31
- | Ambiguous / in-between / you cannot tell | **`specify`** — a skipped spec is where drift hides |
36
+ When you cannot tell, choose `specify`. A skipped spec is where drift hides.
32
37
 
33
- **Triggers — detect the *intent*, never a fixed phrase, in ANY language.** What fires the gate is the **meaning**, not the words: the user wants something to exist or behave differently than it does now — build, add, create, change, replace, integrate, "it should also…", "wouldn't it be nice if…". This is judged **semantically**, so it holds in **any language** — Catalan, Spanish, English, Portuguese, French, Italian, German, Basque, Galician, or anything else, including a language with no example below. The lists that follow are **illustrative examples, NOT a checklist**: never wait for one of these phrases to appear before firing. If the meaning is feature-shaped, the gate fires — full stop.
34
- - **CA:** "m'agradaria afegir/posar…", "vull fer/posar…", "es podria…", "i si…".
35
- - **ES:** "quiero añadir/montar…", "me gustaría…", "se me ha ocurrido", "¿y si…?".
36
- - **EN:** "I want to add…", "let's build…", "it should also…".
37
- - A **URL plus a description of desired behaviour** ("on this page I'd like…") **is** a feature request — in any language.
38
+ Judge the **meaning**, not the wording: the trigger is semantic, so it holds in any language,
39
+ including ones with no example here. A URL plus a description of desired behaviour is a feature
40
+ request. If the user engaged **SDD autopilot**, that one consent covers the whole run — advance
41
+ through the phases without re-asking.
38
42
 
39
- **Stop rationalizing — every one of these is wrong:**
40
- - *"I understand the feature, I'll just build it."* → That is the exact failure SDD prevents. Spec first; the artifact is the contract.
41
- - *"A more specific skill matched (a stack skill, `chatbot`, `course-builder`, marketing…), so it takes precedence."* → **No skill outranks this gate.** Any builder skill **defers** to `specify` for new features and runs *inside* the chain, after the plan is approved. Route first.
42
- - *"The user gave lots of detail / a URL, so they want code now."* → Detail feeds the spec, not the editor.
43
- - *"The dial is L0, so I'll skip the gate to stay terse."* → L0 changes how many words and questions, **never** whether the gate fires.
44
- - *"It's phrased in a language I have no example for, so maybe it isn't a feature request."* → The trigger lists are examples, not a closed set. Judge the **meaning**, not the keywords; any language with build/add/change intent fires the gate identically.
43
+ If `specify` / `sdd` are not installed, offer to add them (§2) before routing.
45
44
 
46
- If `specify` / `sdd` are **not installed**, fall through to the install detector below and offer to add them *before* routing. The gate decides **where the turn goes**; the detector below only handles **missing** skills. Full method and phase map: `../sdd/SKILL.md`.
45
+ Method, phase map and full decision table: `../sdd/SKILL.md`. On Claude Code this rule also arrives
46
+ as a per-turn hook; the brevity here is deduplication, not relaxation.
47
47
 
48
48
  ---
49
49
 
50
- When the current task would clearly benefit from an rsc skill that is **not installed**:
51
-
52
- 1. Name it in plain language: "Para esto va bien `<id>`, que aún no tienes."
53
- 2. Ask one short confirm: "¿La instalo? (sí/no)".
54
- 3. On yes, run `npx @ericrisco/rsc add <id>` (Bash). Then continue the task.
55
-
56
- Rules:
57
-
58
- - Installing changes the user's environment — always confirm first.
59
- - To know what exists, run `npx @ericrisco/rsc catalog --available` (every NOT-installed skill as `id available short description`) and **pick the best fit yourself, by meaning** — see the detector below. Don't guess from memory.
60
- - `npx @ericrisco/rsc consult "<task>"` is only a cheap **lexical** hint: it keyword-matches and silently returns nothing for natural-language or non-English/Catalan intent (e.g. it finds no email skill for "mandar emails"). Never let it be the decider; if it's empty, scan `catalog` and judge semantically.
61
- - Never recommend something already installed (`npx @ericrisco/rsc list`).
62
- - One suggestion at a time. Don't interrupt the flow for nice-to-haves.
50
+ ## 2. Keeping the session equipped
63
51
 
64
- ## Mid-task capability intent detector
52
+ When the task needs a capability the user has **not installed** — building, creating, automating,
53
+ analyzing, connecting, shipping, securing, selling, teaching, governing or documenting something —
54
+ name it and offer it. This runs mid-conversation, not only at project start.
65
55
 
66
- This runs **inside the agent conversation**, not only in the `rsc` CLI. At the start of
67
- each user turn, before planning, coding, writing, researching, or answering in depth,
68
- check whether the user is asking to create, build, fix, connect, automate, analyze,
69
- publish, sell, teach, govern, secure, deploy, or document something that maps to a
70
- known rsc capability.
56
+ 1. `npx @ericrisco/rsc catalog --available` lists every not-installed skill as
57
+ `id available short description`.
58
+ 2. Pick the single best fit **by meaning**, the way you would match a request to a teammate's
59
+ expertise — "mandar emails de bienvenida" → an email/outreach skill, though not one keyword
60
+ overlaps; "login con Google" → an auth skill, not `flutter`. If nothing genuinely fits, say so
61
+ and move on: a tangential suggestion is worse than none.
62
+ 3. Ask once, plainly: "Para esto instalaría `<id>`, que aún no tienes. ¿La instalo? (sí/no)".
63
+ 4. On yes, run `npx @ericrisco/rsc add <id>`, then continue the original task.
71
64
 
72
- This is broader than "start a project". It includes mid-conversation requests such as:
65
+ Installing changes the user's environment, so it is always their call. One suggestion at a time,
66
+ and never to interrupt a flow with a nice-to-have. Never recommend something already installed
67
+ (`npx @ericrisco/rsc list`).
73
68
 
74
- - Technology: "quiero montar una pagina web", "necesito una API", "build me a mobile app", "conecta Stripe", "automatiza este flujo", "deploy this", "review security".
75
- - Creation: "haz una landing que convierta", "write a cold email sequence", "crea un pitch deck", "monta un curso", "edita shorts", "make social posts".
76
- - Data and AI: "analiza estos datos", "build a dashboard", "quiero un agente de IA", "haz RAG sobre mis documentos", "extrae datos de PDFs".
77
- - Business and ops: "organiza mi empresa", "prepara facturas", "monta CRM/pipeline", "haz contratos", "reduce churn", "gestiona soporte".
78
- - Knowledge and research: "documenta cómo funciona esto", "crea una wiki", "procesa este inbox", "research competitors", "turn this into SOPs".
79
- - Other languages: any equivalent phrasing. Match the user's intent, not exact words.
69
+ `npx @ericrisco/rsc consult "<task>"` is a **lexical** hint only: it keyword-matches, and returns
70
+ nothing for natural-language or non-English intent. Never let it decide, and never read its silence
71
+ as "no skill exists" — the catalog plus your judgment is the source of truth.
80
72
 
81
- When a capability intent appears, **you** make the match semantically — do not delegate the decision to a keyword ranker:
82
-
83
- 1. Run `npx @ericrisco/rsc catalog --available` to get every NOT-installed skill as
84
- `id available short description`. (It already excludes what's installed.)
85
- 2. **Read the catalog and pick the single best-fit skill by MEANING** — match the user's
86
- intent to a skill's *purpose*, semantically, in any language (CA/ES/EN/…), the way you'd
87
- match a request to a teammate's expertise. Judge meaning, not shared keywords:
88
- "mandar emails de bienvenida" → `email-connector` even though no words overlap its tags;
89
- "login con Google" → an auth/security skill, not `flutter`; "transcripció de veu" → a
90
- speech/audio skill *if one exists*.
91
- 3. If nothing in the catalog genuinely fits, **say so and move on** — don't force a weak
92
- match, and don't propose a tangential skill just to have an answer. (`npx @ericrisco/rsc
93
- consult "<intent>"` is available as a lexical hint, but it misses natural-language and
94
- Catalan intent and is silent for many real needs — never read its silence as "no skill
95
- exists." The catalog + your judgment is the source of truth.)
96
- 4. Ask before installing: "Para esto instalaría `<id>`, que aún no tienes. ¿La instalo? (sí/no)".
97
- 5. On yes, run `npx @ericrisco/rsc add <id>` and continue the original request. One at a time.
98
-
99
- Example: "quiero montar una pagina web para vender cursos online" → scan the catalog and
100
- pick the web stack skill (usually `nextjs`) by meaning; don't start building before offering it.
101
- Example: "hazme una secuencia de cold emails para vender mi SaaS" → pick the email/outreach
102
- skill (`cold-outreach` / `email-connector`) because it *means* email outreach — not because
103
- the keywords happen to match (they often won't).
104
-
105
- ## Onboarding gate (first contact)
73
+ ---
106
74
 
107
- Before handling the first request of the session, check the workspace state:
75
+ ## 3. First contact
108
76
 
109
- - If `02-DOCS/wiki/harness/user-profile.md` is **missing** AND `.rsc/.no-harness` is **missing**, the harness has never been set up here — your FIRST action is to invoke `init` (auto-onboarding), which opens with the two gauging questions (technical level + accompaniment dial). Don't wait to be asked; don't start the user's task until first contact is done.
110
- - If the user declines or says they don't want a harness here ("sin harness", "solo código", "no quiero esto") — create an empty `.rsc/.no-harness` and never auto-start `init` in this repo again. Confirm in one line.
111
- - Once `02-DOCS/wiki/harness/user-profile.md` exists, this gate is inert — never re-onboard.
77
+ Before handling the first request of a session, check the workspace:
112
78
 
113
- This is the universal layer every assistant reads. On Claude Code a SessionStart hook also prints this reminder deterministically, but the rule above is what makes it fire everywhere.
79
+ - No `02-DOCS/wiki/harness/user-profile.md` **and** no `.rsc/.no-harness` → the harness has never
80
+ been set up here. Invoke `init` first; it opens with the two gauging questions (technical level +
81
+ accompaniment dial). Do not start the user's task before first contact is done.
82
+ - The user declines a harness here ("sin harness", "solo código") → create an empty
83
+ `.rsc/.no-harness`, confirm in one line, and never auto-start `init` in this repo again.
84
+ - Once the profile exists, this gate is inert. Never re-onboard.
114
85
 
115
86
  ## Orientación (siempre)
116
87
 
117
- Cierra cada turno con el **bloque-brújula** (📍 dónde estás · ✅ qué hiciste · 🧭 por qué · ➡️ siguiente, terminando en pregunta), calibrado al dial de `02-DOCS/wiki/harness/user-profile.md`. **Nunca termines en seco.** Protocolo completo: skill `orient` → `skills/orient/references/orientation-contract.md`. (Defiere a `suggest` el "¿instalo la skill que falta?".)
88
+ Cierra cada turno con el **bloque-brújula** (📍 dónde estás · ✅ qué hiciste · 🧭 por qué · ➡️ siguiente,
89
+ terminando en pregunta), calibrado al dial de `02-DOCS/wiki/harness/user-profile.md`. Nunca termines
90
+ en seco. Protocolo completo: skill `orient` → `skills/orient/references/orientation-contract.md`.
91
+ (Defiere a este mismo cuerpo, §2, el "¿instalo la skill que falta?".)
package/targets/claude.js CHANGED
@@ -48,6 +48,9 @@ export function unwireHook(paths) {
48
48
  export function wireHook(paths) {
49
49
  const scriptDest = join(paths.projectRoot, '.rsc', 'session-start.mjs');
50
50
  mkdirSync(dirname(scriptDest), { recursive: true });
51
+ // Shared by session-start + userprompt-gate. Hooks are materialized file by file, so a module
52
+ // they import must be copied as their SIBLING — a subdirectory import would break once copied.
53
+ copyFileSync(join(HERE, 'hook-once.mjs'), join(paths.projectRoot, '.rsc', 'hook-once.mjs'));
51
54
  copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
52
55
 
53
56
  const suggestMd = `${paths.skillDir('suggest')}/SKILL.md`;
@@ -0,0 +1,120 @@
1
+ // hook-once.mjs — cross-scope single-shot guard for rsc hooks.
2
+ //
3
+ // rsc can be wired in more than one scope at once (user-level ~/.claude/settings.json AND
4
+ // project-level .claude/settings.json). Each scope runs its OWN copy of the hook scripts, so
5
+ // the same always-on body and the same SDD gate get injected twice — once per scope. wireHook()
6
+ // is idempotent within a settings file but blind to the other one, so the duplication cannot be
7
+ // solved at install time; it has to be solved at injection time.
8
+ //
9
+ // The marker therefore CANNOT live in `.rsc/` — each scope has its own root, which is the whole
10
+ // problem. It lives in a per-user directory under the OS temp dir, which both scopes can see.
11
+ //
12
+ // Claude Code gives every hook the same `session_id` for both scopes, and the same `prompt_id`
13
+ // within one turn (docs: code.claude.com/docs/en/hooks). That is the dedup key.
14
+ //
15
+ // Fail-open is the rule throughout: if anything about this mechanism misbehaves, the hook prints.
16
+ // A duplicated block costs context; a swallowed always-on layer costs the session its behaviour.
17
+ import { mkdirSync, writeFileSync, statSync, readdirSync, unlinkSync, utimesSync, readFileSync } from 'node:fs';
18
+ import { tmpdir, userInfo } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ const PRUNE_AFTER_MS = 24 * 3600 * 1000;
22
+
23
+ // Per-user so two accounts on one machine never share (or suppress) each other's markers.
24
+ // RSC_HOOK_MARKER_DIR overrides it so tests get an isolated directory per case.
25
+ export function markerDir() {
26
+ if (process.env.RSC_HOOK_MARKER_DIR) {
27
+ const dir = process.env.RSC_HOOK_MARKER_DIR;
28
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
29
+ return dir;
30
+ }
31
+ let uid = 'anon';
32
+ try { uid = String(userInfo().uid ?? userInfo().username ?? 'anon'); } catch { /* keep anon */ }
33
+ const dir = join(tmpdir(), `rsc-hooks-${uid}`);
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ return dir;
36
+ }
37
+
38
+ // Keys are built from hook payload values, so they can contain anything. Flatten to a safe
39
+ // filename — a key must never be able to point outside the marker directory.
40
+ function safeName(key) {
41
+ return String(key).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
42
+ }
43
+
44
+ // Drop markers from previous days so the directory cannot grow without bound. Best-effort.
45
+ function prune(dir, now) {
46
+ try {
47
+ for (const name of readdirSync(dir)) {
48
+ const p = join(dir, name);
49
+ try {
50
+ if (now - statSync(p).mtimeMs > PRUNE_AFTER_MS) unlinkSync(p);
51
+ } catch { /* raced with another hook — fine */ }
52
+ }
53
+ } catch { /* unreadable dir — fine */ }
54
+ }
55
+
56
+ /**
57
+ * Claim the right to emit `key` once.
58
+ *
59
+ * @returns true if this process should print, false if another scope already did.
60
+ *
61
+ * `windowMs` bounds how long a claim lasts. Two scopes fire within milliseconds of each other,
62
+ * while two genuine repeats of the same event (two compactions in one session, which reuse
63
+ * session_id AND source) are minutes apart — so an expired marker means "new event", not
64
+ * "duplicate". This window is the one heuristic in the design, and only SessionStart needs it:
65
+ * UserPromptSubmit keys on prompt_id, which is already unique per turn.
66
+ */
67
+ export function claimOnce(key, { dir = markerDir(), windowMs = 30_000, now = Date.now() } = {}) {
68
+ try {
69
+ const file = join(dir, safeName(key));
70
+ try {
71
+ // Atomic create: whichever scope gets here first is the one that prints.
72
+ writeFileSync(file, String(now), { flag: 'wx' });
73
+ prune(dir, now);
74
+ return true;
75
+ } catch {
76
+ // Marker exists. Fresh → someone else already emitted this exact event.
77
+ const age = now - statSync(file).mtimeMs;
78
+ if (age <= windowMs) return false;
79
+ // Stale → this is a new occurrence of the same key. Re-arm and print.
80
+ const stamp = new Date(now);
81
+ utimesSync(file, stamp, stamp);
82
+ return true;
83
+ }
84
+ } catch {
85
+ return true; // fail-open
86
+ }
87
+ }
88
+
89
+ // The SDD gate text lives here, not inline in userprompt-gate.mjs, so it has exactly one
90
+ // definition: the hook emits it and `rsc doctor` measures it from the same constant. Anything
91
+ // that reports a byte count for text it does not own drifts the moment the text changes.
92
+ export const SDD_GATE_TEXT = `===== rsc SDD new-feature gate (highest precedence) =====
93
+ Before acting on this turn: if the user wants to BUILD, ADD, or CHANGE a feature — in
94
+ ANY language, judged by intent, not by keywords — you MUST route it through SDD via
95
+ \`specify\` FIRST. No feature code is written by ANY skill (stack skills included —
96
+ nextjs/react/fastapi/flutter/go/postgresdb/building-agents/design — and any builder skill
97
+ such as chatbot/course-builder/marketing) until a spec AND a plan exist and the user has
98
+ approved them. No skill outranks this gate.
99
+ - Unclear / in-between? -> \`specify\` (the safe default; a skipped spec is where drift hides).
100
+ - One-line / low-risk change, or a bug fix restoring intended behaviour? -> skip the chain,
101
+ do it, and say so out loud.
102
+ Full gate + decision table live in the always-on \`suggest\` body; method in \`sdd\`.
103
+ =========================================================
104
+ `;
105
+
106
+ /**
107
+ * Parse the hook payload Claude Code writes to stdin.
108
+ * Pass `raw` explicitly in tests; in a hook, call with no argument to read fd 0.
109
+ * Any problem (no stdin, closed fd, malformed JSON) degrades to {} — callers then fail open.
110
+ */
111
+ export function readHookInput(raw) {
112
+ try {
113
+ const text = raw === undefined ? readFileSync(0, 'utf8') : raw;
114
+ if (!text || !text.trim()) return {};
115
+ const parsed = JSON.parse(text);
116
+ return parsed && typeof parsed === 'object' ? parsed : {};
117
+ } catch {
118
+ return {};
119
+ }
120
+ }
@@ -5,14 +5,26 @@
5
5
  // Always emits suggest's always-on body; appends an onboarding banner when the
6
6
  // workspace has no harness profile yet, and an auto-ingest nudge when there is
7
7
  // un-ingested material waiting in the inbox.
8
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
9
- import { join } from 'node:path';
8
+ import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
+ import { homedir } from 'node:os';
11
+ import { claimOnce, readHookInput } from './hook-once.mjs';
10
12
 
11
13
  const STALE_AUDIT_DAYS = 14; // periodic skill-audit cadence (kept in sync with scripts/audit.js)
12
14
 
13
15
  const suggestMd = process.argv[2];
14
16
  const root = process.argv[3] || process.cwd();
15
17
 
18
+ // When rsc is wired in more than one scope (user + project), every scope runs this script and the
19
+ // always-on body lands once per scope. Both invocations share Claude Code's session_id, so the
20
+ // first one to claim the event emits and the rest stay silent. `source` is part of the key so a
21
+ // later compaction — which reuses the session_id — still re-injects, as it must.
22
+ // No session id (no stdin / older Claude Code) → claimOnce fails open and we emit as before.
23
+ const hook = readHookInput();
24
+ const emitBody = hook.session_id
25
+ ? claimOnce(`ss:${hook.session_id}:${hook.source || 'startup'}`)
26
+ : true;
27
+
16
28
  const has = (...p) => existsSync(join(root, ...p));
17
29
 
18
30
  // Is a Context7 MCP server configured for this project (.mcp.json → mcpServers.context7)?
@@ -31,7 +43,64 @@ function auditDue() {
31
43
  } catch { return true; }
32
44
  }
33
45
 
34
- try { process.stdout.write(readFileSync(suggestMd, 'utf8')); } catch { /* missing → emit nothing */ }
46
+ if (emitBody) {
47
+ try { process.stdout.write(readFileSync(suggestMd, 'utf8')); } catch { /* missing → emit nothing */ }
48
+ }
49
+
50
+ // De-dup above only works when EVERY wired scope runs an updated hook. A scope left behind keeps
51
+ // emitting its own copy and cannot be silenced from here, so the only honest move is to say which
52
+ // scope is lagging and how to fix it. Cadence-limited (this is advice, not an alarm) and opt-out-able.
53
+ const SCOPE_WARN_DAYS = 7;
54
+
55
+ function versionAt(scopeRoot) {
56
+ try { return readFileSync(join(scopeRoot, '.rsc', '.version'), 'utf8').trim() || null; } catch { return null; }
57
+ }
58
+
59
+ function isWiredScope(scopeRoot) {
60
+ try { return readFileSync(join(scopeRoot, '.claude', 'settings.json'), 'utf8').includes('.rsc/'); } catch { return false; }
61
+ }
62
+
63
+ function olderThan(a, b) {
64
+ const pa = String(a).split('.').map(Number); const pb = String(b).split('.').map(Number);
65
+ for (let i = 0; i < 3; i++) {
66
+ if ((pa[i] || 0) < (pb[i] || 0)) return true;
67
+ if ((pa[i] || 0) > (pb[i] || 0)) return false;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ function scopeWarnDue() {
73
+ try {
74
+ const { lastRun } = JSON.parse(readFileSync(join(root, '.rsc', 'dup-scope.json'), 'utf8'));
75
+ return (Date.now() - new Date(lastRun).getTime()) / 86400000 >= SCOPE_WARN_DAYS;
76
+ } catch { return true; }
77
+ }
78
+
79
+ if (!has('.rsc', '.no-scope-check') && scopeWarnDue()) {
80
+ // The complementary scope: from a project root it is the user's home, and vice versa.
81
+ const home = homedir();
82
+ const other = resolve(root) === resolve(home) ? (hook.cwd && resolve(hook.cwd) !== resolve(home) ? hook.cwd : null) : home;
83
+ const mine = versionAt(root);
84
+ const theirs = other ? versionAt(other) : null;
85
+ // Both versions must be readable to make a claim; an unreadable scope is not evidence of a problem.
86
+ if (other && mine && theirs && isWiredScope(other) && olderThan(theirs, mine)) {
87
+ process.stdout.write(`
88
+ ===== rsc duplicate rsc scope =====
89
+ rsc is wired in TWO scopes and one is out of date, so this session receives the always-on
90
+ block and the SDD gate TWICE — once per scope. Only the updated scope can de-duplicate.
91
+ this scope: ${root} (${mine})
92
+ lagging scope: ${other} (${theirs})
93
+ ACTION: update the lagging scope, then it will stay silent on its own:
94
+ cd "${other}" && npx @ericrisco/rsc@latest
95
+ Opt out with .rsc/.no-scope-check · this notice repeats at most every ${SCOPE_WARN_DAYS} days.
96
+ ===================================
97
+ `);
98
+ try {
99
+ mkdirSync(join(root, '.rsc'), { recursive: true });
100
+ writeFileSync(join(root, '.rsc', 'dup-scope.json'), JSON.stringify({ lastRun: new Date().toISOString() }) + '\n');
101
+ } catch { /* unwritable → the notice simply repeats next session */ }
102
+ }
103
+ }
35
104
 
36
105
  const profile = join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md');
37
106
  const optout = join(root, '.rsc', '.no-harness');
@@ -13,22 +13,19 @@
13
13
  // Fail-open (never blocks a turn). Opt out per project with .rsc/.no-feature-gate.
14
14
  import { existsSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
+ import { claimOnce, readHookInput, SDD_GATE_TEXT } from './hook-once.mjs';
16
17
 
17
18
  const root = process.argv[2] || process.cwd();
18
19
 
19
20
  // Opt-out: a project that does not want the always-on gate drops this marker file.
20
21
  if (existsSync(join(root, '.rsc', '.no-feature-gate'))) process.exit(0);
21
22
 
22
- process.stdout.write(`===== rsc SDD new-feature gate (highest precedence) =====
23
- Before acting on this turn: if the user wants to BUILD, ADD, or CHANGE a feature — in
24
- ANY language, judged by intent, not by keywords — you MUST route it through SDD via
25
- \`specify\` FIRST. No feature code is written by ANY skill (stack skills included —
26
- nextjs/react/fastapi/flutter/go/postgresdb/building-agents/design — and any builder skill
27
- such as chatbot/course-builder/marketing) until a spec AND a plan exist and the user has
28
- approved them. No skill outranks this gate.
29
- - Unclear / in-between? -> \`specify\` (the safe default; a skipped spec is where drift hides).
30
- - One-line / low-risk change, or a bug fix restoring intended behaviour? -> skip the chain,
31
- do it, and say so out loud.
32
- Full gate + decision table live in the always-on \`suggest\` body; method in \`sdd\`.
33
- =========================================================
34
- `);
23
+ // rsc wired in both user and project scope runs this once per scope, so the gate lands twice on
24
+ // every turn. `prompt_id` is unique per turn and identical across scopes, which makes the dedup
25
+ // exact — no time heuristic needed. Older Claude Code has no prompt_id: fall back to the prompt
26
+ // text, and if there is nothing to key on at all, emit (a duplicated gate beats a missing one).
27
+ const hook = readHookInput();
28
+ const turnKey = hook.prompt_id || (hook.prompt ? `p${hook.prompt.length}:${hook.prompt.slice(0, 64)}` : null);
29
+ if (hook.session_id && turnKey && !claimOnce(`up:${hook.session_id}:${turnKey}`)) process.exit(0);
30
+
31
+ process.stdout.write(SDD_GATE_TEXT);