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.
292 lines (264 loc) • 11.7 kB
text/typescript
// `rig wiki survey` — schema-driven ingestion candidate triage.
//
// Walks the vault's scan root, skips obvious non-sources (hidden /
// gitignored / binary extensions), then asks the configured agent
// (Claude by default) to classify each remaining candidate against the
// wiki's schema.md "Ingestion policy" section.
//
// Output:
// - default: human-readable table path | decision | reason
// - --json: { ok, code, data: { wiki, decisions: [{path, decision, reason}] } }
//
// --apply iterates over `decision === 'ingest'` and runs the same code
// path as `rig wiki ingest <path>` for each, in series.
import fs from 'fs';
import path from 'path';
import print from '../print';
import { requireVault, loadRigConfig, WikiEntry } from './config';
import { isBinaryExtension } from './fileTypes';
import { batchGitignored } from './gitignore';
import { batchWikiIgnored } from './wikiignore';
import { adapters } from './agent/registry';
import { default as wikiIngest } from './ingest';
interface SurveyOpts {
apply?: boolean;
json?: boolean;
limit?: number; // cap candidates passed to the agent (cost/latency safety)
noAgent?: boolean; // skip Claude classification — local rules only
}
type Decision = 'ingest' | 'skip' | 'unclear';
interface SurveyRow {
path: string; // root-relative
decision: Decision;
reason: string;
size: number;
}
const DEFAULT_LIMIT = 500;
const AGENT_TIMEOUT_MS = 5 * 60 * 1000;
export default async function wikiSurvey(opts: SurveyOpts): Promise<void> {
const target = requireVault();
const candidates = collectCandidates(target);
if (candidates.length === 0) {
print.info('no candidates under scan root.');
return;
}
const limit = Math.max(1, opts.limit ?? DEFAULT_LIMIT);
if (candidates.length > limit) {
print.warn(`${candidates.length} candidates found; capping to first ${limit}. Pass --limit <n> to override.`);
}
const truncated = candidates.slice(0, limit);
// Classify
let rows: SurveyRow[];
if (opts.noAgent) {
rows = truncated.map(c => ({ path: c.rel, decision: 'ingest' as Decision, reason: 'no-agent mode — accepts every non-binary candidate', size: c.size }));
} else {
rows = await classifyWithAgent(target, truncated);
}
if (opts.json) {
// eslint-disable-next-line no-console
console.log(JSON.stringify({
ok: true, code: 0,
data: { wiki: target.name, decisions: rows },
}, null, 2));
} else {
printTable(target, rows);
}
if (opts.apply) await applyIngest(target, rows);
}
interface Candidate { abs: string; rel: string; size: number; }
function collectCandidates(entry: WikiEntry): Candidate[] {
const out: Candidate[] = [];
const root = entry.root;
const vaultRel = path.relative(root, entry.path) || path.basename(entry.path);
const stack = [root];
while (stack.length) {
const cur = stack.pop()!;
let entries: fs.Dirent[];
try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch { continue; }
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const full = path.join(cur, e.name);
const rel = path.relative(root, full);
// Skip the vault dir itself (we don't ingest our own wiki pages)
if (rel === vaultRel || rel.startsWith(vaultRel + path.sep)) continue;
// Skip node_modules unconditionally — never useful as wiki sources
if (e.name === 'node_modules') continue;
if (e.isDirectory()) {
stack.push(full);
} else if (e.isFile() && !isBinaryExtension(full)) {
try {
const stat = fs.statSync(full);
out.push({ abs: full, rel, size: stat.size });
} catch { /* unreadable — skip */ }
}
}
}
// Two-layer ignore filter:
// 1. .gitignore — multi-repo-aware via `git check-ignore --stdin`
// 2. .wikiignore — same syntax, wiki-only (lets us hide paths git
// tracks intentionally, e.g. overmind's `keychain/`)
// A candidate is dropped if either layer matches.
const absList = out.map(c => c.abs);
const giIgnored = batchGitignored(absList);
const wkIgnored = batchWikiIgnored(absList, entry.root);
return out.filter(c => !giIgnored.has(c.abs) && !wkIgnored.has(c.abs));
}
async function classifyWithAgent(target: WikiEntry, candidates: Candidate[]): Promise<SurveyRow[]> {
const rig = loadRigConfig();
const which = rig.wiki?.defaultAgent || 'claude';
const adapter = adapters.find(a => a.name === which);
const detect = adapter ? await adapter.detect() : { installed: false };
if (!adapter || !detect.installed) {
print.warn(`${which} not available — falling back to local rules (every non-binary candidate accepted).`);
return candidates.map(c => ({ path: c.rel, decision: 'ingest', reason: `local-rules (${which} unavailable)`, size: c.size }));
}
const policy = readPolicySection(target);
const prompt = buildPrompt(policy, candidates);
print.start(`${which} survey (${candidates.length} candidates)`);
const res = await adapter.run({
prompt,
cwd: target.path,
allowWrite: false,
tools: [],
timeoutMs: AGENT_TIMEOUT_MS,
});
if (!res.ok) {
print.warn(`${which} survey failed (code ${res.exitCode}) — falling back to local rules.`);
return candidates.map(c => ({ path: c.rel, decision: 'unclear', reason: 'agent-failed', size: c.size }));
}
const parsed = parseJsonDecisions(res.stdout, candidates);
if (!parsed) {
print.warn(`could not parse ${which}'s JSON response — falling back.`);
return candidates.map(c => ({ path: c.rel, decision: 'unclear', reason: 'parse-failed', size: c.size }));
}
return parsed;
}
function readPolicySection(target: WikiEntry): string {
const schemaPath = path.join(target.path, 'schema.md');
let body = '';
try { body = fs.readFileSync(schemaPath, 'utf8'); } catch { /* missing schema — use empty policy */ }
// Extract the "## Ingestion policy" section to end-of-file or next H2.
const m = body.match(/##\s+Ingestion policy[\s\S]*?(?=\n##\s|\n$|$)/i);
if (m) return m[0];
// Fallback: hand-rolled default if the schema doesn't have the section.
return [
'## Ingestion policy (default — schema.md has no explicit section)',
'Ingest: markdown, plain text, PDF, images of documents/receipts,',
'structured text (csv/json/yaml). Skip: archives, binaries, AV, design,',
'model weights, lockfiles, anything under hidden/gitignored paths.',
].join('\n');
}
function buildPrompt(policy: string, candidates: Candidate[]): string {
const list = candidates
.map((c, i) => `${i + 1}. ${c.rel} (${humanSize(c.size)})`)
.join('\n');
return [
`You are triaging files for a rig wiki ingestion run.`,
``,
`Below is the wiki's ingestion policy (extracted from schema.md):`,
``,
`\`\`\``,
policy.trim(),
`\`\`\``,
``,
`Below is the list of candidate files (already filtered for hidden /`,
`gitignored / known-binary extensions). For each candidate, decide:`,
``,
` - "ingest" — matches the policy, should become a wiki source`,
` - "skip" — should not be ingested per the policy`,
` - "unclear" — needs a human look (e.g. ambiguous filename)`,
``,
`Output ONE JSON array. Each element MUST be:`,
``,
` {"i": <1-based-index>, "decision": "ingest"|"skip"|"unclear", "reason": "<≤80 chars>"}`,
``,
`Output ONLY the JSON array. No prose, no markdown fences.`,
``,
`Candidates:`,
list,
].join('\n');
}
function parseJsonDecisions(stdout: string, candidates: Candidate[]): SurveyRow[] | null {
if (!stdout) return null;
// Find the first '[' and last ']' to handle stray prose from the model.
const start = stdout.indexOf('[');
const end = stdout.lastIndexOf(']');
if (start < 0 || end <= start) return null;
let parsed: unknown;
try { parsed = JSON.parse(stdout.slice(start, end + 1)); } catch { return null; }
if (!Array.isArray(parsed)) return null;
const byIndex = new Map<number, { decision: Decision; reason: string }>();
for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const o = item as Record<string, unknown>;
const i = typeof o.i === 'number' ? o.i : NaN;
const d = typeof o.decision === 'string' ? o.decision.toLowerCase() : '';
if (!isFinite(i) || (d !== 'ingest' && d !== 'skip' && d !== 'unclear')) continue;
byIndex.set(i, { decision: d as Decision, reason: typeof o.reason === 'string' ? o.reason : '' });
}
return candidates.map((c, idx) => {
const r = byIndex.get(idx + 1);
return r
? { path: c.rel, decision: r.decision, reason: r.reason, size: c.size }
: { path: c.rel, decision: 'unclear', reason: 'no-decision-from-agent', size: c.size };
});
}
function printTable(target: WikiEntry, rows: SurveyRow[]): void {
print.info(`survey: ${target.name} (${rows.length} candidate${rows.length === 1 ? '' : 's'})`);
const counts = rows.reduce((acc, r) => { acc[r.decision] = (acc[r.decision] || 0) + 1; return acc; }, {} as Record<string, number>);
// eslint-disable-next-line no-console
console.log(` ingest ${counts.ingest || 0} skip ${counts.skip || 0} unclear ${counts.unclear || 0}\n`);
const widths = {
decision: 7,
path: Math.min(60, Math.max(4, ...rows.map(r => r.path.length))),
size: Math.max(4, ...rows.map(r => humanSize(r.size).length)),
};
// eslint-disable-next-line no-console
console.log(` ${'DECISION'.padEnd(widths.decision)} ${'SIZE'.padStart(widths.size)} PATH`);
// eslint-disable-next-line no-console
console.log(` ${'-'.repeat(widths.decision)} ${'-'.repeat(widths.size)} ${'-'.repeat(widths.path)}`);
for (const r of rows) {
// eslint-disable-next-line no-console
console.log(` ${r.decision.padEnd(widths.decision)} ${humanSize(r.size).padStart(widths.size)} ${r.path}`);
if (r.decision !== 'ingest' && r.reason) {
// eslint-disable-next-line no-console
console.log(` ${''.padEnd(widths.decision)} ${''.padStart(widths.size)} ↳ ${r.reason}`);
}
}
// eslint-disable-next-line no-console
console.log('');
if (!counts.ingest) {
print.info(`nothing tagged "ingest". Edit schema.md's "Ingestion policy" if this is wrong.`);
} else {
print.info(`re-run with --apply to ingest the ${counts.ingest} "ingest" candidate${counts.ingest === 1 ? '' : 's'}.`);
}
}
async function applyIngest(target: WikiEntry, rows: SurveyRow[]): Promise<void> {
const targets = rows.filter(r => r.decision === 'ingest');
if (targets.length === 0) {
print.info('nothing to apply (no "ingest" decisions).');
return;
}
print.info(`applying ${targets.length} ingest${targets.length === 1 ? '' : 's'} (in series)…`);
let okCount = 0, failCount = 0;
for (const r of targets) {
const absSource = path.resolve(target.root, r.path);
print.start(`ingest ${r.path}`);
try {
// wikiIngest reads CWD-resolved vault; it'll pick up the same target.
// It calls process.exit on error, so wrap defensively if needed.
await wikiIngest(absSource, { dryRun: false });
okCount++;
} catch (e) {
failCount++;
print.error(`ingest ${r.path} failed: ${(e as Error).message}`);
}
}
print.succeed(`survey --apply done: ${okCount} ok, ${failCount} failed.`);
}
function humanSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}M`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`;
}