@ericrisco/rsc 0.1.16 → 0.1.18

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/README.md CHANGED
@@ -273,7 +273,7 @@ duplicated. The wizard asks which ones; `--target a,b` does it non-interactively
273
273
 
274
274
  | Target | Skill destination (→ `.rsc/skills/<id>/`) | Always-on detector |
275
275
  | --- | --- | --- |
276
- | `claude` | `.claude/skills/rsc/<id>/` → symlink | SessionStart hook in `.claude/settings.json` |
276
+ | `claude` | `.claude/skills/<id>/` → symlink (copy on Windows) | SessionStart hook in `.claude/settings.json` |
277
277
  | `codex` | `.codex/rsc/<id>/` → symlink | block in `AGENTS.md` |
278
278
  | `copilot` | `.github/rsc/<id>/` → symlink | block in `.github/copilot-instructions.md` |
279
279
  | `cursor` | `.cursor/rules/<id>.mdc` (converted) | always-apply rule |
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.16",
2
+ "version": "0.1.18",
3
3
  "counts": {
4
4
  "skills": 231
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,224 @@
1
+ // audit.js — inventory installed skills (project + machine), surface possible
2
+ // overlap and over-install (bloat). Advisory only: every finding is a "review
3
+ // this", never a hard error. Run on demand (`rsc audit`), at `init`, and nudged
4
+ // periodically by the SessionStart hook when the last run is stale.
5
+ //
6
+ // Signals it leans on, all already in the repo:
7
+ // - manifest.json → the catalog (id, description, tags) — what a skill is
8
+ // - .rsc/skills/<id> → the project's single source of truth for installed skills
9
+ // - ~/.claude/skills → machine/user-scope skills
10
+ // - detectRepo() → coarse stack signals, to judge "no footprint here"
11
+ // - DOMAINS → the catalog grouped by intent, to judge overlap/heaviness
12
+ import { existsSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
13
+ import { join, dirname } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { loadManifest } from './lib/manifest.js';
17
+ import { detectRepo } from './detect-repo.js';
18
+ import { DOMAINS } from './lib/domains.js';
19
+
20
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
21
+
22
+ // The control plane + SDD pipeline always coexist by design — never flag them as
23
+ // overlap or bloat. A pipeline of phases is not "too many skills".
24
+ const FLOOR_DOMAINS = ['Core & control plane', 'Spec-Driven Development'];
25
+ // Domains whose skills imply a concrete stack in the repo. Only these get the
26
+ // "no footprint detected" check — content/business skills can't be judged from code.
27
+ const CODE_DOMAINS = [
28
+ 'Languages',
29
+ 'Frameworks & app stacks',
30
+ 'Databases & data layer',
31
+ 'Ship & operate — platforms',
32
+ ];
33
+
34
+ // detectRepo() is coarse (a handful of stack tags). Expand each detected signal to
35
+ // the sibling skills it implies, so "Next.js detected" doesn't flag react/vercel as
36
+ // orphans. Anything outside the expanded set in a CODE_DOMAIN is advisory bloat.
37
+ const STACK_SIBLINGS = {
38
+ nextjs: ['nextjs', 'react', 'typescript', 'nodejs', 'vercel', 'design', 'tailwind'],
39
+ design: ['design', 'nextjs', 'react'],
40
+ fastapi: ['fastapi', 'python', 'sql'],
41
+ go: ['go'],
42
+ postgresdb: ['postgresdb', 'sql', 'prisma-orm', 'drizzle-orm', 'db-migrations', 'supabase', 'neon'],
43
+ deployment: ['deployment', 'docker', 'github-actions', 'vercel', 'netlify', 'railway', 'render', 'fly-io'],
44
+ };
45
+
46
+ const OVERLAP_SHARED_TAGS = 3; // pair in same domain sharing ≥ this many tags → "similar ground"
47
+ const HEAVY_DOMAIN_COUNT = 5; // > this many installed in one (non-floor) domain → "heavy"
48
+ const STALE_DAYS = 14; // periodic nudge cadence (used by the SessionStart hook)
49
+
50
+ function domainOf(id) {
51
+ const d = DOMAINS.find((dom) => dom.ids.includes(id));
52
+ return d ? d.title : 'Uncategorized';
53
+ }
54
+
55
+ function subdirs(dir) {
56
+ try {
57
+ return readdirSync(dir, { withFileTypes: true })
58
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
59
+ .map((e) => e.name);
60
+ } catch { return []; }
61
+ }
62
+
63
+ // Installed in THIS project = whatever has a real base under .rsc/skills/<id>,
64
+ // intersected with the catalog (ignore stray dirs). This is target-agnostic: the
65
+ // shared base is the single source of truth regardless of which assistants link it.
66
+ export function installedProject(cwd, catalogIds) {
67
+ const set = new Set(catalogIds);
68
+ return subdirs(join(cwd, '.rsc', 'skills')).filter((id) => set.has(id)).sort();
69
+ }
70
+
71
+ // Installed on the MACHINE = user-scope Claude skills (~/.claude/skills/<id>),
72
+ // intersected with the catalog. Best-effort; other assistants' user scopes vary.
73
+ export function installedMachine(home, catalogIds) {
74
+ const set = new Set(catalogIds);
75
+ return subdirs(join(home, '.claude', 'skills')).filter((id) => set.has(id)).sort();
76
+ }
77
+
78
+ function findOverlaps(ids, tagsById) {
79
+ const out = [];
80
+ const content = ids.filter((id) => !FLOOR_DOMAINS.includes(domainOf(id)));
81
+ for (let i = 0; i < content.length; i++) {
82
+ for (let j = i + 1; j < content.length; j++) {
83
+ const a = content[i];
84
+ const b = content[j];
85
+ if (domainOf(a) !== domainOf(b)) continue;
86
+ const shared = (tagsById[a] || []).filter((t) => (tagsById[b] || []).includes(t));
87
+ if (shared.length >= OVERLAP_SHARED_TAGS) {
88
+ out.push({ a, b, domain: domainOf(a), sharedTags: shared });
89
+ }
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function findHeavyDomains(ids) {
96
+ const byDomain = {};
97
+ for (const id of ids) {
98
+ const d = domainOf(id);
99
+ if (FLOOR_DOMAINS.includes(d)) continue;
100
+ (byDomain[d] ||= []).push(id);
101
+ }
102
+ return Object.entries(byDomain)
103
+ .filter(([, list]) => list.length > HEAVY_DOMAIN_COUNT)
104
+ .map(([domain, list]) => ({ domain, count: list.length, ids: list.sort() }));
105
+ }
106
+
107
+ function findNoFootprint(ids, tagsById, detected) {
108
+ if (!detected.length) return []; // can't judge a non-code / empty repo — stay silent
109
+ const covered = new Set(detected.flatMap((sig) => STACK_SIBLINGS[sig] || [sig]));
110
+ const out = [];
111
+ for (const id of ids) {
112
+ const dom = domainOf(id);
113
+ if (!CODE_DOMAINS.includes(dom)) continue;
114
+ const tags = tagsById[id] || [];
115
+ const hasFootprint = covered.has(id) || tags.some((t) => covered.has(t));
116
+ if (!hasFootprint) {
117
+ out.push({ id, domain: dom, reason: `no detected footprint (repo looks like: ${detected.join(', ')})` });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ export function audit({
124
+ cwd = process.cwd(),
125
+ home = homedir(),
126
+ manifest = loadManifest(),
127
+ date = new Date().toISOString().slice(0, 10),
128
+ } = {}) {
129
+ const catalogIds = manifest.skills.map((s) => s.id);
130
+ const tagsById = Object.fromEntries(manifest.skills.map((s) => [s.id, s.tags || []]));
131
+
132
+ const project = installedProject(cwd, catalogIds);
133
+ const machine = installedMachine(home, catalogIds);
134
+ const detected = detectRepo(cwd);
135
+
136
+ const overlaps = findOverlaps(project, tagsById);
137
+ const heavyDomains = findHeavyDomains(project);
138
+ const noFootprint = findNoFootprint(project, tagsById, detected);
139
+
140
+ const byDomain = {};
141
+ for (const id of project) (byDomain[domainOf(id)] ||= []).push(id);
142
+
143
+ const findings = overlaps.length + heavyDomains.length + noFootprint.length;
144
+ const headline = findings === 0
145
+ ? `${project.length} skills installed — nothing to flag.`
146
+ : `${project.length} installed · ${overlaps.length} possible overlap, ${heavyDomains.length} heavy domain(s), ${noFootprint.length} with no footprint.`;
147
+
148
+ return {
149
+ date,
150
+ project: { root: cwd, installed: project, byDomain },
151
+ machine: { root: join(home, '.claude', 'skills'), installed: machine },
152
+ detectedStacks: detected,
153
+ overlaps,
154
+ heavyDomains,
155
+ noFootprint,
156
+ summary: {
157
+ projectCount: project.length,
158
+ machineCount: machine.length,
159
+ overlapCount: overlaps.length,
160
+ heavyCount: heavyDomains.length,
161
+ noFootprintCount: noFootprint.length,
162
+ clean: findings === 0,
163
+ headline,
164
+ },
165
+ };
166
+ }
167
+
168
+ export function renderAuditMarkdown(report) {
169
+ const L = [];
170
+ L.push(`# Skill audit — ${report.date}`, '');
171
+ L.push(report.summary.headline, '');
172
+ L.push(`- Project skills: **${report.summary.projectCount}** (\`${report.project.root}\`)`);
173
+ L.push(`- Machine skills: **${report.summary.machineCount}** (\`${report.machine.root}\`)`);
174
+ L.push(`- Detected stacks: ${report.detectedStacks.length ? report.detectedStacks.join(', ') : '(none detected)'}`, '');
175
+
176
+ if (report.overlaps.length) {
177
+ L.push('## Possible overlap (review — not necessarily wrong)', '');
178
+ for (const o of report.overlaps) {
179
+ L.push(`- \`${o.a}\` ↔ \`${o.b}\` — same domain *${o.domain}*, share: ${o.sharedTags.join(', ')}`);
180
+ }
181
+ L.push('');
182
+ }
183
+ if (report.heavyDomains.length) {
184
+ L.push('## Heavy domains (more than usual for one project)', '');
185
+ for (const h of report.heavyDomains) {
186
+ L.push(`- **${h.domain}** — ${h.count} installed: ${h.ids.map((i) => `\`${i}\``).join(', ')}`);
187
+ }
188
+ L.push('');
189
+ }
190
+ if (report.noFootprint.length) {
191
+ L.push('## Installed but no footprint detected (verify)', '');
192
+ for (const n of report.noFootprint) {
193
+ L.push(`- \`${n.id}\` (${n.domain}) — ${n.reason}`);
194
+ }
195
+ L.push('');
196
+ }
197
+ if (report.summary.clean) L.push('Nothing to flag. The installed set fits the project.', '');
198
+
199
+ L.push('---', `_Advisory only. Trim with \`npx @ericrisco/rsc uninstall <id>\`. Re-run with \`npx @ericrisco/rsc audit\`._`);
200
+ return L.join('\n') + '\n';
201
+ }
202
+
203
+ // Persist a stamp the SessionStart hook reads to decide if a periodic audit is due.
204
+ export function stampAudit(cwd, date = new Date().toISOString()) {
205
+ const dir = join(cwd, '.rsc');
206
+ mkdirSync(dir, { recursive: true });
207
+ writeFileSync(join(dir, 'audit.json'), JSON.stringify({ lastRun: date }, null, 2) + '\n');
208
+ }
209
+
210
+ // Write the report into the harness wiki when one exists; always stamp .rsc/audit.json.
211
+ export function writeAuditReport(report, cwd = process.cwd()) {
212
+ const written = [];
213
+ const wikiHarness = join(cwd, '02-DOCS', 'wiki', 'harness');
214
+ if (existsSync(join(cwd, '02-DOCS', 'wiki'))) {
215
+ mkdirSync(wikiHarness, { recursive: true });
216
+ const file = join(wikiHarness, `skill-audit-${report.date}.md`);
217
+ writeFileSync(file, renderAuditMarkdown(report));
218
+ written.push(file);
219
+ }
220
+ stampAudit(cwd, `${report.date}T00:00:00.000Z`);
221
+ return written;
222
+ }
223
+
224
+ export { STALE_DAYS, ROOT };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { rmSync, existsSync, cpSync, mkdirSync } from 'node:fs';
2
+ import { rmSync, existsSync, cpSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { planInstall } from './install-plan.js';
@@ -7,11 +7,24 @@ import { targetPaths, writeSkill, wireHook, baseDir } from '../targets/index.js'
7
7
  import { readState, writeState } from './lib/state.js';
8
8
 
9
9
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const CLI_VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
10
11
 
11
- // Materialize the real skill files into the project-local base exactly once.
12
- // Re-installs and other assistants reuse it instead of copying again.
13
- function ensureBase(id, cwd) {
12
+ // `.rsc/.version` records the CLI version the shared bases (.rsc/skills/) were
13
+ // materialized at the single, target-agnostic source of truth for "installed
14
+ // skills version" (read by the SessionStart update check too).
15
+ const versionFile = (cwd) => join(cwd, '.rsc', '.version');
16
+ function readBaseVersion(cwd) {
17
+ try { return readFileSync(versionFile(cwd), 'utf8').trim(); } catch { return undefined; }
18
+ }
19
+
20
+ // Materialize the real skill files into the project-local base. Normally copied
21
+ // once and reused; when `refresh` is set (a different CLI version than the base was
22
+ // materialized at) the base is re-copied so a reinstall actually updates content.
23
+ // Skills are read-only catalog (user customization lives in 02-DOCS/CLAUDE.md), so
24
+ // overwriting on a version change is safe.
25
+ function ensureBase(id, cwd, refresh) {
14
26
  const dest = baseDir(id, cwd);
27
+ if (refresh && existsSync(dest)) rmSync(dest, { recursive: true, force: true });
15
28
  if (!existsSync(dest)) {
16
29
  mkdirSync(dirname(dest), { recursive: true });
17
30
  cpSync(join(ROOT, 'skills', id), dest, { recursive: true });
@@ -23,16 +36,22 @@ export async function applyInstall({ skillIds, target, home, cwd = process.cwd()
23
36
  const paths = targetPaths(target, home, cwd);
24
37
  const plan = planInstall({ skillIds, target, home, cwd });
25
38
  const state = readState(paths.stateFile);
39
+ // Refresh bases when installing a different version than they were materialized at
40
+ // (or a pre-versioning install where the marker is absent). Same version → no-op.
41
+ const refresh = readBaseVersion(cwd) !== CLI_VERSION;
26
42
  for (const step of plan) {
27
43
  if (step.kind === 'skill') {
28
- const base = ensureBase(step.id, cwd);
44
+ const base = ensureBase(step.id, cwd, refresh);
29
45
  const files = await writeSkill(target, step.id, base, step.to);
30
46
  state.skills[step.id] = { files, base };
31
47
  } else if (step.kind === 'hook') {
32
- await wireHook(target, paths, join(ensureBase('suggest', cwd), 'SKILL.md'));
48
+ await wireHook(target, paths, join(ensureBase('suggest', cwd, refresh), 'SKILL.md'));
33
49
  }
34
50
  }
51
+ state.version = CLI_VERSION;
35
52
  writeState(paths.stateFile, state);
53
+ mkdirSync(dirname(versionFile(cwd)), { recursive: true });
54
+ writeFileSync(versionFile(cwd), CLI_VERSION + '\n');
36
55
  return state;
37
56
  }
38
57
 
package/scripts/rsc.js CHANGED
@@ -8,6 +8,7 @@ import { applyInstall, listInstalled, uninstall } from './install-apply.js';
8
8
  import { doctor } from './doctor.js';
9
9
  import { say, select, pickFrom, banner, confirm } from './lib/ui.js';
10
10
  import { refreshRegistry, registryStatus } from './lib/registry.js';
11
+ import { audit, writeAuditReport } from './audit.js';
11
12
  import { DOMAINS } from './lib/domains.js';
12
13
 
13
14
  const argv = process.argv.slice(2);
@@ -175,6 +176,17 @@ async function main() {
175
176
  for (const o of toOutcomes(ids)) say(`${o.id}\t${o.label}`);
176
177
  return;
177
178
  }
179
+ case 'audit': {
180
+ const report = audit();
181
+ const written = writeAuditReport(report);
182
+ say(report.summary.headline);
183
+ for (const o of report.overlaps) say(` ~ overlap: ${o.a} ↔ ${o.b} (${o.sharedTags.join(', ')})`);
184
+ for (const h of report.heavyDomains) say(` ! heavy: ${h.domain} — ${h.count} skills`);
185
+ for (const n of report.noFootprint) say(` ? no footprint: ${n.id} (${n.reason})`);
186
+ if (written.length) say(`\nReport: ${written[0]}`);
187
+ else say('\n(no harness wiki here — printed above only; run `harness` to keep a written record)');
188
+ return;
189
+ }
178
190
  case 'list':
179
191
  return void say(listInstalled({ target }).join('\n') || '(nothing installed)');
180
192
  case 'doctor':
@@ -201,7 +213,7 @@ async function main() {
201
213
  }
202
214
  default:
203
215
  say(`rsc: unknown command '${cmd}'.`);
204
- say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | registry refresh | doctor | uninstall <id>');
216
+ say('Use: npx @ericrisco/rsc | add <id...> | install --profile <p> | consult "<text>" | list | audit | registry refresh | doctor | uninstall <id>');
205
217
  }
206
218
  }
207
219
 
@@ -49,10 +49,14 @@ profiles: [core, full] # optional: named-profile membership
49
49
 
50
50
  ## Invocation
51
51
 
52
- There are no bundles and no `/<bundle>:<id>` namespacing. A skill installs under
53
- the target's rsc namespace (e.g. `~/.claude/skills/rsc/<id>/`) and is invoked by
54
- its `name`. The `suggest` detector is always installed (the floor) and proposes
55
- installing any skill a task needs via `npx @ericrisco/rsc add <id>`.
52
+ There are no bundles and no `/<bundle>:<id>` namespacing. Most targets install a
53
+ skill under their own rsc folder (e.g. `.codex/rsc/<id>/`), reached from that
54
+ assistant's instructions file. **Claude is the exception:** Claude Code only
55
+ discovers project skills one level under `.claude/skills/`, so rsc skills install
56
+ **flat** at `.claude/skills/<id>/SKILL.md` (a nested `.claude/skills/rsc/<id>/`
57
+ is never discovered). A skill is invoked by its `name`. The `suggest` detector is
58
+ always installed (the floor) and proposes installing any skill a task needs via
59
+ `npx @ericrisco/rsc add <id>`.
56
60
 
57
61
  ## Wiring steps for a new skill
58
62
 
@@ -388,7 +388,8 @@ generalizes the SDD session-summary convention to *any* work.
388
388
 
389
389
  - **Hook — `PreCompact` / `SessionEnd`** (Claude Code): fires right before context
390
390
  is lost or the session ends. The hook only *reminds*; the agent writes the
391
- worklog (Karpathy: the LLM writes). Wired by `targets/` → `.rsc/worklog-checkpoint.sh`.
391
+ worklog (Karpathy: the LLM writes). Wired by `targets/` → `.rsc/worklog-checkpoint.mjs`
392
+ (run via `node`, so it works on Windows too).
392
393
  - **Explicit milestone**: after a commit or a shipped feature, capture a worklog.
393
394
  - **Daily curation automation**: distills any pending worklog raw on a timer (see
394
395
  Continuous Improvement and `daily-curation-automation.md`).
@@ -137,6 +137,22 @@ npx @ericrisco/rsc add <skill> [<skill> ...]
137
137
 
138
138
  Install only skills their answers justify — same discipline as "no speculative tools". Full skill map, sample printouts per scenario, and the requirements-first decision pattern → `references/recommend-skills.md`.
139
139
 
140
+ ### Phase 3.5 — GROUND THE PROJECT (git · live docs · skill audit)
141
+
142
+ Three quick, enforced setup checks once the skills are installed. The SessionStart hook nudges each
143
+ of these too; doing them here means the user starts clean.
144
+
145
+ 1. **Version control is required.** If the workspace has no `.git/`, offer `git init` (recommended —
146
+ the SDD chain and the ship guard assume git). If the user declines, write an empty `.rsc/.no-git`
147
+ so the decision is persisted and neither you nor the hook asks again. Log the decision.
148
+ 2. **Offer Context7 (live library docs).** For a software project, offer to wire the Context7 MCP
149
+ once: `claude mcp add --transport http context7 https://mcp.context7.com/mcp`. If they don't want
150
+ it, write `.rsc/.no-context7`. (It gives version-correct docs instead of guessing from memory.)
151
+ 3. **Run a skill audit.** After installing, run `npx @ericrisco/rsc audit`. It inventories the skills
152
+ installed for this project and on the machine, and flags possible overlap or skills with no
153
+ footprint here — so the project starts with the right set, not a pile. It re-runs on a cadence via
154
+ the SessionStart nudge. Summarize the result at the user's accompaniment level.
155
+
140
156
  ### Phase 4 — HANDOFF
141
157
 
142
158
  Tell the user to install `harness` (`npx @ericrisco/rsc add harness`) and then run the **`harness`** skill to actually scaffold the `01-TOOLS/` + `02-DOCS/` workspace. `init` stops here — it has set the profile, recorded the discovery, and recommended the skills. `harness` reads the profile and builds the structure.
@@ -81,6 +81,16 @@ git diff main...HEAD | grep -icE 'api[_-]?key|secret|password|token|BEGIN .*PRIV
81
81
  | sed 's/^/secret-hits: /'
82
82
  ```
83
83
 
84
+ ### Automated guard (PreToolUse) — you cannot quietly abandon a feature
85
+
86
+ When rsc is installed for Claude Code, a `PreToolUse` hook (`.rsc/ship-guard.mjs`) enforces this
87
+ phase at the one deterministic moment it matters: it **denies** any Bash command that switches to
88
+ `main`/`master` or merges while the current feature branch has **uncommitted changes** or **commits
89
+ that were never pushed**. The denial reason names the branch and routes you here. The guard is
90
+ local-only (no network), **fail-open** (any ambiguity allows the command), and can be disabled per
91
+ project with `.rsc/.no-ship-guard`. It guarantees the commit → push step; opening the PR is still
92
+ this skill's job (and its hard rule). If the guard blocks you, do not work around it — run ship.
93
+
84
94
  ## The three landing options — always present exactly three
85
95
 
86
96
  This mirrors the harness "siempre 3 opciones" pattern. Gather the one fact that changes the answer (does this repo use PRs / require review on `main`?), then present **exactly three** with an honest recommendation matched to the workflow and the accompaniment level.
package/targets/claude.js CHANGED
@@ -1,27 +1,34 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, chmodSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, rmSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { linkOrCopy } from './index.js';
5
5
 
6
+ const HERE = dirname(fileURLToPath(import.meta.url));
7
+
6
8
  export function writeSkill(id, fromDir, toPath) {
9
+ // Migrate away from the legacy nested layout (.claude/skills/rsc/<id>) that
10
+ // Claude Code never discovered — it only reads .claude/skills/<name>/SKILL.md.
11
+ // toPath is now .claude/skills/<id>; drop the stale rsc/ sibling if present.
12
+ const legacy = join(dirname(toPath), 'rsc');
13
+ if (existsSync(legacy)) rmSync(legacy, { recursive: true, force: true });
7
14
  return linkOrCopy(fromDir, toPath);
8
15
  }
9
16
 
10
- // SessionStart runs a project-local session-start.sh: it cats suggest's always-on
11
- // body (preserving prior behavior) and appends an onboarding banner when the
12
- // workspace has no harness profile yet. We materialize the script next to the
13
- // shared base and point the hook at it. Any prior rsc SessionStart entry (the old
14
- // `cat …/suggest/SKILL.md` form or a previous script form) is dropped before we
15
- // add the current one idempotent, and it migrates legacy hooks in place. Other
16
- // (non-rsc) SessionStart hooks are preserved.
17
+ // SessionStart runs a project-local session-start.mjs via `node`: it prints
18
+ // suggest's always-on body, an onboarding banner when the workspace has no harness
19
+ // profile yet, and an auto-ingest nudge when the inbox has un-ingested material. We
20
+ // invoke with `node` (not bash) so the hook runs on Windows too. We materialize the
21
+ // script next to the shared base and point the hook at it. Any prior rsc SessionStart
22
+ // entry (the old `cat …/suggest/SKILL.md` form, or a previous `.sh`/`.mjs` script
23
+ // form) is dropped before we add the current one — idempotent, migrating legacy and
24
+ // bash-era hooks in place. Other (non-rsc) SessionStart hooks are preserved.
17
25
  export function wireHook(paths) {
18
- const scriptDest = join(paths.projectRoot, '.rsc', 'session-start.sh');
26
+ const scriptDest = join(paths.projectRoot, '.rsc', 'session-start.mjs');
19
27
  mkdirSync(dirname(scriptDest), { recursive: true });
20
- copyFileSync(join(dirname(fileURLToPath(import.meta.url)), 'session-start.sh'), scriptDest);
21
- chmodSync(scriptDest, 0o755);
28
+ copyFileSync(join(HERE, 'session-start.mjs'), scriptDest);
22
29
 
23
30
  const suggestMd = `${paths.skillDir('suggest')}/SKILL.md`;
24
- const cmd = `bash "${scriptDest}" "${suggestMd}" "${paths.projectRoot}"`;
31
+ const cmd = `node "${scriptDest}" "${suggestMd}" "${paths.projectRoot}"`;
25
32
 
26
33
  const file = paths.hookTarget;
27
34
  const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
@@ -29,28 +36,42 @@ export function wireHook(paths) {
29
36
  settings.hooks.SessionStart ||= [];
30
37
  settings.hooks.SessionStart = settings.hooks.SessionStart.filter((e) => {
31
38
  const s = JSON.stringify(e);
32
- return !s.includes('skills/rsc/suggest') && !s.includes('.rsc/session-start.sh');
39
+ // drop legacy cat-form and any prior session-start script (.sh from the bash era, or .mjs)
40
+ return !s.includes('skills/rsc/suggest') && !s.includes('.rsc/session-start.');
33
41
  });
34
42
  settings.hooks.SessionStart.push({ hooks: [{ type: 'command', command: cmd }] });
35
43
 
36
44
  // Worklog checkpoint: PreCompact + SessionEnd run a project-local
37
- // worklog-checkpoint.sh that reminds the agent to capture what we did this
38
- // session into 02-DOCS/raw/worklog/ (the work-driven on-ramp). Silent when the
39
- // workspace has no harness wiki. Registered idempotently on both events, with
40
- // any prior rsc worklog-checkpoint entry dropped first.
41
- const wlDest = join(paths.projectRoot, '.rsc', 'worklog-checkpoint.sh');
42
- copyFileSync(join(dirname(fileURLToPath(import.meta.url)), 'worklog-checkpoint.sh'), wlDest);
43
- chmodSync(wlDest, 0o755);
44
- const wlCmd = `bash "${wlDest}" "${paths.projectRoot}"`;
45
+ // worklog-checkpoint.mjs via `node` that reminds the agent to capture what we did
46
+ // this session into 02-DOCS/raw/worklog/ (the work-driven on-ramp). Silent when
47
+ // the workspace has no harness wiki. Registered idempotently on both events, with
48
+ // any prior rsc worklog-checkpoint entry (.sh or .mjs) dropped first.
49
+ const wlDest = join(paths.projectRoot, '.rsc', 'worklog-checkpoint.mjs');
50
+ copyFileSync(join(HERE, 'worklog-checkpoint.mjs'), wlDest);
51
+ const wlCmd = `node "${wlDest}" "${paths.projectRoot}"`;
45
52
  for (const event of ['PreCompact', 'SessionEnd']) {
46
53
  settings.hooks[event] ||= [];
47
54
  settings.hooks[event] = settings.hooks[event].filter(
48
- (e) => !JSON.stringify(e).includes('.rsc/worklog-checkpoint.sh'),
55
+ (e) => !JSON.stringify(e).includes('.rsc/worklog-checkpoint.'),
49
56
  );
50
57
  settings.hooks[event].push({ hooks: [{ type: 'command', command: wlCmd }] });
51
58
  }
52
59
 
60
+ // Ship guard: a PreToolUse(Bash) hook that DENIES switching to / merging the trunk
61
+ // while the current feature branch has uncommitted or unpushed work — forcing the
62
+ // commit → push → PR close (the `ship` skill). Materialized + node-run (Windows-safe),
63
+ // registered idempotently, fail-open, opt-out via .rsc/.no-ship-guard. Other
64
+ // (non-rsc) PreToolUse hooks are preserved.
65
+ const sgDest = join(paths.projectRoot, '.rsc', 'ship-guard.mjs');
66
+ copyFileSync(join(HERE, 'ship-guard.mjs'), sgDest);
67
+ const sgCmd = `node "${sgDest}" "${paths.projectRoot}"`;
68
+ settings.hooks.PreToolUse ||= [];
69
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
70
+ (e) => !JSON.stringify(e).includes('.rsc/ship-guard.'),
71
+ );
72
+ settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: sgCmd }] });
73
+
53
74
  mkdirSync(dirname(file), { recursive: true });
54
75
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
55
- return [file, scriptDest, wlDest];
76
+ return [file, scriptDest, wlDest, sgDest];
56
77
  }
package/targets/index.js CHANGED
@@ -11,16 +11,22 @@ export function baseDir(id, cwd = process.cwd()) {
11
11
  return join(cwd, '.rsc', 'skills', id);
12
12
  }
13
13
 
14
- // Point an assistant's skill folder at the shared base via a relative symlink.
15
- // Falls back to a real copy when the filesystem rejects symlinks (e.g. Windows
16
- // without privileges). Idempotent: replaces any existing link/dir at toPath.
14
+ // Point an assistant's skill folder at the shared base. On macOS/Linux a relative
15
+ // symlink avoids duplication. On Windows we copy real files: relative `dir`
16
+ // symlinks require Developer Mode/admin and are not reliably followed by skill
17
+ // discovery, so correctness wins over de-duplication. Idempotent: replaces any
18
+ // existing link/dir at toPath.
17
19
  export function linkOrCopy(fromDir, toPath) {
18
20
  mkdirSync(dirname(toPath), { recursive: true });
19
21
  try { lstatSync(toPath); rmSync(toPath, { recursive: true, force: true }); } catch { /* nothing there */ }
20
- try {
21
- symlinkSync(relative(dirname(toPath), fromDir), toPath, 'dir');
22
- } catch {
22
+ if (process.platform === 'win32') {
23
23
  cpSync(fromDir, toPath, { recursive: true });
24
+ } else {
25
+ try {
26
+ symlinkSync(relative(dirname(toPath), fromDir), toPath, 'dir');
27
+ } catch {
28
+ cpSync(fromDir, toPath, { recursive: true });
29
+ }
24
30
  }
25
31
  return [toPath];
26
32
  }
@@ -31,7 +37,7 @@ export function linkOrCopy(fromDir, toPath) {
31
37
  // is a single converted file, not a linked directory.
32
38
  const SPEC = {
33
39
  // JSON-hook + linked skill dirs
34
- claude: { root: '.claude/skills/rsc', hook: '.claude/settings.json', adapter: 'claude' },
40
+ claude: { root: '.claude/skills', hook: '.claude/settings.json', adapter: 'claude' },
35
41
  // Converted .mdc rules
36
42
  cursor: { root: '.cursor/rules', hook: '.cursor/rules/rsc-suggest.mdc', adapter: 'cursor', skillExt: '.mdc' },
37
43
  // AGENTS.md family — all read the same root AGENTS.md
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ // rsc SessionStart payload (claude). Wired by targets/claude.js as `node ...` so
3
+ // it runs on every platform including Windows (no bash dependency).
4
+ // argv[2] = absolute path to suggest's SKILL.md argv[3] = absolute project root
5
+ // Always emits suggest's always-on body; appends an onboarding banner when the
6
+ // workspace has no harness profile yet, and an auto-ingest nudge when there is
7
+ // un-ingested material waiting in the inbox.
8
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ const STALE_AUDIT_DAYS = 14; // periodic skill-audit cadence (kept in sync with scripts/audit.js)
12
+
13
+ const suggestMd = process.argv[2];
14
+ const root = process.argv[3] || process.cwd();
15
+
16
+ const has = (...p) => existsSync(join(root, ...p));
17
+
18
+ // Is a Context7 MCP server configured for this project (.mcp.json → mcpServers.context7)?
19
+ function hasContext7() {
20
+ try {
21
+ const mcp = JSON.parse(readFileSync(join(root, '.mcp.json'), 'utf8'));
22
+ return Boolean(mcp.mcpServers && mcp.mcpServers.context7);
23
+ } catch { return false; }
24
+ }
25
+
26
+ // Has a skill audit run within the cadence? Missing stamp → never audited → due.
27
+ function auditDue() {
28
+ try {
29
+ const { lastRun } = JSON.parse(readFileSync(join(root, '.rsc', 'audit.json'), 'utf8'));
30
+ return (Date.now() - new Date(lastRun).getTime()) / 86400000 >= STALE_AUDIT_DAYS;
31
+ } catch { return true; }
32
+ }
33
+
34
+ try { process.stdout.write(readFileSync(suggestMd, 'utf8')); } catch { /* missing → emit nothing */ }
35
+
36
+ const profile = join(root, '02-DOCS', 'wiki', 'harness', 'user-profile.md');
37
+ const optout = join(root, '.rsc', '.no-harness');
38
+ const profileExists = existsSync(profile);
39
+ if (!existsSync(profile) && !existsSync(optout)) {
40
+ process.stdout.write(`
41
+ ===== rsc onboarding =====
42
+ Fresh setup: 02-DOCS/wiki/harness/user-profile.md is missing.
43
+ ACTION: invoke \`init\` now (first contact: technical level + accompaniment dial) before the task.
44
+ If the user does not want a harness here: create .rsc/.no-harness
45
+ ==========================
46
+ `);
47
+ }
48
+
49
+ // Auto-Ingest nudge: when a harness wiki exists and the inbox holds a real file
50
+ // (anything other than README.md / dotfiles / the _processed archive), tell the
51
+ // agent to run the Auto-Ingest Sweep. The hook only reminds; the agent does the
52
+ // scan + ingest. Cheap signal here; the thorough workspace scan lives in the sweep.
53
+ const inbox = join(root, '02-DOCS', 'inbox');
54
+ if (existsSync(join(root, '02-DOCS', 'wiki')) && existsSync(inbox)) {
55
+ let pending = false;
56
+ try {
57
+ pending = readdirSync(inbox, { withFileTypes: true })
58
+ .some((e) => e.isFile() && e.name !== 'README.md' && !e.name.startsWith('.'));
59
+ } catch { /* unreadable → no nudge */ }
60
+ if (pending) {
61
+ process.stdout.write(`
62
+ ===== rsc auto-ingest =====
63
+ Un-ingested material is waiting in 02-DOCS/inbox/.
64
+ ACTION: run the Auto-Ingest Sweep now — ingest inbox/, then scan the workspace
65
+ (minus .rscignore) for un-ingested documents, recording them in wiki/.ingested.json.
66
+ Originals are copied, never moved; deleting an emptied folder needs explicit consent.
67
+ ===========================
68
+ `);
69
+ }
70
+ }
71
+
72
+ // Git required: version control is the substrate the SDD chain + ship guard assume.
73
+ // If the project is not under git, push for it once per session. Persist the user's
74
+ // "no" as .rsc/.no-git so the decision lives in a file and we stop re-asking.
75
+ if (!has('.git') && !has('.rsc', '.no-git')) {
76
+ process.stdout.write(`
77
+ ===== rsc git required =====
78
+ This project is NOT under version control (no .git/).
79
+ The SDD flow and the ship guard assume git. ACTION: offer to run \`git init\` now
80
+ (recommended). If the user declines, create .rsc/.no-git so I stop asking.
81
+ ============================
82
+ `);
83
+ }
84
+
85
+ // Context7 MCP: only for active rsc projects (a profile exists). If the live-docs MCP
86
+ // isn't wired, nudge to add it once. Opt out per project with .rsc/.no-context7.
87
+ if (profileExists && !hasContext7() && !has('.rsc', '.no-context7')) {
88
+ process.stdout.write(`
89
+ ===== rsc context7 MCP =====
90
+ Context7 (live, version-correct library docs) is not configured for this project.
91
+ ACTION: install it once — \`claude mcp add --transport http context7 https://mcp.context7.com/mcp\`
92
+ then reload the session. If you don't want it here: create .rsc/.no-context7.
93
+ ============================
94
+ `);
95
+ }
96
+
97
+ // Periodic skill audit: for active rsc projects, nudge to re-audit when the last run
98
+ // is stale (or never ran). Running \`rsc audit\` stamps .rsc/audit.json and silences
99
+ // this for the cadence. Opt out with .rsc/.no-audit.
100
+ if (profileExists && auditDue() && !has('.rsc', '.no-audit')) {
101
+ process.stdout.write(`
102
+ ===== rsc skill audit =====
103
+ A skill audit is due (runs at most every ${STALE_AUDIT_DAYS} days). It flags overlapping
104
+ skills and skills with no footprint in this project.
105
+ ACTION: run \`npx @ericrisco/rsc audit\`. Opt out with .rsc/.no-audit.
106
+ ===========================
107
+ `);
108
+ }
109
+
110
+ // Update check: compare the installed version (.rsc/.version, written at install)
111
+ // against the latest published on npm, and nudge the agent to offer an update.
112
+ // Fail-silent (offline / missing baseline / parse error → nothing). Disable with
113
+ // RSC_NO_UPDATE_CHECK=1. RSC_LATEST overrides the npm lookup (tests / mirrors).
114
+ function isNewer(a, b) {
115
+ const pa = String(a).split('.').map(Number);
116
+ const pb = String(b).split('.').map(Number);
117
+ for (let i = 0; i < 3; i++) {
118
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
119
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ if (!process.env.RSC_NO_UPDATE_CHECK) {
125
+ try {
126
+ const installed = readFileSync(join(root, '.rsc', '.version'), 'utf8').trim();
127
+ let latest = process.env.RSC_LATEST;
128
+ if (!latest) {
129
+ const ctrl = new AbortController();
130
+ const timer = setTimeout(() => ctrl.abort(), 1500);
131
+ const res = await fetch('https://registry.npmjs.org/@ericrisco%2frsc/latest', { signal: ctrl.signal });
132
+ clearTimeout(timer);
133
+ latest = (await res.json()).version;
134
+ }
135
+ if (installed && latest && isNewer(latest, installed)) {
136
+ process.stdout.write(`
137
+ ===== rsc update available =====
138
+ rsc ${latest} is out — you have ${installed}.
139
+ ACTION: tell the user a new version is available and, if they say yes, run:
140
+ npx @ericrisco/rsc@latest
141
+ (That reinstalls and refreshes the skill content to the latest.)
142
+ ================================
143
+ `);
144
+ }
145
+ } catch { /* offline / no baseline / parse error → stay silent */ }
146
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // rsc Ship guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash)
3
+ // as `node ...` so it runs on every platform including Windows.
4
+ // argv[2] = absolute project root stdin = PreToolUse hook JSON
5
+ //
6
+ // Enforces the "close the feature before you leave it" rule at the one deterministic
7
+ // moment it matters: when a Bash command tries to switch to the trunk (main/master)
8
+ // or merge into it. If the current feature branch has uncommitted changes or commits
9
+ // that were never pushed, the guard DENIES the command and tells the agent to run
10
+ // `ship` (commit → push → PR). Opening the PR itself is `ship`'s job and the skill's
11
+ // hard rule; this hook guarantees you cannot quietly abandon unsaved/unpushed work.
12
+ //
13
+ // Design: precise (only fires on a trunk switch/merge), local-only (no network, no gh),
14
+ // and FAIL-OPEN — any ambiguity (detached HEAD, no repo, git error) allows the command.
15
+ // Opt out per project with .rsc/.no-ship-guard.
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { spawnSync } from 'node:child_process';
19
+
20
+ const root = process.argv[2] || process.cwd();
21
+
22
+ function allow() { process.exit(0); }
23
+ function deny(reason) {
24
+ process.stdout.write(JSON.stringify({
25
+ hookSpecificOutput: {
26
+ hookEventName: 'PreToolUse',
27
+ permissionDecision: 'deny',
28
+ permissionDecisionReason: reason,
29
+ },
30
+ }));
31
+ process.exit(0);
32
+ }
33
+
34
+ // Opt-out and "not a git repo" both mean: nothing to enforce.
35
+ if (existsSync(join(root, '.rsc', '.no-ship-guard'))) allow();
36
+ if (!existsSync(join(root, '.git'))) allow();
37
+
38
+ // Read the tool call. Only Bash commands can move branches.
39
+ let input = {};
40
+ try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
41
+ if ((input.tool_name || input.toolName) !== 'Bash') allow();
42
+ const command = input.tool_input?.command || input.toolInput?.command || '';
43
+ if (typeof command !== 'string' || !command) allow();
44
+
45
+ // Does this command try to land on / move to the trunk?
46
+ const TRUNK = /\bgit\s+(?:checkout|switch)\s+(?:-{1,2}\S+\s+)*(?:main|master)\b/;
47
+ const MERGE = /\bgit\s+merge\b/;
48
+ if (!TRUNK.test(command) && !MERGE.test(command)) allow();
49
+
50
+ const git = (...args) => {
51
+ const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
52
+ return r.status === 0 ? (r.stdout || '').trim() : null;
53
+ };
54
+
55
+ const branch = git('rev-parse', '--abbrev-ref', 'HEAD');
56
+ // Not on a feature branch (already trunk, detached, or git failed) → nothing to guard.
57
+ if (!branch || branch === 'HEAD' || branch === 'main' || branch === 'master') allow();
58
+
59
+ const tail = '\n(If this is intentional and you accept the risk, create .rsc/.no-ship-guard to disable this guard.)';
60
+
61
+ // 1) Uncommitted work would be carried off the feature branch.
62
+ const dirty = git('status', '--porcelain');
63
+ if (dirty && dirty.length > 0) {
64
+ deny(`You're leaving feature branch "${branch}" with uncommitted changes. Commit them first — run the \`ship\` skill (commit → push → PR), don't abandon the diff.${tail}`);
65
+ }
66
+
67
+ // 2) Commits exist but were never pushed.
68
+ const upstream = git('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}');
69
+ if (upstream) {
70
+ const ahead = git('rev-list', '--count', `${upstream}..HEAD`);
71
+ if (ahead && Number(ahead) > 0) {
72
+ deny(`Feature branch "${branch}" has ${ahead} commit(s) not pushed to ${upstream}. Push them and open the PR — run the \`ship\` skill — before switching to the trunk.${tail}`);
73
+ }
74
+ } else {
75
+ // No upstream at all: if the branch carries commits beyond the trunk, it was never pushed.
76
+ const aheadOfTrunk = git('rev-list', '--count', 'main..HEAD') ?? git('rev-list', '--count', 'master..HEAD');
77
+ if (aheadOfTrunk && Number(aheadOfTrunk) > 0) {
78
+ deny(`Feature branch "${branch}" was never pushed (no upstream, ${aheadOfTrunk} commit(s) ahead of the trunk). Push it and open a PR — run the \`ship\` skill — before leaving it.${tail}`);
79
+ }
80
+ }
81
+
82
+ allow();
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // rsc Worklog checkpoint (claude). Wired by targets/claude.js onto PreCompact and
3
+ // SessionEnd as `node ...` so it runs on every platform including Windows.
4
+ // argv[2] = absolute project root
5
+ // Reminds the agent to run a Worklog Sweep (capture what we did this session into
6
+ // 02-DOCS/raw/worklog/). The hook only reminds; the agent writes the worklog.
7
+ // Silent when this workspace has no harness wiki yet.
8
+ import { existsSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ const root = process.argv[2] || process.cwd();
12
+
13
+ // No harness wiki here → nothing to document into. Stay silent.
14
+ if (!existsSync(join(root, '02-DOCS', 'wiki'))) process.exit(0);
15
+
16
+ process.stdout.write(`
17
+ ===== rsc worklog checkpoint =====
18
+ If this session did meaningful work (files changed, a decision made, a commit),
19
+ run a WORKLOG SWEEP before context is lost:
20
+ 1. Write 02-DOCS/raw/worklog/<YYYY-MM-DD>-<slug>.md using the harness
21
+ wiki-worklog-template.md (what we did · why · files touched · outcome · next).
22
+ 2. Compile it into wiki/ (update existing articles first; wikilinks + Related);
23
+ append significant decisions to 02-DOCS/wiki/harness/decisions.md.
24
+ Skip entirely if this was a pure read/answer turn with no changes.
25
+ ==================================
26
+ `);
@@ -1,43 +0,0 @@
1
- #!/usr/bin/env bash
2
- # rsc SessionStart payload (claude). Wired by targets/claude.js.
3
- # $1 = absolute path to suggest's SKILL.md $2 = absolute project root
4
- # Always emits suggest's always-on body; appends an onboarding banner when the
5
- # workspace has no harness profile yet, and an auto-ingest nudge when there is
6
- # un-ingested material waiting in the inbox.
7
- set -u
8
-
9
- cat "$1" 2>/dev/null
10
-
11
- profile="$2/02-DOCS/wiki/harness/user-profile.md"
12
- optout="$2/.rsc/.no-harness"
13
-
14
- if [ ! -f "$profile" ] && [ ! -f "$optout" ]; then
15
- cat <<'BANNER'
16
-
17
- ===== rsc onboarding =====
18
- Fresh setup: 02-DOCS/wiki/harness/user-profile.md is missing.
19
- ACTION: invoke `init` now (first contact: technical level + accompaniment dial) before the task.
20
- If the user does not want a harness here: create .rsc/.no-harness
21
- ==========================
22
- BANNER
23
- fi
24
-
25
- # Auto-Ingest nudge: when a harness wiki exists and the inbox holds a real file
26
- # (anything other than README.md / dotfiles / the _processed archive), tell the
27
- # agent to run the Auto-Ingest Sweep. The hook only reminds; the agent does the
28
- # scan + ingest. Cheap signal here; the thorough workspace scan lives in the sweep.
29
- inbox="$2/02-DOCS/inbox"
30
- if [ -d "$2/02-DOCS/wiki" ] && [ -d "$inbox" ]; then
31
- pending=$(find "$inbox" -maxdepth 1 -type f ! -name 'README.md' ! -name '.*' 2>/dev/null | head -1)
32
- if [ -n "$pending" ]; then
33
- cat <<'BANNER'
34
-
35
- ===== rsc auto-ingest =====
36
- Un-ingested material is waiting in 02-DOCS/inbox/.
37
- ACTION: run the Auto-Ingest Sweep now — ingest inbox/, then scan the workspace
38
- (minus .rscignore) for un-ingested documents, recording them in wiki/.ingested.json.
39
- Originals are copied, never moved; deleting an emptied folder needs explicit consent.
40
- ===========================
41
- BANNER
42
- fi
43
- fi
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env bash
2
- # rsc Worklog checkpoint payload (claude). Wired by targets/claude.js onto the
3
- # PreCompact and SessionEnd hooks.
4
- # $1 = absolute project root
5
- # Reminds the agent to run a Worklog Sweep (capture what we did this session into
6
- # 02-DOCS/raw/worklog/ so the harness can compile it into the wiki). The hook only
7
- # REMINDS — the agent writes the worklog (Karpathy: the LLM writes the wiki).
8
- # Silent when this workspace has no harness wiki yet (nothing to document into).
9
- set -u
10
-
11
- root="${1:-$PWD}"
12
-
13
- # No harness wiki here → nothing to do. Stay silent.
14
- [ -d "$root/02-DOCS/wiki" ] || exit 0
15
-
16
- cat <<'BANNER'
17
-
18
- ===== rsc worklog checkpoint =====
19
- If this session did meaningful work (files changed, a decision made, a commit),
20
- run a WORKLOG SWEEP before context is lost:
21
- 1. Write 02-DOCS/raw/worklog/<YYYY-MM-DD>-<slug>.md using the harness
22
- wiki-worklog-template.md (what we did · why · files touched · outcome · next).
23
- 2. Compile it into wiki/ (update existing articles first; wikilinks + Related);
24
- append significant decisions to 02-DOCS/wiki/harness/decisions.md.
25
- Skip entirely if this was a pure read/answer turn with no changes.
26
- ==================================
27
- BANNER