@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.
@@ -32,16 +32,28 @@ export function hubVersion(hub: string): string | undefined {
32
32
  }
33
33
  }
34
34
 
35
- /** Copy every package-owned contract file into the hub, replacing what is there. */
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
- for (const f of MANAGED) {
39
- const src = join(PKG, f);
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, f);
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() ? `${f}/` : f);
56
+ done.push(statSync(src).isDirectory() ? `${managed}/` : managed);
45
57
  }
46
58
  return done;
47
59
  }
@@ -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 INBOX = join(HUB, "inbox");
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 = (v: unknown) =>
17
- v instanceof Date ? !isNaN(v.getTime()) : typeof v === "string" && !isNaN(Date.parse(v));
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(`ERROR ${rule}: ${msg}`); };
21
- const warn = (rule: string, msg: string) => { warnings++; console.warn(`warn ${rule}: ${msg}`); };
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((f) => {
31
- const p = join(dir, f);
32
- if (statSync(p).isDirectory()) return f === "raw" ? [] : walk(p, base);
33
- return [relative(base, p).replaceAll("\\", "/")];
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
- console.log(`khb lint → ${HUB}`);
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 [bi, b] of bundles.entries()) {
41
- const dir = join(BUNDLES, b);
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(`[${bi + 1}/${bundles.length}] ${b}`);
85
+ section(`[${bundleIndex + 1}/${bundles.length}] ${bundle}`);
45
86
 
46
87
  // L2 name
47
- if (!/^[a-z0-9][a-z0-9-]*$/.test(b)) err("L2", `bad bundle name '${b}'`);
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 f of ["index.md", "refs.md", "sources.yaml"])
51
- if (!existsSync(join(dir, f))) err("L1", `${b}: missing ${f}`);
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/${b}/`)) err("L3", `${b}: not listed in outer.index.md`);
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((f) => f.endsWith(".md"));
58
- const concepts = mdFiles.filter((f) => !RESERVED.includes(f.split("/").pop()!));
59
- const indexes = mdFiles.filter((f) => f.split("/").pop() === "index.md");
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 idx of indexes) {
65
- const md = stripComments(read(join(dir, idx)));
66
- for (const l of mdLinks(md)) {
67
- if (l.target.startsWith("http")) continue;
68
- const resolved = l.target.startsWith("/")
69
- ? l.target.slice(1)
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", `${b}: ${idx} links to missing ${resolved}`);
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 c of concepts)
80
- if (!indexed.has(c)) err("L4", `${b}: ${c} not listed in any index.md`);
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 c of concepts) {
83
- const body = read(join(dir, c));
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 fm = body.match(/^---\n([\s\S]*?)\n---/)?.[1];
89
- if (fm === undefined) err("L9", `${b}: ${c} has no YAML frontmatter (OKF requires it)`);
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(fm) ?? {}) as Record<string, unknown>;
94
- } catch (e) {
95
- err("L9", `${b}: ${c} frontmatter is not valid YAML — ${(e as Error).message.split("\n")[0]}`);
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 = (k: string) => (typeof meta![k] === "string" ? (meta![k] as string).trim() : "");
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", `${b}: ${c} frontmatter missing required 'type'`);
102
- for (const k of ["title", "description"])
103
- if (!str(k)) warn("L9", `${b}: ${c} frontmatter missing '${k}'`);
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", `${b}: ${c} 'tags' must be a YAML list, not ${typeof meta.tags}`);
106
- if (Array.isArray(meta.tags) && meta.tags.some((t) => typeof t !== "string"))
107
- err("L9", `${b}: ${c} 'tags' must contain only strings`);
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", `${b}: ${c} 'timestamp' is not an ISO-8601 datetime`);
110
- for (const k of Object.keys(meta))
111
- if (!OKF_FIELDS.has(k)) warn("L9", `${b}: ${c} unknown frontmatter key '${k}'`);
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 l of mdLinks(stripComments(body))) {
117
- if (/(^|\/)bundles\//.test(l.target) || l.target.startsWith("../../"))
118
- err("L6", `${b}: ${c} links into another bundle (${l.target}) — use refs.md`);
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 t of refTargets(read(join(dir, "refs.md"))))
125
- if (!bundles.includes(t)) err("L7", `${b}: refs.md targets missing bundle '${t}'`);
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
- // L8 raw provenance (warning)
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
- if (existsSync(rawDir)) {
131
- const rawFiles = (readdirSync(rawDir, { recursive: true }) as string[]).filter((f) => f.endsWith(".md"));
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 f of readdirSync(rawDir, { recursive: true }) as string[]) {
196
+ for (const rawFile of rawFiles) {
134
197
  try {
135
- if (!f.endsWith(".md")) continue;
136
- const head = read(join(rawDir, f));
137
- const rfm = head.match(/^---\n([\s\S]*?)\n---/)?.[1];
138
- if (rfm === undefined) { warn("L8", `${b}: raw/${f} missing provenance header`); continue; }
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(rfm)) warn("L8", `${b}: raw/${f} provenance missing 'source'`);
141
- const q = rfm.match(/^quality:\s*(\S+)/m)?.[1];
142
- if (q && q !== "high" && q !== "low")
143
- warn("L8", `${b}: raw/${f} quality '${q}' is not high|low`);
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 l of mdLinks(outerIndex)) {
151
- const m = l.target.match(/^bundles\/([a-z0-9-]+)\//);
152
- if (m && !bundles.includes(m[1])) err("L3", `outer.index.md lists missing bundle '${m[1]}'`);
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, md: string) {
157
- for (const block of stripComments(md).split(/\n\s*\n/)) {
158
- const t = block.trim();
159
- if (!t || /^[#|\-*]/.test(t) || t.startsWith("---")) continue;
160
- if (t.split(/\s+/).length > 30) warn("L5", `${name}: paragraph-length prose in an index file`);
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 b of bundles)
165
- if (existsSync(join(BUNDLES, b, "index.md"))) proseCheck(`${b}/index.md`, read(join(BUNDLES, b, "index.md")));
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(`\nlint: ${errors} error(s), ${warnings} warning(s) across ${bundles.length} bundle(s) in ${totalElapsed()}`);
168
- process.exit(errors ? 1 : 0);
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
+ );
@@ -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("Usage: khb new-bundle <name> [scope] (lowercase, digits, hyphens)");
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(`Bundle '${name}' already exists`); process.exit(1); }
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(`Created bundles/${name}/ and registered it in outer.index.md`);
20
- console.log("Next: set its scope line in outer.index.md, add sources to sources.yaml, run: khb lint");
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
+ );
@@ -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(`--port must be a number between 0 and 65535, not: ${portArg}`);
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(`khb visualize → ${url} (${summarize(data)})`);
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.`));
@@ -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 `inbox/extracted/<sha256>.md`, so the
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`,
@@ -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–L9)
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
 
@@ -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 (inbox/extracted/<sha256>.md) — re-derivable from the sources.
8
- /inbox/
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.