@ericrisco/rsc 3.0.22 → 3.1.0

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
@@ -424,6 +424,10 @@ rsc agent-model opencode inherit # agents use the session's model
424
424
  rsc agents status | reset <name> # installed agents, and the ones you edited
425
425
  rsc review consolidate c.md s.md t.md # merge reviewer findings; serious ones go to finding-verifier
426
426
 
427
+ # Documentation
428
+ rsc ingest notas.md --topic crm # move a file into 02-DOCS/raw/ (verified copy), then document it
429
+ rsc copy rules | check ad.txt # the phrases this project forbids, and a check before publishing
430
+
427
431
  # Memory and diagnostics
428
432
  rsc memory status | off | resume
429
433
  rsc doctor # harness health and onboarding readiness
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.0.22",
2
+ "version": "3.1.0",
3
3
  "counts": {
4
4
  "skills": 271,
5
5
  "agents": 34,
@@ -2310,7 +2310,7 @@
2310
2310
  },
2311
2311
  {
2312
2312
  "id": "harness",
2313
- "description": "Use when governing a workspace's control plane, code or not — the `01-TOOLS/` tooling layer, the `02-DOCS/` chaos→knowledge wiki, the root Knowledge map. Audits it, migrates legacy `XX-*` folders, scaffolds provider tooling, sweeps the inbox, writes root CLAUDE.md/AGENTS.md. NOT the bootstrap front door (that is `init`, which hands off here).",
2313
+ "description": "Use when governing a workspace's control plane, code or not — the `01-TOOLS/` tooling layer, the `02-DOCS/` chaos→knowledge wiki, the root Knowledge map. Audits it, migrates legacy `XX-*` folders, scaffolds provider tooling, sweeps the inbox, eats a file the person hands over (\\\"cómete este fichero\\\", \\\"guárdalo en la documentación\\\": moved into 02-DOCS/raw/ and documented), writes root CLAUDE.md/AGENTS.md. NOT the bootstrap front door (that is `init`, which hands off here).",
2314
2314
  "tags": [
2315
2315
  "harness",
2316
2316
  "company",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "3.0.22",
3
+ "version": "3.1.0",
4
4
  "description": "A guided harness builder that turns your goal into a minimal, verified AI-agent setup using strict best practices.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,95 @@
1
+ // `rsc ingest <file>`: a document the person hands to the harness ends up in 02-DOCS/raw/ and leaves
2
+ // the place it was dropped.
3
+ //
4
+ // The harness skill already said loose root files MOVE into raw/. The text injected at every session
5
+ // start said "originals are copied, never moved", and an agent obeys what it reads every session: the
6
+ // root kept every file it had already documented. A move that deletes is the kind of step an agent
7
+ // should not improvise, so it lives here, with one rule that makes it safe: the original is removed
8
+ // only after the copy in raw/ is proven byte-identical by its hash. The agent then compiles the wiki
9
+ // from that copy, so nothing it learned depends on the file that was removed.
10
+ import { createHash } from 'node:crypto';
11
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
12
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
13
+
14
+ // Never moved: the project's own files, secrets, code, and the harness. Moving any of these would
15
+ // break the project, leak a secret into the docs, or eat the documentation itself.
16
+ const PROTECTED_DIRS = new Set(['02-DOCS', '01-TOOLS', '.git', '.rsc', '.claude', '.worktrees', 'node_modules', '.github', '.codex', '.cursor', '.gemini', '.opencode']);
17
+ const PROTECTED_NAMES = /^(?:package(?:-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|readme(?:\.\w+)?|license(?:\.\w+)?|licence(?:\.\w+)?|changelog(?:\.\w+)?|claude\.md|agents\.md|gemini\.md|\.rsc\.json|\.rscignore|\.gitignore|\.gitattributes|\.npmrc|\.nvmrc|makefile|dockerfile|docker-compose\.ya?ml|compose\.ya?ml|tsconfig(?:\.\w+)?\.json|jsconfig\.json|pyproject\.toml|requirements(?:[-.\w]*)?\.txt|setup\.(?:py|cfg)|go\.(?:mod|sum)|cargo\.(?:toml|lock)|composer\.(?:json|lock)|gemfile(?:\.lock)?|pubspec\.(?:yaml|lock)|.*\.config\.\w+)$/i;
18
+ const SECRET = /^\.env(?:\..*)?$|\.(?:pem|key|p12|pfx|keystore|jks)$|^id_(?:rsa|ed25519|ecdsa)/i;
19
+ const CODE = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.kts', '.swift', '.rb', '.php', '.cs', '.c', '.cc', '.cpp', '.h', '.hpp', '.m', '.dart', '.vue', '.svelte', '.sh', '.bash', '.zsh', '.ps1', '.bat', '.lua', '.scala', '.ex', '.exs', '.erl']);
20
+
21
+ const sha256 = (path) => `sha256:${createHash('sha256').update(readFileSync(path)).digest('hex')}`;
22
+ const posix = (p) => p.split(sep).join('/');
23
+ const today = (now) => now.toISOString().slice(0, 10);
24
+
25
+ function refusal(root, rel) {
26
+ const parts = rel.split('/');
27
+ if (PROTECTED_DIRS.has(parts[0])) return `${parts[0]}/ belongs to the harness or the project; it is never moved`;
28
+ const name = basename(rel);
29
+ if (SECRET.test(name)) return 'it looks like a secret; secrets never go into the documentation';
30
+ if (PROTECTED_NAMES.test(name)) return 'it is a project file the code or the assistant relies on';
31
+ if (CODE.has(extname(name).toLowerCase())) return 'it is source code; document it without moving it (use --keep)';
32
+ return null;
33
+ }
34
+
35
+ /** A free name in raw/ for this content: the same name, or the same content already there, or a dated variant. */
36
+ function rawTarget(rawDir, name, hash, now) {
37
+ const first = join(rawDir, name);
38
+ if (!existsSync(first) || sha256(first) === hash) return first;
39
+ const ext = extname(name), stem = basename(name, ext);
40
+ const stamp = now.toISOString().replace(/[-:]/g, '').slice(0, 15);
41
+ for (let i = 0; i < 1000; i++) {
42
+ const candidate = join(rawDir, `${stem}-${stamp}${i ? `-${i}` : ''}${ext}`);
43
+ if (!existsSync(candidate) || sha256(candidate) === hash) return candidate;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function recordInLedger(root, key, entry) {
49
+ const path = join(root, '02-DOCS', 'wiki', '.ingested.json');
50
+ let ledger = {};
51
+ try { ledger = JSON.parse(readFileSync(path, 'utf8')); } catch { /* new or unreadable: start fresh */ }
52
+ if (!ledger || typeof ledger !== 'object' || Array.isArray(ledger)) ledger = {};
53
+ ledger[key] = { ...(ledger[key] || {}), ...entry };
54
+ const tmp = `${path}.${process.pid}.tmp`;
55
+ writeFileSync(tmp, JSON.stringify(ledger, null, 2) + '\n');
56
+ renameSync(tmp, path);
57
+ }
58
+
59
+ /**
60
+ * Move (or with keep, copy) one file into 02-DOCS/raw/.
61
+ * Returns { file, status: 'moved'|'copied'|'refused'|'missing', raw?, hash?, reason? }.
62
+ */
63
+ export function ingestFile(root, input, { keep = false, topic = '', now = new Date() } = {}) {
64
+ const projectRoot = resolve(root);
65
+ const abs = resolve(projectRoot, input);
66
+ const rel = posix(relative(projectRoot, abs));
67
+ const out = { file: isAbsolute(input) ? input : posix(input) };
68
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return { ...out, status: 'refused', reason: 'it is outside this project' };
69
+ if (!existsSync(join(projectRoot, '02-DOCS'))) return { ...out, status: 'refused', reason: 'this project has no 02-DOCS/ yet; set up the harness first' };
70
+ let st;
71
+ try { st = lstatSync(abs); } catch { return { ...out, status: 'missing', reason: 'no such file' }; }
72
+ if (st.isSymbolicLink()) return { ...out, status: 'refused', reason: 'it is a link; ingest the file it points to' };
73
+ if (st.isDirectory()) return { ...out, status: 'refused', reason: 'it is a folder; name the files inside it' };
74
+ if (!st.isFile()) return { ...out, status: 'refused', reason: 'it is not a regular file' };
75
+ const why = refusal(projectRoot, rel);
76
+ if (why) return { ...out, status: 'refused', reason: why };
77
+
78
+ const hash = sha256(abs);
79
+ // raw/<topic>/ is where the wiki convention files a source; choosing it here means the copy is never
80
+ // moved afterwards, which would leave the ledger pointing at a path that no longer exists.
81
+ const slug = String(topic || '').trim().toLowerCase().normalize('NFD').replace(/\p{M}/gu, '').replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
82
+ if (topic && !slug) return { ...out, status: 'refused', reason: `'${topic}' is not a usable topic name` };
83
+ const rawDir = slug ? join(projectRoot, '02-DOCS', 'raw', slug) : join(projectRoot, '02-DOCS', 'raw');
84
+ mkdirSync(rawDir, { recursive: true });
85
+ const target = rawTarget(rawDir, basename(rel), hash, now);
86
+ if (!target) return { ...out, status: 'refused', reason: 'no free name in 02-DOCS/raw/' };
87
+ if (!existsSync(target)) copyFileSync(abs, target);
88
+ // The only rule that makes the delete safe: the copy is proven identical before the original goes.
89
+ if (sha256(target) !== hash) return { ...out, status: 'refused', reason: 'the copy in raw/ did not verify; the original was left in place' };
90
+ const rawRel = posix(relative(projectRoot, target));
91
+ recordInLedger(projectRoot, rel, { hash, ingested: today(now), raw: rawRel });
92
+ if (keep) return { ...out, status: 'copied', raw: rawRel, hash };
93
+ unlinkSync(abs);
94
+ return { ...out, status: 'moved', raw: rawRel, hash };
95
+ }
package/scripts/rsc.js CHANGED
@@ -46,7 +46,7 @@ if (['--version', '-v', 'version'].includes(rawArgv[0])) {
46
46
  process.exit(0);
47
47
  }
48
48
  const GENERAL_HELP = [
49
- 'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | agent-model <target> <model|inherit|status> | agents [status|reset <name|--all>] | memory <on|off|status|save|resume|learn|metrics> | review consolidate [reports…] | copy check <file> | copy rules | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
49
+ 'Use: npx @ericrisco/rsc onboard | reassess | add <id...> | install --profile <p> | consult "<text>" | list | capabilities [--full|gap-log] | audit | registry refresh | doctor | sync | agent-model <target> <model|inherit|status> | agents [status|reset <name|--all>] | memory <on|off|status|save|resume|learn|metrics> | review consolidate [reports…] | ingest <file...> | copy check <file> | copy rules | sello <on|off|status|…> | worktrees [reap [path] [--confirm]] | backups | restore <id|latest> | upgrade | repair | uninstall <id...> | purge | help',
50
50
  'Any command takes --target <claude|codex|cursor|copilot|gemini|…> (comma-separate for several)',
51
51
  ' → without it, rsc uses the assistant already installed here; if two are, it asks instead of guessing.',
52
52
  ].join('\n');
@@ -75,9 +75,9 @@ const PURGE_HELP = [
75
75
  // `--accompaniment` stays listed although the dial is retired: old scripts and agents still pass it,
76
76
  // and its value must keep being skipped rather than read as a command or a skill id.
77
77
  const GLOBAL_VALUE_FLAGS = new Set([
78
- '--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--workflow', '--accept-plan',
78
+ '--topic', '--target', '--technical-level', '--accompaniment', '--project-kind', '--goal', '--goal-base64', '--software-scope', '--workflow', '--accept-plan',
79
79
  ]);
80
- const COMMANDS = new Set(['copy', 'onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'git-permissions', 'agent-model', 'agents', 'main', 'isolation', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'review', 'sello', 'repair', 'uninstall', 'purge']);
80
+ const COMMANDS = new Set(['ingest', 'copy', 'onboard', 'reassess', 'add', 'install', 'consult', 'catalog', 'audit', 'list', 'doctor', 'memory', 'knowledge-sync', 'git-permissions', 'agent-model', 'agents', 'main', 'isolation', 'sync', 'backups', 'restore', 'upgrade', 'registry', 'worktrees', 'capabilities', 'review', 'sello', 'repair', 'uninstall', 'purge']);
81
81
  function positionalTokens(input) {
82
82
  const out = [];
83
83
  for (let i = 0; i < input.length; i++) {
@@ -1181,6 +1181,27 @@ async function main() {
1181
1181
  say('Use: npx @ericrisco/rsc registry refresh | registry status');
1182
1182
  return;
1183
1183
  }
1184
+ case 'ingest': {
1185
+ // "Eat this file": into 02-DOCS/raw/, verified, then gone from where it was dropped (lib/ingest-move.js).
1186
+ const USE = 'Use: npx @ericrisco/rsc ingest <file...> [--topic <topic>] [--keep] [--json]\n Moves each file into 02-DOCS/raw/ (the copy is verified first, then the original is removed) and records it in 02-DOCS/wiki/.ingested.json.\n --keep copies without removing. Project files, code and secrets are refused.';
1187
+ const rest = argv.slice(1);
1188
+ const files = rest.filter((a, i) => !a.startsWith('--') && rest[i - 1] !== '--topic');
1189
+ if (!files.length) { say(USE); process.exitCode = 1; return; }
1190
+ const { ingestFile } = await import('./lib/ingest-move.js');
1191
+ const topic = flag('topic') || '';
1192
+ const results = files.map((f) => ingestFile(process.cwd(), f, { keep: argv.includes('--keep'), topic }));
1193
+ if (argv.includes('--json')) say(JSON.stringify(results, null, 2));
1194
+ else {
1195
+ const lines = results.map((r) => r.status === 'moved' ? `RSC_INGEST_MOVED ${r.file} → ${r.raw}`
1196
+ : r.status === 'copied' ? `RSC_INGEST_COPIED ${r.file} → ${r.raw}`
1197
+ : r.status === 'missing' ? `RSC_INGEST_MISSING ${r.file}: ${r.reason}`
1198
+ : `RSC_INGEST_REFUSED ${r.file}: ${r.reason}`);
1199
+ if (results.some((r) => r.raw)) lines.push('Next: read each raw/ copy and compile it into 02-DOCS/wiki/ (update before create, cross-links, wiki/index.md, wiki/log.md), then tell the person where it lives now.');
1200
+ say(lines.join('\n'));
1201
+ }
1202
+ if (results.some((r) => r.status === 'refused' || r.status === 'missing')) process.exitCode = 1;
1203
+ return;
1204
+ }
1184
1205
  case 'copy': {
1185
1206
  // The phrases this project forbids in what it publishes (targets/copy-rules.mjs). `check` is the
1186
1207
  // gate the copy skills run before delivering text; the Claude Stop hook runs the same check.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: harness
3
- description: "Use when governing a workspace's control plane, code or not — the `01-TOOLS/` tooling layer, the `02-DOCS/` chaos→knowledge wiki, the root Knowledge map. Audits it, migrates legacy `XX-*` folders, scaffolds provider tooling, sweeps the inbox, writes root CLAUDE.md/AGENTS.md. NOT the bootstrap front door (that is `init`, which hands off here)."
3
+ description: "Use when governing a workspace's control plane, code or not — the `01-TOOLS/` tooling layer, the `02-DOCS/` chaos→knowledge wiki, the root Knowledge map. Audits it, migrates legacy `XX-*` folders, scaffolds provider tooling, sweeps the inbox, eats a file the person hands over (\"cómete este fichero\", \"guárdalo en la documentación\": moved into 02-DOCS/raw/ and documented), writes root CLAUDE.md/AGENTS.md. NOT the bootstrap front door (that is `init`, which hands off here)."
4
4
  tags: [harness, company, ops, docs, wiki, connect, tools, knowledge]
5
5
  recommends: [init, connect-tool]
6
6
  profiles: [minimal, core, full]
@@ -27,6 +27,14 @@ The **harness** is the control plane of a workspace. A workspace need not be cod
27
27
  Ingest relocates, never deletes: a loose file at the workspace root moves into `raw/`; a file
28
28
  inside a folder the user maintains is copied, and consolidating that folder needs explicit consent.
29
29
 
30
+ **When the person hands you a file** ("cómete este fichero", "guárdalo en la documentación",
31
+ "ingest this"): run `npx @ericrisco/rsc ingest <file> --topic <topic>`. It moves the file into
32
+ `02-DOCS/raw/<topic>/` only after the copy is verified, and records it in `wiki/.ingested.json`.
33
+ Never move or rename that raw copy afterwards: the ledger points at it. Then compile from the raw copy
34
+ into `wiki/` (update before create, cross-links, `index.md`, `log.md`) and say in one line where it
35
+ lives now. The file must not stay in the root. A refused file (project file, code, secret) stays
36
+ where it is: say why; `--keep` documents it without moving it.
37
+
30
38
  The `wiki/` is simultaneously an OKF-v0.1 bundle
31
39
  ([Open Knowledge Format](https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf))
32
40
  and an Obsidian-native vault: markdown links, YAML frontmatter, readable filenames, `.base` views
@@ -522,7 +522,8 @@ Un-ingested = path absent OR hash changed. `inbox/` files still move to
522
522
  - **Clean-as-you-go.** Loose root-level files and `inbox/` contents are **moved**
523
523
  into `raw/` after a verified ingest (the repo stays clean); files nested in a
524
524
  user-maintained folder are **copied** and consolidation is proposed with consent.
525
- A move never precedes the original being safely written to `raw/`.
525
+ A move never precedes the original being safely written to `raw/`: use
526
+ `npx @ericrisco/rsc ingest <file>`, which verifies the copy by hash before removing the original.
526
527
  - **Never delete.** The original is relocated into `raw/`, never destroyed.
527
528
  Removing an emptied/redundant folder is quoted-consent only.
528
529
  - **Ambiguity is proposed, not grabbed.**
@@ -179,7 +179,9 @@ if (existsSync(join(root, '02-DOCS', 'wiki')) && existsSync(inbox)) {
179
179
  Un-ingested material is waiting in 02-DOCS/inbox/.
180
180
  ACTION: run the Auto-Ingest Sweep now — ingest inbox/, then scan the workspace
181
181
  (minus .rscignore) for un-ingested documents, recording them in wiki/.ingested.json.
182
- Originals are copied, never moved; deleting an emptied folder needs explicit consent.
182
+ Loose root files and inbox/ files move into raw/ with \`npx @ericrisco/rsc ingest <file>\`
183
+ (verified copy, then removed); files inside the person's own folders are copied; deleting a
184
+ folder needs explicit consent.
183
185
  ===========================
184
186
  `);
185
187
  }