@msareen/knowledge-hub-builder 0.1.4 → 0.1.5

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 CHANGED
@@ -135,7 +135,7 @@ From outside, pass `--hub <dir>` or set `$KHB_HUB`.
135
135
  | Command | Purpose |
136
136
  |---|---|
137
137
  | `khb lint` | validate structure against `skills/lint/SKILL.md` |
138
- | `khb upgrade` | refresh this hub's package-owned contract docs |
138
+ | `khb upgrade` | refresh this hub's package-owned contract docs — runs by itself whenever `khb.json`'s stamped version differs from the installed khb, so these docs always match the CLI |
139
139
  | `khb visualize` | regenerate `visualizer/graph.html` |
140
140
  | `khb new-bundle <name>` | scaffold + register a bundle |
141
141
  | `khb ingest <bundle>` | acquire + extract every source in `sources.yaml` → `raw/`; maintains `log.md`. Name the bundle — with none it lists the hub's bundles and stops, unless there is nothing to choose between (no bundles, or only `default`), where it uses `default` |
package/README.md CHANGED
@@ -208,6 +208,12 @@ files under `skills/` when developing KHB.
208
208
  changing `bundles/` or `outer.index.md`. `khb export` includes the same compatibility
209
209
  layout in a standalone bundle export.
210
210
 
211
+ The refresh is also automatic: every command that works on a hub first compares the `khb`
212
+ version stamped in `khb.json` against the installed package, and upgrades the hub in place
213
+ if they differ, printing one line to stderr. So updating the package is enough — the hub's
214
+ contract docs can never be a version behind the CLI acting on them. Set
215
+ `KHB_NO_AUTO_UPGRADE=1` to suppress the check and leave the hub as it is.
216
+
211
217
  ## Commands
212
218
 
213
219
  Commands can be run directly or requested through the matching agent skill.
@@ -215,7 +221,7 @@ Commands can be run directly or requested through the matching agent skill.
215
221
  | Command | Purpose |
216
222
  |---|---|
217
223
  | `khb init [dir]` | Create a hub |
218
- | `khb upgrade` | Refresh package-owned contracts and skills |
224
+ | `khb upgrade` | Refresh package-owned contracts and skills (also runs automatically on version drift) |
219
225
  | `khb new-bundle <name> ["scope"]` | Create and register a bundle |
220
226
  | `khb ingest [bundle] [--force]` | Acquire and extract declared sources |
221
227
  | `khb lint` | Validate routing, bundle structure, and OKF metadata |
@@ -229,14 +235,15 @@ Additional ingest flags:
229
235
 
230
236
  Run against a hub outside the current directory with `--hub <dir>` or `$KHB_HUB`.
231
237
 
232
- To update the installed package and then refresh a hub:
238
+ To update the installed package:
233
239
 
234
240
  ```bash
235
241
  bun update -g @msareen/knowledge-hub-builder
236
- cd ~/my-knowledge
237
- khb upgrade
238
242
  ```
239
243
 
244
+ Each hub refreshes itself the next time you run any command in it. `khb upgrade` does the
245
+ same thing on demand, with a fuller report.
246
+
240
247
  ## Hub Layout
241
248
 
242
249
  ```text
package/SPEC.md CHANGED
@@ -92,6 +92,14 @@ agent is opened on the hub folder and must find its rules there, without knowing
92
92
  `khb` is installed. They are package-owned: `khb upgrade` overwrites them in place and
93
93
  leaves `bundles/` and `outer.index.md` alone.
94
94
 
95
+ Copies drift, so the copy is kept honest by the CLI rather than by the user's memory.
96
+ `khb.json` stamps the version that wrote the hub's copies; before running any command that
97
+ touches a hub, `khb` compares that stamp to the installed package version and, if they
98
+ differ, performs the upgrade in place and says so on stderr. Only `init` (no hub yet) and
99
+ `upgrade` (which is the operation) skip the check, and `KHB_NO_AUTO_UPGRADE=1` disables it.
100
+ The invariant it buys: **a hub's contract docs always state the same version as the `khb`
101
+ acting on them** — an agent can never read a protocol the CLI no longer implements.
102
+
95
103
  ### 2b. The package — `@msareen/knowledge-hub-builder`, installed once
96
104
 
97
105
  > **None of the following is in your hub.** This section describes the *installed tool*,
@@ -105,7 +113,7 @@ leaves `bundles/` and `outer.index.md` alone.
105
113
  @msareen/knowledge-hub-builder/ # installed once, globally — NOT part of a hub
106
114
  ├── package.json # bin: khb → scripts/cli.ts
107
115
  ├── scripts/
108
- │ ├── cli.ts # subcommand dispatch; owns the global --hub flag
116
+ │ ├── cli.ts # subcommand dispatch; --hub flag; the version drift check
109
117
  │ ├── init.ts # khb init / khb upgrade
110
118
  │ ├── new-bundle.ts # scaffold from .bundle_template, register in outer.index.md
111
119
  │ ├── export.ts # bundle + common patterns → standalone shareable folder
@@ -116,6 +124,7 @@ leaves `bundles/` and `outer.index.md` alone.
116
124
  │ ├── extract.ts # every local extractor + the content-hash cache
117
125
  │ ├── ledger.ts # log.md read/write
118
126
  │ ├── paths.ts # package-side paths — importing it never needs a hub
127
+ │ ├── upgrade.ts # the refresh itself: `khb upgrade` and the drift check
119
128
  │ └── util.ts # hub resolution + shared helpers
120
129
  ├── .bundle_template/ # copied by `khb new-bundle`
121
130
  ├── templates/hub/ # copied by `khb init`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msareen/knowledge-hub-builder",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Knowledge Hub Builder — a bundle-of-bundles knowledge base you build with an agent",
5
5
  "keywords": ["knowledge-base", "okf", "agent", "wiki", "bundles", "claude", "codex", "rag"],
6
6
  "license": "MIT",
package/scripts/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  // khb — the CLI. Subcommands are loaded lazily: `init` must run before a hub exists,
3
3
  // so nothing that resolves a hub may be imported at module scope.
4
- import { version } from "./lib/paths";
4
+ import { version, findHub, markerIn, MARKER } from "./lib/paths";
5
5
 
6
6
  const COMMANDS: Record<string, { load: () => Promise<unknown>; help: string }> = {
7
7
  init: { load: () => import("./init"), help: "khb init [dir] create a hub here (or in dir)" },
@@ -59,6 +59,28 @@ if (!entry) {
59
59
  process.exit(1);
60
60
  }
61
61
 
62
+ // Version drift: a hub carries package-owned copies of the agent contract, and a hub
63
+ // stamped at an older version than the installed khb is stating an older contract than
64
+ // the one the CLI now implements. Rather than let the two disagree, refresh the hub in
65
+ // place before running the command — `khb upgrade` touches nothing the user wrote.
66
+ // `init` has no hub yet, `upgrade` does this itself, and $KHB_NO_AUTO_UPGRADE opts out.
67
+ if (cmd !== "init" && cmd !== "upgrade" && !process.env.KHB_NO_AUTO_UPGRADE) {
68
+ const hub = findHub();
69
+ if (hub) {
70
+ const { hubVersion, upgradeHub } = await import("./lib/upgrade");
71
+ // A marker under a pre-rename name is drift too, even at a matching version.
72
+ if (hubVersion(hub) !== version() || markerIn(hub) !== MARKER) {
73
+ const { from, to, pruned, renamed } = upgradeHub(hub);
74
+ // stderr, so a command's own output stays pipeable.
75
+ console.error(
76
+ `khb: hub was built by ${from ?? "an unknown version"}, khb is ${to} — refreshed its contract docs.`,
77
+ );
78
+ if (renamed) console.error(`khb: renamed ${renamed} -> khb.json`);
79
+ if (pruned.length) console.error(`khb: removed (no longer part of the contract): ${pruned.join(", ")}`);
80
+ }
81
+ }
82
+ }
83
+
62
84
  // Subcommand modules parse process.argv.slice(2) themselves — reshape it so they see
63
85
  // their own arguments and not the subcommand name.
64
86
  process.argv = [process.argv[0], process.argv[1], ...argv];
package/scripts/init.ts CHANGED
@@ -5,63 +5,23 @@
5
5
  // copied INTO the hub — an agent opened on the hub folder must be able to read them
6
6
  // without knowing where khb is installed. Those copies are package-owned: `upgrade`
7
7
  // overwrites them.
8
- import { cpSync, mkdirSync, writeFileSync, existsSync, statSync, rmSync } from "node:fs";
9
- import { join, resolve, basename, dirname } from "node:path";
10
- import { PKG, HUB_TEMPLATE, MANAGED, RETIRED, MARKER, markerIn, version } from "./lib/paths";
8
+ //
9
+ // The mechanism itself lives in lib/upgrade.ts, because cli.ts also runs it on version
10
+ // drift before any hub command.
11
+ import { cpSync, mkdirSync, existsSync } from "node:fs";
12
+ import { join, resolve, basename } from "node:path";
13
+ import { HUB_TEMPLATE, MARKER, markerIn } from "./lib/paths";
14
+ import { upgradeHub, syncManaged, stamp } from "./lib/upgrade";
11
15
 
12
16
  const upgrading = process.env.KHB_SUBCOMMAND === "upgrade";
13
17
  const [dirArg] = process.argv.slice(2);
14
18
 
15
- /** Copy every package-owned contract file into the hub, replacing what is there. */
16
- function syncManaged(hub: string): string[] {
17
- const done: string[] = [];
18
- for (const f of MANAGED) {
19
- const src = join(PKG, f);
20
- if (!existsSync(src)) continue;
21
- const dest = join(hub, f);
22
- mkdirSync(dirname(dest), { recursive: true });
23
- cpSync(src, dest, { recursive: true, force: true });
24
- done.push(statSync(src).isDirectory() ? `${f}/` : f);
25
- }
26
- return done;
27
- }
28
-
29
- /** Drop package-owned files that later versions stopped shipping. */
30
- function pruneRetired(hub: string): string[] {
31
- const gone: string[] = [];
32
- for (const f of RETIRED) {
33
- const p = join(hub, f);
34
- if (!existsSync(p)) continue;
35
- rmSync(p, { recursive: true, force: true });
36
- gone.push(f);
37
- }
38
- return gone;
39
- }
40
-
41
- function stamp(hub: string, created?: string) {
42
- writeFileSync(
43
- join(hub, MARKER),
44
- JSON.stringify(
45
- { khb: version(), created: created ?? new Date().toISOString(), upgraded: new Date().toISOString() },
46
- null,
47
- 2,
48
- ) + "\n",
49
- );
50
- }
51
-
52
19
  if (upgrading) {
53
20
  const { HUB } = await import("./lib/util"); // resolves the hub, or exits with guidance
54
- // The hub may still carry a marker name from an older version; stamp() writes MARKER,
55
- // so drop the old file rather than leaving the hub with two.
56
- const found = markerIn(HUB)!;
57
- const before = JSON.parse(await Bun.file(join(HUB, found)).text());
58
- if (found !== MARKER) rmSync(join(HUB, found));
59
- const synced = syncManaged(HUB);
60
- const pruned = pruneRetired(HUB);
61
- stamp(HUB, before.created);
62
- console.log(`Upgraded ${HUB}: ${before.khb ?? before.bkr ?? "?"} -> ${version()}`);
21
+ const { from, to, synced, pruned, renamed } = upgradeHub(HUB);
22
+ console.log(`Upgraded ${HUB}: ${from ?? "?"} -> ${to}`);
63
23
  console.log(` refreshed: ${synced.join(", ")}`);
64
- if (found !== MARKER) console.log(` renamed: ${found} -> ${MARKER}`);
24
+ if (renamed) console.log(` renamed: ${renamed} -> ${MARKER}`);
65
25
  if (pruned.length) console.log(` removed (no longer part of the contract): ${pruned.join(", ")}`);
66
26
  console.log(`Your bundles/ and outer.index.md were not touched. Next: khb lint`);
67
27
  } else {
@@ -1,7 +1,7 @@
1
1
  // Package-side paths. Importing this must never require a hub to exist — `khb init`
2
2
  // runs before there is one. Hub-side paths live in util.ts.
3
3
  import { fileURLToPath } from "node:url";
4
- import { join } from "node:path";
4
+ import { join, dirname, resolve } from "node:path";
5
5
  import { readFileSync, existsSync } from "node:fs";
6
6
 
7
7
  /** Root of the installed @msareen/knowledge-hub-builder package (NOT the user's hub). */
@@ -29,6 +29,26 @@ export const LEGACY_MARKERS = ["bkr.json"];
29
29
  export const markerIn = (dir: string): string | undefined =>
30
30
  [MARKER, ...LEGACY_MARKERS].find((m) => existsSync(join(dir, m)));
31
31
 
32
+ /**
33
+ * The hub this invocation acts on, or undefined if there is none. Precedence:
34
+ * $KHB_HUB (set from --hub by cli.ts) > nearest ancestor of cwd holding a marker.
35
+ * Soft by design — util.ts turns "none" into an error with guidance, while the version
36
+ * drift check in cli.ts must stay silent for commands that run outside a hub.
37
+ */
38
+ export function findHub(): string | undefined {
39
+ const explicit = process.env.KHB_HUB;
40
+ if (explicit) {
41
+ const dir = resolve(explicit);
42
+ return markerIn(dir) ? dir : undefined;
43
+ }
44
+ for (let dir = process.cwd(); ; ) {
45
+ if (markerIn(dir)) return dir;
46
+ const up = dirname(dir);
47
+ if (up === dir) return undefined;
48
+ dir = up;
49
+ }
50
+ }
51
+
32
52
  /**
33
53
  * Package-owned files copied into every hub by `khb init` and refreshed by
34
54
  * `khb upgrade`. These are the agent contract — the hub needs its own copies so an
@@ -0,0 +1,93 @@
1
+ // The upgrade mechanism: refresh a hub's package-owned contract docs to match the
2
+ // installed khb. Lives here rather than in init.ts because two callers need it — the
3
+ // explicit `khb upgrade`, and the drift check cli.ts runs before every hub command.
4
+ //
5
+ // Nothing here may import util.ts: the drift check runs before a hub is resolved, and
6
+ // util.ts resolves one or exits.
7
+ import { cpSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { PKG, MANAGED, RETIRED, MARKER, markerIn, version } from "./paths";
10
+
11
+ export type UpgradeResult = {
12
+ /** Version recorded in the hub's marker before the upgrade, if it recorded one. */
13
+ from?: string;
14
+ to: string;
15
+ synced: string[];
16
+ pruned: string[];
17
+ /** Set when a legacy marker name was renamed to khb.json. */
18
+ renamed?: string;
19
+ };
20
+
21
+ /** Read the khb version a hub was last stamped with. Undefined if it records none. */
22
+ export function hubVersion(hub: string): string | undefined {
23
+ const found = markerIn(hub);
24
+ if (!found) return undefined;
25
+ try {
26
+ const marker = JSON.parse(readFileSync(join(hub, found), "utf8"));
27
+ return marker.khb ?? marker.bkr; // bkr: the pre-rename field name
28
+ } catch {
29
+ return undefined; // unreadable marker — treat as drifted, upgrade will restamp it
30
+ }
31
+ }
32
+
33
+ /** Copy every package-owned contract file into the hub, replacing what is there. */
34
+ export function syncManaged(hub: string): string[] {
35
+ const done: string[] = [];
36
+ for (const f of MANAGED) {
37
+ const src = join(PKG, f);
38
+ if (!existsSync(src)) continue;
39
+ const dest = join(hub, f);
40
+ mkdirSync(dirname(dest), { recursive: true });
41
+ cpSync(src, dest, { recursive: true, force: true });
42
+ done.push(statSync(src).isDirectory() ? `${f}/` : f);
43
+ }
44
+ return done;
45
+ }
46
+
47
+ /** Drop package-owned files that later versions stopped shipping. */
48
+ function pruneRetired(hub: string): string[] {
49
+ const gone: string[] = [];
50
+ for (const f of RETIRED) {
51
+ const p = join(hub, f);
52
+ if (!existsSync(p)) continue;
53
+ rmSync(p, { recursive: true, force: true });
54
+ gone.push(f);
55
+ }
56
+ return gone;
57
+ }
58
+
59
+ /** Write khb.json with the installed version, preserving the hub's creation date. */
60
+ export function stamp(hub: string, created?: string) {
61
+ writeFileSync(
62
+ join(hub, MARKER),
63
+ JSON.stringify(
64
+ { khb: version(), created: created ?? new Date().toISOString(), upgraded: new Date().toISOString() },
65
+ null,
66
+ 2,
67
+ ) + "\n",
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Bring `hub`'s package-owned files up to the installed version. Touches only what the
73
+ * package owns: bundles/, outer.index.md and anything else the user wrote are untouched.
74
+ */
75
+ export function upgradeHub(hub: string): UpgradeResult {
76
+ // The hub may still carry a marker name from an older version; stamp() writes MARKER,
77
+ // so drop the old file rather than leaving the hub with two.
78
+ const found = markerIn(hub)!;
79
+ let created: string | undefined;
80
+ let from: string | undefined;
81
+ try {
82
+ const before = JSON.parse(readFileSync(join(hub, found), "utf8"));
83
+ created = before.created;
84
+ from = before.khb ?? before.bkr;
85
+ } catch {
86
+ /* unreadable marker: rewritten below with today's date */
87
+ }
88
+ if (found !== MARKER) rmSync(join(hub, found));
89
+ const synced = syncManaged(hub);
90
+ const pruned = pruneRetired(hub);
91
+ stamp(hub, created);
92
+ return { from, to: version(), synced, pruned, renamed: found === MARKER ? undefined : found };
93
+ }
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
- import { join, basename, dirname, resolve } from "node:path";
4
- import { MARKER, markerIn } from "./paths";
3
+ import { join, basename, resolve } from "node:path";
4
+ import { MARKER, markerIn, findHub } from "./paths";
5
5
 
6
6
  /**
7
7
  * Find the hub root — the folder holding khb.json, outer.index.md and bundles/.
@@ -10,20 +10,12 @@ import { MARKER, markerIn } from "./paths";
10
10
  * ancestor of cwd containing the marker.
11
11
  */
12
12
  function resolveHub(): string {
13
+ const found = findHub();
14
+ if (found) return found;
13
15
  const explicit = process.env.KHB_HUB;
14
16
  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;
17
+ console.error(`Not a KHB hub (no ${MARKER}): ${resolve(explicit)}`);
18
+ process.exit(1);
27
19
  }
28
20
  console.error(`No KHB hub found in ${process.cwd()} or any parent directory.`);
29
21
  console.error(`Create one: khb init <dir>`);