rigjs 3.0.32 → 4.0.2
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/.claude/skills/rig-wiki/SKILL.md +104 -0
- package/.claude-plugin/plugin.json +14 -0
- package/README.md +18 -1
- package/README_CN.md +17 -1
- package/RIG_CREW_SKILL.md +274 -0
- package/RIG_WIKI_SKILL.md +104 -0
- package/bin/rig.js +0 -0
- package/built/index.js +376 -299
- package/doc/architecture/README.md +139 -0
- package/doc/architecture/agents.md +180 -0
- package/doc/architecture/fc.md +17 -0
- package/doc/architecture/wiki.md +278 -0
- package/lib/crew/ask.ts +24 -0
- package/lib/crew/board.ts +123 -0
- package/lib/crew/config.ts +109 -0
- package/lib/crew/doctor.ts +40 -0
- package/lib/crew/inbox.ts +29 -0
- package/lib/crew/index.ts +108 -0
- package/lib/crew/init.ts +113 -0
- package/lib/crew/paths.ts +13 -0
- package/lib/crew/project.ts +84 -0
- package/lib/crew/role.ts +121 -0
- package/lib/crew/roleCommand.ts +150 -0
- package/lib/crew/state.ts +19 -0
- package/lib/crew/status.ts +27 -0
- package/lib/crew/stub.ts +9 -0
- package/lib/crew/sync.ts +15 -0
- package/lib/crew/task.ts +92 -0
- package/lib/crew/vault.ts +266 -0
- package/lib/installLocal.ts +189 -0
- package/lib/rig/index.ts +26 -3
- package/lib/tag/index.ts +1 -1
- package/lib/wiki/README.md +79 -0
- package/lib/wiki/agent/claude.ts +65 -0
- package/lib/wiki/agent/codex.ts +22 -0
- package/lib/wiki/agent/index.ts +11 -0
- package/lib/wiki/agent/list.ts +27 -0
- package/lib/wiki/agent/pi.ts +21 -0
- package/lib/wiki/agent/registry.ts +16 -0
- package/lib/wiki/agent/types.ts +37 -0
- package/lib/wiki/agent/use.ts +21 -0
- package/lib/wiki/config.ts +99 -0
- package/lib/wiki/daemon/index.ts +25 -0
- package/lib/wiki/daemon/install.ts +69 -0
- package/lib/wiki/daemon/logs.ts +16 -0
- package/lib/wiki/daemon/runner.ts +42 -0
- package/lib/wiki/daemon/start.ts +20 -0
- package/lib/wiki/daemon/status.ts +23 -0
- package/lib/wiki/daemon/stop.ts +16 -0
- package/lib/wiki/daemon/uninstall.ts +17 -0
- package/lib/wiki/db.ts +71 -0
- package/lib/wiki/fetch.ts +206 -0
- package/lib/wiki/index.ts +106 -0
- package/lib/wiki/indexCmd.ts +23 -0
- package/lib/wiki/ingest.ts +271 -0
- package/lib/wiki/init.ts +125 -0
- package/lib/wiki/installSkill.ts +92 -0
- package/lib/wiki/lint.ts +252 -0
- package/lib/wiki/list.ts +69 -0
- package/lib/wiki/pathGuard.ts +87 -0
- package/lib/wiki/paths.ts +29 -0
- package/lib/wiki/platform.ts +8 -0
- package/lib/wiki/qmd.ts +205 -0
- package/lib/wiki/query.ts +144 -0
- package/lib/wiki/rebuild.ts +56 -0
- package/lib/wiki/register.ts +94 -0
- package/lib/wiki/scan.ts +0 -0
- package/lib/wiki/uninstallSkill.ts +37 -0
- package/lib/wiki/unregister.ts +16 -0
- package/package.json +36 -6
- package/scripts/postinstall.mjs +108 -0
- package/scripts/publish.mjs +93 -0
- package/scripts/sync-skill.mjs +33 -0
- package/scripts/version-code.mjs +86 -0
- package/skills.md +54 -0
- package/.github/workflows/npm-publish.yml +0 -22
- package/demo/.env.oem1 +0 -4
- package/demo/.env.oem2 +0 -4
- package/demo/babel.config.js +0 -5
- package/demo/env.rig.json5 +0 -8
- package/demo/jsconfig.json +0 -19
- package/demo/package.json +0 -59
- package/demo/package.rig.json5 +0 -78
- package/demo/public/favicon.ico +0 -0
- package/demo/public/index.html +0 -17
- package/demo/rig_dev/.gitkeep +0 -0
- package/demo/rig_helper.d.ts +0 -4
- package/demo/rig_helper.js +0 -10
- package/demo/rigs/.gitkeep +0 -0
- package/demo/src/App.vue +0 -34
- package/demo/src/assets/logo.png +0 -0
- package/demo/src/components/HelloWorld.vue +0 -58
- package/demo/src/main.js +0 -8
- package/demo/vue.config.js +0 -8
- package/demo/yarn.lock +0 -6312
- package/develop.png +0 -0
- package/jest/test.rig.json5 +0 -14
- package/jest.config.ts +0 -16
- package/production.png +0 -0
- package/tsconfig.json +0 -53
package/lib/wiki/init.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import print from '../print';
|
|
4
|
+
import { guardPath, refusalMessage } from './pathGuard';
|
|
5
|
+
|
|
6
|
+
const PURPOSE_TMPL = `# Purpose
|
|
7
|
+
|
|
8
|
+
This wiki is <author>'s <scope> (single, do not mix).
|
|
9
|
+
Key questions it aims to answer:
|
|
10
|
+
- ...
|
|
11
|
+
- ...
|
|
12
|
+
What's in scope: ...
|
|
13
|
+
What's out of scope: ...
|
|
14
|
+
Audience: <author> + agents.
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const SCHEMA_TMPL = `# Schema
|
|
18
|
+
|
|
19
|
+
## Layers
|
|
20
|
+
- raw/, purpose.md, schema.md: read-only for LLM
|
|
21
|
+
- wiki/, index.md, overview.md, log.md, reviews.md: LLM is sole author
|
|
22
|
+
|
|
23
|
+
## Page types
|
|
24
|
+
- sources/<slug>.md : 1-source summary
|
|
25
|
+
- entities/<slug>.md : 1 thing with properties
|
|
26
|
+
- concepts/<slug>.md : 1 abstract idea
|
|
27
|
+
- synthesis/<slug>.md : cross-source integration
|
|
28
|
+
- queries/<slug>.md : archived Q&A worth keeping
|
|
29
|
+
|
|
30
|
+
## Frontmatter (every wiki page MUST have)
|
|
31
|
+
- type: source | entity | concept | synthesis | query
|
|
32
|
+
- sources: [<source-slug>, ...]
|
|
33
|
+
- source-sha: <sha> # source pages only
|
|
34
|
+
- source-path: raw/... | <relpath> # source pages only
|
|
35
|
+
- ingested-at: <ISO>
|
|
36
|
+
- last-updated: <ISO>
|
|
37
|
+
|
|
38
|
+
## Naming
|
|
39
|
+
- kebab-case; no spaces; no dates in wiki/ filenames
|
|
40
|
+
- raw/ filenames keep YYYY-MM-DD prefix
|
|
41
|
+
|
|
42
|
+
## Linking
|
|
43
|
+
- use [[wikilink]] to other wiki pages by slug
|
|
44
|
+
- every wiki page must link to >=1 other page or be flagged orphan
|
|
45
|
+
|
|
46
|
+
## Contradictions
|
|
47
|
+
- flag inline: > Contradiction: A vs B (see [[source-A]], [[source-B]])
|
|
48
|
+
- never silently merge; lint surfaces them for human resolution
|
|
49
|
+
|
|
50
|
+
## Hard rules
|
|
51
|
+
- never edit raw/, purpose.md, schema.md
|
|
52
|
+
- raw/ file sha drift = error, not a re-ingest trigger
|
|
53
|
+
- living-doc paths (in include[]) sha drift = MODIFIED, propose re-ingest
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const SUBDIRS = ['sources', 'entities', 'concepts', 'synthesis', 'queries'];
|
|
57
|
+
|
|
58
|
+
// What lives inside the wiki dir but must not enter git / Obsidian Sync:
|
|
59
|
+
// - qmd's project-local vector cache (sqlite-vec, non-deterministic, rebuilds
|
|
60
|
+
// locally with `rig wiki index` / `rig wiki rebuild`)
|
|
61
|
+
// - lint reports (auto-regenerated)
|
|
62
|
+
// - daemon proposal diffs (transient, per-machine)
|
|
63
|
+
// - editor scratch
|
|
64
|
+
const GITIGNORE_TMPL = `# rig wiki — local-only artifacts (do not commit)
|
|
65
|
+
# qmd vector cache (sqlite-vec, machine-specific, rebuildable)
|
|
66
|
+
.qmd/index.sqlite*
|
|
67
|
+
.qmd/*.sqlite-wal
|
|
68
|
+
.qmd/*.sqlite-shm
|
|
69
|
+
# auto-generated reports
|
|
70
|
+
lint-report-*.md
|
|
71
|
+
# daemon proposal queue (per-machine)
|
|
72
|
+
proposals/
|
|
73
|
+
# editor scratch
|
|
74
|
+
.DS_Store
|
|
75
|
+
*.swp
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
export default function wikiInit(givenPath?: string): void {
|
|
79
|
+
if (!givenPath || !givenPath.trim()) {
|
|
80
|
+
print.error('rig wiki init requires a target subdirectory.');
|
|
81
|
+
print.info('usage: rig wiki init <subdir> (e.g. `rig wiki init knowledge` / `rig wiki init harness/llm-wiki`)');
|
|
82
|
+
print.info('refusing to default to CWD — that would litter the project root with wiki templates.');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const root = path.resolve(givenPath);
|
|
86
|
+
const guard = guardPath(root, process.cwd());
|
|
87
|
+
if (!guard.ok) {
|
|
88
|
+
print.error('refusing to initialize a wiki at a hidden or gitignored path.');
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.error(refusalMessage(root, guard));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
fs.mkdirSync(root, { recursive: true });
|
|
94
|
+
|
|
95
|
+
writeIfMissing(path.join(root, 'purpose.md'), PURPOSE_TMPL);
|
|
96
|
+
writeIfMissing(path.join(root, 'schema.md'), SCHEMA_TMPL);
|
|
97
|
+
writeIfMissing(path.join(root, 'index.md'), '# Index\n');
|
|
98
|
+
writeIfMissing(path.join(root, 'overview.md'), '# Overview\n');
|
|
99
|
+
writeIfMissing(path.join(root, 'log.md'), '# Log\n');
|
|
100
|
+
writeIfMissing(path.join(root, 'reviews.md'), '# Reviews\n');
|
|
101
|
+
writeIfMissing(path.join(root, '.gitignore'), GITIGNORE_TMPL);
|
|
102
|
+
|
|
103
|
+
fs.mkdirSync(path.join(root, 'raw'), { recursive: true });
|
|
104
|
+
writeIfMissing(path.join(root, 'raw', '.gitkeep'), '');
|
|
105
|
+
|
|
106
|
+
for (const sub of SUBDIRS) {
|
|
107
|
+
const d = path.join(root, 'wiki', sub);
|
|
108
|
+
fs.mkdirSync(d, { recursive: true });
|
|
109
|
+
writeIfMissing(path.join(d, '.gitkeep'), '');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
print.succeed(`wiki initialized at ${root}`);
|
|
113
|
+
print.info(`next: edit purpose.md + schema.md, then \`rig wiki register ${shortPath(root)}\``);
|
|
114
|
+
print.info('on a new device, after cloning, run `rig wiki rebuild` to refresh local caches.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeIfMissing(file: string, content: string) {
|
|
118
|
+
if (fs.existsSync(file)) return;
|
|
119
|
+
fs.writeFileSync(file, content, 'utf8');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function shortPath(p: string) {
|
|
123
|
+
const home = process.env.HOME || '';
|
|
124
|
+
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
125
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import print from '../print';
|
|
4
|
+
import { paths } from './paths';
|
|
5
|
+
|
|
6
|
+
const BUNDLED_SKILLS = [
|
|
7
|
+
{ name: 'rig-wiki', file: 'RIG_WIKI_SKILL.md' },
|
|
8
|
+
{ name: 'rig-crew', file: 'RIG_CREW_SKILL.md' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/** Find rig's package root by walking up from built/ or lib/. */
|
|
12
|
+
function findRigRoot(): string | undefined {
|
|
13
|
+
// Walk up from `built/index.js` (prod) or `lib/wiki/installSkill.ts` (dev)
|
|
14
|
+
// looking for the rigjs package root.
|
|
15
|
+
let dir = __dirname;
|
|
16
|
+
for (let i = 0; i < 10; i++) {
|
|
17
|
+
const pkg = path.join(dir, 'package.json');
|
|
18
|
+
if (fs.existsSync(pkg)) {
|
|
19
|
+
try {
|
|
20
|
+
const p = JSON.parse(fs.readFileSync(pkg, 'utf8'));
|
|
21
|
+
if (p.name === 'rigjs') return dir;
|
|
22
|
+
} catch { /* keep walking */ }
|
|
23
|
+
}
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir) break;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface InstallOpts { force?: boolean; }
|
|
32
|
+
|
|
33
|
+
export default function wikiInstallSkill(opts: InstallOpts): void {
|
|
34
|
+
const root = findRigRoot();
|
|
35
|
+
if (!root) {
|
|
36
|
+
print.error('could not locate the rigjs install root. Reinstall rigjs?');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!fs.existsSync(paths.claudeSkillsDir)) {
|
|
41
|
+
print.error(`Claude Code skills dir not found: ${paths.claudeSkillsDir}`);
|
|
42
|
+
print.info('install Claude Code first, then re-run `rig wiki install-skill`.');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const skill of BUNDLED_SKILLS) {
|
|
47
|
+
const src = path.join(root, skill.file);
|
|
48
|
+
if (!fs.existsSync(src)) {
|
|
49
|
+
print.warn(`skipping ${skill.name}: ${skill.file} not found inside rigjs install`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
linkSkill(skill.name, src, opts);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
print.info('restart Claude Code to pick up new or updated skills.');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function linkSkill(name: string, src: string, opts: InstallOpts): void {
|
|
59
|
+
const targetDir = path.join(paths.claudeSkillsDir, name);
|
|
60
|
+
const target = path.join(targetDir, 'SKILL.md');
|
|
61
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
62
|
+
|
|
63
|
+
if (fs.existsSync(target) || isBrokenSymlink(target)) {
|
|
64
|
+
const existing = safeReadlink(target);
|
|
65
|
+
if (existing === src) {
|
|
66
|
+
print.info(`already linked: ${target}`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!opts.force) {
|
|
70
|
+
const what = existing ? `symlink -> ${existing}` : 'a regular file';
|
|
71
|
+
print.error(`${target} exists as ${what}. Pass --force to replace.`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
fs.rmSync(target, { force: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fs.symlinkSync(src, target);
|
|
78
|
+
print.succeed(`linked ${target} -> ${src}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function safeReadlink(p: string): string | null {
|
|
82
|
+
try { return fs.readlinkSync(p); } catch { return null; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isBrokenSymlink(p: string): boolean {
|
|
86
|
+
try {
|
|
87
|
+
fs.statSync(p);
|
|
88
|
+
return false;
|
|
89
|
+
} catch {
|
|
90
|
+
try { return Boolean(fs.readlinkSync(p)); } catch { return false; }
|
|
91
|
+
}
|
|
92
|
+
}
|
package/lib/wiki/lint.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// `rig wiki lint` — health-check a wiki.
|
|
2
|
+
//
|
|
3
|
+
// Checks:
|
|
4
|
+
// - every wiki/**/*.md has YAML frontmatter with required keys
|
|
5
|
+
// - source pages reference a real raw/ file and the recorded source-sha
|
|
6
|
+
// still matches (drift → MODIFIED, not an error in lint, just info)
|
|
7
|
+
// - [[wikilink]] targets exist (broken refs → severe)
|
|
8
|
+
// - orphan pages (linked-by 0, excluding sources/) → warn
|
|
9
|
+
// - reviews.md backlog count → warn
|
|
10
|
+
//
|
|
11
|
+
// Output: human-readable summary on stdout, full report at
|
|
12
|
+
// <wiki>/lint-report-YYYY-MM-DD.md.
|
|
13
|
+
// Exit 11 if any severe finding. Other findings are non-fatal.
|
|
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, WikiEntry } from './config';
|
|
20
|
+
import { recordLastRun } from './db';
|
|
21
|
+
|
|
22
|
+
interface LintOpts { wiki?: string; all?: boolean; json?: boolean; }
|
|
23
|
+
|
|
24
|
+
interface PageMeta {
|
|
25
|
+
rel: string; // wiki-relative path, e.g. "wiki/sources/foo.md"
|
|
26
|
+
slug: string; // filename without ext
|
|
27
|
+
sub: string; // "sources" | "entities" | "concepts" | "synthesis" | "queries"
|
|
28
|
+
frontmatter: Record<string, unknown> | null;
|
|
29
|
+
links: string[]; // [[wikilink]] targets
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Findings {
|
|
33
|
+
missingFrontmatter: string[];
|
|
34
|
+
missingRequiredKey: { rel: string; key: string }[];
|
|
35
|
+
brokenWikilinks: { rel: string; target: string }[];
|
|
36
|
+
missingRawSource: { rel: string; sourcePath: string }[];
|
|
37
|
+
shaDriftSource: { rel: string; oldSha: string; newSha: string }[];
|
|
38
|
+
orphanPages: string[];
|
|
39
|
+
reviewsBacklog: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REQUIRED_KEYS = ['type', 'sources', 'ingested-at', 'last-updated'] as const;
|
|
43
|
+
const SOURCE_EXTRA_KEYS = ['source-sha', 'source-path'] as const;
|
|
44
|
+
const WIKI_SUBDIRS = ['sources', 'entities', 'concepts', 'synthesis', 'queries'] as const;
|
|
45
|
+
|
|
46
|
+
export default async function wikiLint(opts: LintOpts): Promise<void> {
|
|
47
|
+
const cfg = loadWikiConfig();
|
|
48
|
+
const targets: WikiEntry[] = opts.all
|
|
49
|
+
? cfg.wikis
|
|
50
|
+
: [resolveWiki(cfg, opts.wiki)].filter(Boolean) as WikiEntry[];
|
|
51
|
+
if (targets.length === 0) {
|
|
52
|
+
print.error('no wiki resolved. Pass --wiki <name>, --all, or run from inside a registered project.');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let severeFound = false;
|
|
57
|
+
const reports: { wiki: string; findings: Findings }[] = [];
|
|
58
|
+
for (const t of targets) {
|
|
59
|
+
const findings = lintOne(t);
|
|
60
|
+
reports.push({ wiki: t.name, findings });
|
|
61
|
+
const sev =
|
|
62
|
+
findings.missingFrontmatter.length +
|
|
63
|
+
findings.missingRequiredKey.length +
|
|
64
|
+
findings.brokenWikilinks.length +
|
|
65
|
+
findings.missingRawSource.length;
|
|
66
|
+
if (sev > 0) severeFound = true;
|
|
67
|
+
if (!opts.json) printSummary(t.name, findings);
|
|
68
|
+
writeReport(t, findings);
|
|
69
|
+
recordLastRun(t.name, 'lint', sev > 0 ? 11 : 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (opts.json) {
|
|
73
|
+
// eslint-disable-next-line no-console
|
|
74
|
+
console.log(JSON.stringify({ ok: !severeFound, code: severeFound ? 11 : 0, data: reports }, null, 2));
|
|
75
|
+
}
|
|
76
|
+
if (severeFound) process.exit(11);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function lintOne(wiki: WikiEntry): Findings {
|
|
80
|
+
const f: Findings = {
|
|
81
|
+
missingFrontmatter: [],
|
|
82
|
+
missingRequiredKey: [],
|
|
83
|
+
brokenWikilinks: [],
|
|
84
|
+
missingRawSource: [],
|
|
85
|
+
shaDriftSource: [],
|
|
86
|
+
orphanPages: [],
|
|
87
|
+
reviewsBacklog: 0,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const pages: PageMeta[] = [];
|
|
91
|
+
for (const sub of WIKI_SUBDIRS) {
|
|
92
|
+
const dir = path.join(wiki.path, 'wiki', sub);
|
|
93
|
+
if (!fs.existsSync(dir)) continue;
|
|
94
|
+
for (const name of fs.readdirSync(dir)) {
|
|
95
|
+
if (!name.endsWith('.md') || name === '.gitkeep') continue;
|
|
96
|
+
const abs = path.join(dir, name);
|
|
97
|
+
pages.push(parsePage(wiki.path, abs, sub));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const slugToRel = new Map<string, string>();
|
|
102
|
+
for (const p of pages) slugToRel.set(p.slug, p.rel);
|
|
103
|
+
|
|
104
|
+
const linkedSlugs = new Set<string>();
|
|
105
|
+
for (const p of pages) {
|
|
106
|
+
if (!p.frontmatter) { f.missingFrontmatter.push(p.rel); continue; }
|
|
107
|
+
for (const k of REQUIRED_KEYS) {
|
|
108
|
+
if (!(k in p.frontmatter)) f.missingRequiredKey.push({ rel: p.rel, key: k });
|
|
109
|
+
}
|
|
110
|
+
if (p.sub === 'sources') {
|
|
111
|
+
for (const k of SOURCE_EXTRA_KEYS) {
|
|
112
|
+
if (!(k in p.frontmatter)) f.missingRequiredKey.push({ rel: p.rel, key: k });
|
|
113
|
+
}
|
|
114
|
+
const sourcePath = String(p.frontmatter['source-path'] || '');
|
|
115
|
+
if (sourcePath) {
|
|
116
|
+
const abs = path.isAbsolute(sourcePath) ? sourcePath : path.resolve(wiki.path, sourcePath);
|
|
117
|
+
if (!fs.existsSync(abs)) {
|
|
118
|
+
f.missingRawSource.push({ rel: p.rel, sourcePath });
|
|
119
|
+
} else {
|
|
120
|
+
const newSha = sha256(abs);
|
|
121
|
+
const oldSha = String(p.frontmatter['source-sha'] || '');
|
|
122
|
+
if (oldSha && oldSha !== newSha) f.shaDriftSource.push({ rel: p.rel, oldSha, newSha });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const target of p.links) {
|
|
127
|
+
if (!slugToRel.has(target)) {
|
|
128
|
+
f.brokenWikilinks.push({ rel: p.rel, target });
|
|
129
|
+
} else {
|
|
130
|
+
linkedSlugs.add(target);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Orphans: non-source pages that nothing else links to. Sources may
|
|
136
|
+
// legitimately have no inbound links until ingested into a synthesis.
|
|
137
|
+
for (const p of pages) {
|
|
138
|
+
if (p.sub === 'sources') continue;
|
|
139
|
+
if (!linkedSlugs.has(p.slug)) f.orphanPages.push(p.rel);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Reviews backlog: count non-empty bullet lines in reviews.md.
|
|
143
|
+
const reviewsPath = path.join(wiki.path, 'reviews.md');
|
|
144
|
+
if (fs.existsSync(reviewsPath)) {
|
|
145
|
+
const lines = fs.readFileSync(reviewsPath, 'utf8').split('\n');
|
|
146
|
+
f.reviewsBacklog = lines.filter(l => /^\s*[-*]\s+\S/.test(l)).length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return f;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parsePage(wikiRoot: string, abs: string, sub: string): PageMeta {
|
|
153
|
+
const rel = path.relative(wikiRoot, abs);
|
|
154
|
+
const slug = path.basename(abs, path.extname(abs));
|
|
155
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
156
|
+
const { frontmatter, body } = splitFrontmatter(content);
|
|
157
|
+
const links = extractWikilinks(body);
|
|
158
|
+
return { rel, slug, sub, frontmatter, links };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function splitFrontmatter(content: string): { frontmatter: Record<string, unknown> | null; body: string } {
|
|
162
|
+
if (!content.startsWith('---\n')) return { frontmatter: null, body: content };
|
|
163
|
+
const end = content.indexOf('\n---\n', 4);
|
|
164
|
+
if (end < 0) return { frontmatter: null, body: content };
|
|
165
|
+
const yaml = content.slice(4, end);
|
|
166
|
+
const body = content.slice(end + 5);
|
|
167
|
+
return { frontmatter: parseTinyYaml(yaml), body };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Minimal YAML parser — handles the rig wiki frontmatter shape only
|
|
171
|
+
// (key: scalar / key: [a, b, c]). Anything else returns null for that key.
|
|
172
|
+
function parseTinyYaml(src: string): Record<string, unknown> | null {
|
|
173
|
+
const out: Record<string, unknown> = {};
|
|
174
|
+
for (const line of src.split('\n')) {
|
|
175
|
+
const trimmed = line.replace(/#.*$/, '').trimEnd();
|
|
176
|
+
if (!trimmed) continue;
|
|
177
|
+
const m = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(trimmed);
|
|
178
|
+
if (!m) continue;
|
|
179
|
+
const [, key, valRaw] = m;
|
|
180
|
+
const val = valRaw.trim();
|
|
181
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
182
|
+
out[key] = val.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
183
|
+
} else {
|
|
184
|
+
out[key] = val.replace(/^['"]|['"]$/g, '');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function extractWikilinks(body: string): string[] {
|
|
191
|
+
const out: string[] = [];
|
|
192
|
+
const re = /\[\[([^\]|\n]+)(?:\|[^\]]*)?\]\]/g;
|
|
193
|
+
let m: RegExpExecArray | null;
|
|
194
|
+
while ((m = re.exec(body)) !== null) {
|
|
195
|
+
const slug = m[1].trim().split('#')[0].split('/').pop() || '';
|
|
196
|
+
if (slug) out.push(slug);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sha256(file: string): string {
|
|
202
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function printSummary(wikiName: string, f: Findings): void {
|
|
206
|
+
print.info(`lint: ${wikiName}`);
|
|
207
|
+
const lines: string[] = [];
|
|
208
|
+
if (f.missingFrontmatter.length) lines.push(` MISSING FRONTMATTER (${f.missingFrontmatter.length})`);
|
|
209
|
+
if (f.missingRequiredKey.length) lines.push(` MISSING REQUIRED KEY (${f.missingRequiredKey.length})`);
|
|
210
|
+
if (f.brokenWikilinks.length) lines.push(` BROKEN WIKILINKS (${f.brokenWikilinks.length})`);
|
|
211
|
+
if (f.missingRawSource.length) lines.push(` MISSING RAW SOURCE (${f.missingRawSource.length})`);
|
|
212
|
+
if (f.shaDriftSource.length) lines.push(` SOURCE SHA DRIFT (${f.shaDriftSource.length}) [info — propose re-ingest]`);
|
|
213
|
+
if (f.orphanPages.length) lines.push(` ORPHAN PAGES (${f.orphanPages.length}) [warn]`);
|
|
214
|
+
if (f.reviewsBacklog) lines.push(` REVIEWS BACKLOG (${f.reviewsBacklog} item${f.reviewsBacklog === 1 ? '' : 's'}) [warn]`);
|
|
215
|
+
if (lines.length === 0) {
|
|
216
|
+
print.succeed(' clean');
|
|
217
|
+
} else {
|
|
218
|
+
for (const l of lines) {
|
|
219
|
+
// eslint-disable-next-line no-console
|
|
220
|
+
console.log(l);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function writeReport(wiki: WikiEntry, f: Findings): void {
|
|
226
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
227
|
+
const out = path.join(wiki.path, `lint-report-${today}.md`);
|
|
228
|
+
const parts: string[] = [];
|
|
229
|
+
parts.push(`# lint report — ${wiki.name} — ${today}`);
|
|
230
|
+
parts.push('');
|
|
231
|
+
parts.push(`Generated by \`rig wiki lint\`. Severe sections trigger exit code 11.`);
|
|
232
|
+
parts.push('');
|
|
233
|
+
|
|
234
|
+
section(parts, 'Missing frontmatter (severe)', f.missingFrontmatter.map(r => `- ${r}`));
|
|
235
|
+
section(parts, 'Missing required key (severe)', f.missingRequiredKey.map(x => `- ${x.rel} — \`${x.key}\``));
|
|
236
|
+
section(parts, 'Broken wikilinks (severe)', f.brokenWikilinks.map(x => `- ${x.rel} → [[${x.target}]]`));
|
|
237
|
+
section(parts, 'Missing raw source (severe)', f.missingRawSource.map(x => `- ${x.rel} → ${x.sourcePath}`));
|
|
238
|
+
section(parts, 'Source sha drift (re-ingest recommended)', f.shaDriftSource.map(x => `- ${x.rel}\n - old: \`${x.oldSha.slice(0, 12)}…\`\n - new: \`${x.newSha.slice(0, 12)}…\``));
|
|
239
|
+
section(parts, 'Orphan pages', f.orphanPages.map(r => `- ${r}`));
|
|
240
|
+
parts.push(`## Reviews backlog\n\n${f.reviewsBacklog} item(s).`);
|
|
241
|
+
parts.push('');
|
|
242
|
+
|
|
243
|
+
fs.writeFileSync(out, parts.join('\n'), 'utf8');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function section(out: string[], title: string, items: string[]): void {
|
|
247
|
+
out.push(`## ${title}`);
|
|
248
|
+
out.push('');
|
|
249
|
+
if (items.length === 0) out.push('_(none)_');
|
|
250
|
+
else for (const item of items) out.push(item);
|
|
251
|
+
out.push('');
|
|
252
|
+
}
|
package/lib/wiki/list.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import print from '../print';
|
|
4
|
+
import { loadWikiConfig, loadRigConfig } from './config';
|
|
5
|
+
import { getLastRun } from './db';
|
|
6
|
+
import { detectQmd } from './qmd';
|
|
7
|
+
import { adapters } from './agent/registry';
|
|
8
|
+
|
|
9
|
+
export default async function wikiList(): Promise<void> {
|
|
10
|
+
const cfg = loadWikiConfig();
|
|
11
|
+
const rig = loadRigConfig();
|
|
12
|
+
if (cfg.wikis.length === 0) {
|
|
13
|
+
print.info('no wikis registered. Use `rig wiki register [<path>]` to add one.');
|
|
14
|
+
} else {
|
|
15
|
+
const rows = cfg.wikis.map(w => ({
|
|
16
|
+
name: w.name,
|
|
17
|
+
path: shortPath(w.path),
|
|
18
|
+
pages: countPages(w.path),
|
|
19
|
+
lastScan: fmtTs(getLastRun(w.name, 'scan')?.ts),
|
|
20
|
+
lastIngest: fmtTs(getLastRun(w.name, 'ingest')?.ts),
|
|
21
|
+
lastLint: fmtTs(getLastRun(w.name, 'lint')?.ts),
|
|
22
|
+
}));
|
|
23
|
+
const header = ['NAME', 'PATH', 'PAGES', 'LAST SCAN', 'LAST INGEST', 'LAST LINT'];
|
|
24
|
+
printTable(header, rows.map(r => [r.name, r.path, String(r.pages), r.lastScan, r.lastIngest, r.lastLint]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const qmd = detectQmd();
|
|
28
|
+
const defaultAgent = rig.wiki?.defaultAgent || 'claude';
|
|
29
|
+
const agentDetect = await adapters.find(a => a.name === defaultAgent)?.detect();
|
|
30
|
+
// eslint-disable-next-line no-console
|
|
31
|
+
console.log(`\nagent: ${defaultAgent}${agentDetect?.installed ? ` (${agentDetect.version || 'installed'})` : ' (NOT installed)'}` +
|
|
32
|
+
` qmd: ${qmd.installed ? qmd.version || 'installed' : 'not installed (fallback mode)'}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function countPages(wikiDir: string): number {
|
|
36
|
+
const wiki = path.join(wikiDir, 'wiki');
|
|
37
|
+
if (!fs.existsSync(wiki)) return 0;
|
|
38
|
+
let n = 0;
|
|
39
|
+
for (const sub of ['sources', 'entities', 'concepts', 'synthesis', 'queries']) {
|
|
40
|
+
const d = path.join(wiki, sub);
|
|
41
|
+
if (!fs.existsSync(d)) continue;
|
|
42
|
+
for (const f of fs.readdirSync(d)) if (f.endsWith('.md') && f !== '.gitkeep') n++;
|
|
43
|
+
}
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function shortPath(p: string): string {
|
|
48
|
+
const home = process.env.HOME || '';
|
|
49
|
+
return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fmtTs(ts?: number): string {
|
|
53
|
+
if (!ts) return '—';
|
|
54
|
+
const d = new Date(ts);
|
|
55
|
+
return d.toISOString().replace('T', ' ').slice(0, 16);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printTable(header: string[], rows: string[][]): void {
|
|
59
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map(r => (r[i] || '').length)));
|
|
60
|
+
const fmt = (cells: string[]) => cells.map((c, i) => (c || '').padEnd(widths[i])).join(' ');
|
|
61
|
+
// eslint-disable-next-line no-console
|
|
62
|
+
console.log(fmt(header));
|
|
63
|
+
// eslint-disable-next-line no-console
|
|
64
|
+
console.log(widths.map(w => '-'.repeat(w)).join(' '));
|
|
65
|
+
for (const r of rows) {
|
|
66
|
+
// eslint-disable-next-line no-console
|
|
67
|
+
console.log(fmt(r));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared guards for rig wiki — refuse to operate on hidden paths or
|
|
2
|
+
// .gitignored content. The user must explicitly copy such files into a
|
|
3
|
+
// visible, tracked location before they can become wiki sources.
|
|
4
|
+
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
7
|
+
|
|
8
|
+
export interface PathGuardResult {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
reason?: 'hidden' | 'gitignored';
|
|
11
|
+
segment?: string; // for hidden: the offending segment
|
|
12
|
+
detail?: string; // human-readable detail
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** True if any path segment (except `.` / `..`) starts with `.`. */
|
|
16
|
+
export function isHiddenPath(p: string): boolean {
|
|
17
|
+
for (const seg of segmentsOf(p)) {
|
|
18
|
+
if (seg.startsWith('.')) return true;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** First hidden segment, or null. */
|
|
24
|
+
export function hiddenSegment(p: string): string | null {
|
|
25
|
+
for (const seg of segmentsOf(p)) {
|
|
26
|
+
if (seg.startsWith('.')) return seg;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function segmentsOf(p: string): string[] {
|
|
32
|
+
return p.split(path.sep).filter(s => s && s !== '.' && s !== '..');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns true if the path is ignored by git in `repoCwd`'s repo.
|
|
37
|
+
* Returns false if tracked/non-ignored. Returns null if not in a git repo
|
|
38
|
+
* (so callers can pass the check transparently outside git contexts).
|
|
39
|
+
*/
|
|
40
|
+
export function isGitignored(p: string, repoCwd: string): boolean | null {
|
|
41
|
+
const r = spawnSync('git', ['check-ignore', '-q', '--', p], {
|
|
42
|
+
cwd: repoCwd,
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
});
|
|
45
|
+
if (r.status === 0) return true; // ignored
|
|
46
|
+
if (r.status === 1) return false; // not ignored
|
|
47
|
+
return null; // exit 128 (not a repo) or git missing
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate a path as a wiki source / target. Returns ok if visible AND
|
|
52
|
+
* not gitignored. Use for `init` target and `ingest` source.
|
|
53
|
+
*/
|
|
54
|
+
export function guardPath(absPath: string, repoCwd: string): PathGuardResult {
|
|
55
|
+
const seg = hiddenSegment(absPath);
|
|
56
|
+
if (seg) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
reason: 'hidden',
|
|
60
|
+
segment: seg,
|
|
61
|
+
detail: `path contains a hidden segment "${seg}"`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const gi = isGitignored(absPath, repoCwd);
|
|
65
|
+
if (gi === true) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: 'gitignored',
|
|
69
|
+
detail: '.gitignore matches this path',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function refusalMessage(target: string, r: PathGuardResult): string {
|
|
76
|
+
const lines = [
|
|
77
|
+
`refused: ${target}`,
|
|
78
|
+
` reason: ${r.reason} — ${r.detail}`,
|
|
79
|
+
'',
|
|
80
|
+
' rig wiki refuses to operate on hidden files/dirs or .gitignored content.',
|
|
81
|
+
' If you really need this content, copy it to a visible, tracked location first:',
|
|
82
|
+
'',
|
|
83
|
+
' cp -R <hidden-or-ignored> <wiki>/raw/manual-copy/ # then `rig wiki ingest`',
|
|
84
|
+
'',
|
|
85
|
+
];
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export const RIG_HOME = process.env.RIG_HOME || path.join(os.homedir(), '.rig');
|
|
5
|
+
|
|
6
|
+
export const paths = {
|
|
7
|
+
home: RIG_HOME,
|
|
8
|
+
config: path.join(RIG_HOME, 'config.json5'),
|
|
9
|
+
wikiConfig: path.join(RIG_HOME, 'wiki.config.json5'),
|
|
10
|
+
stateDb: path.join(RIG_HOME, 'state.db'),
|
|
11
|
+
locks: path.join(RIG_HOME, 'locks'),
|
|
12
|
+
logs: path.join(RIG_HOME, 'logs'),
|
|
13
|
+
daemonLog: path.join(RIG_HOME, 'logs', 'wiki-daemon.log'),
|
|
14
|
+
cache: path.join(RIG_HOME, 'cache'),
|
|
15
|
+
sandbox: path.join(RIG_HOME, 'cache', 'sandbox'),
|
|
16
|
+
launchAgent: path.join(os.homedir(), 'Library', 'LaunchAgents', 'ai.flashhand.rig.wiki.plist'),
|
|
17
|
+
claudeSkillsDir: path.join(os.homedir(), '.claude', 'skills'),
|
|
18
|
+
builtinSkillRelative: 'RIG_WIKI_SKILL.md',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const daemonLabel = 'ai.flashhand.rig.wiki';
|
|
22
|
+
|
|
23
|
+
export function wikiLogDir(wikiName: string) {
|
|
24
|
+
return path.join(paths.logs, 'wikis', wikiName);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function wikiLockFile(wikiName: string) {
|
|
28
|
+
return path.join(paths.locks, `${wikiName}.lock`);
|
|
29
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import print from '../print';
|
|
2
|
+
|
|
3
|
+
export function requireMacOS(): void {
|
|
4
|
+
if (process.platform !== 'darwin') {
|
|
5
|
+
print.error(`rig wiki currently supports macOS only (detected: ${process.platform}). Linux support is on the P5 roadmap; Windows is not planned.`);
|
|
6
|
+
process.exit(32);
|
|
7
|
+
}
|
|
8
|
+
}
|