rigjs 3.0.33 → 4.0.3

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.
Files changed (99) hide show
  1. package/.claude/skills/rig-wiki/SKILL.md +104 -0
  2. package/.claude-plugin/plugin.json +14 -0
  3. package/README.md +18 -1
  4. package/README_CN.md +17 -1
  5. package/RIG_CREW_SKILL.md +274 -0
  6. package/RIG_WIKI_SKILL.md +104 -0
  7. package/bin/rig.js +0 -0
  8. package/built/index.js +376 -299
  9. package/doc/architecture/README.md +139 -0
  10. package/doc/architecture/agents.md +180 -0
  11. package/doc/architecture/fc.md +17 -0
  12. package/doc/architecture/wiki.md +278 -0
  13. package/lib/crew/ask.ts +24 -0
  14. package/lib/crew/board.ts +123 -0
  15. package/lib/crew/config.ts +109 -0
  16. package/lib/crew/doctor.ts +40 -0
  17. package/lib/crew/inbox.ts +29 -0
  18. package/lib/crew/index.ts +108 -0
  19. package/lib/crew/init.ts +113 -0
  20. package/lib/crew/paths.ts +13 -0
  21. package/lib/crew/project.ts +84 -0
  22. package/lib/crew/role.ts +121 -0
  23. package/lib/crew/roleCommand.ts +150 -0
  24. package/lib/crew/state.ts +19 -0
  25. package/lib/crew/status.ts +27 -0
  26. package/lib/crew/stub.ts +9 -0
  27. package/lib/crew/sync.ts +15 -0
  28. package/lib/crew/task.ts +92 -0
  29. package/lib/crew/vault.ts +266 -0
  30. package/lib/installLocal.ts +189 -0
  31. package/lib/rig/index.ts +26 -3
  32. package/lib/wiki/README.md +79 -0
  33. package/lib/wiki/agent/claude.ts +65 -0
  34. package/lib/wiki/agent/codex.ts +22 -0
  35. package/lib/wiki/agent/index.ts +11 -0
  36. package/lib/wiki/agent/list.ts +27 -0
  37. package/lib/wiki/agent/pi.ts +21 -0
  38. package/lib/wiki/agent/registry.ts +16 -0
  39. package/lib/wiki/agent/types.ts +37 -0
  40. package/lib/wiki/agent/use.ts +21 -0
  41. package/lib/wiki/config.ts +99 -0
  42. package/lib/wiki/daemon/index.ts +25 -0
  43. package/lib/wiki/daemon/install.ts +69 -0
  44. package/lib/wiki/daemon/logs.ts +16 -0
  45. package/lib/wiki/daemon/runner.ts +42 -0
  46. package/lib/wiki/daemon/start.ts +20 -0
  47. package/lib/wiki/daemon/status.ts +23 -0
  48. package/lib/wiki/daemon/stop.ts +16 -0
  49. package/lib/wiki/daemon/uninstall.ts +17 -0
  50. package/lib/wiki/db.ts +71 -0
  51. package/lib/wiki/fetch.ts +206 -0
  52. package/lib/wiki/index.ts +106 -0
  53. package/lib/wiki/indexCmd.ts +23 -0
  54. package/lib/wiki/ingest.ts +271 -0
  55. package/lib/wiki/init.ts +125 -0
  56. package/lib/wiki/installSkill.ts +92 -0
  57. package/lib/wiki/lint.ts +252 -0
  58. package/lib/wiki/list.ts +69 -0
  59. package/lib/wiki/pathGuard.ts +87 -0
  60. package/lib/wiki/paths.ts +29 -0
  61. package/lib/wiki/platform.ts +8 -0
  62. package/lib/wiki/qmd.ts +205 -0
  63. package/lib/wiki/query.ts +144 -0
  64. package/lib/wiki/rebuild.ts +56 -0
  65. package/lib/wiki/register.ts +131 -0
  66. package/lib/wiki/scan.ts +0 -0
  67. package/lib/wiki/uninstallSkill.ts +37 -0
  68. package/lib/wiki/unregister.ts +16 -0
  69. package/package.json +36 -6
  70. package/scripts/postinstall.mjs +108 -0
  71. package/scripts/publish.mjs +93 -0
  72. package/scripts/sync-skill.mjs +33 -0
  73. package/scripts/version-code.mjs +86 -0
  74. package/skills.md +54 -0
  75. package/.github/workflows/npm-publish.yml +0 -22
  76. package/demo/.env.oem1 +0 -4
  77. package/demo/.env.oem2 +0 -4
  78. package/demo/babel.config.js +0 -5
  79. package/demo/env.rig.json5 +0 -8
  80. package/demo/jsconfig.json +0 -19
  81. package/demo/package.json +0 -59
  82. package/demo/package.rig.json5 +0 -78
  83. package/demo/public/favicon.ico +0 -0
  84. package/demo/public/index.html +0 -17
  85. package/demo/rig_dev/.gitkeep +0 -0
  86. package/demo/rig_helper.d.ts +0 -4
  87. package/demo/rig_helper.js +0 -10
  88. package/demo/rigs/.gitkeep +0 -0
  89. package/demo/src/App.vue +0 -34
  90. package/demo/src/assets/logo.png +0 -0
  91. package/demo/src/components/HelloWorld.vue +0 -58
  92. package/demo/src/main.js +0 -8
  93. package/demo/vue.config.js +0 -8
  94. package/demo/yarn.lock +0 -6312
  95. package/develop.png +0 -0
  96. package/jest/test.rig.json5 +0 -14
  97. package/jest.config.ts +0 -16
  98. package/production.png +0 -0
  99. package/tsconfig.json +0 -53
@@ -0,0 +1,206 @@
1
+ // `rig wiki fetch <url>` — verbatim download a URL into raw/.
2
+ //
3
+ // Two paths:
4
+ // - default: Node native fetch → if HTML, strip tags + collapse whitespace
5
+ // to a markdown-ish body; if md/txt, pass through. Writes a single
6
+ // raw/YYYY-MM-DD-<slug>.md with frontmatter (source-url, fetched-at,
7
+ // fetcher, content-type, content-sha).
8
+ // - --via-agent: invoke Claude with the WebFetch tool to do better
9
+ // HTML→markdown conversion; same frontmatter shape.
10
+ //
11
+ // Never summarizes; that's `ingest`'s job. Filename includes YYYY-MM-DD-
12
+ // prefix so raw/ files are append-only-friendly.
13
+
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import crypto from 'crypto';
17
+ import print from '../print';
18
+ import { loadWikiConfig, resolveWiki, WikiEntry } from './config';
19
+ import { adapters } from './agent/registry';
20
+
21
+ interface FetchOpts { wiki?: string; json?: boolean; viaAgent?: boolean; slug?: string; }
22
+
23
+ const FETCH_TIMEOUT_MS = 60 * 1000;
24
+ const MAX_BYTES = 20 * 1024 * 1024; // 20MB cap to avoid pulling videos accidentally
25
+
26
+ export default async function wikiFetch(url: string, opts: FetchOpts): Promise<void> {
27
+ if (!/^https?:\/\//.test(url)) {
28
+ print.error(`unsupported URL scheme: ${url}`);
29
+ process.exit(1);
30
+ }
31
+ const cfg = loadWikiConfig();
32
+ const target = resolveWiki(cfg, opts.wiki);
33
+ if (!target) {
34
+ print.error('no wiki resolved. Pass --wiki <name> or run from inside a registered project.');
35
+ process.exit(1);
36
+ }
37
+
38
+ const slug = opts.slug || urlToSlug(url);
39
+ const today = new Date().toISOString().slice(0, 10);
40
+ const rawDir = path.join(target.path, 'raw');
41
+ fs.mkdirSync(rawDir, { recursive: true });
42
+ const destPath = path.join(rawDir, `${today}-${slug}.md`);
43
+ if (fs.existsSync(destPath)) {
44
+ print.error(`already exists: ${path.relative(target.path, destPath)} (delete it first or pass --slug)`);
45
+ process.exit(1);
46
+ }
47
+
48
+ if (opts.viaAgent) {
49
+ await fetchViaAgent(target, url, destPath);
50
+ } else {
51
+ await fetchDirect(url, destPath);
52
+ }
53
+
54
+ const rel = path.relative(target.path, destPath);
55
+ if (opts.json) {
56
+ // eslint-disable-next-line no-console
57
+ console.log(JSON.stringify({ ok: true, code: 0, data: { path: rel, url } }, null, 2));
58
+ } else {
59
+ print.succeed(`fetched ${url}`);
60
+ print.info(` -> ${rel}`);
61
+ }
62
+ }
63
+
64
+ async function fetchDirect(url: string, destPath: string): Promise<void> {
65
+ print.start(`fetch ${url}`);
66
+ const ctrl = new AbortController();
67
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
68
+ let res: Response;
69
+ try {
70
+ res = await fetch(url, {
71
+ signal: ctrl.signal,
72
+ headers: { 'User-Agent': 'rig-wiki-fetch/4.0.1 (+https://github.com/FlashHand/rig)' },
73
+ redirect: 'follow',
74
+ });
75
+ } catch (e) {
76
+ clearTimeout(timer);
77
+ print.error(`fetch failed: ${(e as Error).message}`);
78
+ process.exit(1);
79
+ }
80
+ clearTimeout(timer);
81
+ if (!res.ok) {
82
+ print.error(`HTTP ${res.status} ${res.statusText}`);
83
+ process.exit(1);
84
+ }
85
+ const contentType = (res.headers.get('content-type') || '').toLowerCase();
86
+ const ab = await res.arrayBuffer();
87
+ if (ab.byteLength > MAX_BYTES) {
88
+ print.error(`response exceeds ${MAX_BYTES} bytes (${ab.byteLength}); refuse to write.`);
89
+ process.exit(1);
90
+ }
91
+ const buf = Buffer.from(ab);
92
+ const sha = crypto.createHash('sha256').update(buf).digest('hex');
93
+ const raw = buf.toString('utf8');
94
+
95
+ const body =
96
+ contentType.includes('text/html') || /^\s*<!doctype html|<html/i.test(raw)
97
+ ? htmlToText(raw)
98
+ : raw;
99
+
100
+ const fm = buildFrontmatter({
101
+ sourceUrl: url,
102
+ fetchedAt: new Date().toISOString(),
103
+ fetcher: 'rig-wiki-fetch',
104
+ contentType: contentType || 'unknown',
105
+ contentSha: sha,
106
+ });
107
+ fs.writeFileSync(destPath, fm + '\n' + body.trimEnd() + '\n', 'utf8');
108
+ }
109
+
110
+ async function fetchViaAgent(wiki: WikiEntry, url: string, destPath: string): Promise<void> {
111
+ const adapter = adapters.find(a => a.name === 'claude');
112
+ if (!adapter) { print.error('no claude adapter available.'); process.exit(1); }
113
+ const detect = await adapter.detect();
114
+ if (!detect.installed) {
115
+ print.error('claude not installed on PATH; pass without --via-agent or install Claude Code.');
116
+ process.exit(1);
117
+ }
118
+ const rel = path.relative(wiki.path, destPath);
119
+ const fetchedAt = new Date().toISOString();
120
+ const prompt = [
121
+ `WebFetch the URL: ${url}`,
122
+ `Convert HTML to clean markdown (no script/style/nav noise). DO NOT summarize.`,
123
+ `Write the result verbatim to ${rel} with this frontmatter at the top:`,
124
+ ``,
125
+ '---',
126
+ `source-url: ${url}`,
127
+ `fetched-at: ${fetchedAt}`,
128
+ `fetcher: claude-webfetch`,
129
+ `content-type: text/html`,
130
+ '---',
131
+ ``,
132
+ `Then a blank line, then the converted markdown body. Nothing else.`,
133
+ ].join('\n');
134
+ print.start(`claude WebFetch ${url}`);
135
+ const res = await adapter.run({
136
+ prompt,
137
+ cwd: wiki.path,
138
+ allowWrite: true,
139
+ tools: ['webfetch'],
140
+ timeoutMs: 5 * 60 * 1000,
141
+ });
142
+ if (!res.ok) {
143
+ print.error(`claude failed (code ${res.exitCode})${res.stderr ? `: ${res.stderr.trim().slice(0, 300)}` : ''}`);
144
+ process.exit(1);
145
+ }
146
+ if (!fs.existsSync(destPath)) {
147
+ print.error(`claude reported success but file not written: ${rel}`);
148
+ process.exit(1);
149
+ }
150
+ // Stamp content-sha after the agent wrote the file.
151
+ const buf = fs.readFileSync(destPath);
152
+ const sha = crypto.createHash('sha256').update(buf).digest('hex');
153
+ const content = buf.toString('utf8');
154
+ const stamped = content.startsWith('---\n')
155
+ ? content.replace('---\n', `---\ncontent-sha: ${sha}\n`)
156
+ : `---\ncontent-sha: ${sha}\n---\n\n` + content;
157
+ fs.writeFileSync(destPath, stamped, 'utf8');
158
+ }
159
+
160
+ function buildFrontmatter(fm: {
161
+ sourceUrl: string;
162
+ fetchedAt: string;
163
+ fetcher: string;
164
+ contentType: string;
165
+ contentSha: string;
166
+ }): string {
167
+ return [
168
+ '---',
169
+ `source-url: ${fm.sourceUrl}`,
170
+ `fetched-at: ${fm.fetchedAt}`,
171
+ `fetcher: ${fm.fetcher}`,
172
+ `content-type: ${fm.contentType}`,
173
+ `content-sha: ${fm.contentSha}`,
174
+ '---',
175
+ ].join('\n');
176
+ }
177
+
178
+ // Best-effort HTML → text. Drops script/style/nav/svg blocks, decodes the
179
+ // handful of common entities, collapses whitespace. Not a replacement for
180
+ // readability — that's what --via-agent is for.
181
+ function htmlToText(html: string): string {
182
+ let s = html;
183
+ s = s.replace(/<!--[\s\S]*?-->/g, '');
184
+ s = s.replace(/<(script|style|nav|svg|footer|header|aside)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
185
+ s = s.replace(/<br\s*\/?>/gi, '\n');
186
+ s = s.replace(/<\/(p|div|h[1-6]|li|tr|td|th)>/gi, '\n');
187
+ s = s.replace(/<[^>]+>/g, '');
188
+ s = s.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
189
+ .replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
190
+ s = s.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n');
191
+ return s.trim();
192
+ }
193
+
194
+ function urlToSlug(url: string): string {
195
+ try {
196
+ const u = new URL(url);
197
+ const path = u.pathname.replace(/\/+$/, '');
198
+ const last = path.split('/').filter(Boolean).pop() || u.hostname;
199
+ return last
200
+ .toLowerCase()
201
+ .replace(/\.(html?|md|txt|aspx?|php|jsp)$/, '')
202
+ .replace(/[^a-z0-9-]+/g, '-')
203
+ .replace(/^-+|-+$/g, '')
204
+ .slice(0, 64) || 'page';
205
+ } catch { return 'page'; }
206
+ }
@@ -0,0 +1,106 @@
1
+ import wikiInit from './init';
2
+ import wikiRegister from './register';
3
+ import wikiUnregister from './unregister';
4
+ import wikiList from './list';
5
+ import wikiScan from './scan';
6
+ import wikiFetch from './fetch';
7
+ import wikiIngest from './ingest';
8
+ import wikiQuery from './query';
9
+ import wikiLint from './lint';
10
+ import wikiIndex from './indexCmd';
11
+ import wikiRebuild from './rebuild';
12
+ import wikiInstallSkill from './installSkill';
13
+ import wikiUninstallSkill from './uninstallSkill';
14
+ import { registerAgentCommands } from './agent';
15
+ import { registerDaemonCommands } from './daemon';
16
+
17
+ // `program` is commander's Command instance. commander@6.1.0 typings are
18
+ // inconsistent between `CommanderStatic.Command` (the global `program`) and
19
+ // the per-call `Command` returned by `.command()`, so we type loose here to
20
+ // match the rest of the codebase.
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ export function registerWikiCommands(program: any): void {
23
+ const wiki = program.command('wiki').description('Karpathy-style LLM Wiki ops (macOS only in v1)');
24
+
25
+ wiki.command('init <path>')
26
+ .description('bootstrap a wiki dir at <path> (required; refuses to default to CWD)')
27
+ .action(wikiInit);
28
+
29
+ wiki.command('register [path]')
30
+ .description('register a wiki into ~/.rig/wiki.config.json5')
31
+ .option('-n, --as <slug>', 'override the wiki name (`--name` would clash with commander)')
32
+ .option('-f, --force', 'overwrite an existing entry with the same name')
33
+ .action(wikiRegister);
34
+
35
+ wiki.command('unregister <nameOrPath>')
36
+ .description('remove a wiki from ~/.rig/wiki.config.json5 (disk untouched)')
37
+ .action(wikiUnregister);
38
+
39
+ wiki.command('list')
40
+ .description('list registered wikis + daemon/agent/qmd status')
41
+ .action(wikiList);
42
+
43
+ wiki.command('scan [path]')
44
+ .description('compute NEW/MODIFIED/DELETED/RAW DRIFT report')
45
+ .option('-w, --wiki <name>', 'target wiki name')
46
+ .option('-a, --all', 'scan every registered wiki')
47
+ .option('--json', 'machine-readable output')
48
+ .action(wikiScan);
49
+
50
+ wiki.command('fetch <url>')
51
+ .description('verbatim download URL into raw/YYYY-MM-DD-<slug>.md')
52
+ .option('-w, --wiki <name>', 'target wiki name')
53
+ .option('--slug <slug>', 'override the auto-derived slug')
54
+ .option('--via-agent', 'use Claude WebFetch for HTML→md conversion')
55
+ .option('--json', 'machine-readable output')
56
+ .action(wikiFetch);
57
+
58
+ wiki.command('ingest <source>')
59
+ .description('two-step CoT ingest of one source (preview diff, then apply)')
60
+ .option('-w, --wiki <name>', 'target wiki name')
61
+ .option('--dry-run', 'print diff but do not apply')
62
+ .option('--json', 'machine-readable output')
63
+ .action(wikiIngest);
64
+
65
+ wiki.command('query <q>')
66
+ .description('semantic search — Qwen3 vector + Qwen3 reranker, cross-lingual CN/EN')
67
+ .option('-w, --wiki <name>', 'target wiki name')
68
+ .option('-l, --limit <n>', 'top-k hits (1-50, default 10)', (v) => parseInt(v, 10))
69
+ .option('--no-rerank', 'skip the reranker pass (faster, no reranker model load)')
70
+ .option('-s, --synth', 'use Claude to synthesize a paragraph answer with citations')
71
+ .option('--json', 'machine-readable output')
72
+ .action(wikiQuery);
73
+
74
+ wiki.command('lint')
75
+ .description('contradictions / orphans / stale claims / broken refs')
76
+ .option('-w, --wiki <name>', 'target wiki name')
77
+ .option('-a, --all', 'lint every registered wiki')
78
+ .option('--json', 'machine-readable output')
79
+ .action(wikiLint);
80
+
81
+ wiki.command('index')
82
+ .description('build/refresh qmd vector index (incremental by default)')
83
+ .option('-w, --wiki <name>', 'target wiki name')
84
+ .option('-a, --all', 'index every registered wiki')
85
+ .option('-f, --force', 'force full re-embed (use after switching embed models)')
86
+ .action(wikiIndex);
87
+
88
+ wiki.command('rebuild')
89
+ .description('refresh local caches (sha index + qmd vectors) — for new devices or after switching embed models')
90
+ .option('-w, --wiki <name>', 'target wiki name')
91
+ .option('-a, --all', 'rebuild every registered wiki')
92
+ .option('--skip-embed', 'only clear ~/.rig/state.db rows, do not touch qmd at all')
93
+ .action(wikiRebuild);
94
+
95
+ wiki.command('install-skill')
96
+ .description('symlink bundled rig-wiki skill into ~/.claude/skills/')
97
+ .option('-f, --force', 'replace an existing symlink')
98
+ .action(wikiInstallSkill);
99
+
100
+ wiki.command('uninstall-skill')
101
+ .description('remove the symlink from ~/.claude/skills/rig-wiki')
102
+ .action(wikiUninstallSkill);
103
+
104
+ registerAgentCommands(wiki);
105
+ registerDaemonCommands(wiki);
106
+ }
@@ -0,0 +1,23 @@
1
+ import print from '../print';
2
+ import { loadWikiConfig, resolveWiki, WikiEntry } from './config';
3
+ import { qmdEmbed } from './qmd';
4
+
5
+ interface IndexOpts { wiki?: string; all?: boolean; force?: boolean; }
6
+
7
+ export default async function wikiIndex(opts: IndexOpts): Promise<void> {
8
+ const cfg = loadWikiConfig();
9
+ const targets: WikiEntry[] = opts.all
10
+ ? cfg.wikis
11
+ : [resolveWiki(cfg, opts.wiki)].filter(Boolean) as WikiEntry[];
12
+ if (targets.length === 0) {
13
+ print.error('no wiki resolved. Pass --wiki <name>, --all, or run from inside a registered project.');
14
+ process.exit(1);
15
+ }
16
+
17
+ for (const t of targets) {
18
+ print.start(`qmd embed: ${t.name}`);
19
+ const res = await qmdEmbed(t.name, t.path, { force: !!opts.force });
20
+ if (res.ok) print.succeed(`qmd embed: ${t.name} done`);
21
+ else { print.error(`qmd embed: ${t.name} failed: ${res.stderr.trim()}`); process.exitCode = 1; }
22
+ }
23
+ }
@@ -0,0 +1,271 @@
1
+ // `rig wiki ingest <source>` — two-step CoT digestion of one source file
2
+ // into a wiki page tree.
3
+ //
4
+ // Pipeline:
5
+ // 1. Snapshot every wiki/<sub>/*.md + index.md + overview.md + log.md +
6
+ // reviews.md (NOT raw/, NOT purpose/schema) — these are LLM-writable.
7
+ // 2. Spawn Claude inside the wiki dir with --allowedTools Read,Write,Edit
8
+ // and a two-step prompt (analysis → generation).
9
+ // 3. After Claude exits, diff the writable surface against the snapshot.
10
+ // 4. Filter the diff to reject any edit to forbidden paths (raw/, purpose,
11
+ // schema). With --dry-run, print the diff and revert. Otherwise: keep
12
+ // the agent's writes, append a log entry, trigger qmd incremental
13
+ // embed.
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import crypto from 'crypto';
18
+ import print from '../print';
19
+ import { loadWikiConfig, resolveWiki, loadRigConfig, WikiEntry } from './config';
20
+ import { paths } from './paths';
21
+ import { recordLastRun } from './db';
22
+ import { qmdEmbed } from './qmd';
23
+ import { adapters } from './agent/registry';
24
+ import { guardPath, refusalMessage } from './pathGuard';
25
+
26
+ interface IngestOpts { wiki?: string; dryRun?: boolean; json?: boolean; }
27
+
28
+ const AGENT_TIMEOUT_MS = 15 * 60 * 1000;
29
+
30
+ // LLM-writable surface — everything outside this set is filtered out of the
31
+ // diff (raw/, purpose.md, schema.md, .gitignore, lint-report-*, proposals/).
32
+ const WRITABLE_TOP = new Set(['index.md', 'overview.md', 'log.md', 'reviews.md']);
33
+ const WRITABLE_DIRS = ['wiki/sources', 'wiki/entities', 'wiki/concepts', 'wiki/synthesis', 'wiki/queries'];
34
+
35
+ export default async function wikiIngest(source: string, opts: IngestOpts): Promise<void> {
36
+ const cfg = loadWikiConfig();
37
+ const target = resolveWiki(cfg, opts.wiki);
38
+ if (!target) {
39
+ print.error('no wiki resolved. Pass --wiki <name> or run from inside a registered project.');
40
+ process.exit(1);
41
+ }
42
+
43
+ const absSource = path.isAbsolute(source) ? source : path.resolve(target.path, source);
44
+ if (!fs.existsSync(absSource)) {
45
+ print.error(`source not found: ${source}`);
46
+ process.exit(1);
47
+ }
48
+ const guard = guardPath(absSource, target.project || target.path);
49
+ if (!guard.ok) {
50
+ print.error('refusing to ingest from a hidden or gitignored path.');
51
+ // eslint-disable-next-line no-console
52
+ console.error(refusalMessage(absSource, guard));
53
+ process.exit(1);
54
+ }
55
+ const relSource = path.relative(target.path, absSource);
56
+
57
+ // Snapshot writable surface BEFORE the agent runs.
58
+ const before = snapshot(target);
59
+
60
+ const rig = loadRigConfig();
61
+ const which = rig.wiki?.defaultAgent || 'claude';
62
+ const adapter = adapters.find(a => a.name === which);
63
+ if (!adapter) { print.error(`no agent adapter "${which}"`); process.exit(20); }
64
+ const detect = await adapter.detect();
65
+ if (!detect.installed) {
66
+ print.error(`${which} not installed on PATH. Install Claude Code (\`yarn dlx @anthropics/claude-code\`) or pick another adapter via \`rig wiki agent use\`.`);
67
+ process.exit(20);
68
+ }
69
+
70
+ const prompt = buildPrompt(target, absSource);
71
+ print.start(`${which} ingest ${relSource}`);
72
+ const res = await adapter.run({
73
+ prompt,
74
+ cwd: target.path,
75
+ allowWrite: true,
76
+ tools: ['bash'],
77
+ timeoutMs: AGENT_TIMEOUT_MS,
78
+ });
79
+ if (!res.ok) {
80
+ print.error(`${which} failed (code ${res.exitCode})${res.stderr ? `: ${res.stderr.trim().slice(0, 400)}` : ''}`);
81
+ recordLastRun(target.name, 'ingest', 1);
82
+ process.exit(1);
83
+ }
84
+
85
+ // Snapshot AFTER and diff.
86
+ const after = snapshot(target);
87
+ const { applied, rejected } = diffSnapshots(target, before, after);
88
+
89
+ // Reject any edits to forbidden paths by reverting them to the snapshot.
90
+ for (const r of rejected) revertOne(target, before, r);
91
+
92
+ // Append a log entry. log.md is in WRITABLE_TOP so even if agent didn't
93
+ // touch it, we do — this is the rig's contribution.
94
+ appendLog(target, relSource, applied, !!opts.dryRun);
95
+
96
+ if (opts.dryRun) {
97
+ // Restore the writable surface from the snapshot — dry-run leaves no trace.
98
+ for (const f of applied) revertOne(target, before, f);
99
+ if (opts.json) {
100
+ // eslint-disable-next-line no-console
101
+ console.log(JSON.stringify({ ok: true, code: 0, data: { source: relSource, applied, rejected, dryRun: true } }, null, 2));
102
+ } else {
103
+ print.info(`dry-run diff (${applied.length} file${applied.length === 1 ? '' : 's'} would be written; ${rejected.length} rejected):`);
104
+ for (const f of applied) {
105
+ // eslint-disable-next-line no-console
106
+ console.log(` + ${f}`);
107
+ }
108
+ for (const f of rejected) {
109
+ // eslint-disable-next-line no-console
110
+ console.log(` ✗ ${f} [rejected: outside writable surface]`);
111
+ }
112
+ print.info('re-run without --dry-run to apply.');
113
+ }
114
+ recordLastRun(target.name, 'ingest', 0);
115
+ return;
116
+ }
117
+
118
+ // Real ingest — trigger incremental embed.
119
+ print.info(`applied ${applied.length} file change${applied.length === 1 ? '' : 's'}; rejected ${rejected.length}.`);
120
+ const embedRes = await qmdEmbed(target.name, target.path);
121
+ if (!embedRes.ok) {
122
+ print.warn(`qmd embed failed after ingest: ${embedRes.stderr.trim().slice(0, 300)}`);
123
+ print.warn('your wiki content is committed to disk; only the vector index is stale.');
124
+ }
125
+
126
+ if (opts.json) {
127
+ // eslint-disable-next-line no-console
128
+ console.log(JSON.stringify({ ok: true, code: 0, data: { source: relSource, applied, rejected } }, null, 2));
129
+ } else {
130
+ print.succeed(`ingested ${relSource} → ${applied.length} file${applied.length === 1 ? '' : 's'}.`);
131
+ }
132
+ recordLastRun(target.name, 'ingest', 0);
133
+ }
134
+
135
+ // ----------------------------------------------------------------------
136
+ // snapshot + diff
137
+ // ----------------------------------------------------------------------
138
+
139
+ interface Snapshot {
140
+ // wiki-relative path → content (or null if file was absent)
141
+ files: Map<string, string | null>;
142
+ }
143
+
144
+ function snapshot(wiki: WikiEntry): Snapshot {
145
+ const out: Snapshot = { files: new Map() };
146
+ for (const top of WRITABLE_TOP) {
147
+ const abs = path.join(wiki.path, top);
148
+ out.files.set(top, fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null);
149
+ }
150
+ for (const sub of WRITABLE_DIRS) {
151
+ const dir = path.join(wiki.path, sub);
152
+ if (!fs.existsSync(dir)) continue;
153
+ for (const name of fs.readdirSync(dir)) {
154
+ if (name === '.gitkeep') continue;
155
+ const rel = path.join(sub, name);
156
+ out.files.set(rel, fs.readFileSync(path.join(wiki.path, rel), 'utf8'));
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ function diffSnapshots(wiki: WikiEntry, before: Snapshot, after: Snapshot): { applied: string[]; rejected: string[] } {
163
+ const applied: string[] = [];
164
+ const rejected: string[] = [];
165
+ // Collect every path mentioned by either snapshot OR newly written.
166
+ const seen = new Set<string>([...before.files.keys(), ...after.files.keys()]);
167
+ // Also scan disk for any new files in writable dirs that "after" missed (it
168
+ // shouldn't, but be defensive).
169
+ for (const sub of WRITABLE_DIRS) {
170
+ const dir = path.join(wiki.path, sub);
171
+ if (!fs.existsSync(dir)) continue;
172
+ for (const name of fs.readdirSync(dir)) {
173
+ if (name === '.gitkeep') continue;
174
+ seen.add(path.join(sub, name));
175
+ }
176
+ }
177
+
178
+ for (const rel of seen) {
179
+ if (!isWritable(rel)) {
180
+ // Edit outside writable surface — check if changed vs snapshot.
181
+ const orig = before.files.get(rel);
182
+ const now = readMaybe(path.join(wiki.path, rel));
183
+ if (orig !== now) rejected.push(rel);
184
+ continue;
185
+ }
186
+ const orig = before.files.get(rel) ?? null;
187
+ const now = readMaybe(path.join(wiki.path, rel));
188
+ if (orig !== now) applied.push(rel);
189
+ }
190
+ return { applied, rejected };
191
+ }
192
+
193
+ function isWritable(rel: string): boolean {
194
+ if (WRITABLE_TOP.has(rel)) return true;
195
+ return WRITABLE_DIRS.some(d => rel === d || rel.startsWith(d + path.sep) || rel.startsWith(d + '/'));
196
+ }
197
+
198
+ function readMaybe(abs: string): string | null {
199
+ return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
200
+ }
201
+
202
+ function revertOne(wiki: WikiEntry, before: Snapshot, rel: string): void {
203
+ const abs = path.join(wiki.path, rel);
204
+ const orig = before.files.get(rel);
205
+ if (orig == null) {
206
+ // file didn't exist before → delete
207
+ if (fs.existsSync(abs)) fs.rmSync(abs, { force: true });
208
+ } else {
209
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
210
+ fs.writeFileSync(abs, orig, 'utf8');
211
+ }
212
+ }
213
+
214
+ function appendLog(wiki: WikiEntry, relSource: string, applied: string[], dryRun: boolean): void {
215
+ const logPath = path.join(wiki.path, 'log.md');
216
+ const ts = new Date().toISOString();
217
+ const entry = [
218
+ ``,
219
+ `## ${ts} — ingest ${relSource}${dryRun ? ' (dry-run)' : ''}`,
220
+ ...applied.map(a => `- ${a}`),
221
+ ``,
222
+ ].join('\n');
223
+ fs.appendFileSync(logPath, entry, 'utf8');
224
+ }
225
+
226
+ // ----------------------------------------------------------------------
227
+ // prompt
228
+ // ----------------------------------------------------------------------
229
+
230
+ function buildPrompt(wiki: WikiEntry, sourceAbs: string): string {
231
+ const sourceRel = path.relative(wiki.path, sourceAbs);
232
+ const sourceSha = crypto.createHash('sha256').update(fs.readFileSync(sourceAbs)).digest('hex');
233
+ const today = new Date().toISOString();
234
+
235
+ return [
236
+ `You are running INGEST for the rig wiki at \`${wiki.path}\`.`,
237
+ ``,
238
+ `Step 1 — ANALYSIS (do NOT write files yet):`,
239
+ ` - Read \`purpose.md\`, \`schema.md\`, \`overview.md\`, \`index.md\`.`,
240
+ ` - Read the source: \`${sourceRel}\`.`,
241
+ ` - In your head, list: entities mentioned, concepts touched, contradictions vs existing pages, items that need human review.`,
242
+ ``,
243
+ `Step 2 — GENERATION (write files):`,
244
+ ` - Create \`wiki/sources/<slug>.md\` summarizing this source. \`<slug>\` = source basename minus YYYY-MM-DD prefix and extension, kebab-case.`,
245
+ ` - For each new or affected entity / concept / synthesis page, create or UPDATE the corresponding file under \`wiki/entities/\`, \`wiki/concepts/\`, \`wiki/synthesis/\`.`,
246
+ ` - Update \`index.md\` and \`overview.md\` to reflect the new content.`,
247
+ ` - If anything is unclear or contradictory, append a bullet to \`reviews.md\`. Do NOT silently merge contradictions.`,
248
+ ``,
249
+ `Frontmatter — every wiki/**/*.md MUST have:`,
250
+ '```yaml',
251
+ `type: source | entity | concept | synthesis | query`,
252
+ `sources: [<source-slug>, ...] # source-slug is the source page slug, not raw filename`,
253
+ `ingested-at: ${today}`,
254
+ `last-updated: ${today}`,
255
+ '```',
256
+ `Source pages additionally need:`,
257
+ '```yaml',
258
+ `source-sha: ${sourceSha}`,
259
+ `source-path: ${sourceRel}`,
260
+ '```',
261
+ ``,
262
+ `Hard rules — the host will REJECT any patch that violates these:`,
263
+ ` - DO NOT modify \`raw/\`, \`purpose.md\`, or \`schema.md\`.`,
264
+ ` - Use kebab-case slugs; no spaces; no date prefixes inside \`wiki/\` filenames.`,
265
+ ` - Link related pages with [[wikilink]]. Every wiki page should link to ≥1 other page.`,
266
+ ` - For contradictions, write inline: \`> Contradiction: A vs B (see [[page-A]], [[page-B]])\`.`,
267
+ ``,
268
+ `Output: stdout is for status only. All content goes to files via the Write/Edit tools.`,
269
+ `When done, print a single line: \`INGEST DONE: <slug>\`.`,
270
+ ].join('\n');
271
+ }