rigjs
Version:
A multi-repos dev tool based on yarn and git.Rigjs is intended to be the simplest way to develop,share and deliver codes between different developers or different projects.
311 lines (280 loc) • 14.8 kB
text/typescript
// `rig wiki ingest <source>` — two-step CoT digestion of one source file
// into a wiki page tree.
//
// Pipeline:
// 1. Snapshot every wiki/<sub>/*.md + index.md + overview.md + log.md +
// reviews.md (NOT raw/, NOT purpose/schema) — these are LLM-writable.
// 2. Spawn Claude inside the wiki dir with --allowedTools Read,Write,Edit
// and a two-step prompt (analysis → generation).
// 3. After Claude exits, diff the writable surface against the snapshot.
// 4. Filter the diff to reject any edit to forbidden paths (raw/, purpose,
// schema). With --dry-run, print the diff and revert. Otherwise: keep
// the agent's writes, append a log entry, trigger qmd incremental
// embed.
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import print from '../print';
import { requireVault, loadRigConfig, WikiEntry } from './config';
import { paths } from './paths';
import { recordLastRun, upsertSourceSha } from './db';
import { qmdEmbed } from './qmd';
import { adapters } from './agent/registry';
import { guardPath, refusalMessage } from './pathGuard';
interface IngestOpts { dryRun?: boolean; json?: boolean; }
const AGENT_TIMEOUT_MS = 15 * 60 * 1000;
// LLM-writable surface — everything outside this set is filtered out of the
// diff (raw/, purpose.md, schema.md, .gitignore, lint-report-*, proposals/).
const WRITABLE_TOP = new Set(['index.md', 'overview.md', 'log.md', 'reviews.md']);
const WRITABLE_DIRS = ['sources', 'entities', 'concepts', 'synthesis', 'queries'];
export default async function wikiIngest(source: string, opts: IngestOpts): Promise<void> {
const target = requireVault();
const absSource = path.isAbsolute(source) ? source : path.resolve(target.path, source);
if (!fs.existsSync(absSource)) {
print.error(`source not found: ${source}`);
process.exit(1);
}
const guard = guardPath(absSource, target.root || target.path, target.root);
if (!guard.ok) {
print.error('refusing to ingest from a hidden or gitignored path.');
// eslint-disable-next-line no-console
console.error(refusalMessage(absSource, guard));
process.exit(1);
}
const relSource = path.relative(target.path, absSource);
// Snapshot writable surface BEFORE the agent runs.
const before = snapshot(target);
const rig = loadRigConfig();
const which = rig.wiki?.defaultAgent || 'claude';
const adapter = adapters.find(a => a.name === which);
if (!adapter) { print.error(`no agent adapter "${which}"`); process.exit(20); }
const detect = await adapter.detect();
if (!detect.installed) {
print.error(`${which} not installed on PATH. Install Claude Code (\`yarn dlx @anthropics/claude-code\`) or pick another adapter via \`rig wiki agent use\`.`);
process.exit(20);
}
const prompt = buildPrompt(target, absSource);
print.start(`${which} ingest ${relSource}`);
const res = await adapter.run({
prompt,
cwd: target.path,
allowWrite: true,
tools: ['bash'],
timeoutMs: AGENT_TIMEOUT_MS,
});
if (!res.ok) {
print.error(`${which} failed (code ${res.exitCode})${res.stderr ? `: ${res.stderr.trim().slice(0, 400)}` : ''}`);
recordLastRun(target.name, 'ingest', 1);
process.exit(1);
}
// Snapshot AFTER and diff.
const after = snapshot(target);
const { applied, rejected } = diffSnapshots(target, before, after);
// Reject any edits to forbidden paths by reverting them to the snapshot.
for (const r of rejected) revertOne(target, before, r);
// Append a log entry. log.md is in WRITABLE_TOP so even if agent didn't
// touch it, we do — this is the rig's contribution.
appendLog(target, relSource, applied, !!opts.dryRun);
if (opts.dryRun) {
// Restore the writable surface from the snapshot — dry-run leaves no trace.
for (const f of applied) revertOne(target, before, f);
if (opts.json) {
// eslint-disable-next-line no-console
console.log(JSON.stringify({ ok: true, code: 0, data: { source: relSource, applied, rejected, dryRun: true } }, null, 2));
} else {
print.info(`dry-run diff (${applied.length} file${applied.length === 1 ? '' : 's'} would be written; ${rejected.length} rejected):`);
for (const f of applied) {
// eslint-disable-next-line no-console
console.log(` + ${f}`);
}
for (const f of rejected) {
// eslint-disable-next-line no-console
console.log(` ✗ ${f} [rejected: outside writable surface]`);
}
print.info('re-run without --dry-run to apply.');
}
recordLastRun(target.name, 'ingest', 0);
return;
}
// Real ingest — trigger incremental embed.
print.info(`applied ${applied.length} file change${applied.length === 1 ? '' : 's'}; rejected ${rejected.length}.`);
const embedRes = await qmdEmbed(target.name, target.path, target.root);
if (!embedRes.ok) {
print.warn(`qmd embed failed after ingest: ${embedRes.stderr.trim().slice(0, 300)}`);
print.warn('your wiki content is committed to disk; only the vector index is stale.');
}
// Baseline this source into state.db.source_sha so future `rig wiki scan`
// can detect MODIFIED on this exact file. Key by root-relative path to
// match what scan uses for lookups.
try {
const stat = fs.statSync(absSource);
const sha = crypto.createHash('sha256').update(fs.readFileSync(absSource)).digest('hex');
const relFromRoot = path.relative(target.root, absSource);
upsertSourceSha(target.name, relFromRoot, sha, stat.mtimeMs);
} catch (e) {
print.warn(`source_sha upsert failed: ${(e as Error).message}. Future scans may misreport this file as NEW.`);
}
if (opts.json) {
// eslint-disable-next-line no-console
console.log(JSON.stringify({ ok: true, code: 0, data: { source: relSource, applied, rejected } }, null, 2));
} else {
print.succeed(`ingested ${relSource} → ${applied.length} file${applied.length === 1 ? '' : 's'}.`);
}
recordLastRun(target.name, 'ingest', 0);
}
// ----------------------------------------------------------------------
// snapshot + diff
// ----------------------------------------------------------------------
interface Snapshot {
// wiki-relative path → content (or null if file was absent)
files: Map<string, string | null>;
}
function snapshot(wiki: WikiEntry): Snapshot {
const out: Snapshot = { files: new Map() };
for (const top of WRITABLE_TOP) {
const abs = path.join(wiki.path, top);
out.files.set(top, fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null);
}
for (const sub of WRITABLE_DIRS) {
const dir = path.join(wiki.path, sub);
if (!fs.existsSync(dir)) continue;
for (const name of fs.readdirSync(dir)) {
if (name === '.gitkeep') continue;
const rel = path.join(sub, name);
out.files.set(rel, fs.readFileSync(path.join(wiki.path, rel), 'utf8'));
}
}
return out;
}
function diffSnapshots(wiki: WikiEntry, before: Snapshot, after: Snapshot): { applied: string[]; rejected: string[] } {
const applied: string[] = [];
const rejected: string[] = [];
// Collect every path mentioned by either snapshot OR newly written.
const seen = new Set<string>([...before.files.keys(), ...after.files.keys()]);
// Also scan disk for any new files in writable dirs that "after" missed (it
// shouldn't, but be defensive).
for (const sub of WRITABLE_DIRS) {
const dir = path.join(wiki.path, sub);
if (!fs.existsSync(dir)) continue;
for (const name of fs.readdirSync(dir)) {
if (name === '.gitkeep') continue;
seen.add(path.join(sub, name));
}
}
for (const rel of seen) {
if (!isWritable(rel)) {
// Edit outside writable surface — check if changed vs snapshot.
const orig = before.files.get(rel);
const now = readMaybe(path.join(wiki.path, rel));
if (orig !== now) rejected.push(rel);
continue;
}
const orig = before.files.get(rel) ?? null;
const now = readMaybe(path.join(wiki.path, rel));
if (orig !== now) applied.push(rel);
}
return { applied, rejected };
}
function isWritable(rel: string): boolean {
if (WRITABLE_TOP.has(rel)) return true;
return WRITABLE_DIRS.some(d => rel === d || rel.startsWith(d + path.sep) || rel.startsWith(d + '/'));
}
function readMaybe(abs: string): string | null {
return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
}
function revertOne(wiki: WikiEntry, before: Snapshot, rel: string): void {
const abs = path.join(wiki.path, rel);
const orig = before.files.get(rel);
if (orig == null) {
// file didn't exist before → delete
if (fs.existsSync(abs)) fs.rmSync(abs, { force: true });
} else {
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, orig, 'utf8');
}
}
function appendLog(wiki: WikiEntry, relSource: string, applied: string[], dryRun: boolean): void {
const logPath = path.join(wiki.path, 'log.md');
const ts = new Date().toISOString();
const entry = [
``,
`## ${ts} — ingest ${relSource}${dryRun ? ' (dry-run)' : ''}`,
...applied.map(a => `- ${a}`),
``,
].join('\n');
fs.appendFileSync(logPath, entry, 'utf8');
}
// ----------------------------------------------------------------------
// prompt
// ----------------------------------------------------------------------
function buildPrompt(wiki: WikiEntry, sourceAbs: string): string {
const sourceSha = crypto.createHash('sha256').update(fs.readFileSync(sourceAbs)).digest('hex');
const today = new Date().toISOString();
// The Obsidian vault root is the parent of the rig-wiki/ metadata dir.
// The Obsidian vault NAME defaults to that dir's basename. All source-path
// references in generated pages use obsidian:// URLs so that links survive
// moves of the wiki dir and are clickable from inside Obsidian.
const obsidianRoot = path.dirname(wiki.path);
const obsidianVaultName = path.basename(obsidianRoot);
const sourceFromObsidianRoot = path.relative(obsidianRoot, sourceAbs);
const sourceObsidianUrl = obsidianUrl(obsidianVaultName, sourceFromObsidianRoot);
const ext = path.extname(sourceAbs).toLowerCase();
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].includes(ext);
const isPdf = ext === '.pdf';
const isSpreadsheet = ['.xlsx', '.xls', '.ods', '.numbers'].includes(ext);
return [
`You are running INGEST for the rig wiki at \`${wiki.path}\`.`,
`This wiki lives inside an Obsidian vault rooted at \`${obsidianRoot}\` (Obsidian vault name: "${obsidianVaultName}").`,
`Use obsidian:// URLs — never raw file paths — to reference sources or any file in the vault.`,
``,
`Source for this ingest: ${sourceObsidianUrl}`,
`Source filesystem path (for your Read tool only): ${sourceAbs}`,
``,
`Step 1 — ANALYSIS (do NOT write files yet):`,
` - Read \`purpose.md\`, \`schema.md\`, \`overview.md\`, \`index.md\` from the wiki.`,
` - Read the source. Notes by type:`,
` · text / markdown / json / code → use the Read tool normally`,
isImage ? ` · this source is an IMAGE (${ext}) — Read it; you'll receive it as a visual input. Describe what's depicted, plus any visible text / numbers / structures.` : '',
isPdf ? ` · this source is a PDF — Read it; if it is >10 pages, read in chunks with the \`pages\` parameter.` : '',
isSpreadsheet ? ` · this source is a SPREADSHEET (${ext}) — the Read tool does NOT natively decode spreadsheets in v1. Try Read first; if it fails, write a stub source page with the obsidian:// link + filename + last-modified date, and append a \`reviews.md\` bullet asking the user to export it as CSV / JSON for re-ingest. Do NOT invent contents.` : '',
` - In your head, list: entities mentioned, concepts touched, contradictions vs existing pages, items that need human review.`,
``,
`Step 2 — GENERATION (write files):`,
` - Write the source summary to \`sources/<slug>.md\`. \`<slug>\` = source basename minus YYYY-MM-DD prefix and extension, kebab-case.`,
` - **If \`sources/<slug>.md\` already exists, UPDATE IT IN PLACE.** Do NOT create a sibling like \`<slug>-2.md\` or \`<slug>-updated.md\`. Re-ingest of the same source MUST overwrite its existing source page so any [[wikilinks]] pointing at \`[[<slug>]]\` keep working. Refresh \`last-updated\` and \`source-sha\` in the frontmatter; preserve \`ingested-at\` (the original first-ingest timestamp).`,
` - For each new or affected entity / concept / synthesis page, create or UPDATE the corresponding file under \`entities/\`, \`concepts/\`, \`synthesis/\` (at the vault root — there is no \`wiki/\` subdir). Same in-place rule: never create \`-2.md\` siblings.`,
` - Update \`index.md\` and \`overview.md\` to reflect the new content.`,
` - If anything is unclear or contradictory, append a bullet to \`reviews.md\`. Do NOT silently merge contradictions.`,
``,
`Frontmatter — every page under sources/ entities/ concepts/ synthesis/ queries/ MUST have:`,
'```yaml',
`type: source | entity | concept | synthesis | query`,
`sources: [<source-slug>, ...] # source-slug is the source page slug, not raw filename`,
`ingested-at: ${today}`,
`last-updated: ${today}`,
'```',
`Source pages additionally need:`,
'```yaml',
`source-sha: ${sourceSha}`,
`source-path: ${sourceObsidianUrl}`,
'```',
``,
`Reference rules — IMPORTANT:`,
` - To reference the original source file inside markdown body, use the obsidian:// URL: ${sourceObsidianUrl}`,
` - To reference OTHER files in the same Obsidian vault, build URLs the same way: \`obsidian://open?vault=${obsidianVaultName}&file=<vault-relative-path>\`. URL-encode spaces / specials with encodeURI, keep forward slashes.`,
` - To reference other wiki pages, use [[wikilink]] (slug only).`,
``,
`Hard rules — the host will REJECT any patch that violates these:`,
` - DO NOT modify \`raw/\`, \`purpose.md\`, or \`schema.md\`.`,
` - Use kebab-case slugs; no spaces; no date prefixes in page filenames.`,
` - Every wiki page should link to ≥1 other page (via [[wikilink]] or obsidian:// URL).`,
` - For contradictions, write inline: \`> Contradiction: A vs B (see [[page-A]], [[page-B]])\`.`,
``,
`Output: stdout is for status only. All content goes to files via the Write/Edit tools.`,
`When done, print a single line: \`INGEST DONE: <slug>\`.`,
].filter(Boolean).join('\n');
}
/** Build an Obsidian URL. encodeURI preserves `/`, encodes spaces and Unicode. */
function obsidianUrl(vaultName: string, fileRel: string): string {
return `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURI(fileRel)}`;
}