@msareen/knowledge-hub-builder 0.2.1 → 0.2.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.md +4 -2
- package/README.md +69 -11
- package/SPEC.md +35 -8
- package/package.json +3 -1
- package/scripts/cli.ts +46 -25
- package/scripts/config.ts +229 -0
- package/scripts/doctor.ts +206 -0
- package/scripts/export.ts +8 -4
- package/scripts/hubs.ts +185 -93
- package/scripts/ingest/acquire.ts +1 -1
- package/scripts/ingest/folder.ts +2 -1
- package/scripts/ingest/index.ts +21 -13
- package/scripts/init.ts +29 -13
- package/scripts/lib/color.ts +75 -0
- package/scripts/lib/config-check.ts +364 -0
- package/scripts/lib/extract.ts +36 -4
- package/scripts/lib/log.ts +4 -2
- package/scripts/lib/registry.ts +20 -1
- package/scripts/lib/relocate.ts +1 -1
- package/scripts/lib/upgrade.ts +17 -5
- package/scripts/lib/util.ts +1 -1
- package/scripts/lint.ts +185 -77
- package/scripts/new-bundle.ts +9 -4
- package/scripts/visualize.ts +7 -6
- package/skills/ingest/SKILL.md +1 -1
- package/skills/lint/SKILL.md +24 -2
- package/templates/hub/gitignore +2 -2
package/scripts/lib/upgrade.ts
CHANGED
|
@@ -32,16 +32,28 @@ export function hubVersion(hub: string): string | undefined {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Copy every package-owned contract file into the hub, replacing what is there.
|
|
37
|
+
*
|
|
38
|
+
* A file is skipped when source and destination are the same path. That is not a corner
|
|
39
|
+
* case: the khb development repo is its own hub (see the `note` in this repo's `khb.json`),
|
|
40
|
+
* so `PKG` and `hub` are one directory there and every managed path resolves to itself.
|
|
41
|
+
* `cpSync` rejects that outright with EINVAL, which turned any version drift in the dev
|
|
42
|
+
* repo into a crash on *every* in-hub command — the working copy is already the source of
|
|
43
|
+
* truth, so the honest answer is that there is nothing to copy.
|
|
44
|
+
*/
|
|
36
45
|
export function syncManaged(hub: string): string[] {
|
|
37
46
|
const done: string[] = [];
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
const same = (left: string, right: string) =>
|
|
48
|
+
process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
49
|
+
for (const managed of MANAGED) {
|
|
50
|
+
const src = join(PKG, managed);
|
|
40
51
|
if (!existsSync(src)) continue;
|
|
41
|
-
const dest = join(hub,
|
|
52
|
+
const dest = join(hub, managed);
|
|
53
|
+
if (same(resolve(src), resolve(dest))) continue;
|
|
42
54
|
mkdirSync(dirname(dest), { recursive: true });
|
|
43
55
|
cpSync(src, dest, { recursive: true, force: true });
|
|
44
|
-
done.push(statSync(src).isDirectory() ? `${
|
|
56
|
+
done.push(statSync(src).isDirectory() ? `${managed}/` : managed);
|
|
45
57
|
}
|
|
46
58
|
return done;
|
|
47
59
|
}
|
package/scripts/lib/util.ts
CHANGED
|
@@ -25,7 +25,7 @@ function resolveHub(): string {
|
|
|
25
25
|
|
|
26
26
|
export const HUB = resolveHub();
|
|
27
27
|
export const BUNDLES = join(HUB, "bundles");
|
|
28
|
-
export const
|
|
28
|
+
export const INGEST_CACHE = join(HUB, ".ingest-cache");
|
|
29
29
|
export { TEMPLATE, markerIn } from "./paths";
|
|
30
30
|
|
|
31
31
|
export function listBundles(): string[] {
|
package/scripts/lint.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// khb lint — enforce skills/lint/SKILL.md (structural rules + OKF v0.1 conformance) across the hub
|
|
2
2
|
import { HUB, BUNDLES, listBundles, read, mdLinks, refTargets, join, existsSync } from "./lib/util";
|
|
3
|
+
import { readLedger } from "./lib/ledger";
|
|
3
4
|
import { detail, section, totalElapsed } from "./lib/log";
|
|
4
5
|
import { readdirSync, statSync } from "node:fs";
|
|
5
6
|
import { dirname, relative } from "node:path";
|
|
6
7
|
import { parse as parseYaml } from "yaml";
|
|
7
8
|
import { rejectUnknownFlags } from "./lib/args";
|
|
9
|
+
import { paint, paintErr } from "./lib/color";
|
|
8
10
|
|
|
9
11
|
rejectUnknownFlags(process.argv.slice(2), "khb lint");
|
|
10
12
|
|
|
@@ -13,156 +15,262 @@ rejectUnknownFlags(process.argv.slice(2), "khb lint");
|
|
|
13
15
|
const OKF_FIELDS = new Set(["type", "title", "description", "resource", "tags", "timestamp"]);
|
|
14
16
|
|
|
15
17
|
/** Accepts a YAML-parsed Date (unquoted) or an ISO-8601 string (quoted). */
|
|
16
|
-
const isTimestamp = (
|
|
17
|
-
|
|
18
|
+
const isTimestamp = (value: unknown) =>
|
|
19
|
+
value instanceof Date
|
|
20
|
+
? !isNaN(value.getTime())
|
|
21
|
+
: typeof value === "string" && !isNaN(Date.parse(value));
|
|
18
22
|
|
|
19
23
|
let errors = 0, warnings = 0;
|
|
20
|
-
const err = (rule: string, msg: string) => { errors++; console.error(
|
|
21
|
-
const warn = (rule: string, msg: string) => { warnings++; console.warn(
|
|
24
|
+
const err = (rule: string, msg: string) => { errors++; console.error(`${paintErr.bad("ERROR")} ${paintErr.name(rule)}: ${msg}`); };
|
|
25
|
+
const warn = (rule: string, msg: string) => { warnings++; console.warn(`${paintErr.warn("warn ")} ${paintErr.name(rule)}: ${msg}`); };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Drop everything that is markup *about* markdown rather than markdown: HTML comments, and
|
|
29
|
+
* every code span or fenced block.
|
|
30
|
+
*
|
|
31
|
+
* Code has to go before any link is extracted. A doc explaining the index form writes
|
|
32
|
+
* `` `* [Title](path.md) - description` `` as an example, and a link rule that cannot tell
|
|
33
|
+
* an example from a link reports it as a dead one — which in a project whose concept docs
|
|
34
|
+
* document its own conventions is a false positive on exactly the docs most worth writing.
|
|
35
|
+
* The same reasoning covers L6: a code sample *showing* a forbidden cross-bundle link is
|
|
36
|
+
* teaching the rule, not breaking it.
|
|
37
|
+
*
|
|
38
|
+
* One pattern handles spans and fences alike: a run of N backticks closes on the next run
|
|
39
|
+
* of exactly N, so ``` fences and the `` `…` `` form that quotes inner backticks both pair
|
|
40
|
+
* correctly. An unbalanced backtick simply fails to match and leaves the text alone.
|
|
41
|
+
*/
|
|
42
|
+
const stripNonProse = (markdown: string) =>
|
|
43
|
+
markdown.replace(/<!--[\s\S]*?-->/g, "").replace(/(`+)[\s\S]*?\1/g, "");
|
|
22
44
|
|
|
23
|
-
const stripComments = (md: string) => md.replace(/<!--[\s\S]*?-->/g, "");
|
|
24
45
|
const RESERVED = ["index.md", "log.md", "refs.md"]; // refs.md is KHB-reserved
|
|
25
46
|
const bundles = listBundles();
|
|
26
47
|
const outerIndex = read(join(HUB, "outer.index.md"));
|
|
27
48
|
|
|
28
49
|
/** All files under dir (relative paths), skipping raw/. */
|
|
29
50
|
function walk(dir: string, base = dir): string[] {
|
|
30
|
-
return readdirSync(dir).flatMap((
|
|
31
|
-
const
|
|
32
|
-
if (statSync(
|
|
33
|
-
return [relative(base,
|
|
51
|
+
return readdirSync(dir).flatMap((entry: string) => {
|
|
52
|
+
const path = join(dir, entry);
|
|
53
|
+
if (statSync(path).isDirectory()) return entry === "raw" ? [] : walk(path, base);
|
|
54
|
+
return [relative(base, path).replaceAll("\\", "/")];
|
|
34
55
|
});
|
|
35
56
|
}
|
|
36
57
|
|
|
37
|
-
|
|
58
|
+
/**
|
|
59
|
+
* A markdown link target resolved to a path relative to the bundle root, or undefined when
|
|
60
|
+
* it names nothing in this bundle's files: an external URL, a `mailto:`, or a bare `#anchor`
|
|
61
|
+
* pointing inside the linking document itself.
|
|
62
|
+
*
|
|
63
|
+
* `/from/bundle/root.md` is the form AGENTS.md prefers; anything else is relative to the
|
|
64
|
+
* file doing the linking. A trailing `#section` names a place *within* the target, not a
|
|
65
|
+
* different file, so it is dropped before the path is resolved — without that, every
|
|
66
|
+
* `[text](concept.md#heading)` reads as a link to a file that does not exist.
|
|
67
|
+
*/
|
|
68
|
+
function resolveLink(bundleRoot: string, fromRelative: string, target: string): string | undefined {
|
|
69
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) return undefined;
|
|
70
|
+
const path = target.split("#")[0].split("?")[0].trim();
|
|
71
|
+
if (!path) return undefined;
|
|
72
|
+
const resolved = path.startsWith("/")
|
|
73
|
+
? path.slice(1)
|
|
74
|
+
: relative(bundleRoot, join(bundleRoot, dirname(fromRelative), path)).replaceAll("\\", "/");
|
|
75
|
+
return resolved.replace(/\/$/, "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log(`${paint.head("khb lint")} → ${paint.path(HUB)}`);
|
|
38
79
|
detail(`${bundles.length} bundle(s): ${bundles.join(", ") || "none"}`);
|
|
39
80
|
|
|
40
|
-
for (const [
|
|
41
|
-
const dir = join(BUNDLES,
|
|
81
|
+
for (const [bundleIndex, bundle] of bundles.entries()) {
|
|
82
|
+
const dir = join(BUNDLES, bundle);
|
|
42
83
|
// Name the bundle before its findings: an unattributed "ERROR L4" in a fifty-bundle hub
|
|
43
84
|
// sends you grepping, and a clean bundle should still show that it was actually checked.
|
|
44
|
-
section(`[${
|
|
85
|
+
section(`[${bundleIndex + 1}/${bundles.length}] ${bundle}`);
|
|
45
86
|
|
|
46
87
|
// L2 name
|
|
47
|
-
if (!/^[a-z0-9][a-z0-9-]*$/.test(
|
|
88
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(bundle)) err("L2", `bad bundle name '${bundle}'`);
|
|
48
89
|
|
|
49
90
|
// L1 required files
|
|
50
|
-
for (const
|
|
51
|
-
if (!existsSync(join(dir,
|
|
91
|
+
for (const required of ["index.md", "refs.md", "sources.yaml"])
|
|
92
|
+
if (!existsSync(join(dir, required))) err("L1", `${bundle}: missing ${required}`);
|
|
52
93
|
|
|
53
94
|
// L3 registered in outer index
|
|
54
|
-
if (!outerIndex.includes(`bundles/${
|
|
95
|
+
if (!outerIndex.includes(`bundles/${bundle}/`))
|
|
96
|
+
err("L3", `${bundle}: not listed in outer.index.md`);
|
|
55
97
|
|
|
56
98
|
const files = existsSync(dir) ? walk(dir) : [];
|
|
57
|
-
const mdFiles = files.filter((
|
|
58
|
-
const concepts = mdFiles.filter((
|
|
59
|
-
const indexes = mdFiles.filter((
|
|
99
|
+
const mdFiles = files.filter((file) => file.endsWith(".md"));
|
|
100
|
+
const concepts = mdFiles.filter((file) => !RESERVED.includes(file.split("/").pop()!));
|
|
101
|
+
const indexes = mdFiles.filter((file) => file.split("/").pop() === "index.md");
|
|
60
102
|
detail(`${concepts.length} concept doc(s), ${indexes.length} index file(s)`);
|
|
61
103
|
|
|
62
104
|
// Collect all index link targets, resolved to bundle-relative paths
|
|
63
105
|
const indexed = new Set<string>();
|
|
64
|
-
for (const
|
|
65
|
-
const
|
|
66
|
-
for (const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
: relative(dir, join(dir, dirname(idx), l.target)).replaceAll("\\", "/");
|
|
71
|
-
indexed.add(resolved.replace(/\/$/, ""));
|
|
106
|
+
for (const indexFile of indexes) {
|
|
107
|
+
const markdown = stripNonProse(read(join(dir, indexFile)));
|
|
108
|
+
for (const link of mdLinks(markdown)) {
|
|
109
|
+
const resolved = resolveLink(dir, indexFile, link.target);
|
|
110
|
+
if (resolved === undefined) continue;
|
|
111
|
+
indexed.add(resolved);
|
|
72
112
|
// L4b index links resolve (warning — OKF tolerates not-yet-written knowledge)
|
|
73
113
|
if (!existsSync(join(dir, resolved)))
|
|
74
|
-
warn("L4", `${
|
|
114
|
+
warn("L4", `${bundle}: ${indexFile} links to missing ${resolved}`);
|
|
75
115
|
}
|
|
76
116
|
}
|
|
77
117
|
|
|
78
118
|
// L4a every concept is indexed somewhere
|
|
79
|
-
for (const
|
|
80
|
-
if (!indexed.has(
|
|
119
|
+
for (const concept of concepts)
|
|
120
|
+
if (!indexed.has(concept)) err("L4", `${bundle}: ${concept} not listed in any index.md`);
|
|
81
121
|
|
|
82
|
-
for (const
|
|
83
|
-
const body = read(join(dir,
|
|
122
|
+
for (const concept of concepts) {
|
|
123
|
+
const body = read(join(dir, concept));
|
|
84
124
|
|
|
85
125
|
// L9 OKF conformance: frontmatter must parse and carry a usable field set.
|
|
86
126
|
// Frontmatter is the machine-readable half of a concept — routing, filtering and any
|
|
87
127
|
// future index generator read it — so a typo'd key is a silent data loss, not a style nit.
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
128
|
+
const frontmatter = body.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
|
129
|
+
if (frontmatter === undefined)
|
|
130
|
+
err("L9", `${bundle}: ${concept} has no YAML frontmatter (OKF requires it)`);
|
|
90
131
|
else {
|
|
91
132
|
let meta: Record<string, unknown> | undefined;
|
|
92
133
|
try {
|
|
93
|
-
meta = (parseYaml(
|
|
94
|
-
} catch (
|
|
95
|
-
err(
|
|
134
|
+
meta = (parseYaml(frontmatter) ?? {}) as Record<string, unknown>;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
err(
|
|
137
|
+
"L9",
|
|
138
|
+
`${bundle}: ${concept} frontmatter is not valid YAML — ${(error as Error).message.split("\n")[0]}`,
|
|
139
|
+
);
|
|
96
140
|
}
|
|
97
141
|
if (meta) {
|
|
98
|
-
const str = (
|
|
142
|
+
const str = (key: string) =>
|
|
143
|
+
typeof meta![key] === "string" ? (meta![key] as string).trim() : "";
|
|
99
144
|
// type is the one OKF hard requirement; the rest degrade to warnings so an
|
|
100
145
|
// in-progress hub still lints clean while its authors fill things in.
|
|
101
|
-
if (!str("type")) err("L9", `${
|
|
102
|
-
for (const
|
|
103
|
-
if (!str(
|
|
146
|
+
if (!str("type")) err("L9", `${bundle}: ${concept} frontmatter missing required 'type'`);
|
|
147
|
+
for (const key of ["title", "description"])
|
|
148
|
+
if (!str(key)) warn("L9", `${bundle}: ${concept} frontmatter missing '${key}'`);
|
|
104
149
|
if ("tags" in meta && !Array.isArray(meta.tags))
|
|
105
|
-
err("L9", `${
|
|
106
|
-
if (Array.isArray(meta.tags) && meta.tags.some((
|
|
107
|
-
err("L9", `${
|
|
150
|
+
err("L9", `${bundle}: ${concept} 'tags' must be a YAML list, not ${typeof meta.tags}`);
|
|
151
|
+
if (Array.isArray(meta.tags) && meta.tags.some((tag) => typeof tag !== "string"))
|
|
152
|
+
err("L9", `${bundle}: ${concept} 'tags' must contain only strings`);
|
|
108
153
|
if ("timestamp" in meta && !isTimestamp(meta.timestamp))
|
|
109
|
-
warn("L9", `${
|
|
110
|
-
for (const
|
|
111
|
-
if (!OKF_FIELDS.has(
|
|
154
|
+
warn("L9", `${bundle}: ${concept} 'timestamp' is not an ISO-8601 datetime`);
|
|
155
|
+
for (const key of Object.keys(meta))
|
|
156
|
+
if (!OKF_FIELDS.has(key)) warn("L9", `${bundle}: ${concept} unknown frontmatter key '${key}'`);
|
|
112
157
|
}
|
|
113
158
|
}
|
|
114
159
|
|
|
115
160
|
// L6 no cross-bundle links from concept docs
|
|
116
|
-
for (const
|
|
117
|
-
if (/(^|\/)bundles\//.test(
|
|
118
|
-
err("L6", `${
|
|
161
|
+
for (const link of mdLinks(stripNonProse(body))) {
|
|
162
|
+
if (/(^|\/)bundles\//.test(link.target) || link.target.startsWith("../../")) {
|
|
163
|
+
err("L6", `${bundle}: ${concept} links into another bundle (${link.target}) — use refs.md`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// L11 in-bundle concept links resolve. Concepts link to each other as the bundle's
|
|
167
|
+
// actual structure — the catalog cross-link pass and the query skill's back-links to
|
|
168
|
+
// a synthesis's sources both live in these links, and a synthesis nobody can reach
|
|
169
|
+
// from its sources is a dead end. Only the index side of this was ever checked.
|
|
170
|
+
// A warning, like L4b and for the same reason: a link to a concept somebody intends
|
|
171
|
+
// to write next is not-yet-written knowledge, which OKF tolerates by design.
|
|
172
|
+
const resolved = resolveLink(dir, concept, link.target);
|
|
173
|
+
if (resolved !== undefined && !existsSync(join(dir, resolved)))
|
|
174
|
+
warn("L11", `${bundle}: ${concept} links to missing ${resolved}`);
|
|
119
175
|
}
|
|
120
176
|
}
|
|
121
177
|
|
|
122
178
|
// L7 ref targets exist
|
|
123
179
|
if (existsSync(join(dir, "refs.md"))) {
|
|
124
|
-
for (const
|
|
125
|
-
if (!bundles.includes(
|
|
180
|
+
for (const target of refTargets(read(join(dir, "refs.md"))))
|
|
181
|
+
if (!bundles.includes(target)) err("L7", `${bundle}: refs.md targets missing bundle '${target}'`);
|
|
126
182
|
}
|
|
127
183
|
|
|
128
|
-
//
|
|
184
|
+
// Enumerated once, bundle-relative (`raw/<type>/<file>.md`) — the spelling the ledger
|
|
185
|
+
// stores, so L10 can compare the two sides without renormalizing on every row.
|
|
129
186
|
const rawDir = join(dir, "raw");
|
|
130
|
-
|
|
131
|
-
|
|
187
|
+
const rawFiles = existsSync(rawDir)
|
|
188
|
+
? (readdirSync(rawDir, { recursive: true }) as string[])
|
|
189
|
+
.filter((file) => file.endsWith(".md"))
|
|
190
|
+
.map((file) => `raw/${file.replaceAll("\\", "/")}`)
|
|
191
|
+
: [];
|
|
192
|
+
|
|
193
|
+
// L8 raw provenance (warning)
|
|
194
|
+
if (rawFiles.length) {
|
|
132
195
|
detail(`${rawFiles.length} raw/ file(s) checked for provenance`);
|
|
133
|
-
for (const
|
|
196
|
+
for (const rawFile of rawFiles) {
|
|
134
197
|
try {
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
198
|
+
const head = read(join(dir, rawFile));
|
|
199
|
+
const provenance = head.match(/^---\n([\s\S]*?)\n---/)?.[1];
|
|
200
|
+
if (provenance === undefined) {
|
|
201
|
+
warn("L8", `${bundle}: ${rawFile} missing provenance header`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
139
204
|
// `source` is the whole point of the header: it is how a bad extraction gets re-read.
|
|
140
|
-
if (!/^source:\s*\S/m.test(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
205
|
+
if (!/^source:\s*\S/m.test(provenance))
|
|
206
|
+
warn("L8", `${bundle}: ${rawFile} provenance missing 'source'`);
|
|
207
|
+
const quality = provenance.match(/^quality:\s*(\S+)/m)?.[1];
|
|
208
|
+
if (quality && quality !== "high" && quality !== "low")
|
|
209
|
+
warn("L8", `${bundle}: ${rawFile} quality '${quality}' is not high|low`);
|
|
144
210
|
} catch {}
|
|
145
211
|
}
|
|
146
212
|
}
|
|
213
|
+
|
|
214
|
+
// L10 ledger integrity. log.md is the durable record across both halves of the workflow,
|
|
215
|
+
// and its empty `curated` cells *are* the catalog backlog — but nothing has ever checked
|
|
216
|
+
// that its paths still name anything, so a concept renamed after cataloging leaves a row
|
|
217
|
+
// claiming work that can no longer be found, and neither side notices.
|
|
218
|
+
const ledger = readLedger(dir);
|
|
219
|
+
if (ledger.size) {
|
|
220
|
+
detail(`${ledger.size} log.md row(s) checked`);
|
|
221
|
+
const claimed = new Set<string>();
|
|
222
|
+
for (const row of ledger.values()) {
|
|
223
|
+
if (row.raw) {
|
|
224
|
+
claimed.add(row.raw);
|
|
225
|
+
// raw/ is gitignored and re-derivable, so a hub that was cloned rather than ingested
|
|
226
|
+
// legitimately has every row and no files at all. Only hold a row to its raw file
|
|
227
|
+
// once raw/ has actually been populated; an empty one is that ordinary state.
|
|
228
|
+
if (rawFiles.length && !existsSync(join(dir, row.raw)))
|
|
229
|
+
warn("L10", `${bundle}: log.md row '${row.source}' names missing ${row.raw}`);
|
|
230
|
+
}
|
|
231
|
+
// `declined` is the documented way to close a row without writing a concept
|
|
232
|
+
// (skills/catalog/SKILL.md §5); anything else is a path the row claims to have written.
|
|
233
|
+
// An error, unlike the link rules: there is no not-yet-written case here, since the
|
|
234
|
+
// column is only filled once the concept exists.
|
|
235
|
+
if (row.curated && row.curated !== "declined") {
|
|
236
|
+
const curatedPaths = row.curated.split(",").map((path) => path.trim()).filter(Boolean);
|
|
237
|
+
for (const curated of curatedPaths)
|
|
238
|
+
if (!existsSync(join(dir, curated)))
|
|
239
|
+
err("L10", `${bundle}: log.md row '${row.source}' claims missing concept ${curated}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// An extracted file no row names is invisible work: it is not offered as backlog, so it
|
|
243
|
+
// stays uncurated without ever appearing to be outstanding.
|
|
244
|
+
for (const rawFile of rawFiles)
|
|
245
|
+
if (!claimed.has(rawFile)) warn("L10", `${bundle}: ${rawFile} has no log.md row`);
|
|
246
|
+
} else if (rawFiles.length) {
|
|
247
|
+
warn("L10", `${bundle}: ${rawFiles.length} file(s) in raw/ but log.md records none of them`);
|
|
248
|
+
}
|
|
147
249
|
}
|
|
148
250
|
|
|
149
251
|
// L3 reverse: outer index entries exist
|
|
150
|
-
for (const
|
|
151
|
-
const
|
|
152
|
-
if (
|
|
252
|
+
for (const link of mdLinks(outerIndex)) {
|
|
253
|
+
const match = link.target.match(/^bundles\/([a-z0-9-]+)\//);
|
|
254
|
+
if (match && !bundles.includes(match[1]))
|
|
255
|
+
err("L3", `outer.index.md lists missing bundle '${match[1]}'`);
|
|
153
256
|
}
|
|
154
257
|
|
|
155
258
|
// L5 index prose check (rough): paragraph-length prose in index files
|
|
156
|
-
function proseCheck(name: string,
|
|
157
|
-
for (const block of
|
|
158
|
-
const
|
|
159
|
-
if (!
|
|
160
|
-
if (
|
|
259
|
+
function proseCheck(name: string, markdown: string) {
|
|
260
|
+
for (const block of stripNonProse(markdown).split(/\n\s*\n/)) {
|
|
261
|
+
const text = block.trim();
|
|
262
|
+
if (!text || /^[#|\-*]/.test(text) || text.startsWith("---")) continue;
|
|
263
|
+
if (text.split(/\s+/).length > 30)
|
|
264
|
+
warn("L5", `${name}: paragraph-length prose in an index file`);
|
|
161
265
|
}
|
|
162
266
|
}
|
|
163
267
|
proseCheck("outer.index.md", outerIndex);
|
|
164
|
-
for (const
|
|
165
|
-
if (existsSync(join(BUNDLES,
|
|
268
|
+
for (const bundle of bundles)
|
|
269
|
+
if (existsSync(join(BUNDLES, bundle, "index.md")))
|
|
270
|
+
proseCheck(`${bundle}/index.md`, read(join(BUNDLES, bundle, "index.md")));
|
|
166
271
|
|
|
167
|
-
console.log(
|
|
168
|
-
|
|
272
|
+
console.log(
|
|
273
|
+
`\n${paint.head("lint")}: ${errors ? paint.bad(`${errors} error(s)`) : paint.ok("0 errors")}, ` +
|
|
274
|
+
`${warnings ? paint.warn(`${warnings} warning(s)`) : paint.ok("0 warnings")} ` +
|
|
275
|
+
`across ${bundles.length} bundle(s) in ${totalElapsed()}`,
|
|
276
|
+
);
|
package/scripts/new-bundle.ts
CHANGED
|
@@ -3,18 +3,23 @@
|
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { BUNDLES, join } from "./lib/util";
|
|
5
5
|
import { createBundle, VALID_NAME } from "./lib/scaffold";
|
|
6
|
+
import { paint, paintErr } from "./lib/color";
|
|
6
7
|
import { rejectUnknownFlags } from "./lib/args";
|
|
7
8
|
|
|
8
9
|
const argv = process.argv.slice(2);
|
|
9
10
|
rejectUnknownFlags(argv, 'khb new-bundle <name> ["scope"]');
|
|
10
11
|
const [name, scope = "TODO scope"] = argv;
|
|
11
12
|
if (!name || !VALID_NAME.test(name)) {
|
|
12
|
-
console.error(
|
|
13
|
+
console.error(
|
|
14
|
+
`Usage: ${paintErr.cmd("khb new-bundle <name> [scope]")} ${paintErr.dim("(lowercase, digits, hyphens)")}`,
|
|
15
|
+
);
|
|
13
16
|
process.exit(1);
|
|
14
17
|
}
|
|
15
|
-
if (existsSync(join(BUNDLES, name))) { console.error(
|
|
18
|
+
if (existsSync(join(BUNDLES, name))) { console.error(`${paintErr.bad("Bundle already exists:")} ${name}`); process.exit(1); }
|
|
16
19
|
|
|
17
20
|
createBundle(name, scope);
|
|
18
21
|
|
|
19
|
-
console.log(
|
|
20
|
-
console.log(
|
|
22
|
+
console.log(`${paint.ok("Created")} ${paint.name(`bundles/${name}/`)} and registered it in outer.index.md`);
|
|
23
|
+
console.log(
|
|
24
|
+
`Next: set its scope line in outer.index.md, add sources to sources.yaml, run: ${paint.cmd("khb lint")}`,
|
|
25
|
+
);
|
package/scripts/visualize.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { buildGraphData, readConceptFile } from "./lib/graph";
|
|
9
9
|
import { renderGraphPage } from "./lib/graph-page";
|
|
10
10
|
import { takeFlag, takeOpt, rejectUnknownFlags } from "./lib/args";
|
|
11
|
+
import { paint, paintErr } from "./lib/color";
|
|
11
12
|
|
|
12
13
|
const USAGE = "khb visualize [--port N] [--no-open]";
|
|
13
14
|
const argv = process.argv.slice(2);
|
|
@@ -21,8 +22,8 @@ rejectUnknownFlags(argv, USAGE);
|
|
|
21
22
|
// looks like the flag working right up until nothing is listening where you expected.
|
|
22
23
|
const requestedPort = portArg === undefined ? 0 : Number(portArg);
|
|
23
24
|
if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
|
|
24
|
-
console.error(
|
|
25
|
-
console.error(`Usage: ${USAGE}`);
|
|
25
|
+
console.error(`${paintErr.bad("--port must be a number between 0 and 65535, not:")} ${portArg}`);
|
|
26
|
+
console.error(`Usage: ${paintErr.cmd(USAGE)}`);
|
|
26
27
|
process.exit(1);
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -76,7 +77,7 @@ try {
|
|
|
76
77
|
server = Bun.serve({ port: requestedPort, fetch: fetchHandler });
|
|
77
78
|
} catch (e) {
|
|
78
79
|
if (requestedPort && (e as { code?: string }).code === "EADDRINUSE") {
|
|
79
|
-
console.warn(`Port ${requestedPort} is busy — picking a free one instead.`);
|
|
80
|
+
console.warn(paintErr.warn(`Port ${requestedPort} is busy — picking a free one instead.`));
|
|
80
81
|
server = Bun.serve({ port: 0, fetch: fetchHandler });
|
|
81
82
|
} else throw e;
|
|
82
83
|
}
|
|
@@ -89,7 +90,7 @@ setInterval(() => {
|
|
|
89
90
|
}, HEARTBEAT_INTERVAL_MS).unref();
|
|
90
91
|
|
|
91
92
|
const url = `http://localhost:${server.port}`;
|
|
92
|
-
console.log(
|
|
93
|
+
console.log(`${paint.head("khb visualize")} → ${paint.cmd(url)} ${paint.dim(`(${summarize(data)})`)}`);
|
|
93
94
|
|
|
94
95
|
// Open the default browser. If that fails the URL is already printed above, so a headless
|
|
95
96
|
// or locked-down box just falls back to copy-paste rather than erroring out.
|
|
@@ -103,7 +104,7 @@ if (!noOpen) {
|
|
|
103
104
|
try {
|
|
104
105
|
Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" }).unref();
|
|
105
106
|
} catch {
|
|
106
|
-
console.warn("Could not launch a browser — open the URL above yourself.");
|
|
107
|
+
console.warn(paintErr.warn("Could not launch a browser — open the URL above yourself."));
|
|
107
108
|
}
|
|
108
109
|
}
|
|
109
|
-
console.log(`The server exits on its own once you close the tab. Ctrl+C also works.`);
|
|
110
|
+
console.log(paint.dim(`The server exits on its own once you close the tab. Ctrl+C also works.`));
|
package/skills/ingest/SKILL.md
CHANGED
|
@@ -154,7 +154,7 @@ curation, not transcription.
|
|
|
154
154
|
| `.mp3 .wav .m4a .mp4 .mov .mkv` | local `vno` (whisper.cpp), else `whisper` / `faster-whisper` | **low** |
|
|
155
155
|
| `.vtt .srt` | built-in caption reader | high |
|
|
156
156
|
|
|
157
|
-
Extracted text is cached hub-wide by content hash at
|
|
157
|
+
Extracted text is cached hub-wide by content hash at `.ingest-cache/extracted/<sha256>.md`, so the
|
|
158
158
|
same file appearing in two bundles converts once.
|
|
159
159
|
|
|
160
160
|
**A recording next to its captions is one source, not two.** `talk.vtt` (or `talk.en.vtt`,
|
package/skills/lint/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ description: Validate KHB structure (routing integrity, bundle shape, OKF confor
|
|
|
12
12
|
log it in `bundles/meta/notes/decisions.md`. No meta bundle means no decision log — do
|
|
13
13
|
not create one to have somewhere to write.
|
|
14
14
|
|
|
15
|
-
## The rules (L1–
|
|
15
|
+
## The rules (L1–L11)
|
|
16
16
|
|
|
17
17
|
Enforced by `khb lint`. Combines KHB routing rules with
|
|
18
18
|
OKF v0.1 conformance (see the OKF spec). Reserved filenames: `index.md`, `log.md`
|
|
@@ -33,7 +33,8 @@ is a **concept document**.
|
|
|
33
33
|
`outer.index.md` exists on disk.
|
|
34
34
|
- L4. Every concept doc is listed in at least one of the bundle's `index.md` files
|
|
35
35
|
(error). Index links pointing at missing files are a warning only — OKF treats
|
|
36
|
-
broken links as not-yet-written knowledge.
|
|
36
|
+
broken links as not-yet-written knowledge. A `#section` suffix names a place inside
|
|
37
|
+
the target and is dropped before the path is checked.
|
|
37
38
|
- L5. Index files contain routing only: headings, bullet/table link lines, one-line
|
|
38
39
|
descriptions. Paragraph-length prose is a violation (warning).
|
|
39
40
|
|
|
@@ -42,6 +43,12 @@ is a **concept document**.
|
|
|
42
43
|
- L6. No markdown link from a concept doc into another bundle's files. Cross-bundle
|
|
43
44
|
pointers live in `refs.md` only.
|
|
44
45
|
- L7. Every target bundle named in `refs.md` exists.
|
|
46
|
+
- L11. Markdown links *within* a bundle resolve to a file that exists (warning). Concept
|
|
47
|
+
links are the bundle's real structure — the catalog cross-link pass and the back-links
|
|
48
|
+
the query skill writes from a synthesis to its sources are both made of them, and a
|
|
49
|
+
synthesis nobody can reach from its sources is a dead end. A warning rather than an
|
|
50
|
+
error, for L4's reason: a link to a concept somebody means to write next is
|
|
51
|
+
not-yet-written knowledge.
|
|
45
52
|
|
|
46
53
|
### Provenance
|
|
47
54
|
|
|
@@ -49,6 +56,21 @@ is a **concept document**.
|
|
|
49
56
|
non-empty `source:`, and `quality:` — if set — reading exactly `high` or `low`.
|
|
50
57
|
`source` is what makes a bad extraction recoverable, so a raw file without one is
|
|
51
58
|
uncatalogable, not merely untidy.
|
|
59
|
+
- L10. `log.md` still describes what is on disk. It is the durable record across ingest
|
|
60
|
+
and catalog, and its empty `curated` cells *are* the catalog backlog, so a row that has
|
|
61
|
+
come loose from its files misreports the work outstanding:
|
|
62
|
+
- a `curated` path names a file that exists (**error**). `declined` is the documented
|
|
63
|
+
way to close a row without a concept and is accepted as-is; anything else is a path
|
|
64
|
+
the row claims to have written, and unlike a link there is no not-yet-written case —
|
|
65
|
+
the column is filled only once the concept exists. Renaming a concept after
|
|
66
|
+
cataloging is what usually breaks it.
|
|
67
|
+
- a row's `raw` path names a file that exists (warning), checked **only** when `raw/`
|
|
68
|
+
has files in it. `raw/` is gitignored and re-derivable, so a hub that was cloned
|
|
69
|
+
rather than ingested has every row and no files at all — that is an ordinary state,
|
|
70
|
+
not a finding.
|
|
71
|
+
- every `.md` under `raw/` has a row (warning). An extracted file no row names is
|
|
72
|
+
invisible work: never offered as backlog, so it stays uncurated without ever looking
|
|
73
|
+
outstanding.
|
|
52
74
|
|
|
53
75
|
### OKF conformance
|
|
54
76
|
|
package/templates/hub/gitignore
CHANGED
|
@@ -4,8 +4,8 @@ bundles/*/raw/
|
|
|
4
4
|
# Agent-specific machine-local permissions; shared skills remain tracked.
|
|
5
5
|
.claude/settings.local.json
|
|
6
6
|
|
|
7
|
-
# Extraction cache (
|
|
8
|
-
/
|
|
7
|
+
# Extraction cache (.ingest-cache/extracted/<sha256>.md) — re-derivable from the sources.
|
|
8
|
+
/.ingest-cache/
|
|
9
9
|
|
|
10
10
|
# Generated. The leading slash matters: a bare `export/` also matches the `skills/export/`
|
|
11
11
|
# these files ship with, and silently drops the export skill from the hub's own history.
|