@msareen/knowledge-hub-builder 0.1.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.
- package/.agents/skills/catalog/SKILL.md +7 -0
- package/.agents/skills/export/SKILL.md +7 -0
- package/.agents/skills/ingest/SKILL.md +7 -0
- package/.agents/skills/lint/SKILL.md +7 -0
- package/.agents/skills/new-bundle/SKILL.md +7 -0
- package/.agents/skills/query/SKILL.md +7 -0
- package/.agents/skills/visualize/SKILL.md +7 -0
- package/.bundle_template/index.md +9 -0
- package/.bundle_template/log.md +10 -0
- package/.bundle_template/raw/.gitkeep +15 -0
- package/.bundle_template/refs.md +6 -0
- package/.bundle_template/sources.yaml +13 -0
- package/.claude/skills/catalog/SKILL.md +7 -0
- package/.claude/skills/export/SKILL.md +7 -0
- package/.claude/skills/ingest/SKILL.md +7 -0
- package/.claude/skills/lint/SKILL.md +7 -0
- package/.claude/skills/new-bundle/SKILL.md +7 -0
- package/.claude/skills/query/SKILL.md +7 -0
- package/.claude/skills/visualize/SKILL.md +7 -0
- package/AGENTS.md +167 -0
- package/CLAUDE.md +13 -0
- package/README.md +289 -0
- package/SPEC.md +354 -0
- package/document/faq.md +156 -0
- package/package.json +52 -0
- package/scripts/cli.ts +66 -0
- package/scripts/export.ts +42 -0
- package/scripts/ingest/acquire.ts +189 -0
- package/scripts/ingest/exts.ts +29 -0
- package/scripts/ingest/files.ts +29 -0
- package/scripts/ingest/folder.ts +44 -0
- package/scripts/ingest/index.ts +125 -0
- package/scripts/ingest/protect.ts +42 -0
- package/scripts/ingest/web.ts +54 -0
- package/scripts/init.ts +93 -0
- package/scripts/lib/args.ts +8 -0
- package/scripts/lib/extract.ts +384 -0
- package/scripts/lib/graph-page.ts +477 -0
- package/scripts/lib/graph.ts +117 -0
- package/scripts/lib/ledger.ts +136 -0
- package/scripts/lib/log.ts +53 -0
- package/scripts/lib/paths.ts +55 -0
- package/scripts/lib/scaffold.ts +56 -0
- package/scripts/lib/util.ts +154 -0
- package/scripts/lint.ts +165 -0
- package/scripts/new-bundle.ts +17 -0
- package/scripts/visualize.ts +104 -0
- package/skills/catalog/SKILL.md +164 -0
- package/skills/export/SKILL.md +31 -0
- package/skills/ingest/SKILL.md +229 -0
- package/skills/lint/SKILL.md +69 -0
- package/skills/new-bundle/SKILL.md +25 -0
- package/skills/query/SKILL.md +114 -0
- package/skills/visualize/SKILL.md +33 -0
- package/templates/hub/gitattributes +12 -0
- package/templates/hub/gitignore +12 -0
- package/templates/hub/outer.index.md +13 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// log.md — the durable ingest ledger (OKF-reserved filename, so lint never treats it
|
|
2
|
+
// as a concept doc). Committed, unlike raw/, so it survives raw/ being deleted and
|
|
3
|
+
// re-derived. Columns: source | sha256 | fetched | raw | curated.
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export type Entry = {
|
|
8
|
+
source: string; // canonical URI of the origin: absolute path, url, or tool query
|
|
9
|
+
sha256: string; // content hash — drives skip-unchanged and cross-bundle dedup
|
|
10
|
+
fetched: string; // ISO timestamp of last acquisition
|
|
11
|
+
raw: string; // bundle-relative raw/ path, or "" when extraction is still pending
|
|
12
|
+
curated: string; // concept doc(s) distilled from it, or "" when not yet curated
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const HEADER = `# {{name}} — ingest log
|
|
16
|
+
|
|
17
|
+
Ingestion ledger, one row per source. \`khb ingest\` maintains \`source\`, \`sha256\`,
|
|
18
|
+
\`fetched\` and \`raw\`; the agent fills \`curated\` while cataloging (skills/catalog/SKILL.md).
|
|
19
|
+
|
|
20
|
+
Empty \`raw\` = seen but not extracted (protected, unreadable, or skipped by a flag).
|
|
21
|
+
Empty \`curated\` = in raw/ but not yet distilled into a concept doc.
|
|
22
|
+
|
|
23
|
+
| source | sha256 | fetched | raw | curated |
|
|
24
|
+
|---|---|---|---|---|
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
const unwrap = (s: string) => s.trim().replace(/^`|`$/g, "").trim();
|
|
28
|
+
const wrap = (s: string) => (s ? `\`${s.replaceAll("|", "\\|")}\`` : "");
|
|
29
|
+
|
|
30
|
+
export function ledgerPath(bundleDir: string) {
|
|
31
|
+
return join(bundleDir, "log.md");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse log.md into entries keyed by source. Missing file = empty ledger. */
|
|
35
|
+
export function readLedger(bundleDir: string): Map<string, Entry> {
|
|
36
|
+
const p = ledgerPath(bundleDir);
|
|
37
|
+
const out = new Map<string, Entry>();
|
|
38
|
+
if (!existsSync(p)) return out;
|
|
39
|
+
for (const line of readFileSync(p, "utf8").split("\n")) {
|
|
40
|
+
if (!line.startsWith("|")) continue;
|
|
41
|
+
const cells = line.split("|").slice(1, -1).map(unwrap);
|
|
42
|
+
if (cells.length < 5) continue;
|
|
43
|
+
if (cells[0] === "source" || /^-+$/.test(cells[0])) continue; // header / separator
|
|
44
|
+
const [source, sha256, fetched, raw, curated] = cells;
|
|
45
|
+
out.set(source, { source, sha256, fetched, raw, curated });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Rewrite log.md, preserving any prose above the table. */
|
|
51
|
+
export function writeLedger(bundleDir: string, entries: Map<string, Entry>, bundleName: string) {
|
|
52
|
+
const p = ledgerPath(bundleDir);
|
|
53
|
+
const existing = existsSync(p) ? readFileSync(p, "utf8") : HEADER.replaceAll("{{name}}", bundleName);
|
|
54
|
+
const preamble = existing.split("\n").filter((l) => !l.startsWith("|")).join("\n").trimEnd();
|
|
55
|
+
const rows = [...entries.values()]
|
|
56
|
+
.sort((a, b) => a.source.localeCompare(b.source))
|
|
57
|
+
.map((e) => `| ${wrap(e.source)} | ${wrap(e.sha256.slice(0, 12))} | ${e.fetched} | ${wrap(e.raw)} | ${wrap(e.curated)} |`);
|
|
58
|
+
const table = ["", "| source | sha256 | fetched | raw | curated |", "|---|---|---|---|---|", ...rows, ""];
|
|
59
|
+
writeFileSync(p, preamble + "\n" + table.join("\n"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Upsert, preserving the agent-owned `curated` column. Returns the merged entry.
|
|
64
|
+
*/
|
|
65
|
+
export function record(entries: Map<string, Entry>, e: Omit<Entry, "curated">): Entry {
|
|
66
|
+
const merged = { ...e, curated: entries.get(e.source)?.curated ?? "" };
|
|
67
|
+
entries.set(e.source, merged);
|
|
68
|
+
return merged;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True when this source is already acquired at this exact content hash and its raw/
|
|
73
|
+
* file is still on disk — the skip condition for incremental re-ingest.
|
|
74
|
+
*/
|
|
75
|
+
export function isFresh(entries: Map<string, Entry>, bundleDir: string, source: string, hash: string) {
|
|
76
|
+
const e = entries.get(source);
|
|
77
|
+
return !!e && e.sha256.startsWith(hash.slice(0, 12)) && !!e.raw && existsSync(join(bundleDir, e.raw));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A ledger row acquired from a local file, as opposed to a URL or a tool query. */
|
|
81
|
+
const isLocalSource = (s: string) => !/^[a-z][a-z0-9+.-]*:\/\//i.test(s);
|
|
82
|
+
|
|
83
|
+
export type Identity =
|
|
84
|
+
| { kind: "moved"; from: Entry } // same bytes, old path gone — one file, renamed
|
|
85
|
+
| { kind: "copy"; twin: Entry } // same bytes, old path still there — two files
|
|
86
|
+
| { kind: "ambiguous"; twins: Entry[] } // several rows share these bytes; do not guess
|
|
87
|
+
| { kind: "new" };
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decide what a not-yet-seen source path actually *is*, by content hash.
|
|
91
|
+
*
|
|
92
|
+
* Keying the ledger on the path alone means moving a file reads as a deletion plus an
|
|
93
|
+
* unrelated arrival: a second raw/ file with identical bytes, a second row with an empty
|
|
94
|
+
* `curated`, and so a second concept for material already cataloged. The bytes are the
|
|
95
|
+
* identity; the path is just where they happen to live today.
|
|
96
|
+
*
|
|
97
|
+
* Only local-file rows are candidates — a URL cannot have been "moved" on this disk — and
|
|
98
|
+
* a row is only a move if its old path is genuinely gone. Two live paths with the same
|
|
99
|
+
* bytes are a copy, which is a real (if redundant) second source and the agent's call, not
|
|
100
|
+
* ours. Several candidates at once is ambiguous, and guessing there would silently rewire
|
|
101
|
+
* provenance, so we report and leave it alone.
|
|
102
|
+
*/
|
|
103
|
+
export function identify(entries: Map<string, Entry>, bundleDir: string, source: string, hash: string): Identity {
|
|
104
|
+
if (entries.has(source)) return { kind: "new" }; // known path: freshness, not identity
|
|
105
|
+
const twins = [...entries.values()].filter(
|
|
106
|
+
(e) =>
|
|
107
|
+
e.source !== source &&
|
|
108
|
+
isLocalSource(e.source) &&
|
|
109
|
+
e.sha256.startsWith(hash.slice(0, 12)) &&
|
|
110
|
+
!!e.raw &&
|
|
111
|
+
existsSync(join(bundleDir, e.raw)),
|
|
112
|
+
);
|
|
113
|
+
if (!twins.length) return { kind: "new" };
|
|
114
|
+
const orphaned = twins.filter((e) => !existsSync(e.source));
|
|
115
|
+
if (orphaned.length === 1) return { kind: "moved", from: orphaned[0] };
|
|
116
|
+
if (orphaned.length > 1) return { kind: "ambiguous", twins: orphaned };
|
|
117
|
+
return { kind: "copy", twin: twins[0] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Re-point an existing row at the file's new path, keeping its raw/ file and — the whole
|
|
122
|
+
* point — its `curated` value, so cataloged material is not offered as backlog again.
|
|
123
|
+
*
|
|
124
|
+
* The raw/ filename deliberately does *not* change: concept docs cite raw paths in their
|
|
125
|
+
* Citations sections, and renaming the file underneath them would break those links to
|
|
126
|
+
* cosmetically match a path that is already recorded inside the file's own provenance
|
|
127
|
+
* header. The header is what gets corrected.
|
|
128
|
+
*/
|
|
129
|
+
export function adopt(entries: Map<string, Entry>, from: Entry, source: string): Entry {
|
|
130
|
+
entries.delete(from.source);
|
|
131
|
+
const moved = { ...from, source };
|
|
132
|
+
entries.set(source, moved);
|
|
133
|
+
return moved;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { HEADER as LEDGER_HEADER };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Progress reporting for the long-running commands.
|
|
2
|
+
//
|
|
3
|
+
// Ingest is mostly waiting, and it waits inside libraries that say nothing: a scanned PDF is
|
|
4
|
+
// seconds per page, a video is minutes per file. A silent process is indistinguishable from
|
|
5
|
+
// a hung one, and after the fact "which file produced that?" is unanswerable. So every unit
|
|
6
|
+
// of work announces itself BEFORE the work starts, carrying its position in the run, and
|
|
7
|
+
// closes with what happened and how long it took.
|
|
8
|
+
//
|
|
9
|
+
// Verbose is the only mode on purpose. There is no --quiet: the per-file line is the audit
|
|
10
|
+
// trail for a pass that rewrites a bundle's raw/, and a run whose output you have to re-run
|
|
11
|
+
// to reconstruct is worse than a noisy one. Nothing here changes what khb does.
|
|
12
|
+
|
|
13
|
+
const RUN_START = Date.now();
|
|
14
|
+
let itemStart = RUN_START;
|
|
15
|
+
|
|
16
|
+
export const secs = (ms: number) => `${(ms / 1000).toFixed(1)}s`;
|
|
17
|
+
|
|
18
|
+
/** Wall-clock time since the process started — the closing line of a command. */
|
|
19
|
+
export const totalElapsed = () => secs(Date.now() - RUN_START);
|
|
20
|
+
|
|
21
|
+
/** "[ 3/57]" — the counter is right-aligned so filenames stay in one column. */
|
|
22
|
+
export function pos(i: number, n: number): string {
|
|
23
|
+
return `[${String(i).padStart(String(n).length)}/${n}]`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A blank-line-separated heading: a source, a bundle, a phase. */
|
|
27
|
+
export function section(title: string) {
|
|
28
|
+
console.log(`\n${title}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Indented context under a heading — settings, counts, where things are going. */
|
|
32
|
+
export function detail(msg: string) {
|
|
33
|
+
console.log(` ${msg}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The unit of work about to start. Prints before any work happens — that is the whole
|
|
38
|
+
* point — and starts this item's clock.
|
|
39
|
+
*/
|
|
40
|
+
export function item(prefix: string, label: string) {
|
|
41
|
+
itemStart = Date.now();
|
|
42
|
+
console.log(` ${prefix} ${label}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A step inside the current item: what khb is about to do, or what it just learned. */
|
|
46
|
+
export function note(msg: string) {
|
|
47
|
+
console.log(` ${msg}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** How the current item ended, with its own elapsed time. Exactly one per item. */
|
|
51
|
+
export function outcome(msg: string) {
|
|
52
|
+
console.log(` ${msg} (${secs(Date.now() - itemStart)})`);
|
|
53
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Package-side paths. Importing this must never require a hub to exist — `khb init`
|
|
2
|
+
// runs before there is one. Hub-side paths live in util.ts.
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
/** Root of the installed @msareen/knowledge-hub-builder package (NOT the user's hub). */
|
|
8
|
+
export const PKG = fileURLToPath(new URL("../..", import.meta.url));
|
|
9
|
+
|
|
10
|
+
export const TEMPLATE = join(PKG, ".bundle_template");
|
|
11
|
+
export const HUB_TEMPLATE = join(PKG, "templates", "hub");
|
|
12
|
+
|
|
13
|
+
/** Marker file that identifies a hub root; `khb` walks up from cwd looking for it. */
|
|
14
|
+
export const MARKER = "khb.json";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Marker names used by earlier versions. Still recognised when resolving a hub —
|
|
18
|
+
* otherwise a hub created before the rename becomes invisible to every command,
|
|
19
|
+
* `khb upgrade` included, and there is no way in from the CLI. `khb upgrade` renames
|
|
20
|
+
* the file it finds to MARKER, so each hub carries a legacy name at most once.
|
|
21
|
+
*/
|
|
22
|
+
export const LEGACY_MARKERS = ["bkr.json"];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The marker file present in `dir`, if it is a hub at all. Lives here rather than in
|
|
26
|
+
* util.ts because `khb init` needs it *before* a hub exists, and importing util.ts
|
|
27
|
+
* resolves a hub or exits.
|
|
28
|
+
*/
|
|
29
|
+
export const markerIn = (dir: string): string | undefined =>
|
|
30
|
+
[MARKER, ...LEGACY_MARKERS].find((m) => existsSync(join(dir, m)));
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Package-owned files copied into every hub by `khb init` and refreshed by
|
|
34
|
+
* `khb upgrade`. These are the agent contract — the hub needs its own copies so an
|
|
35
|
+
* agent opened on the hub folder can read them without knowing where khb is installed.
|
|
36
|
+
* Anything here is overwritten on upgrade, so users must not edit them.
|
|
37
|
+
*/
|
|
38
|
+
export const MANAGED = [
|
|
39
|
+
"AGENTS.md",
|
|
40
|
+
"CLAUDE.md",
|
|
41
|
+
"SPEC.md",
|
|
42
|
+
"skills",
|
|
43
|
+
".agents/skills",
|
|
44
|
+
".claude/skills",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Files that used to be MANAGED and no longer are. `khb upgrade` deletes them from the
|
|
49
|
+
* hub — a stale copy left behind states an older contract than the one now shipping,
|
|
50
|
+
* and an agent has no way to tell which is current. Only ever list package-owned names.
|
|
51
|
+
*/
|
|
52
|
+
export const RETIRED = ["AGENT.md", "query.md", "ingest.md", "lint.md"];
|
|
53
|
+
|
|
54
|
+
export const version = (): string =>
|
|
55
|
+
JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version ?? "0.0.0";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Bundle creation, shared by `khb new-bundle` and by ingest's default-bundle fallback.
|
|
2
|
+
// One implementation so a bundle born from a bare `khb ingest` is indistinguishable from
|
|
3
|
+
// one the user named: same template, same {{name}} substitution, same outer.index.md row.
|
|
4
|
+
import { cpSync, readFileSync, writeFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
5
|
+
import { HUB, BUNDLES, TEMPLATE, join } from "./util";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_BUNDLE = "default";
|
|
8
|
+
|
|
9
|
+
export const VALID_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
10
|
+
|
|
11
|
+
/** Scaffold bundles/<name>/ from the template and register it in outer.index.md. */
|
|
12
|
+
export function createBundle(name: string, scope: string): string {
|
|
13
|
+
const dest = join(BUNDLES, name);
|
|
14
|
+
cpSync(TEMPLATE, dest, { recursive: true });
|
|
15
|
+
|
|
16
|
+
// fill {{name}} placeholders
|
|
17
|
+
const walk = (d: string): string[] =>
|
|
18
|
+
readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
|
|
19
|
+
for (const f of walk(dest)) {
|
|
20
|
+
const c = readFileSync(f, "utf8");
|
|
21
|
+
if (c.includes("{{name}}")) writeFileSync(f, c.replaceAll("{{name}}", name));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// register in outer.index.md (append to first table)
|
|
25
|
+
const outerPath = join(HUB, "outer.index.md");
|
|
26
|
+
const lines = readFileSync(outerPath, "utf8").split("\n");
|
|
27
|
+
const row = `| [${name}](bundles/${name}/index.md) | ${scope} | TODO |`;
|
|
28
|
+
let lastTableRow = -1;
|
|
29
|
+
for (let i = 0; i < lines.length; i++) if (lines[i].startsWith("|")) lastTableRow = i;
|
|
30
|
+
lines.splice(lastTableRow + 1, 0, row);
|
|
31
|
+
writeFileSync(outerPath, lines.join("\n"));
|
|
32
|
+
|
|
33
|
+
return dest;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the bundle to ingest into, creating `default` if that is the target and it does
|
|
38
|
+
* not exist yet. A hub with no bundles must still have somewhere for bytes to land — the
|
|
39
|
+
* alternative is refusing the first ingest anyone ever runs. Only `default` is ever
|
|
40
|
+
* conjured this way: a misspelled explicit name is a mistake, not a request to scaffold.
|
|
41
|
+
*/
|
|
42
|
+
export function bundleForIngest(name: string): string {
|
|
43
|
+
const dir = join(BUNDLES, name);
|
|
44
|
+
if (existsSync(dir)) return dir;
|
|
45
|
+
if (name !== DEFAULT_BUNDLE) {
|
|
46
|
+
console.error(`No such bundle: ${name}`);
|
|
47
|
+
console.error(`Create it: khb new-bundle ${name} "<scope>"`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
// The scope line lands in outer.index.md, where every agent reads it — so it must not
|
|
51
|
+
// read as an instruction to reorganize the hub. Splitting `default` into real bundles is
|
|
52
|
+
// the user's call, exactly like any other bundle decision.
|
|
53
|
+
createBundle(DEFAULT_BUNDLE, "Unsorted material — where an ingest with no named bundle lands; moves out when you say which bundle owns it");
|
|
54
|
+
console.log(`Created bundles/${DEFAULT_BUNDLE}/ — the landing bundle for unrouted material.`);
|
|
55
|
+
return dir;
|
|
56
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { join, basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { MARKER, markerIn } from "./paths";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Find the hub root — the folder holding khb.json, outer.index.md and bundles/.
|
|
8
|
+
* It is the user's knowledge, and lives wherever they put it; the khb package holds
|
|
9
|
+
* no knowledge of its own. Precedence: $KHB_HUB (set from --hub by cli.ts) > nearest
|
|
10
|
+
* ancestor of cwd containing the marker.
|
|
11
|
+
*/
|
|
12
|
+
function resolveHub(): string {
|
|
13
|
+
const explicit = process.env.KHB_HUB;
|
|
14
|
+
if (explicit) {
|
|
15
|
+
const dir = resolve(explicit);
|
|
16
|
+
if (!markerIn(dir)) {
|
|
17
|
+
console.error(`Not a KHB hub (no ${MARKER}): ${dir}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
for (let dir = process.cwd(); ; ) {
|
|
23
|
+
if (markerIn(dir)) return dir;
|
|
24
|
+
const up = dirname(dir);
|
|
25
|
+
if (up === dir) break;
|
|
26
|
+
dir = up;
|
|
27
|
+
}
|
|
28
|
+
console.error(`No KHB hub found in ${process.cwd()} or any parent directory.`);
|
|
29
|
+
console.error(`Create one: khb init <dir>`);
|
|
30
|
+
console.error(`Or point at an existing one: khb --hub <dir> <command> (or set $KHB_HUB)`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const HUB = resolveHub();
|
|
35
|
+
export const BUNDLES = join(HUB, "bundles");
|
|
36
|
+
export const INBOX = join(HUB, "inbox");
|
|
37
|
+
export { TEMPLATE, markerIn } from "./paths";
|
|
38
|
+
|
|
39
|
+
export function listBundles(): string[] {
|
|
40
|
+
if (!existsSync(BUNDLES)) return [];
|
|
41
|
+
return readdirSync(BUNDLES).filter((d) => statSync(join(BUNDLES, d)).isDirectory());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function read(path: string): string {
|
|
45
|
+
return readFileSync(path, "utf8");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** All markdown link targets in a file: [text](target) */
|
|
49
|
+
export function mdLinks(md: string): { text: string; target: string }[] {
|
|
50
|
+
const out: { text: string; target: string }[] = [];
|
|
51
|
+
for (const m of md.matchAll(/\[([^\]]*)\]\(([^)]+)\)/g)) out.push({ text: m[1], target: m[2] });
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Bundle names mentioned in a refs.md table (first column). */
|
|
56
|
+
export function refTargets(refsMd: string): string[] {
|
|
57
|
+
const out: string[] = [];
|
|
58
|
+
for (const line of refsMd.split("\n")) {
|
|
59
|
+
const m = line.match(/^\|\s*\[?([a-z0-9][a-z0-9-]*)\]?[^|]*\|/);
|
|
60
|
+
if (m && !["bundle", "---"].includes(m[1])) out.push(m[1]);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Provenance recorded on every raw/ file. `source` is the whole point of the phase:
|
|
67
|
+
* extraction is sometimes lossy, so curation must always be able to walk back to the
|
|
68
|
+
* original bytes. `tool` and `quality` say how much to trust what follows — `low` means
|
|
69
|
+
* OCR or a transcript guessed at it, and re-reading the source is the remedy.
|
|
70
|
+
*/
|
|
71
|
+
export type RawMeta = {
|
|
72
|
+
source: string;
|
|
73
|
+
sha256?: string;
|
|
74
|
+
tool?: string;
|
|
75
|
+
quality?: "high" | "low";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const safeRawName = (name: string) => name.replace(/[^\w.-]+/g, "_") || "source.md";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Keep the readable filename unless another source already owns it. The suffix is based on
|
|
82
|
+
* source identity, not content, so identical bytes acquired from two origins retain honest
|
|
83
|
+
* provenance instead of overwriting each other.
|
|
84
|
+
*/
|
|
85
|
+
export function rawNameFor(
|
|
86
|
+
dir: string,
|
|
87
|
+
name: string,
|
|
88
|
+
source: string,
|
|
89
|
+
entries: Iterable<{ source: string; raw: string }>,
|
|
90
|
+
): string {
|
|
91
|
+
const safe = safeRawName(name);
|
|
92
|
+
const rel = `raw/${basename(dir)}/${safe}`;
|
|
93
|
+
const owner = [...entries].find((e) => e.raw === rel);
|
|
94
|
+
if ((!owner || owner.source === source) && (owner || !existsSync(join(dir, safe)))) return safe;
|
|
95
|
+
const stem = safe.toLowerCase().endsWith(".md") ? safe.slice(0, -3) : safe;
|
|
96
|
+
return `${stem}--${sha256(source).slice(0, 12)}.md`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Write a raw/ file with provenance front matter. Returns its bundle-relative path.
|
|
101
|
+
* Silent by design — the caller owns the per-file line, and knows the verb ("copied",
|
|
102
|
+
* "extracted", "transcribed") that a write on its own cannot.
|
|
103
|
+
*/
|
|
104
|
+
export function writeRaw(dir: string, name: string, meta: RawMeta, body: string): string {
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
const safe = safeRawName(name);
|
|
107
|
+
const fm =
|
|
108
|
+
`---\nsource: ${JSON.stringify(meta.source)}\nfetched: ${new Date().toISOString()}\n` +
|
|
109
|
+
(meta.sha256 ? `sha256: ${meta.sha256}\n` : "") +
|
|
110
|
+
(meta.tool ? `extract_tool: ${JSON.stringify(meta.tool)}\n` : "") +
|
|
111
|
+
(meta.quality ? `quality: ${meta.quality}\n` : "") +
|
|
112
|
+
`---\n\n`;
|
|
113
|
+
writeFileSync(join(dir, safe), fm + body);
|
|
114
|
+
// dir is <bundle>/raw/<type>; report the path as the ledger stores it
|
|
115
|
+
const type = basename(dir);
|
|
116
|
+
return `raw/${type}/${safe}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Correct the `source:` line of an already-written raw/ file, for when the same bytes turn
|
|
121
|
+
* up at a new path. Only that one line moves: the body is unchanged by definition (same
|
|
122
|
+
* hash), and rewriting the file wholesale would churn a diff for no reason.
|
|
123
|
+
*/
|
|
124
|
+
export function retargetRaw(bundleDir: string, rawRel: string, source: string): boolean {
|
|
125
|
+
const p = join(bundleDir, rawRel);
|
|
126
|
+
if (!existsSync(p)) return false;
|
|
127
|
+
const text = readFileSync(p, "utf8");
|
|
128
|
+
const end = text.indexOf("\n---", 3);
|
|
129
|
+
if (!text.startsWith("---\n") || end === -1) return false; // no provenance header to fix
|
|
130
|
+
const head = text.slice(0, end).replace(/^source:.*$/m, `source: ${JSON.stringify(source)}`);
|
|
131
|
+
writeFileSync(p, head + text.slice(end));
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const sha256 = (buf: Buffer | string) => createHash("sha256").update(buf).digest("hex");
|
|
136
|
+
|
|
137
|
+
/** Hash a file in chunks — corpora contain multi-GB binaries we must not slurp. */
|
|
138
|
+
export async function sha256File(path: string): Promise<string> {
|
|
139
|
+
const h = createHash("sha256");
|
|
140
|
+
const stream = (await import("node:fs")).createReadStream(path, { highWaterMark: 1 << 20 });
|
|
141
|
+
for await (const chunk of stream) h.update(chunk as Buffer);
|
|
142
|
+
return h.digest("hex");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function bundleDir(name: string): string {
|
|
146
|
+
const dir = join(BUNDLES, name);
|
|
147
|
+
if (!existsSync(dir)) {
|
|
148
|
+
console.error(`No such bundle: ${name}`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
return dir;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { join, basename, existsSync, mkdirSync };
|
package/scripts/lint.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// khb lint — enforce skills/lint/SKILL.md (structural rules + OKF v0.1 conformance) across the hub
|
|
2
|
+
import { HUB, BUNDLES, listBundles, read, mdLinks, refTargets, join, existsSync } from "./lib/util";
|
|
3
|
+
import { detail, section, totalElapsed } from "./lib/log";
|
|
4
|
+
import { readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { dirname, relative } from "node:path";
|
|
6
|
+
import { parse as parseYaml } from "yaml";
|
|
7
|
+
|
|
8
|
+
/** OKF v0.1 concept frontmatter. Unknown keys are warned, not rejected — OKF is permissive,
|
|
9
|
+
* but a `titel:` typo silently loses the field, so it is worth one line of noise. */
|
|
10
|
+
const OKF_FIELDS = new Set(["type", "title", "description", "resource", "tags", "timestamp"]);
|
|
11
|
+
|
|
12
|
+
/** Accepts a YAML-parsed Date (unquoted) or an ISO-8601 string (quoted). */
|
|
13
|
+
const isTimestamp = (v: unknown) =>
|
|
14
|
+
v instanceof Date ? !isNaN(v.getTime()) : typeof v === "string" && !isNaN(Date.parse(v));
|
|
15
|
+
|
|
16
|
+
let errors = 0, warnings = 0;
|
|
17
|
+
const err = (rule: string, msg: string) => { errors++; console.error(`ERROR ${rule}: ${msg}`); };
|
|
18
|
+
const warn = (rule: string, msg: string) => { warnings++; console.warn(`warn ${rule}: ${msg}`); };
|
|
19
|
+
|
|
20
|
+
const stripComments = (md: string) => md.replace(/<!--[\s\S]*?-->/g, "");
|
|
21
|
+
const RESERVED = ["index.md", "log.md", "refs.md"]; // refs.md is KHB-reserved
|
|
22
|
+
const bundles = listBundles();
|
|
23
|
+
const outerIndex = read(join(HUB, "outer.index.md"));
|
|
24
|
+
|
|
25
|
+
/** All files under dir (relative paths), skipping raw/. */
|
|
26
|
+
function walk(dir: string, base = dir): string[] {
|
|
27
|
+
return readdirSync(dir).flatMap((f) => {
|
|
28
|
+
const p = join(dir, f);
|
|
29
|
+
if (statSync(p).isDirectory()) return f === "raw" ? [] : walk(p, base);
|
|
30
|
+
return [relative(base, p).replaceAll("\\", "/")];
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(`khb lint → ${HUB}`);
|
|
35
|
+
detail(`${bundles.length} bundle(s): ${bundles.join(", ") || "none"}`);
|
|
36
|
+
|
|
37
|
+
for (const [bi, b] of bundles.entries()) {
|
|
38
|
+
const dir = join(BUNDLES, b);
|
|
39
|
+
// Name the bundle before its findings: an unattributed "ERROR L4" in a fifty-bundle hub
|
|
40
|
+
// sends you grepping, and a clean bundle should still show that it was actually checked.
|
|
41
|
+
section(`[${bi + 1}/${bundles.length}] ${b}`);
|
|
42
|
+
|
|
43
|
+
// L2 name
|
|
44
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(b)) err("L2", `bad bundle name '${b}'`);
|
|
45
|
+
|
|
46
|
+
// L1 required files
|
|
47
|
+
for (const f of ["index.md", "refs.md", "sources.yaml"])
|
|
48
|
+
if (!existsSync(join(dir, f))) err("L1", `${b}: missing ${f}`);
|
|
49
|
+
|
|
50
|
+
// L3 registered in outer index
|
|
51
|
+
if (!outerIndex.includes(`bundles/${b}/`)) err("L3", `${b}: not listed in outer.index.md`);
|
|
52
|
+
|
|
53
|
+
const files = existsSync(dir) ? walk(dir) : [];
|
|
54
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
55
|
+
const concepts = mdFiles.filter((f) => !RESERVED.includes(f.split("/").pop()!));
|
|
56
|
+
const indexes = mdFiles.filter((f) => f.split("/").pop() === "index.md");
|
|
57
|
+
detail(`${concepts.length} concept doc(s), ${indexes.length} index file(s)`);
|
|
58
|
+
|
|
59
|
+
// Collect all index link targets, resolved to bundle-relative paths
|
|
60
|
+
const indexed = new Set<string>();
|
|
61
|
+
for (const idx of indexes) {
|
|
62
|
+
const md = stripComments(read(join(dir, idx)));
|
|
63
|
+
for (const l of mdLinks(md)) {
|
|
64
|
+
if (l.target.startsWith("http")) continue;
|
|
65
|
+
const resolved = l.target.startsWith("/")
|
|
66
|
+
? l.target.slice(1)
|
|
67
|
+
: relative(dir, join(dir, dirname(idx), l.target)).replaceAll("\\", "/");
|
|
68
|
+
indexed.add(resolved.replace(/\/$/, ""));
|
|
69
|
+
// L4b index links resolve (warning — OKF tolerates not-yet-written knowledge)
|
|
70
|
+
if (!existsSync(join(dir, resolved)))
|
|
71
|
+
warn("L4", `${b}: ${idx} links to missing ${resolved}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// L4a every concept is indexed somewhere
|
|
76
|
+
for (const c of concepts)
|
|
77
|
+
if (!indexed.has(c)) err("L4", `${b}: ${c} not listed in any index.md`);
|
|
78
|
+
|
|
79
|
+
for (const c of concepts) {
|
|
80
|
+
const body = read(join(dir, c));
|
|
81
|
+
|
|
82
|
+
// L9 OKF conformance: frontmatter must parse and carry a usable field set.
|
|
83
|
+
// Frontmatter is the machine-readable half of a concept — routing, filtering and any
|
|
84
|
+
// future index generator read it — so a typo'd key is a silent data loss, not a style nit.
|
|
85
|
+
const fm = body.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
|
86
|
+
if (fm === undefined) err("L9", `${b}: ${c} has no YAML frontmatter (OKF requires it)`);
|
|
87
|
+
else {
|
|
88
|
+
let meta: Record<string, unknown> | undefined;
|
|
89
|
+
try {
|
|
90
|
+
meta = (parseYaml(fm) ?? {}) as Record<string, unknown>;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
err("L9", `${b}: ${c} frontmatter is not valid YAML — ${(e as Error).message.split("\n")[0]}`);
|
|
93
|
+
}
|
|
94
|
+
if (meta) {
|
|
95
|
+
const str = (k: string) => (typeof meta![k] === "string" ? (meta![k] as string).trim() : "");
|
|
96
|
+
// type is the one OKF hard requirement; the rest degrade to warnings so an
|
|
97
|
+
// in-progress hub still lints clean while its authors fill things in.
|
|
98
|
+
if (!str("type")) err("L9", `${b}: ${c} frontmatter missing required 'type'`);
|
|
99
|
+
for (const k of ["title", "description"])
|
|
100
|
+
if (!str(k)) warn("L9", `${b}: ${c} frontmatter missing '${k}'`);
|
|
101
|
+
if ("tags" in meta && !Array.isArray(meta.tags))
|
|
102
|
+
err("L9", `${b}: ${c} 'tags' must be a YAML list, not ${typeof meta.tags}`);
|
|
103
|
+
if (Array.isArray(meta.tags) && meta.tags.some((t) => typeof t !== "string"))
|
|
104
|
+
err("L9", `${b}: ${c} 'tags' must contain only strings`);
|
|
105
|
+
if ("timestamp" in meta && !isTimestamp(meta.timestamp))
|
|
106
|
+
warn("L9", `${b}: ${c} 'timestamp' is not an ISO-8601 datetime`);
|
|
107
|
+
for (const k of Object.keys(meta))
|
|
108
|
+
if (!OKF_FIELDS.has(k)) warn("L9", `${b}: ${c} unknown frontmatter key '${k}'`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// L6 no cross-bundle links from concept docs
|
|
113
|
+
for (const l of mdLinks(stripComments(body))) {
|
|
114
|
+
if (/(^|\/)bundles\//.test(l.target) || l.target.startsWith("../../"))
|
|
115
|
+
err("L6", `${b}: ${c} links into another bundle (${l.target}) — use refs.md`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// L7 ref targets exist
|
|
120
|
+
if (existsSync(join(dir, "refs.md"))) {
|
|
121
|
+
for (const t of refTargets(read(join(dir, "refs.md"))))
|
|
122
|
+
if (!bundles.includes(t)) err("L7", `${b}: refs.md targets missing bundle '${t}'`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// L8 raw provenance (warning)
|
|
126
|
+
const rawDir = join(dir, "raw");
|
|
127
|
+
if (existsSync(rawDir)) {
|
|
128
|
+
const rawFiles = (readdirSync(rawDir, { recursive: true }) as string[]).filter((f) => f.endsWith(".md"));
|
|
129
|
+
detail(`${rawFiles.length} raw/ file(s) checked for provenance`);
|
|
130
|
+
for (const f of readdirSync(rawDir, { recursive: true }) as string[]) {
|
|
131
|
+
try {
|
|
132
|
+
if (!f.endsWith(".md")) continue;
|
|
133
|
+
const head = read(join(rawDir, f));
|
|
134
|
+
const rfm = head.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
|
135
|
+
if (rfm === undefined) { warn("L8", `${b}: raw/${f} missing provenance header`); continue; }
|
|
136
|
+
// `source` is the whole point of the header: it is how a bad extraction gets re-read.
|
|
137
|
+
if (!/^source:\s*\S/m.test(rfm)) warn("L8", `${b}: raw/${f} provenance missing 'source'`);
|
|
138
|
+
const q = rfm.match(/^quality:\s*(\S+)/m)?.[1];
|
|
139
|
+
if (q && q !== "high" && q !== "low")
|
|
140
|
+
warn("L8", `${b}: raw/${f} quality '${q}' is not high|low`);
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// L3 reverse: outer index entries exist
|
|
147
|
+
for (const l of mdLinks(outerIndex)) {
|
|
148
|
+
const m = l.target.match(/^bundles\/([a-z0-9-]+)\//);
|
|
149
|
+
if (m && !bundles.includes(m[1])) err("L3", `outer.index.md lists missing bundle '${m[1]}'`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// L5 index prose check (rough): paragraph-length prose in index files
|
|
153
|
+
function proseCheck(name: string, md: string) {
|
|
154
|
+
for (const block of stripComments(md).split(/\n\s*\n/)) {
|
|
155
|
+
const t = block.trim();
|
|
156
|
+
if (!t || /^[#|\-*]/.test(t) || t.startsWith("---")) continue;
|
|
157
|
+
if (t.split(/\s+/).length > 30) warn("L5", `${name}: paragraph-length prose in an index file`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
proseCheck("outer.index.md", outerIndex);
|
|
161
|
+
for (const b of bundles)
|
|
162
|
+
if (existsSync(join(BUNDLES, b, "index.md"))) proseCheck(`${b}/index.md`, read(join(BUNDLES, b, "index.md")));
|
|
163
|
+
|
|
164
|
+
console.log(`\nlint: ${errors} error(s), ${warnings} warning(s) across ${bundles.length} bundle(s) in ${totalElapsed()}`);
|
|
165
|
+
process.exit(errors ? 1 : 0);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// khb new-bundle <name> ["scope line"] — scaffold from .bundle_template + register.
|
|
2
|
+
// Template comes from the package; the bundle lands in the hub.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { BUNDLES, join } from "./lib/util";
|
|
5
|
+
import { createBundle, VALID_NAME } from "./lib/scaffold";
|
|
6
|
+
|
|
7
|
+
const [name, scope = "TODO scope"] = process.argv.slice(2);
|
|
8
|
+
if (!name || !VALID_NAME.test(name)) {
|
|
9
|
+
console.error("Usage: khb new-bundle <name> [scope] (lowercase, digits, hyphens)");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
if (existsSync(join(BUNDLES, name))) { console.error(`Bundle '${name}' already exists`); process.exit(1); }
|
|
13
|
+
|
|
14
|
+
createBundle(name, scope);
|
|
15
|
+
|
|
16
|
+
console.log(`Created bundles/${name}/ and registered it in outer.index.md`);
|
|
17
|
+
console.log("Next: set its scope line in outer.index.md, add sources to sources.yaml, run: khb lint");
|