@cavelang/cli 0.5.0 → 0.13.0

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/src/cli.ts CHANGED
@@ -7,9 +7,13 @@
7
7
  *
8
8
  * - `cave parse [file]` — lint / dump the AST (`--json`)
9
9
  * - `cave highlight [file]` — ANSI syntax colors (async, routed in `main.ts`)
10
- * - `cave add [--db <path>] [file…]` — ingest into a store (`--strict`)
11
- * - `cave query [--db <path>] '<pattern>'` — CAVE-Q (`--json`, `--all`)
10
+ * - `cave add [--db <path>] [file…]` — ingest into a store (`--strict`, `--check`)
11
+ * - `cave query [--db <path>] '<pattern>'` — CAVE-Q (`--json`, `--all`, `--aliases`, `--as-of`)
12
+ * - `cave derive [--db <path>] [rules.cave…]` — fire rules (`--dry-run`, `--full`, `--list`, `--retract`)
13
+ * - `cave act [--db <path>] <name> [param=value…]` — execute an action (spec §25; `--declare`, `--list`, `--retract`)
14
+ * - `cave check [--db <path>]` — knowledge health report (`--stale`, `--json`)
12
15
  * - `cave export [--db <path>]` — canonical text out (`--current`)
16
+ * - `cave connect [--db <path>] <source>` — deterministic structured ingestion (async, routed in `main.ts`)
13
17
  * - `cave demo` — the cave-loop multi-hop recovery demo
14
18
  * - `cave version` — print the cave version
15
19
  * - `cave help [command]` — overview, or one command's help
@@ -25,6 +29,12 @@ import { parseDocument } from '@cavelang/parser'
25
29
  import { Registry, standardRegistry } from '@cavelang/canonical'
26
30
  import { defaultDbPath, open } from '@cavelang/store'
27
31
  import { query as caveQuery } from '@cavelang/query'
32
+ import { check as caveCheck, defaultStaleDays, gatedIngest } from '@cavelang/shape'
33
+ import type { Report, Violation } from '@cavelang/shape'
34
+ import { declareRules, defaultMaxPasses, defaultMinConf, derive, listRules, retractRule } from '@cavelang/rules'
35
+ import type { Declaration, DeriveReport } from '@cavelang/rules'
36
+ import { act, declareActions, listActions, retractAction } from '@cavelang/act'
37
+ import type { ActReport } from '@cavelang/act'
28
38
  import { Demo } from '@cavelang/loop'
29
39
 
30
40
  export type Output = {
@@ -44,12 +54,17 @@ export const usage = `cave — Compressed Atomic Verb Expressions
44
54
  Usage:
45
55
  cave parse [file...] [--json] lint CAVE text (stdin when no file)
46
56
  cave highlight [file...] print CAVE text with ANSI syntax colors
47
- cave add [--db <path>] [file...] ingest into a store [--strict] [--no-prelude]
48
- cave import [--db <path>] [file...] restore/merge from CAVE text (same as add)
49
- cave query [--db <path>] <pattern> run a CAVE-Q pattern [--json] [--all] [--no-prelude]
57
+ cave add [--db <path>] [file...] ingest into a store [--strict] [--check] [--no-prelude] [--no-src]
58
+ cave import [--db <path>] [file...] restore/merge from CAVE text (add without @src: stamping)
59
+ cave query [--db <path>] <pattern> run a CAVE-Q pattern [--json] [--all] [--aliases] [--as-of <t>] [--no-prelude]
60
+ cave derive [--db <path>] [rules.cave..] declare + fire rules (spec §24) [--dry-run] [--full] [--list] [--retract <rule>]
61
+ cave act [--db <path>] <name> [p=v...] execute an action (spec §25) [--dry-run] [--no-check] [--hooks <file>]
62
+ cave act --declare [file...] declare actions from a CAVE document; --list / --retract <name> manage them
63
+ cave check [--db <path>] knowledge health report (spec §20) [--stale <days>] [--json]
50
64
  cave export [--db <path>] [--out <file>] emit canonical CAVE text [--current] [--no-prelude]
51
65
  cave mcp [--db <path>] serve the engine as an MCP server on stdio [--no-prelude]
52
66
  cave ingest [--db <path>] <globs/urls..> LLM-driven ingestion of files and web pages
67
+ cave connect <source> --map <file> deterministic structured ingestion (CSV/JSON/SQLite/URL, spec §23)
53
68
  cave demo run the cave-loop reconstruction demo
54
69
  cave version print the cave version
55
70
  cave help [command] this text, or one command's options and examples
@@ -97,21 +112,27 @@ Examples:
97
112
  add: `cave add — ingest CAVE text into a knowledge database
98
113
 
99
114
  Usage:
100
- cave add [--db <path>] [file...] [--strict] [--no-prelude]
115
+ cave add [--db <path>] [file...] [--strict] [--check] [--no-prelude] [--no-src]
101
116
 
102
117
  Options:
103
118
  ${dbHelp}
104
119
  --strict reject the whole ingest on any problem
120
+ --check shape gate (spec §20.3): roll the append back if it
121
+ introduces new expectation violations
105
122
  --no-prelude open the store without the standard verb registry
123
+ --no-src do not stamp actor provenance on appended claims
106
124
 
107
- Reads stdin when no file (or \`-\`) is given.
125
+ Reads stdin when no file (or \`-\`) is given. Claims that carry no @src:
126
+ context are stamped @src:cli (spec §9.5); use \`cave import\` to replay
127
+ exported text without stamping.
108
128
 
109
129
  Examples:
110
130
  cave add --db k.db notes.cave
111
131
  echo 'auth USES jwt @ 90%' | cave add
112
- cave add --db k.db --strict reviewed.cave`,
132
+ cave add --db k.db --strict reviewed.cave
133
+ cave add --db k.db --check new-services.cave`,
113
134
 
114
- import: `cave import — restore/merge a database from CAVE text (same as add)
135
+ import: `cave import — restore/merge a database from CAVE text (add without @src: stamping)
115
136
 
116
137
  Usage:
117
138
  cave import [--db <path>] [file...] [--strict] [--no-prelude]
@@ -123,7 +144,9 @@ Options:
123
144
 
124
145
  Canonical CAVE text is the interchange format (spec §2.2): importing a
125
146
  file written by \`cave export\` replays its claims append-only, preserving
126
- the belief series, qualifier edges and in-band declarations.
147
+ the belief series, qualifier edges and in-band declarations. Unlike
148
+ \`cave add\`, import never stamps actor provenance — replayed claims must
149
+ keep the claim keys they were exported with (spec §9.5).
127
150
 
128
151
  Examples:
129
152
  cave export --db old.db --out backup.cave
@@ -132,12 +155,16 @@ Examples:
132
155
  query: `cave query — run a CAVE-Q pattern against a store
133
156
 
134
157
  Usage:
135
- cave query [--db <path>] <pattern> [WHERE <filter>] [--json] [--all] [--no-prelude]
158
+ cave query [--db <path>] <pattern> [WHERE <filter>] [--json] [--all] [--aliases] [--as-of <t>] [--no-prelude]
136
159
 
137
160
  Options:
138
161
  ${dbHelp}
139
162
  --json emit matches as JSON
140
163
  --all match all beliefs, not just current ones
164
+ --aliases resolve entities through current ALIAS claims (spec §13.6)
165
+ --as-of <t> resolve beliefs as of a past moment (spec §12.3): a date
166
+ (whole day included), a timestamp (whole second), or a
167
+ transaction id — rows recorded later are invisible
141
168
  --no-prelude open the store without the standard verb registry
142
169
 
143
170
  Patterns are claim triples with ?variables and optional metadata filters
@@ -149,8 +176,119 @@ Examples:
149
176
  cave query --db k.db '?x HAS bug: ?bug #security'
150
177
  cave query --db k.db '?cause CAUSE app/crash' 'WHERE conf >= 0.5'
151
178
  cave query --db k.db 'terrier EXTENDS+ animal'
179
+ cave query --db k.db '?x USES postgres' --aliases
180
+ cave query --db k.db 'server IS compromised' --as-of 2026-01-15
152
181
  cave query --db k.db '?x ?verb ?y @production' --json`,
153
182
 
183
+ derive: `cave derive — declare and fire rules (spec §24)
184
+
185
+ Usage:
186
+ cave derive [--db <path>] [rules.cave...] [--dry-run] [--full] [--aliases]
187
+ [--min-conf <p>] [--max-passes <n>] [--json] [--no-prelude]
188
+ cave derive [--db <path>] --list
189
+ cave derive [--db <path>] --retract <digest|subject>
190
+
191
+ Options:
192
+ ${dbHelp}
193
+ --dry-run compute and report inside a rolled-back transaction
194
+ --full ignore stored watermarks — re-fire every rule (spec §24.4)
195
+ --aliases premises match through the alias closure (spec §13.6)
196
+ --min-conf <p> do not assert conclusions below this confidence
197
+ (0..1 or N%, default ${defaultMinConf})
198
+ --max-passes <n> fixpoint guard (default ${defaultMaxPasses})
199
+ --list print the store's current rules and exit
200
+ --retract <r> retract a rule (digest, unambiguous prefix, or subject)
201
+ together with everything it derived, and exit
202
+ --json emit the report as JSON
203
+ --no-prelude open the store without the standard verb registry
204
+
205
+ Rule files are CAVE documents whose \`premises => conclusion\` lines are
206
+ rules (spec §24.1); other lines — verb declarations the rules need — are
207
+ prelude, ingested first. Rules are stored in-band as
208
+ \`rule/<digest> HAS rule: \\\`…\\\`\` claims, so \`cave derive\` with no file
209
+ fires whatever the store already knows. Derived claims are stamped
210
+ @src:rule/<digest>, carry BECAUSE edges to their premise rows and a VIA
211
+ edge to the rule, and update append-only: re-runs are idempotent and
212
+ watermark-incremental, and conclusions whose premises no longer hold are
213
+ retracted (spec §24.2–§24.5).
214
+
215
+ Examples:
216
+ cave derive --db k.db rules.cave
217
+ cave derive --db k.db
218
+ echo '?x NEEDS ?y, ?y NEEDS ?z => ?x NEEDS ?z' | cave derive --db k.db -
219
+ cave derive --db k.db --dry-run --json
220
+ cave derive --db k.db --list
221
+ cave derive --db k.db --retract 4a0bb974f43c`,
222
+
223
+ act: `cave act — execute, declare, list and retract actions (spec §25)
224
+
225
+ Usage:
226
+ cave act [--db <path>] <name> [param=value...] [--dry-run] [--no-check]
227
+ [--aliases] [--hooks <file>] [--json] [--no-prelude]
228
+ cave act [--db <path>] --declare [file...]
229
+ cave act [--db <path>] --list [--json]
230
+ cave act [--db <path>] --retract <name>
231
+
232
+ Options:
233
+ ${dbHelp}
234
+ --dry-run validate and report inside a rolled-back transaction;
235
+ hooks never fire
236
+ --no-check skip the shape gate (spec §25.3) — by default an
237
+ execution that introduces new EXPECTS violations is
238
+ rolled back
239
+ --aliases preconditions match through the alias closure (spec §13.6)
240
+ --hooks <file> JSON file of out-of-band hook command templates,
241
+ name → shell template (spec §25.4); default: $CAVE_HOOKS
242
+ --declare declare the actions of CAVE documents (stdin when no
243
+ file); other lines are prelude, ingested first
244
+ --list print the store's current actions and exit
245
+ --retract <n> retract an action's declaration (effects of past
246
+ executions stay recorded) and exit
247
+ --json emit the report as JSON
248
+ --no-prelude open the store without the standard verb registry
249
+
250
+ An action is a named, governed write template (spec §25):
251
+
252
+ action/mark-deployed HAS action: \`?service, ?version,
253
+ ?service IS service => ?service HAS deployed-version: ?version\`
254
+
255
+ Executing validates the parameters, checks each precondition against
256
+ current belief (a premise with no match fails the action — nothing is
257
+ appended), then appends the effects atomically, stamped
258
+ @src:action/<name> with BECAUSE/VIA lineage. Re-runs are idempotent. A
259
+ declared hook (action/<name> HAS hook: <hook>) runs after commit when
260
+ the configuration defines it — {action} and {param} placeholders are
261
+ shell-quoted, the appended claims arrive on stdin.
262
+
263
+ Examples:
264
+ cave act --db k.db --declare actions.cave
265
+ cave act --db k.db mark-deployed service=api-gateway version=1.2.3
266
+ cave act --db k.db mark-deployed service=api version=2 --dry-run
267
+ cave act --db k.db mark-deployed service=api version=2 --hooks hooks.json
268
+ cave act --db k.db --list
269
+ cave act --db k.db --retract mark-deployed`,
270
+
271
+ check: `cave check — knowledge health report (spec §20)
272
+
273
+ Usage:
274
+ cave check [--db <path>] [--stale <days>] [--json] [--no-prelude]
275
+
276
+ Options:
277
+ ${dbHelp}
278
+ --stale <days> staleness horizon (default: ${defaultStaleDays})
279
+ --json emit the full report as JSON
280
+ --no-prelude open the store without the standard verb registry
281
+
282
+ Reads the store against its own in-band EXPECTS declarations (spec §20.1)
283
+ and reports shape violations, stale current beliefs, review candidates
284
+ (conf 0.3–0.7), alias disagreements (spec §13.6) and coverage stats.
285
+ Exits 1 when violations exist; everything else is advisory.
286
+
287
+ Examples:
288
+ cave check --db k.db
289
+ cave check --db k.db --stale 30
290
+ cave check --db k.db --json | jq '.violations'`,
291
+
154
292
  export: `cave export — emit canonical CAVE text from a store
155
293
 
156
294
  Usage:
@@ -226,26 +364,50 @@ export const highlightCommand = async (argv: readonly string[]): Promise<Output>
226
364
  return ok(ansi(input))
227
365
  }
228
366
 
229
- const ingestCommand = (name: string) => (argv: readonly string[]): Output => {
367
+ /**
368
+ * Shared by `add` and `import`; the difference is actor provenance
369
+ * (spec §9.5). `add` records knowledge authored now, so claims without a
370
+ * `@src:` context are stamped `@src:cli` (`--no-src` opts out); `import`
371
+ * replays interchange text, which must preserve claim keys as exported,
372
+ * so it never stamps.
373
+ */
374
+ const formatViolation = (violation: Violation): string =>
375
+ `${violation.entity} missing ${violation.expectation.kind} ${violation.expectation.name} ` +
376
+ `(${violation.entity} IS ${violation.via}; ${violation.expectation.type} EXPECTS ${violation.expectation.name})`
377
+
378
+ const ingestCommand = (name: 'add' | 'import') => (argv: readonly string[]): Output => {
230
379
  const { values, positionals } = parseArgs({
231
380
  args: [...argv],
232
381
  options: {
233
382
  db: { type: 'string' },
234
383
  strict: { type: 'boolean' },
235
- 'no-prelude': { type: 'boolean' }
384
+ 'no-prelude': { type: 'boolean' },
385
+ ...name === 'add' ? { 'no-src': { type: 'boolean' }, check: { type: 'boolean' } } : {}
236
386
  },
237
387
  allowPositionals: true
238
388
  })
239
389
  const input = readInput(positionals)
240
390
  const store = open(values.db ?? defaultDbPath(), values['no-prelude'] === true ? { registry: Registry.empty } : {})
241
391
  try {
242
- const result = store.ingest(input, { strict: values.strict === true })
243
- const problems = result.problems
392
+ const options = {
393
+ strict: values.strict === true,
394
+ ...name === 'add' && values['no-src'] !== true ? { source: 'cli' } : {}
395
+ }
396
+ // The shape gate (spec §20.3): append + check in one transaction,
397
+ // rolled back when the append introduces new expectation violations.
398
+ const outcome = name === 'add' && values.check === true ?
399
+ gatedIngest(store, input, options) :
400
+ { ok: true as const, result: store.ingest(input, options) }
401
+ if (!outcome.ok) {
402
+ const detail = outcome.violations.map(violation => ` ${formatViolation(violation)}`).join('\n')
403
+ return fail(`rejected: ${outcome.violations.length} new violation(s), nothing added (spec §20.3)\n${detail}\n`)
404
+ }
405
+ const problems = outcome.result.problems
244
406
  .map(problem => `line ${problem.line}: ${problem.message}`)
245
407
  .join('\n')
246
408
  return {
247
409
  code: 0,
248
- out: `added ${result.ids.length} claim(s), ${result.edges} edge(s)\n`,
410
+ out: `added ${outcome.result.ids.length} claim(s), ${outcome.result.edges} edge(s)\n`,
249
411
  err: problems === '' ? '' : `${problems}\n`
250
412
  }
251
413
  } catch (error) {
@@ -259,13 +421,15 @@ export const addCommand = ingestCommand('add')
259
421
 
260
422
  /**
261
423
  * `cave import` — restore/merge a knowledge database from CAVE text files.
262
- * Identical to `add` by design: canonical CAVE text *is* the interchange
424
+ * `add` minus provenance stamping: canonical CAVE text *is* the interchange
263
425
  * format (spec §2.2), so importing a file exported with `cave export`
264
- * replays its claims append-only. What the text round trip preserves:
265
- * every claim with metadata, full belief-series order (rows export in tx
266
- * order and re-ingest with fresh monotonic tx ids), qualifier/grouping
267
- * edges, and in-band registry declarations. Original tx timestamps are
268
- * re-minted — the text format carries no transaction identity.
426
+ * replays its claims append-only — and because contexts are part of claim
427
+ * identity, replay must not add a `@src:` stamp (spec §9.5). What the text
428
+ * round trip preserves: every claim with metadata, full belief-series order
429
+ * (rows export in tx order and re-ingest with fresh monotonic tx ids),
430
+ * qualifier/grouping edges, and in-band registry declarations. Original tx
431
+ * timestamps are re-minted — the text format carries no transaction
432
+ * identity.
269
433
  */
270
434
  export const importCommand = ingestCommand('import')
271
435
 
@@ -276,6 +440,8 @@ export const queryCommand = (argv: readonly string[]): Output => {
276
440
  db: { type: 'string' },
277
441
  json: { type: 'boolean' },
278
442
  all: { type: 'boolean' },
443
+ aliases: { type: 'boolean' },
444
+ 'as-of': { type: 'string' },
279
445
  'no-prelude': { type: 'boolean' }
280
446
  },
281
447
  allowPositionals: true
@@ -286,7 +452,11 @@ export const queryCommand = (argv: readonly string[]): Output => {
286
452
  const pattern = positionals.join('\n')
287
453
  const store = open(values.db ?? defaultDbPath(), values['no-prelude'] === true ? { registry: Registry.empty } : {})
288
454
  try {
289
- const matches = caveQuery(store, pattern, { all: values.all === true })
455
+ const matches = caveQuery(store, pattern, {
456
+ all: values.all === true,
457
+ aliases: values.aliases === true,
458
+ ...values['as-of'] === undefined ? {} : { asOf: values['as-of'] }
459
+ })
290
460
  if (values.json === true) {
291
461
  return ok(`${JSON.stringify(matches, undefined, 2)}\n`)
292
462
  }
@@ -309,6 +479,321 @@ export const queryCommand = (argv: readonly string[]): Output => {
309
479
  }
310
480
  }
311
481
 
482
+ export const deriveCommand = (argv: readonly string[]): Output => {
483
+ const { values, positionals } = parseArgs({
484
+ args: [...argv],
485
+ options: {
486
+ db: { type: 'string' },
487
+ 'dry-run': { type: 'boolean' },
488
+ full: { type: 'boolean' },
489
+ aliases: { type: 'boolean' },
490
+ 'min-conf': { type: 'string' },
491
+ 'max-passes': { type: 'string' },
492
+ list: { type: 'boolean' },
493
+ retract: { type: 'string' },
494
+ json: { type: 'boolean' },
495
+ 'no-prelude': { type: 'boolean' }
496
+ },
497
+ allowPositionals: true
498
+ })
499
+ const minConf = values['min-conf'] === undefined ?
500
+ undefined :
501
+ values['min-conf'].endsWith('%') ? Number(values['min-conf'].slice(0, -1)) / 100 : Number(values['min-conf'])
502
+ if (minConf !== undefined && (!Number.isFinite(minConf) || minConf < 0 || minConf > 1)) {
503
+ return fail(`cave derive: --min-conf expects a confidence in 0..1 or N%, got ${JSON.stringify(values['min-conf'])}\n`)
504
+ }
505
+ const maxPasses = values['max-passes'] === undefined ? undefined : Number(values['max-passes'])
506
+ if (maxPasses !== undefined && (!Number.isInteger(maxPasses) || maxPasses < 1)) {
507
+ return fail(`cave derive: --max-passes expects a positive integer, got ${JSON.stringify(values['max-passes'])}\n`)
508
+ }
509
+ const store = open(values.db ?? defaultDbPath(), values['no-prelude'] === true ? { registry: Registry.empty } : {})
510
+ try {
511
+ if (values.list === true) {
512
+ const rules = listRules(store)
513
+ if (values.json === true) {
514
+ return ok(`${JSON.stringify(rules, undefined, 2)}\n`)
515
+ }
516
+ if (rules.length === 0) {
517
+ return ok('no rules\n')
518
+ }
519
+ const lines = rules.map(rule =>
520
+ `${rule.subject} \`${rule.text}\`${rule.label === undefined ? '' : ` ; ${rule.label}`}` +
521
+ (rule.ok ? '' : `\n problems: ${rule.problems.join('; ')}`))
522
+ return ok(`${lines.join('\n')}\n`)
523
+ }
524
+ if (values.retract !== undefined) {
525
+ const outcome = retractRule(store, values.retract)
526
+ if (!outcome.ok) {
527
+ return fail(`cave derive: ${outcome.error}\n`)
528
+ }
529
+ return ok(`retracted ${outcome.subjects.join(', ')} and ${outcome.derived} derived claim(s)\n`)
530
+ }
531
+
532
+ const err: string[] = []
533
+ const out: string[] = []
534
+ // Unlike parse/add, no positional means "fire the stored rules", never
535
+ // stdin — pass `-` to read a rule file from stdin explicitly.
536
+ const input = positionals.length > 0 ? readInput(positionals) : undefined
537
+ const options = {
538
+ full: values.full === true,
539
+ aliases: values.aliases === true,
540
+ ...minConf === undefined ? {} : { minConf },
541
+ ...maxPasses === undefined ? {} : { maxPasses }
542
+ }
543
+ let declaration: undefined | Declaration
544
+ let report: DeriveReport
545
+ if (values['dry-run'] === true) {
546
+ // A dry run persists nothing — rule declarations from the file
547
+ // included, so declare + fire share one rolled-back transaction.
548
+ const rolledBack = Symbol('cave derive --dry-run')
549
+ let outcome: undefined | { declaration?: Declaration, report: DeriveReport }
550
+ try {
551
+ store.transaction(() => {
552
+ outcome = {
553
+ ...input === undefined ? {} : { declaration: declareRules(store, input) },
554
+ report: derive(store, options)
555
+ }
556
+ throw rolledBack
557
+ })
558
+ } catch (error) {
559
+ if (error !== rolledBack) {
560
+ throw error
561
+ }
562
+ }
563
+ declaration = outcome!.declaration
564
+ report = outcome!.report
565
+ } else {
566
+ declaration = input === undefined ? undefined : declareRules(store, input)
567
+ report = derive(store, options)
568
+ }
569
+ if (declaration !== undefined) {
570
+ err.push(...declaration.problems.map(problem => `rules line ${problem.line}: ${problem.message}`))
571
+ out.push(`declared ${declaration.declared} rule(s)` +
572
+ (declaration.unchanged > 0 ? `, ${declaration.unchanged} unchanged` : '') +
573
+ (declaration.prelude > 0 ? `, +${declaration.prelude} prelude claim(s)` : ''))
574
+ }
575
+ if (values.json === true) {
576
+ return { code: report.problems.length > 0 ? 1 : 0, out: `${JSON.stringify(report, undefined, 2)}\n`, err: err.join('\n') + (err.length > 0 ? '\n' : '') }
577
+ }
578
+ for (const problem of report.problems) {
579
+ err.push(`${problem.subject}: ${problem.problems.join('; ')}`)
580
+ }
581
+ for (const rule of report.rules) {
582
+ const state = rule.fired ?
583
+ `${rule.solutions} solution(s), +${rule.appended} appended, ${rule.updated} updated, ${rule.retracted} retracted, ${rule.unchanged} unchanged` :
584
+ 'unchanged premises, skipped'
585
+ out.push(`${rule.subject}: ${state}${rule.label === undefined ? '' : ` ; ${rule.label}`}`)
586
+ err.push(...rule.problems.map(problem => `${rule.subject}: ${problem}`))
587
+ }
588
+ out.push(...report.notes.map(note => `note: ${note}`))
589
+ out.push(
590
+ `derived${values['dry-run'] === true ? ' (dry run)' : ''}: ` +
591
+ `+${report.appended} appended, ${report.updated} updated, ${report.retracted} retracted, ` +
592
+ `${report.unchanged} unchanged (${report.passes} pass(es))`)
593
+ const hasProblems = report.problems.length > 0 || report.rules.some(rule => rule.problems.length > 0)
594
+ return { code: hasProblems ? 1 : 0, out: `${out.join('\n')}\n`, err: err.length === 0 ? '' : `${err.join('\n')}\n` }
595
+ } catch (error) {
596
+ return fail(`${error instanceof Error ? error.message : String(error)}\n`)
597
+ } finally {
598
+ store.close()
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Loads a hooks configuration file (spec §25.4): a JSON object mapping
604
+ * hook names to shell command templates.
605
+ */
606
+ const readHooks = (path: string): Record<string, string> => {
607
+ const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
608
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) ||
609
+ Object.values(parsed).some(value => typeof value !== 'string')) {
610
+ throw new Error(`${path}: hooks must be a JSON object of name → shell template strings`)
611
+ }
612
+ return parsed as Record<string, string>
613
+ }
614
+
615
+ /** Text rendering of a spec §25.2 execution report. */
616
+ const renderActReport = (report: ActReport): { lines: string[], code: number } => {
617
+ if (!report.ok) {
618
+ const lines = [`cave act: ${report.error}`]
619
+ for (const violation of report.violations ?? []) {
620
+ lines.push(` ${formatViolation(violation)}`)
621
+ }
622
+ return { lines, code: 1 }
623
+ }
624
+ const lines = [
625
+ `executed ${report.subject}${report.dryRun ? ' (dry run)' : ''}: ` +
626
+ `+${report.appended} appended, ${report.updated} updated, ${report.unchanged} unchanged ` +
627
+ `(${report.solutions} solution(s))`,
628
+ ...report.effects.map(effect => ` ${effect.outcome}: ${effect.line}`)
629
+ ]
630
+ let code = 0
631
+ if (report.hook !== undefined) {
632
+ if (!report.hook.fired) {
633
+ lines.push(`hook ${report.hook.name}: not fired (${report.hook.note})`)
634
+ } else if (report.hook.error === undefined) {
635
+ lines.push(`hook ${report.hook.name}: ok`)
636
+ } else {
637
+ // The claims committed before the hook ran (spec §25.4) — the
638
+ // failure is reported, and the exit code carries it.
639
+ lines.push(`hook ${report.hook.name}: ${report.hook.error}`)
640
+ code = 1
641
+ }
642
+ }
643
+ return { lines, code }
644
+ }
645
+
646
+ export const actCommand = (argv: readonly string[]): Output => {
647
+ const { values, positionals } = parseArgs({
648
+ args: [...argv],
649
+ options: {
650
+ db: { type: 'string' },
651
+ declare: { type: 'boolean' },
652
+ list: { type: 'boolean' },
653
+ retract: { type: 'string' },
654
+ 'dry-run': { type: 'boolean' },
655
+ 'no-check': { type: 'boolean' },
656
+ aliases: { type: 'boolean' },
657
+ hooks: { type: 'string' },
658
+ json: { type: 'boolean' },
659
+ 'no-prelude': { type: 'boolean' }
660
+ },
661
+ allowPositionals: true
662
+ })
663
+ const store = open(values.db ?? defaultDbPath(), values['no-prelude'] === true ? { registry: Registry.empty } : {})
664
+ try {
665
+ if (values.declare === true) {
666
+ const declaration = declareActions(store, readInput(positionals))
667
+ const err = declaration.problems.map(problem => `line ${problem.line}: ${problem.message}`)
668
+ const out = `declared ${declaration.declared} action(s)` +
669
+ (declaration.unchanged > 0 ? `, ${declaration.unchanged} unchanged` : '') +
670
+ (declaration.prelude > 0 ? `, +${declaration.prelude} prelude claim(s)` : '')
671
+ return {
672
+ code: declaration.problems.length > 0 ? 1 : 0,
673
+ out: `${out}\n`,
674
+ err: err.length === 0 ? '' : `${err.join('\n')}\n`
675
+ }
676
+ }
677
+ if (values.list === true) {
678
+ const actions = listActions(store)
679
+ if (values.json === true) {
680
+ return ok(`${JSON.stringify(actions, undefined, 2)}\n`)
681
+ }
682
+ if (actions.length === 0) {
683
+ return ok('no actions\n')
684
+ }
685
+ const lines = actions.flatMap(action => [
686
+ `${action.subject} \`${action.text}\`${action.description === undefined ? '' : ` ; ${action.description}`}`,
687
+ ...action.params.map(param => ` ?${param.name}${param.doc === undefined ? '' : ` — ${param.doc}`}`),
688
+ ...action.hook === undefined ? [] : [` hook: ${action.hook}`],
689
+ ...action.ok ? [] : [` problems: ${action.problems.join('; ')}`]
690
+ ])
691
+ return ok(`${lines.join('\n')}\n`)
692
+ }
693
+ if (values.retract !== undefined) {
694
+ const outcome = retractAction(store, values.retract)
695
+ if (!outcome.ok) {
696
+ return fail(`cave act: ${outcome.error}\n`)
697
+ }
698
+ return ok(`retracted ${outcome.subject} — effects of past executions stay recorded (spec §25.1)\n`)
699
+ }
700
+
701
+ const [name, ...pairs] = positionals
702
+ if (name === undefined) {
703
+ return fail('cave act: an action name is required — or --declare, --list, --retract (spec §25.2)\n')
704
+ }
705
+ const args: Record<string, string> = {}
706
+ for (const pair of pairs) {
707
+ const at = pair.indexOf('=')
708
+ if (at <= 0) {
709
+ return fail(`cave act: expected param=value, got ${JSON.stringify(pair)}\n`)
710
+ }
711
+ args[pair.slice(0, at)] = pair.slice(at + 1)
712
+ }
713
+ const hooksPath = values.hooks ?? process.env['CAVE_HOOKS']
714
+ const report = act(store, name, args, {
715
+ dryRun: values['dry-run'] === true,
716
+ check: values['no-check'] !== true,
717
+ aliases: values.aliases === true,
718
+ ...hooksPath === undefined ? {} : { hooks: readHooks(hooksPath) }
719
+ })
720
+ if (values.json === true) {
721
+ return { code: report.ok && report.hook?.error === undefined ? 0 : 1, out: `${JSON.stringify(report, undefined, 2)}\n`, err: '' }
722
+ }
723
+ const { lines, code } = renderActReport(report)
724
+ return report.ok ?
725
+ { code, out: `${lines.join('\n')}\n`, err: '' } :
726
+ { code, out: '', err: `${lines.join('\n')}\n` }
727
+ } catch (error) {
728
+ return fail(`${error instanceof Error ? error.message : String(error)}\n`)
729
+ } finally {
730
+ store.close()
731
+ }
732
+ }
733
+
734
+ /** Text rendering of the §20.2 report: always shape + coverage, non-empty advisory sections. */
735
+ const renderReport = (report: Report, staleDays: number): string => {
736
+ const lines: string[] = []
737
+ const { coverage } = report
738
+ lines.push(`shape: ${coverage.expectations} expectation(s), ${coverage.instances} instance(s), ${coverage.satisfied}/${coverage.checks} satisfied`)
739
+ if (report.violations.length > 0) {
740
+ lines.push(`violations (${report.violations.length}):`)
741
+ lines.push(...report.violations.map(violation => ` ${formatViolation(violation)}`))
742
+ }
743
+ if (report.stale.length > 0) {
744
+ lines.push(`stale (${report.stale.length}, older than ${staleDays} day(s)):`)
745
+ lines.push(...report.stale.map(({ row, ageDays }) => ` ${row.raw_line} (${ageDays}d)`))
746
+ }
747
+ if (report.review.length > 0) {
748
+ lines.push(`review candidates (${report.review.length}, conf 0.3-0.7):`)
749
+ lines.push(...report.review.map(row => ` ${row.raw_line}`))
750
+ }
751
+ if (report.disagreements.length > 0) {
752
+ lines.push(`alias disagreements (${report.disagreements.length}):`)
753
+ for (const disagreement of report.disagreements) {
754
+ lines.push(` ${disagreement.about} across ${disagreement.entities.join(', ')}:`)
755
+ lines.push(...disagreement.rows.map(row => ` ${row.raw_line}`))
756
+ }
757
+ }
758
+ const confidence = coverage.averageConfidence === null ?
759
+ '' :
760
+ `; avg conf ${Math.round(coverage.averageConfidence * 100)}%, ${coverage.lowConfidence} low (< 0.3)`
761
+ lines.push(
762
+ `coverage: ${coverage.rows} row(s), ${coverage.facts} fact(s) — ` +
763
+ `${coverage.current} current, ${coverage.retracted} retracted, ${coverage.negated} negated` +
764
+ `${confidence}; ${coverage.entities} entities, ${coverage.typedEntities} typed`
765
+ )
766
+ return `${lines.join('\n')}\n`
767
+ }
768
+
769
+ export const checkCommand = (argv: readonly string[]): Output => {
770
+ const { values } = parseArgs({
771
+ args: [...argv],
772
+ options: {
773
+ db: { type: 'string' },
774
+ stale: { type: 'string' },
775
+ json: { type: 'boolean' },
776
+ 'no-prelude': { type: 'boolean' }
777
+ },
778
+ allowPositionals: false
779
+ })
780
+ const staleDays = values.stale === undefined ? defaultStaleDays : Number(values.stale)
781
+ if (!Number.isFinite(staleDays) || staleDays < 0) {
782
+ return fail(`cave check: --stale expects a non-negative number of days, got ${JSON.stringify(values.stale)}\n`)
783
+ }
784
+ const store = open(values.db ?? defaultDbPath(), values['no-prelude'] === true ? { registry: Registry.empty } : {})
785
+ try {
786
+ const report = caveCheck(store, { staleDays })
787
+ const out = values.json === true ?
788
+ `${JSON.stringify(report, undefined, 2)}\n` :
789
+ renderReport(report, staleDays)
790
+ // Violations fail the check (spec §20.2); the other sections are advisory.
791
+ return { code: report.violations.length > 0 ? 1 : 0, out, err: '' }
792
+ } finally {
793
+ store.close()
794
+ }
795
+ }
796
+
312
797
  export const exportCommand = (argv: readonly string[]): Output => {
313
798
  const { values } = parseArgs({
314
799
  args: [...argv],
@@ -350,8 +835,8 @@ export const helpCommand = (argv: readonly string[]): Output => {
350
835
  if (text !== undefined) {
351
836
  return ok(`${text}\n`)
352
837
  }
353
- // mcp and ingest own their help (main.ts forwards `help X` to `X --help`).
354
- if (topic === 'mcp' || topic === 'ingest') {
838
+ // mcp, ingest and connect own their help (main.ts forwards `help X` to `X --help`).
839
+ if (topic === 'mcp' || topic === 'ingest' || topic === 'connect') {
355
840
  return ok(`see: cave ${topic} --help\n`)
356
841
  }
357
842
  return fail(`cave help: unknown command ${JSON.stringify(topic)}\n\n${usage}\n`, 2)
@@ -375,6 +860,12 @@ export const cave = (argv: readonly string[]): Output => {
375
860
  return importCommand(rest)
376
861
  case 'query':
377
862
  return queryCommand(rest)
863
+ case 'derive':
864
+ return deriveCommand(rest)
865
+ case 'act':
866
+ return actCommand(rest)
867
+ case 'check':
868
+ return checkCommand(rest)
378
869
  case 'export':
379
870
  return exportCommand(rest)
380
871
  case 'demo':