khub in the session: the khub and setup skills, a band above the prompt with what the session changed, and compact rows for khub calls

Schema-bound, agent-facing context management.
khub gives an AI agent typed, validated, queryable context (structured memory it can navigate and write back to) instead of unstructured documents stuffed into a context window.
One generic engine: entities live in git as Markdown with YAML frontmatter (the default), as .json/.yaml documents, or as rows of a single-file collection, per-type schema config. The khub schema is the contract: types, attributes, and legal relations, authored in YAML and resolved in memory. A Go core provides schema-validated CRUD and graph queries; a generic khub CLI and an agent skill are thin, schema-driven surfaces over it. It ships as one static binary — no runtime to install. The graph is a projection rebuilt from workspace files on demand; no database is ever the source of truth.
Built on top of the Open Knowledge Format (OKF). khub's Markdown entities are OKF concepts; on top, khub adds a typed schema, a graph, and the serialization formats and collections OKF lacks. Any workspace projects to a conformant OKF bundle.
The schema is the operational setup. It configures what a given hub is for. Canonical ontology presets, one per domain (building and maintaining a system, consulting, research, operations), are the reusable IP. Each engagement gets its own repo seeded from a preset, where agents and humans co-author entities and extend the schema as the work demands.
add / get ID... / edit / link / unlink / remove: schema-validated writes, referential integrity hard-fails, capture never blocked, minimal-diff round-trips. One get returns one record; multiple IDs return an all-or-nothing array in argument order. A templated type's add seeds the body from its template (--no-template opts out).query (frontmatter + edge filters), search (BM25 full-text over titles, bodies, and fields: FTS5, built in-memory per call, never stale), neighbors / impact / history (graph walks).validate (per-entity well-formedness, including body structure against the type's template: required section headings as an ordered subsequence; per-section rules report as gaps, which never gate) and check (graph-wide completeness, dangling edges, strays, cycles, missing required singletons, body shape; orphans informational unless --strict, thin bodies informational as thin); stale reads git at entity altitude, row-accurate even inside collections.reindex (OKF index.md), viz (Cytoscape HTML), backfill (git-derived dates and scaffolding).layout (file / folder / collection / singleton) × format (md / json / yaml; collections take json / jsonl / yaml). A singleton is one fixed file whose slug is the type name (khub get prd). Non-md entities carry prose in a reserved body field. Every existing-workspace mutation holds one workspace-wide lock from scan through publication; atomic writes preserve existing permissions and refuse workspace storage symlinks.<name>/{ontology,policy,storage}.yaml + templates/*.yaml); khub init copies them into .khub/ and creates every missing md singleton from its template (creations only — an existing file is never touched); khub upgrade --dry-run preflights the complete candidate and tails without workspace artifacts. A real upgrade publishes schema, templates and scaffolds transactionally, preserves edited files as <name>.bak, writes the version last, then refreshes wiring and index.md as non-fatal tails.wire: link the schema into a project's agent files. Bare wire updates whichever of CLAUDE.md / AGENTS.md exist; --target claude|agents|both creates one. CLAUDE.md gets @.khub/ontology.yaml (+ policy/storage) imports, AGENTS.md a schema pointer, both with the command surface — so an agent reasons in the ontology with or without the CLI.Full command surface and JSON contracts: docs/cli.md. Feature history: CHANGELOG.md. All documentation: docs/.
khub is a single static binary for macOS and Linux (amd64, arm64). The primary install is the npm package @endgame-build/khub — one package carrying a prebuilt binary per platform behind a launcher that picks the matching one — pinned per repo, so everyone (and every agent) touching that repo runs the same khub:
npm install -D @endgame-build/khub # exact per-repo pin (required: later commands run it)
npx @endgame-build/khub init firm-ops ./my-hub # scaffold .khub/ (+ templates, singletons), wire agent files
cd my-hub
npx skills add endgame-build/khub # the agent skills (Claude Code: the plugin, below)
With no version after the name, npx @endgame-build/khub runs the version package.json pins.
Upgrading a repo is a one-line package.json bump in a PR: any on-disk byte changes a release makes land in that reviewed commit, not in everyone's unrelated diffs. More in docs/getting-started.md.
Author entities. Referential integrity hard-fails on write (a relation to a missing target is rejected), but a missing field never blocks capture:
khub add client --name "Acme Corp" --industry manufacturing
khub add person --name "Dana Lee" --role partner
khub add project --title "Acme Diagnostic" --client acme-corp --owner dana-lee
Then walk the graph and gate it:
khub query --type project # → project/acme-diagnostic, with orphan/stale flags
khub neighbors acme-diagnostic # → its client and owner edges
khub check # graph-wide: relations resolve, nothing dangling → passed
Every read command takes --format json for an agent and prints a Rich table for a human.
Headless by design. Every input is a flag; a missing one is a usage error, never a prompt. khub carried an interactive wizard through 0.8.0 behind a gate that switched it off for agents, pipes, and CI — dead weight on exactly the invocation khub is built for. Removed in 0.9.0, along with the --agent flag that disabled it.
| Instead of… | What you give up |
|---|---|
| A folder of Markdown / Obsidian | No schema, no typed relations, no integrity gate: nothing rejects a broken or dangling reference, and an agent can't walk the graph. |
| A database | Truth stops being git — no diff, no PR review, no plain-text portability — and the schema lives in migrations instead of one readable file. |
| A vector / RAG store | Retrieval is fuzzy and lossy: no exact relations to traverse, no completeness gate, and writes don't round-trip. |
khub keeps git as the source of truth, then adds a typed schema and a derived graph on top: exact reads, validated writes, and an integrity gate an agent can rely on.
A preset is a canonical ontology for one domain — a directory holding its three layer files (ontology.yaml for entity types, attributes and legal relations; policy.yaml for workspace gates; storage.yaml for layouts and paths) and optional body templates/. khub init copies them into an engagement's .khub/, which agents and humans then extend as the work demands. The base block every entity carries (type, created/updated, tags, the OKF fields, the any → any edges) is embedded in the binary and supplied at resolve time — never copied into a workspace.
Two presets ship today:
firm-ops — the operating graph of a consulting and delivery firm (client, project, person, opportunity, meeting, and more); see docs/firm-ops-preset.md.build-hub — the knowledge hub of a build project: eleven types (prd, arc42, capability, actor, use-case, requirement, adr, system, component, api, repo) in one flat knowledge/; see docs/build-hub-preset.md. build-lite is an alias — khub init build-lite resolves to it and records build-hub. New to khub? Start with docs/getting-started.md.khub ships two skills: khub (the read and write verbs) and setup (install the CLI, set up a project). They live in this repo under plugin/skills/, apart from the binary.
Claude Code — the plugin. The repo is a plugin marketplace. The khub plugin carries both skills and a small session mod. The mod draws a one-line band above the prompt with the entity count, what the session added, removed and edited, the draft count and the check verdict. In a verbose session it also draws each khub call Claude makes as a compact row. See docs/plugin.md.
/plugin marketplace add endgame-build/khub
/plugin install khub@khub
To update the plugin later, run these from a shell, then start a new session or run /reload-plugins:
claude plugin marketplace update khub
claude plugin update khub@khub
Every other agent — npx skills. opencode, Cursor, Codex, Gemini CLI and any other agent that reads a SKILL.md get the two skills, with no mod. Needs Node and access to the repo:
npx skills add endgame-build/khub # both skills, into the agents it finds
npx skills add endgame-build/khub -s setup # only setup, on a machine with no khub yet
On a machine with no khub yet, install setup and ask the agent to set khub up.
Status: v1 engine shipped and in daily use on a live firm-operations corpus that cut over from hand-rolled scripts. Design rationale in docs/design-memo.md; the collections row model in docs/collections-design.md.
Apache License 2.0 — see LICENSE.
hooks/register.tsx 356 lines1// This file is the shell. Every call on `$` lives here, because the engine follows `$`
2// into the hooks module only. The files beside it are pure and hold the logic.
3
4import { atom, memberOf, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { KhubCall, KhubType } from '../types'
8import { summaryLine } from './band'
9import { clean, documentsOf, firstLine, parseJson, refusal } from './cli'
10import { callsOf, classify, mutates } from './parse'
11import { callRow, emptyBlock } from './rows'
12import { bandParts, diffOf, editedBy, EMPTY_SESSION, summaryOf, withEdited, withIds } from './session'
13import { finished, waiting } from './summarize'
14import { ancestors, binCandidates, entityAt, isElsewhere, isOlder, MIN_KHUB, relative } from './workspace'
15import type { Ws } from './workspace'
16
17// The engine reads which state a module touches from atoms that are consts of this file.
18const workspaceAtom = atom({ plugin: 'khub', key: 'workspace' } as const, null)
19const summaryAtom = atom({ plugin: 'khub', key: 'summary' } as const, null)
20const sessionAtom = atom({ plugin: 'khub', key: 'session' } as const, EMPTY_SESSION)
21
22// These two are families with one member per tool_use_id.
23const callRows = atom({ plugin: 'khub', key: 'calls' } as const, null)
24const rawRows = atom({ plugin: 'khub', key: 'rawRows' } as const, false)
25
26type Ran = { exit: number; json: unknown; out: string; note: string; isCut: boolean }
27type SchemaView = { provenance?: { preset?: string; version?: string }; types?: KhubType[] }
28
29// The session's workspace is kept beside its state copy, so hooks read it without a hop.
30let ws: Ws | null = null
31
32// Refreshes run one after another, so an older read never lands on a newer one.
33let refreshing: Promise<void> = Promise.resolve()
34
35// A refresh asked for while one waits to start joins the waiting one.
36let isQueued = false
37
38// Runs khub in the workspace. Exit 0 is success, 1 a failed gate, 2 a refusal, and -1
39// means khub did not run, with the reason in `note`.
40async function khub($: EngineInterface, here: Ws, args: readonly string[]): Promise<Ran> {
41 try {
42 const ran = await $.process.run([...here.bin, '-C', here.root, ...args], { timeoutMs: 20_000 })
43
44 return {
45 exit: ran.exitCode,
46 json: parseJson(ran.stdout),
47 out: ran.stdout,
48 note: ran.stderr.trim(),
49 isCut: ran.isStdoutTruncated,
50 }
51 } catch (error) {
52 return { exit: -1, json: undefined, out: '', note: String(error), isCut: false }
53 }
54}
55
56// Lets work run on after its hook returned. A failure goes to the debug log.
57function background($: EngineInterface, work: Promise<unknown>) {
58 void work.catch(error => $.ui.log(String(error), { to: 'debug' }))
59}
60
61// Reads the resolved schema. A schema that does not resolve is the status line's trouble
62// until it does.
63async function loadSchema($: EngineInterface) {
64 const here = ws
65
66 if (!here) return
67
68 const ran = await khub($, here, ['schema', '--format', 'json'])
69 const view = ran.json as SchemaView | undefined
70
71 if (ran.exit !== 0 || !view?.types) {
72 $.ui.status(clean(`schema: ${firstLine(refusal(ran.json)?.message ?? (ran.out || ran.note))}`))
73
74 return
75 }
76
77 const types = view.types.map(({ name, layout, format, path }) => ({ name, layout, format, path }))
78
79 // The preset's name and version come from workspace files, so they are cleaned like entity text.
80 const workspace = {
81 root: here.root,
82 bin: here.bin,
83 version: here.version,
84 preset: clean(view.provenance?.preset ?? ''),
85 presetVersion: clean(view.provenance?.version ?? ''),
86 }
87
88 ws = { ...workspace, types }
89 $.ui.status(undefined)
90 await update($, workspaceAtom, () => workspace)
91}
92
93// Finds the workspace above `cwd`, resolves khub and loads the schema.
94async function detect($: EngineInterface, cwd: string, binary: string): Promise<'ok' | 'none' | 'no-binary' | 'old'> {
95 ws = null
96
97 let root: string | undefined
98
99 for (const dir of ancestors(cwd)) {
100 if (await $.fs.exists(`${dir}/.khub`)) {
101 root = dir
102 break
103 }
104 }
105
106 if (root === undefined) return 'none'
107
108 let found: Ws | null = null
109
110 for (const bin of binCandidates(root, binary)) {
111 const program = bin[0] as string
112
113 if (program.startsWith('/') && !(await $.fs.exists(program))) continue
114
115 try {
116 const ran = await $.process.run([...bin, '--version'], { timeoutMs: 5_000 })
117
118 if (ran.exitCode === 0) {
119 found = { root, bin, version: ran.stdout.trim(), preset: '', presetVersion: '', types: [] }
120 break
121 }
122 } catch {
123 // This candidate is not installed, so the next one is tried.
124 }
125 }
126
127 if (found === null) return 'no-binary'
128
129 // An older khub prints documents the mod misreads, so every hook passes through.
130 if (isOlder(found.version, MIN_KHUB)) return 'old'
131
132 ws = found
133 await loadSchema($)
134
135 return 'ok'
136}
137
138// Reads the gate and the entities. A cut document, or a call that did not answer, keeps
139// what the band shows.
140async function runRefresh($: EngineInterface) {
141 const here = ws
142
143 if (!here) return
144
145 const [check, query] = await Promise.all([
146 khub($, here, ['check', '--format', 'json']),
147 khub($, here, ['query', '--format', 'json']),
148 ])
149 const found = check.isCut || query.isCut || query.exit !== 0 ? null : summaryOf(check.json, query.json)
150
151 if (found === null) return
152
153 await update($, summaryAtom, () => found.summary)
154 await update($, sessionAtom, session => withIds(session, found.ids))
155}
156
157// Queues a refresh behind the running one. The first refresh of a session fixes the baseline.
158function refresh($: EngineInterface): Promise<void> {
159 if (isQueued) return refreshing
160
161 isQueued = true
162
163 const run = () => {
164 isQueued = false
165
166 return runRefresh($)
167 }
168
169 refreshing = refreshing.then(run, run)
170
171 return refreshing
172}
173
174// Looks for the workspace and khub. The status line says why khub cannot be asked, and
175// the engine draws that line as a warning under the plugin's name.
176async function start($: EngineInterface, cwd: string, binary: string) {
177 const found = await detect($, cwd, binary)
178
179 if (found === 'no-binary') $.ui.status('not installed')
180 if (found === 'old') $.ui.status(`needs khub ${MIN_KHUB} or newer`)
181 if (found === 'ok') await refresh($)
182}
183
184// Follows an Edit or a Write of a file khub reads. An entity file counts its entity as
185// edited, and a schema file is read again before the band.
186async function afterFile($: EngineInterface, path: string) {
187 const here = ws
188 const inside = here ? relative(here, path) : null
189
190 if (!here || inside === null) return
191
192 if (inside.startsWith('.khub/')) {
193 background($, loadSchema($).then(() => refresh($)))
194
195 return
196 }
197
198 const entity = entityAt(here, path)
199
200 if (entity === null) return
201
202 // A collection file holds many entities, so it names none.
203 if (entity.slug !== '*') {
204 const id = `${entity.type}/${entity.slug}`
205
206 await update($, sessionAtom, session => withEdited(session, [id]))
207 }
208
209 background($, refresh($))
210}
211
212export const register: Register = (on, options) => {
213 const binary = String(options.binary ?? '')
214
215 // The session's directory is kept for a later look when khub could not be asked.
216 let cwd = ''
217
218 // The baseline is taken before the first prompt, so no write lands inside it.
219 on('session.start', async ($, e, next) => {
220 cwd = e.cwd
221 await start($, cwd, binary)
222
223 return next(e)
224 })
225
226 // A turn may follow edits made outside Claude, so the schema and the band are read again.
227 // Without a workspace the mod looks again, so trouble clears once its cause is gone.
228 on('turn.start', ($, e, next) => {
229 if (ws) background($, loadSchema($).then(() => refresh($)))
230 else if (cwd !== '') background($, start($, cwd, binary))
231
232 return next(e)
233 })
234
235 // The call runs unchanged and the model reads what khub printed. A simple call and a
236 // chain of calls get a compact row, and a call that changes the workspace is followed
237 // by a refresh.
238 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
239 if (!ws) return next(e)
240
241 const here = ws
242 const parsed = classify(e.command)
243 const calls = callsOf(parsed).filter(call => !isElsewhere(here, call.workspace))
244
245 if (calls.length === 0) return next(e)
246
247 const simple = parsed.kind === 'simple' ? parsed : null
248
249 // A chain keeps its row only while every call in it is aimed at this workspace.
250 const chain = parsed.kind === 'chain' && calls.length === parsed.calls.length ? parsed.calls : null
251 const shown = simple ? [simple] : (chain ?? [])
252 const row = memberOf(callRows, { requestId: e.tool_use_id ?? '' })
253
254 if (shown.length > 0) await update($, row, (): KhubCall[] => waiting(shown))
255
256 const startedAt = await $.clock.now()
257 const ran = await next(e)
258 const ms = (await $.clock.now()) - startedAt
259
260 // A call the user or a hook refused never ran, so its row is the engine's.
261 if (ran.deny !== undefined) {
262 if (shown.length > 0) await update($, row, () => null)
263
264 return ran
265 }
266
267 if (shown.length > 0) {
268 // A failed gate or a refusal arrives as an error whose text is what khub printed.
269 const result = (ran.isError === true ? undefined : ran.result) as
270 | { stdout?: string; stderr?: string; persistedOutputPath?: string }
271 | undefined
272 const stdout = ran.isError === true ? (ran.text ?? '') : (result?.stdout ?? '')
273 const stderr = result?.stderr ?? ''
274
275 // A simple call may print prose, or notes around its document. A chain must print
276 // one document per call, and one that does not is the engine's row.
277 const docs = simple ? [parseJson(stdout)] : documentsOf(stdout, shown.length)
278 const isProse = simple !== null && docs?.[0] === undefined
279
280 // Output the engine moved to disk may be cut here. A cut document is the engine's row.
281 const isCut = isProse && result?.persistedOutputPath !== undefined
282
283 // A prose command prints no document, so its first line is the result. stderr does
284 // not say which call of a chain wrote it, so a chain's rows carry no note.
285 const note = simple ? (isProse ? stdout || stderr : stderr) : ''
286 const edited = docs === null ? [] : shown.flatMap((call, i) => editedBy(call, docs[i]))
287
288 await update($, row, (): KhubCall[] | null => (docs === null || isCut ? null : finished(shown, docs, ms, note)))
289 if (edited.length > 0) await update($, sessionAtom, session => withEdited(session, edited))
290 }
291
292 if (calls.some(mutates)) background($, refresh($))
293
294 return ran
295 }).catch(async ($, e, next) => {
296 // A failure in the mod hands the row back to the engine and passes the call on.
297 await update($, memberOf(callRows, { requestId: e.tool_use_id ?? '' }), () => null)
298
299 return next(e)
300 })
301
302 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
303 const ran = await next(e)
304
305 if (ran.deny === undefined && ran.isError !== true) await afterFile($, e.file_path)
306
307 return ran
308 }).catch(($, e, next) => next(e))
309
310 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
311 const ran = await next(e)
312
313 if (ran.deny === undefined && ran.isError !== true) await afterFile($, e.file_path)
314
315 return ran
316 }).catch(($, e, next) => next(e))
317
318 // The compact row holds a call's head, its result and its list, so it stands for the
319 // whole row. The `json` button hands the row back to the engine.
320 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
321 if (!ws || e.props.tool !== 'Bash') return next(e)
322
323 const calls = await read($, memberOf(callRows, e))
324
325 if (calls === null || (await read($, memberOf(rawRows, e)))) return next(e)
326
327 return callRow($.ui.resolve(e), calls, () => background($, update($, memberOf(rawRows, e), () => true)))
328 })
329
330 // The detailed transcript draws a call's result as a block of its own. The compact row
331 // already shows the result, so the block of a row the mod drew is drawn as nothing.
332 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
333 if (!ws || e.props.tool !== 'Bash') return next(e)
334
335 const calls = await read($, memberOf(callRows, e))
336
337 if (calls === null || (await read($, memberOf(rawRows, e)))) return next(e)
338
339 return emptyBlock($.ui.resolve(e))
340 })
341
342 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
343 if (!ws || e.props.hasSurvey) return next(e)
344
345 const summary = await read($, summaryAtom)
346 const workspace = await read($, workspaceAtom)
347
348 // Before the first refresh there is nothing to say yet.
349 if (summary === null || workspace === null) return next(e)
350
351 const parts = bandParts(workspace.preset, workspace.presetVersion, summary, diffOf(await read($, sessionAtom)))
352
353 return summaryLine($.ui.resolve(e), parts, !summary.passed, e.props.bodyColumns)
354 })
355}
356hooks/band.tsx 27 lines1import type { RenderElement } from 'claude-code'
2
3import { toneColor } from './el'
4import type { El } from './el'
5import type { Part } from './session'
6
7// Draws the one line above the prompt, cut to `columns`. A failing check draws the line red.
8// Otherwise a plain part is dim and a toned part takes its color.
9export function summaryLine({ Box, Text }: El, parts: Part[], isFailing: boolean, columns: number): RenderElement {
10 const style = (part: Part) => (isFailing ? { color: 'red' } : part.tone === 'plain' ? { dimColor: true } : toneColor(part.tone))
11
12 return (
13 <Box width={columns}>
14 <Box flexShrink={0} marginRight={2}>
15 <Text {...(isFailing ? { color: 'red' } : { dimColor: true })}>khub</Text>
16 </Box>
17 <Box flexShrink={1}>
18 <Text wrap="truncate-end">
19 {parts.map(part => (
20 <Text {...style(part)}>{part.text}</Text>
21 ))}
22 </Text>
23 </Box>
24 </Box>
25 )
26}
27hooks/cli.ts 67 lines1// This file reads khub's output.
2
3export type Row = Record<string, unknown>
4export type Refusal = { code: string; message: string }
5
6// Returns the lines that may hold a JSON document. khub prints each document on one line.
7const documentLines = (text: string) => text.split('\n').filter(line => /^[[{]/.test(line))
8
9// Returns the first JSON document in khub's output. A `2>&1` call may carry `note:` lines
10// around it.
11export function parseJson(text: string): unknown {
12 const trimmed = text.trim()
13
14 for (const candidate of [trimmed, ...documentLines(trimmed)]) {
15 try {
16 return JSON.parse(candidate)
17 } catch {
18 continue
19 }
20 }
21
22 return undefined
23}
24
25// Returns one JSON document per call of a chain, or null when the output does not hold
26// exactly `count` of them.
27export function documentsOf(text: string, count: number): unknown[] | null {
28 const lines = documentLines(text)
29
30 if (lines.length !== count) return null
31
32 try {
33 return lines.map(line => JSON.parse(line) as unknown)
34 } catch {
35 return null
36 }
37}
38
39// khub's error envelope is `{"error": {"code", "message"}}`, printed on stdout with exit 2.
40export function refusal(json: unknown): Refusal | undefined {
41 const error = (json as { error?: Partial<Refusal> } | undefined)?.error
42
43 return typeof error?.code === 'string' ? { code: error.code, message: String(error.message ?? '') } : undefined
44}
45
46// These `khub check` buckets never fail the gate. Orphans fail it under `--strict`.
47export const INFORMATIONAL = new Set(['draft_singletons', 'orphans', 'thin'])
48
49export const isRow = (value: unknown): value is Row =>
50 typeof value === 'object' && value !== null && !Array.isArray(value)
51
52export const rowsOf = (json: unknown): Row[] => (Array.isArray(json) ? json.filter(isRow) : [])
53
54export const text = (value: unknown) => (value === undefined || value === null ? '' : String(value))
55
56export const count = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
57
58// Returns the first line that says something.
59export const firstLine = (lines: string) =>
60 lines
61 .split('\n')
62 .map(line => line.trim())
63 .find(line => line !== '') ?? ''
64
65// Entity text is untrusted, so control characters never reach the terminal.
66export const clean = (value: string) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
67hooks/parse.ts 380 lines1// This file recognizes khub invocations in a Bash command string.
2
3export type Simple = {
4 kind: 'simple'
5 verb: string
6
7 // Holds the second word of a two-word verb, such as `show` in `schema show adr`.
8 sub: string | null
9
10 // Holds the value of `-C` or `--workspace`, when the call names one.
11 workspace: string | null
12 positional: string[]
13 flags: Record<string, string | true>
14}
15
16// `simple` is one khub call whose stdout is khub's alone. `chain` is several such calls
17// joined by `&&` or `;`. `compound` runs khub among other things, and lists the khub
18// calls it could read.
19export type Parsed =
20 | { kind: 'none' }
21 | { kind: 'compound'; calls: Simple[] }
22 | { kind: 'chain'; calls: Simple[] }
23 | Simple
24
25type Word = { text: string }
26type Operator = { op: '&&' | '||' | ';' | '|' | '&' | '>out' | '>err' | '<' }
27type Token = Word | Operator
28type Segment = { words: string[]; ops: string[] }
29
30const NONE: Parsed = { kind: 'none' }
31const BOOLEAN_FLAGS = new Set([
32 'active', 'draft', 'dry-run', 'edges', 'force', 'help', 'in', 'no-schema',
33 'no-template', 'no-wire', 'open', 'orphan', 'out', 'plain', 'reverse', 'stale', 'strict',
34])
35const WRITES = new Set(['add', 'edit', 'link', 'unlink', 'remove'])
36const OPERATORS = new Set(['backfill', 'init', 'reindex', 'upgrade', 'wire'])
37const SCHEMA_VIEWS = new Set(['base', 'diff', 'edges', 'show', 'snapshot', 'types'])
38
39// These flags of `khub edit` name no field.
40const EDIT_OPTIONS = new Set(['format', 'strict'])
41
42const isWord = (token: Token): token is Word => 'text' in token
43const isOn = (flag: string | true | undefined) => flag !== undefined && flag !== 'false'
44const call = (verb: string, flags: Simple['flags'] = {}): Simple => ({
45 kind: 'simple',
46 verb,
47 sub: null,
48 workspace: null,
49 positional: [],
50 flags,
51})
52
53// Returns the index of the `)` that closes the `$(` opening at `start`, or -1.
54function closeOf(command: string, start: number): number {
55 let depth = 0
56
57 for (let i = start + 1; i < command.length; i += 1) {
58 const c = command[i]
59
60 if (c === '\\') {
61 i += 1
62 } else if (c === "'") {
63 const end = command.indexOf("'", i + 1)
64
65 if (end < 0) return -1
66 i = end
67 } else if (c === '(') {
68 depth += 1
69 } else if (c === ')') {
70 depth -= 1
71 if (depth === 0) return i
72 }
73 }
74
75 return -1
76}
77
78// Returns the command with the body of the heredoc that opens at `at` (just past `<<`) cut
79// out, and the index where reading goes on. It is null when the heredoc never closes.
80function withoutHeredoc(command: string, at: number): { command: string; next: number } | null {
81 const opener = /^-?\s*(['"]?)([^\s'"<>|;&]+)\1/.exec(command.slice(at))
82 const lineEnd = command.indexOf('\n', at)
83
84 if (!opener || lineEnd < 0) return null
85
86 const stripsTabs = command[at] === '-'
87 const lines = command.slice(lineEnd + 1).split('\n')
88 const closing = lines.findIndex(line => (stripsTabs ? line.replace(/^\t+/, '') : line) === opener[2])
89
90 if (closing < 0) return null
91
92 return {
93 command: [command.slice(0, lineEnd), ...lines.slice(closing + 1)].join('\n'),
94 next: at + opener[0].length,
95 }
96}
97
98// Splits a command into words and operators. A substitution stays inside its word,
99// unread. Returns null for a construct it does not follow, such as a subshell or an
100// unterminated quote.
101export function tokenize(input: string): Token[] | null {
102 const tokens: Token[] = []
103 let command = input
104 let word: string | null = null
105 let isTarget = false
106 let i = 0
107
108 // A redirection's target is read as a word and dropped.
109 const flush = () => {
110 if (word !== null && !isTarget) tokens.push({ text: word })
111 if (word !== null) isTarget = false
112 word = null
113 }
114
115 // Takes a `$(…)` or a backtick span whole into the current word.
116 const substitution = (): boolean => {
117 const end = command[i] === '`' ? command.indexOf('`', i + 1) : closeOf(command, i)
118
119 if (end < 0) return false
120 word = (word ?? '') + command.slice(i, end + 1)
121 i = end + 1
122
123 return true
124 }
125
126 while (i < command.length) {
127 const c = command[i] as string
128 const pair = command.slice(i, i + 2)
129
130 if (c === "'") {
131 const end = command.indexOf("'", i + 1)
132
133 if (end < 0) return null
134 word = (word ?? '') + command.slice(i + 1, end)
135 i = end + 1
136 } else if (c === '"') {
137 word ??= ''
138 i += 1
139
140 while (command[i] !== '"') {
141 if (i >= command.length) return null
142
143 if (command[i] === '`' || command.slice(i, i + 2) === '$(') {
144 if (!substitution()) return null
145 continue
146 }
147
148 if (command[i] === '\\' && '"\\$`'.includes(command[i + 1] ?? '')) i += 1
149 word += command[i]
150 i += 1
151 }
152
153 i += 1
154 } else if (c === '\\') {
155 word = (word ?? '') + (command[i + 1] ?? '')
156 i += 2
157 } else if (c === '`' || pair === '$(') {
158 if (!substitution()) return null
159 } else if (c === '(' || c === ')') {
160 return null
161 } else if (c === ' ' || c === '\t') {
162 flush()
163 i += 1
164 } else if (c === '\n' || c === ';') {
165 flush()
166 tokens.push({ op: ';' })
167 i += 1
168 } else if (pair === '&&' || pair === '||') {
169 flush()
170 tokens.push({ op: pair })
171 i += 2
172 } else if (pair === '<<' && command[i + 2] !== '<') {
173 const cut = withoutHeredoc(command, i + 2)
174
175 if (cut === null) return null
176 flush()
177 tokens.push({ op: '<' })
178 command = cut.command
179 i = cut.next
180 } else if (c === '>' || c === '<' || pair === '&>') {
181 // A digit word straight before `>` is the descriptor, such as `2` in `2>`.
182 const fd = pair !== '&>' && word !== null && /^\d+$/.test(word) ? word : ''
183
184 if (fd === '') flush()
185 word = null
186 i += pair === '&>' ? 2 : 1
187 while (command[i] === '>' || command[i] === '<') i += 1
188 tokens.push({ op: c === '<' ? '<' : fd === '2' ? '>err' : '>out' })
189
190 if (command[i] === '&') {
191 i += 1
192 while (/\d/.test(command[i] ?? '')) i += 1
193 } else {
194 isTarget = true
195 }
196 } else if (c === '|' || c === '&') {
197 flush()
198 tokens.push({ op: c })
199 i += 1
200 } else {
201 word = (word ?? '') + c
202 i += 1
203 }
204 }
205
206 flush()
207
208 return tokens
209}
210
211// Reads one segment's words as a khub call, or null when it runs something else.
212function khubCall(words: string[]): Simple | null {
213 let i = 0
214
215 while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] ?? '')) i += 1
216
217 const binary = words[i] ?? ''
218
219 if (binary === 'npx') {
220 i += 1
221 while ((words[i] ?? '').startsWith('-')) i += 1
222 if (!/^@endgame-build\/khub(@|$)/.test(words[i] ?? '')) return null
223 } else if (binary !== 'khub' && !binary.endsWith('/khub')) {
224 return null
225 }
226
227 i += 1
228
229 let workspace: string | null = null
230
231 // Global options come before the verb.
232 for (;;) {
233 const word = words[i] ?? ''
234
235 if (word === '-C' || word === '--workspace') {
236 workspace = words[i + 1] ?? null
237 i += 2
238 } else if (word.startsWith('--workspace=')) {
239 workspace = word.slice('--workspace='.length)
240 i += 1
241 } else {
242 break
243 }
244 }
245
246 const verb = words[i] ?? ''
247
248 if (verb === '' || verb.startsWith('-')) return { ...call(verb.replace(/^--/, '')), workspace }
249
250 i += 1
251
252 const found: Simple = { ...call(verb), workspace }
253
254 if (verb === 'schema' && SCHEMA_VIEWS.has(words[i] ?? '')) {
255 found.sub = words[i] as string
256 i += 1
257 }
258
259 while (i < words.length) {
260 const word = words[i] as string
261
262 if (!word.startsWith('--')) {
263 found.positional.push(word)
264 i += 1
265 continue
266 }
267
268 const equals = word.indexOf('=')
269 const name = word.slice(2, equals < 0 ? undefined : equals)
270 const next = words[i + 1]
271
272 if (equals >= 0) {
273 found.flags[name] = word.slice(equals + 1)
274 i += 1
275 } else if (BOOLEAN_FLAGS.has(name) || next === undefined || next.startsWith('--')) {
276 found.flags[name] = true
277 i += 1
278 } else {
279 found.flags[name] = next
280 i += 2
281 }
282 }
283
284 return found
285}
286
287// Reads the khub calls off the text of a command the tokenizer could not follow. It keeps
288// each verb, and whether `--force` or `--dry-run` follows it.
289function scan(command: string): Simple[] {
290 const calls = command.matchAll(
291 /(?:^|[\s;&|(`])(?:[^\s;&|]*\/)?khub\s+(?:(?:-C|--workspace)\s+\S+\s+)?([a-z][a-z-]*)([^;&|\n]*)/g,
292 )
293
294 return [...calls].map(match => {
295 const rest = match[2] ?? ''
296
297 return call(match[1] as string, {
298 ...(rest.includes('--force') ? { force: true as const } : {}),
299 ...(rest.includes('--dry-run') ? { 'dry-run': true as const } : {}),
300 })
301 })
302}
303
304export function classify(command: string): Parsed {
305 if (!command.includes('khub')) return NONE
306
307 const tokens = tokenize(command)
308
309 if (tokens === null) {
310 const calls = scan(command)
311
312 return calls.length === 0 ? NONE : { kind: 'compound', calls }
313 }
314
315 // Collects segments between control operators, with the operators and redirections each saw.
316 const segments: Segment[] = [{ words: [], ops: [] }]
317 const separators: string[] = []
318
319 for (const token of tokens) {
320 const segment = segments[segments.length - 1] as Segment
321
322 if (isWord(token)) {
323 segment.words.push(token.text)
324 } else if (token.op === '>out' || token.op === '>err' || token.op === '<') {
325 segment.ops.push(token.op)
326 } else {
327 separators.push(token.op)
328 segments.push({ words: [], ops: [] })
329 }
330 }
331
332 const filled = segments.filter(segment => segment.words.length > 0)
333 const read = filled.map(segment => khubCall(segment.words))
334 const calls = read.filter((found): found is Simple => found !== null)
335
336 if (calls.length === 0) return NONE
337
338 const firstKhub = read.findIndex(found => found !== null)
339
340 // The output is khub's alone when nothing but `cd <dir>` comes before the first khub call,
341 // every part from there on is a khub call with stdout left alone, and `&&` or `;` joins them.
342 const isKhubOnly =
343 separators.every(op => op === '&&' || op === ';') &&
344 filled.every((segment, i) =>
345 i < firstKhub
346 ? segment.words[0] === 'cd' && segment.words.length === 2
347 : read[i] !== null && !segment.ops.includes('>out'),
348 )
349
350 if (!isKhubOnly) return { kind: 'compound', calls }
351
352 return calls.length === 1 ? (calls[0] as Simple) : { kind: 'chain', calls }
353}
354
355// Returns the khub calls of a command, however it was read.
356export const callsOf = (parsed: Parsed): Simple[] =>
357 parsed.kind === 'simple' ? [parsed] : parsed.kind === 'none' ? [] : parsed.calls
358
359export const isWrite = (found: Simple) => WRITES.has(found.verb)
360
361// Tells whether a call changes entities, the schema, the index or the agent files.
362export const mutates = (found: Simple) =>
363 isWrite(found) ||
364 (OPERATORS.has(found.verb) && !isOn(found.flags['dry-run'])) ||
365 (found.verb === 'schema' && found.sub === 'snapshot')
366
367// Returns what a `khub edit` call changed, as `<field> <value>` pairs. A body is named,
368// never quoted.
369export function edited(found: Simple): string {
370 const [, field, value] = found.positional
371 const pairs = field === undefined ? [] : [field === 'body' ? 'body' : `${field} ${value ?? ''}`.trim()]
372
373 for (const [name, flag] of Object.entries(found.flags)) {
374 if (EDIT_OPTIONS.has(name)) continue
375 pairs.push(name === 'body' || name === 'body-file' ? 'body' : flag === true ? name : `${name} ${flag}`)
376 }
377
378 return pairs.join(', ')
379}
380hooks/rows.tsx 128 lines1// This file draws the transcript row of a command's khub calls. Each call gets a head line,
2// a result line, and the first rows of a list.
3
4import type { RenderElement } from 'claude-code'
5
6import type { KhubCall, KhubRow, KhubTone } from '../types'
7import { toneColor as tint } from './el'
8import type { El } from './el'
9
10// Caps the padding of a list's first column.
11const TEXT_MAX = 48
12
13const duration = (ms: number) => (ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`)
14
15// Splits a result line into `[mark, text, check]`. An ok line is colored on its leading sign and
16// its trailing check alone; a warning or an error is colored whole.
17function partsOf(line: string, tone: KhubTone): [string, string, string] {
18 if (tone !== 'ok') return ['', line, '']
19
20 const sign = /^[+~→⇢−] /.exec(line)?.[0] ?? ''
21 const check = line.endsWith(' ✓') ? ' ✓' : ''
22
23 return [sign, line.slice(sign.length, line.length - check.length), check]
24}
25
26// Draws the head line. `onRaw` flips the row to the engine's own drawing, and only the
27// first call of a command carries its button.
28function headLine({ Box, Text, Button }: El, call: KhubCall, onRaw: (() => void) | null): RenderElement {
29 return (
30 <Box>
31 <Box flexShrink={0}>
32 <Text {...tint(call.tone)} dimColor={call.isRunning}>
33 {'● '}
34 </Text>
35 </Box>
36 <Text bold wrap="truncate-end">
37 {call.head}
38 </Text>
39 <Box flexShrink={0}>
40 {call.ms > 0 && <Text dimColor>{` ${duration(call.ms)}`}</Text>}
41 {onRaw !== null && <Text> </Text>}
42 {onRaw !== null && <Button key="json" label="json" plain dimColor onPress={onRaw} />}
43 </Box>
44 </Box>
45 )
46}
47
48// Draws ` ⎿ ` and what the call answered. The text is the one part that truncates.
49function resultLine({ Box, Text }: El, call: KhubCall): RenderElement {
50 if (call.line === '') {
51 return (
52 <Box>
53 <Text dimColor>{call.isRunning ? ' ⎿ …' : ' ⎿ (no output)'}</Text>
54 </Box>
55 )
56 }
57
58 const [sign, text, check] = partsOf(call.line, call.tone)
59 const whole = call.tone === 'ok' ? {} : tint(call.tone)
60
61 return (
62 <Box>
63 <Box flexShrink={0}>
64 <Text dimColor>{' ⎿ '}</Text>
65 {sign !== '' && <Text {...tint(call.tone)}>{sign}</Text>}
66 </Box>
67 <Text {...whole} wrap="truncate-end">
68 {text}
69 </Text>
70 {check !== '' && (
71 <Box flexShrink={0}>
72 <Text {...tint(call.tone)}>{check}</Text>
73 </Box>
74 )}
75 </Box>
76 )
77}
78
79// Draws one list row under the result line: the padded first column, the note, the dim flags.
80function listRow({ Box, Text }: El, row: KhubRow, width: number): RenderElement {
81 return (
82 <Box>
83 <Box flexShrink={0}>
84 <Text>{` ${row.text.padEnd(width)}`}</Text>
85 </Box>
86 <Text wrap="truncate-end">{row.note === '' ? '' : ` ${row.note}`}</Text>
87 {row.flags.length > 0 && (
88 <Box flexShrink={0}>
89 <Text dimColor>{` ${row.flags.join(' ')}`}</Text>
90 </Box>
91 )}
92 </Box>
93 )
94}
95
96// Draws the lines of one khub call. The list's first column is padded to its widest id.
97function callLines(el: El, call: KhubCall, onRaw: (() => void) | null): RenderElement[] {
98 const { Box, Text } = el
99 const width = Math.min(TEXT_MAX, Math.max(0, ...call.rows.map(row => row.text.length)))
100
101 return [
102 headLine(el, call, onRaw),
103 resultLine(el, call),
104 ...call.rows.map(row => listRow(el, row, width)),
105 ...(call.more > 0
106 ? [
107 <Box>
108 <Text dimColor>{` … ${call.more} more`}</Text>
109 </Box>,
110 ]
111 : []),
112 ]
113}
114
115// Draws the whole row of a command's khub calls, one after another. The engine may draw a
116// call's result inside its ToolUse row, so this one tree holds the heads, the results and
117// the lists.
118export function callRow(el: El, calls: KhubCall[], onRaw: () => void): RenderElement {
119 const { Box } = el
120
121 return <Box flexDirection="column">{calls.flatMap((call, i) => callLines(el, call, i === 0 ? onRaw : null))}</Box>
122}
123
124// Draws a result block with nothing in it, for a call whose row already shows the result.
125export function emptyBlock({ Box }: El): RenderElement {
126 return <Box />
127}
128hooks/session.ts 85 lines1// This file tracks what the session changed and builds the band's line. The functions are pure.
2
3import type { KhubSession, KhubSummary, KhubTone } from '../types'
4import { count, isRow, refusal, rowsOf, text } from './cli'
5import type { Simple } from './parse'
6import { failingOf } from './summarize'
7
8export const EMPTY_SESSION: KhubSession = { baseline: null, current: [], edited: [] }
9
10// These verbs change an entity in place. An add or a remove moves the id set instead.
11const EDITS = new Set(['edit', 'link', 'unlink'])
12
13// Returns what a refresh read from `khub check` and `khub query`, or null when either
14// document is not the one khub prints.
15export function summaryOf(check: unknown, query: unknown): { summary: KhubSummary; ids: string[] } | null {
16 if (!isRow(check) || typeof check.passed !== 'boolean' || !Array.isArray(query)) return null
17
18 const rows = rowsOf(query)
19 const errors = failingOf(check).reduce((sum, found) => sum + found.items.length, 0)
20
21 return {
22 summary: { total: rows.length, draft: rows.filter(row => row.draft === true).length, passed: check.passed, errors },
23 ids: rows.map(row => text(row.id)).filter(id => id !== ''),
24 }
25}
26
27// Returns the entity a khub call changed in place, by qualified id. A refusal and an edge
28// call that changed nothing name none.
29export function editedBy(call: Simple, json: unknown): string[] {
30 if (!EDITS.has(call.verb) || !isRow(json) || refusal(json) || json.changed === false) return []
31
32 const id = text(json.id)
33
34 return id === '' ? [] : [id]
35}
36
37// Returns the session after a refresh read `ids`. The first read fixes the baseline.
38export const withIds = (session: KhubSession, ids: string[]): KhubSession => ({
39 ...session,
40 baseline: session.baseline ?? ids,
41 current: ids,
42})
43
44// Returns the session after its tools changed the entities `ids` name.
45export function withEdited(session: KhubSession, ids: string[]): KhubSession {
46 const fresh = ids.filter(id => !session.edited.includes(id))
47
48 return fresh.length === 0 ? session : { ...session, edited: [...session.edited, ...fresh] }
49}
50
51export type Diff = { added: number; removed: number; edited: number }
52
53// Counts against the baseline. An entity added and then edited counts as added alone.
54export function diffOf(session: KhubSession): Diff {
55 const baseline = new Set(session.baseline ?? session.current)
56 const current = new Set(session.current)
57
58 return {
59 added: session.current.filter(id => !baseline.has(id)).length,
60 removed: [...baseline].filter(id => !current.has(id)).length,
61 edited: session.edited.filter(id => baseline.has(id) && current.has(id)).length,
62 }
63}
64
65export type Part = { text: string; tone: KhubTone }
66
67// Returns the band's line after its `khub` label, such as
68// `build-hub 0.6.0 · 87 entities [+2 −1 ~3] · 4 draft · check ✓`. A plain part draws dim.
69export function bandParts(preset: string, version: string, summary: KhubSummary, diff: Diff): Part[] {
70 const plain = (text: string): Part => ({ text, tone: 'plain' })
71 const changes: Part[] = [
72 ...(diff.added > 0 ? [{ text: `+${diff.added}`, tone: 'ok' as const }] : []),
73 ...(diff.removed > 0 ? [{ text: `−${diff.removed}`, tone: 'bad' as const }] : []),
74 ...(diff.edited > 0 ? [plain(`~${diff.edited}`)] : []),
75 ]
76 const name = [preset, version].filter(word => word !== '').join(' ')
77 const check = summary.passed ? 'check ✓' : `check ✗ ${count(summary.errors, 'error')}`
78
79 return [
80 plain(`${name === '' ? '' : `${name} · `}${count(summary.total, 'entity', 'entities')}`),
81 ...(changes.length > 0 ? [plain(' ['), ...changes.flatMap((part, i) => (i > 0 ? [plain(' '), part] : [part])), plain(']')] : []),
82 plain(`${summary.draft > 0 ? ` · ${summary.draft} draft` : ''} · ${check}`),
83 ]
84}
85hooks/summarize.ts 282 lines1import type { KhubCall, KhubRow, KhubTone } from '../types'
2import { clean, count, firstLine, INFORMATIONAL, isRow, refusal, rowsOf, text } from './cli'
3import type { Row } from './cli'
4import { edited } from './parse'
5import type { Simple } from './parse'
6
7export type Summary = { head: string; line: string; tone: KhubTone }
8
9type Result = { line: string; tone: KhubTone }
10
11// The first positional of these verbs names what the call is about, a type for add and an
12// id for the rest.
13const SUBJECT_VERBS = new Set([
14 'add', 'edit', 'get', 'history', 'impact', 'link', 'neighbors', 'remove', 'unlink', 'validate',
15])
16
17const NOTHING: Result = { line: '', tone: 'plain' }
18
19const sizeOf = (value: unknown) => (Array.isArray(value) ? value.length : 0)
20
21// Returns the title a record carries, at its top or in its frontmatter. An entity is
22// titled by `title` or by `name`, so both are read.
23function titleOf(row: Row): string {
24 const meta = isRow(row.frontmatter) ? row.frontmatter : row
25
26 return text(meta.title ?? meta.name)
27}
28
29// Returns the buckets of a `khub check` payload that fail the gate, each with its findings.
30export function failingOf(check: Row): Array<{ bucket: string; items: unknown[] }> {
31 return Object.entries(check)
32 .filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1]) && entry[1].length > 0)
33 .filter(([bucket]) => !INFORMATIONAL.has(bucket) || (bucket === 'orphans' && check.strict === true))
34 .map(([bucket, items]) => ({ bucket, items }))
35}
36
37function subjectOf(call: Simple): string {
38 if (call.verb === 'query') return typeof call.flags.type === 'string' ? call.flags.type : ''
39 if (call.verb === 'search') return call.positional[0] === undefined ? '' : `"${call.positional[0]}"`
40 if (call.verb === 'schema') return call.sub === 'show' ? (call.positional[0] ?? '') : ''
41
42 return SUBJECT_VERBS.has(call.verb) ? (call.positional[0] ?? '') : ''
43}
44
45// The subject is text the agent typed, so it is cleaned like entity text.
46const headOf = (call: Simple) =>
47 clean(['khub', call.verb, call.sub ?? '', subjectOf(call)].filter(word => word !== '').join(' '))
48
49function edgeResult(call: Simple, row: Row): Result {
50 if (row.changed === false) {
51 return { line: call.verb === 'link' ? 'edge already present' : 'no such edge', tone: 'plain' }
52 }
53
54 // An edge added draws `→`, an edge taken away `⇢`.
55 const sign = call.verb === 'link' ? '→' : '⇢'
56
57 return { line: `${sign} ${text(row.slug)} ${text(row.predicate)} ${text(row.target)}`, tone: 'ok' }
58}
59
60function searchResult(rows: Row[], note: string): Result {
61 const hits = rows.length === 0 ? 'no hits' : `${count(rows.length, 'hit')} · top ${text(rows[0]?.id)}`
62 const noted = firstLine(note).replace(/^note: /, '').slice(0, 80)
63
64 if (noted === '') return { line: hits, tone: 'plain' }
65
66 // khub's own note for an empty result already says there were no hits.
67 return { line: rows.length === 0 && /^no hits/i.test(noted) ? noted : `${hits} · ${noted}`, tone: 'warn' }
68}
69
70function validateResult(row: Row): Result {
71 const errors = sizeOf(row.errors)
72 const gaps = sizeOf(row.gaps)
73 const line = `${text(row.count)} checked · ${count(errors, 'error')} · ${count(gaps, 'gap')}`
74
75 if (errors > 0) return { line, tone: 'bad' }
76
77 return gaps > 0 ? { line, tone: 'warn' } : { line: `${line} ✓`, tone: 'ok' }
78}
79
80function checkResult(row: Row): Result {
81 if (row.passed === true) return { line: 'passed ✓', tone: 'ok' }
82
83 const failing = failingOf(row)
84
85 return { line: ['failed', ...failing.map(found => `${found.bucket} ${found.items.length}`)].join(' · '), tone: 'bad' }
86}
87
88function schemaResult(call: Simple, json: unknown): Result {
89 const row = isRow(json) ? json : {}
90
91 if (call.sub === null || call.sub === 'types') {
92 return { line: count(sizeOf(Array.isArray(json) ? json : row.types), 'type'), tone: 'plain' }
93 }
94
95 if (call.sub === 'show') {
96 const fields = count(sizeOf(row.fields), 'field')
97
98 return { line: `${text(row.name)} · ${fields} · ${count(sizeOf(row.relations), 'relation')}`, tone: 'plain' }
99 }
100
101 if (call.sub === 'edges') return { line: count(sizeOf(json), 'predicate'), tone: 'plain' }
102
103 if (call.sub === 'diff') {
104 const line = row.pending === true ? `pending · ${count(sizeOf(row.changes), 'change')}` : 'no pending changes'
105
106 return { line, tone: 'plain' }
107 }
108
109 return call.sub === 'snapshot' ? { line: `snapshot · ${count(Number(row.types), 'type')}`, tone: 'ok' } : NOTHING
110}
111
112// Reads what a call's JSON document says, by verb and by the document's shape.
113function resultOf(call: Simple, json: unknown, note: string): Result {
114 const rows = rowsOf(json)
115 const row = isRow(json) ? json : {}
116 const entities = { line: rows.length === 0 ? 'no entities' : count(rows.length, 'entity', 'entities'), tone: 'plain' } as const
117
118 switch (call.verb) {
119 case 'add':
120 return { line: `+ ${text(row.id)}${row.draft === true ? ' · draft' : ''}`, tone: 'ok' }
121
122 case 'edit': {
123 const fields = edited(call)
124
125 return { line: `~ ${text(row.id ?? call.positional[0])}${fields === '' ? '' : ` · ${fields}`}`, tone: 'ok' }
126 }
127
128 case 'link':
129 case 'unlink':
130 return edgeResult(call, row)
131
132 case 'remove':
133 return { line: `− ${text(row.id)}`, tone: 'ok' }
134
135 case 'get': {
136 if (Array.isArray(json)) return entities
137
138 const title = titleOf(row)
139 const edges = Array.isArray(row.edges) ? count(row.edges.length, 'edge') : ''
140
141 return { line: [text(row.id), title, edges].filter(part => part !== '').join(' · '), tone: 'plain' }
142 }
143
144 case 'query':
145 case 'stale':
146 return entities
147
148 case 'search':
149 return searchResult(rows, note)
150
151 case 'neighbors': {
152 const inbound = rows.filter(found => found.direction === 'in').length
153
154 return { line: `${count(rows.length, 'neighbor')} · in ${inbound} · out ${rows.length - inbound}`, tone: 'plain' }
155 }
156
157 case 'impact': {
158 // The depth-0 row is the entity itself.
159 const depths = rows.map(found => Number(found.depth)).filter(depth => depth > 0)
160
161 return { line: `${depths.length} affected · depth ${Math.max(0, ...depths)}`, tone: 'plain' }
162 }
163
164 case 'history':
165 return { line: `${rows.length} in chain`, tone: 'plain' }
166
167 case 'status': {
168 const total = count(Number(row.total), 'entity', 'entities')
169
170 return { line: `${total} · ${text(row.draft)} draft · ${text(row.orphan)} orphan · ${text(row.stale)} stale`, tone: 'plain' }
171 }
172
173 case 'validate':
174 return validateResult(row)
175
176 case 'check':
177 return checkResult(row)
178
179 case 'schema':
180 return schemaResult(call, json)
181
182 default:
183 return { line: firstLine(note), tone: 'plain' }
184 }
185}
186
187// Returns one line for a khub call. `head` names the call and `line` says what came back.
188// `json` is khub's stdout document (undefined when it printed none), `note` its stderr.
189export function summarize(call: Simple, json: unknown, note: string): Summary {
190 const head = headOf(call)
191 const refused = refusal(json)
192
193 if (refused) return { head, line: clean(`✗ ${refused.code}: ${refused.message}`), tone: 'bad' }
194
195 // With no document the call is still running, or it printed prose.
196 if (json === undefined) return { head, line: clean(firstLine(note)), tone: 'plain' }
197
198 const result = resultOf(call, json, note)
199
200 return { head, line: clean(result.line), tone: result.tone }
201}
202
203// Sets how many list rows a call's row shows before `… n more`.
204const PREVIEW = 5
205
206const FLAGS = ['draft', 'orphan', 'stale']
207
208const entityRow = (row: Row): KhubRow => ({
209 text: text(row.id),
210 note: titleOf(row),
211 flags: FLAGS.filter(flag => row[flag] === true),
212})
213
214// Returns one row per finding of a bucket that fails the gate, named by its id, path or alias.
215function findingRows(check: Row): KhubRow[] {
216 return failingOf(check).flatMap(found =>
217 found.items.map(item => ({
218 text: found.bucket,
219 note: isRow(item) ? text(item.id ?? item.path ?? item.alias) : text(item),
220 flags: [],
221 })),
222 )
223}
224
225// Returns every row a call's document lists, by verb.
226function listOf(call: Simple, json: unknown): KhubRow[] {
227 const rows = rowsOf(json)
228 const row = isRow(json) ? json : {}
229
230 switch (call.verb) {
231 case 'get':
232 case 'query':
233 case 'search':
234 case 'stale':
235 return rows.map(entityRow)
236
237 case 'neighbors':
238 return rows.map(found => ({
239 text: text(found.id),
240 note: `${text(found.direction)} ${text(found.predicate)}`.trim(),
241 flags: [],
242 }))
243
244 case 'check':
245 return row.passed === false ? findingRows(row) : []
246
247 case 'validate':
248 return rowsOf(row.errors).map(found => ({
249 text: text(found.id),
250 note: `${text(found.field)}: ${text(found.reason)}`,
251 flags: [],
252 }))
253
254 default:
255 return []
256 }
257}
258
259// Returns the first rows of a list result, and how many more it holds.
260export function previewOf(call: Simple, json: unknown): { rows: KhubRow[]; more: number } {
261 const all = refusal(json) ? [] : listOf(call, json)
262
263 return {
264 rows: all.slice(0, PREVIEW).map(row => ({ ...row, text: clean(row.text), note: clean(row.note) })),
265 more: Math.max(0, all.length - PREVIEW),
266 }
267}
268
269// Returns the rows of a command's calls while the command runs.
270export const waiting = (calls: Simple[]): KhubCall[] =>
271 calls.map(call => ({ ...summarize(call, undefined, ''), isRunning: true, ms: 0, rows: [], more: 0 }))
272
273// Returns the rows of a command's calls once it has answered, each read from its own
274// document. The first row carries the command's duration.
275export const finished = (calls: Simple[], docs: unknown[], ms: number, note: string): KhubCall[] =>
276 calls.map((call, i) => ({
277 ...summarize(call, docs[i], note),
278 isRunning: false,
279 ms: i === 0 ? ms : 0,
280 ...previewOf(call, docs[i]),
281 }))
282hooks/workspace.ts 89 lines1// This file handles paths and layouts of a khub workspace. The functions are pure, and the
2// shell holds the workspace value.
3
4import type { KhubType, KhubWorkspace } from '../types'
5
6export type Entity = { type: string; slug: string }
7export type Ws = KhubWorkspace & { types: KhubType[] }
8
9export const dirname = (path: string) => path.replace(/\/[^/]*$/, '') || '/'
10
11// Lists every directory from `cwd` up to the root, nearest first.
12export function ancestors(cwd: string): string[] {
13 const dirs = [cwd]
14
15 while (dirs[dirs.length - 1] !== '/') dirs.push(dirname(dirs[dirs.length - 1] as string))
16
17 return dirs
18}
19
20// Lists the commands to try for khub, in order: the setting, the repo's pinned install, the path.
21export function binCandidates(root: string, setting: string): string[][] {
22 return setting.trim() ? [setting.trim().split(/\s+/)] : [[`${root}/node_modules/.bin/khub`], ['khub']]
23}
24
25// Returns the path under the workspace root, or null outside it. macOS spells /tmp and /var
26// two ways.
27export function relative(ws: Ws, path: string): string | null {
28 const roots = [ws.root, ws.root.replace(/^\/private/, ''), `/private${ws.root}`]
29 const root = roots.find(candidate => path.startsWith(`${candidate}/`))
30
31 return root === undefined ? null : path.slice(root.length + 1)
32}
33
34// Returns the entity a file holds, by its type's layout. A collection file holds many, so
35// its slug is `*`.
36export function entityAt(ws: Ws, path: string): Entity | null {
37 const rel = relative(ws, path)
38
39 if (rel === null) return null
40
41 for (const type of ws.types) {
42 if (type.layout === 'singleton' || type.layout === 'collection') {
43 if (rel === type.path) return { type: type.name, slug: type.layout === 'singleton' ? type.name : '*' }
44 continue
45 }
46
47 const ext = `.${type.format}`
48
49 if (!rel.startsWith(`${type.path}/`) || !rel.endsWith(ext)) continue
50
51 const parts = rel.slice(type.path.length + 1, -ext.length).split('/')
52
53 if (type.layout === 'file' && parts.length === 1) return { type: type.name, slug: parts[0] as string }
54 if (type.layout === 'folder' && parts.length === 2 && parts[1] === '_index') {
55 return { type: type.name, slug: parts[0] as string }
56 }
57 }
58
59 return null
60}
61
62// The oldest khub whose output the mod reads. `search --plain` and `schema diff`, which the
63// row summaries read, arrived with it.
64export const MIN_KHUB = '0.27.0'
65
66// Tells whether what `khub --version` printed names a release before `min`. Text with no
67// version in it is a development build, which is taken as new enough. A pre-release of
68// `min` is older.
69export function isOlder(printed: string, min: string): boolean {
70 const found = /(\d+)\.(\d+)\.(\d+)(-)?/.exec(printed)
71 const need = min.split('.').map(Number)
72
73 if (!found) return false
74
75 const have = found.slice(1, 4).map(Number)
76
77 for (let i = 0; i < 3; i += 1) {
78 if (have[i] !== need[i]) return (have[i] as number) < (need[i] as number)
79 }
80
81 return found[4] !== undefined
82}
83
84// Tells whether a `-C` value names a workspace other than this one. khub resolves the
85// nearest `.khub` at or above the path, so a directory inside the root is this workspace,
86// and a relative value is taken as this one.
87export const isElsewhere = (ws: Ws, workspace: string | null) =>
88 workspace !== null && workspace.startsWith('/') && relative(ws, `${workspace.replace(/\/$/, '')}/.`) === null
89hooks/el.ts 15 lines1import type { Elements } from 'claude-code'
2
3import type { KhubTone } from '../types'
4
5// Holds the elements every surface draws. A view takes this table from the shell's
6// `$.ui.resolve(e)`.
7export type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
8
9// Returns a tone's text color, as props to spread. A plain tone keeps the surface's own color.
10export function toneColor(tone: KhubTone): { color?: string } {
11 if (tone === 'plain') return {}
12
13 return { color: tone === 'ok' ? 'green' : tone === 'warn' ? 'yellow' : 'red' }
14}
15types/index.d.ts 66 lines1// The khub mod's session state. The file is self-contained, because the engine refuses a contract that imports.
2
3// How a type is stored, as `khub schema --format json` lists it.
4export type KhubType = {
5 name: string
6 layout: string
7 format: string
8 path: string
9}
10
11export type KhubWorkspace = {
12 root: string
13 bin: string[]
14 version: string
15 preset: string
16 presetVersion: string
17}
18
19// What the last refresh read from `khub query` and `khub check`.
20export type KhubSummary = {
21 total: number
22 draft: number
23 passed: boolean
24 errors: number
25}
26
27// Qualified ids, `type/slug`. `baseline` is the first read of the session, null until then.
28export type KhubSession = {
29 baseline: string[] | null
30 current: string[]
31 edited: string[]
32}
33
34export type KhubTone = 'ok' | 'warn' | 'bad' | 'plain'
35
36// One list row under a call's result line.
37export type KhubRow = {
38 text: string
39 note: string
40 flags: string[]
41}
42
43// One khub call the agent ran through Bash. A command's calls are keyed by tool_use_id,
44// and the first one carries the command's duration.
45export type KhubCall = {
46 head: string
47 line: string
48 tone: KhubTone
49 isRunning: boolean
50 ms: number
51 rows: KhubRow[]
52 more: number
53}
54
55declare module 'claude-code' {
56 interface PluginState {
57 khub: {
58 workspace: KhubWorkspace | null
59 summary: KhubSummary | null
60 session: KhubSession
61 calls: StateFamily<KhubCall[] | null>
62 rawRows: StateFamily<boolean>
63 }
64 }
65}
66