@saasontools/strauss-kb 0.1.7 → 0.1.8

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/README.md CHANGED
@@ -224,6 +224,8 @@ strauss-kb [--bundle PATH] <command> [args]
224
224
  index The index, rebuilt if it disagrees with the records.
225
225
  log What touched what, and when.
226
226
  validate Cross-record checks. Exits 1 when it reports a problem.
227
+ doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]
228
+ Health sweep: what expired, went unconfirmed, aged, or was orphaned.
227
229
  schema JSON Schema for the format.
228
230
  types The twelve types, their sections and initial status.
229
231
  pin [bundle-path] [flags] Pin a base. --mode, --profiles, --frozen; --local/--user pick the layer.
@@ -233,14 +235,20 @@ strauss-kb [--bundle PATH] <command> [args]
233
235
  sync-instructions <file> Plant the context block between sentinels in an instruction file.
234
236
 
235
237
  --bundle PATH defaults to ./.strauss/kb
238
+ --json the machine shape, where a command prints a table
239
+ -- everything after it is text, not flags
236
240
  STRAUSS_KB_ACTOR names the writer in the log
237
241
  ```
238
242
 
239
243
  Results go to stdout as JSON — `index` and `pack` are markdown, which is what
240
- they are. Errors
241
- go to stderr and exit 1. `validate` is the one command whose exit code is not
242
- just "did it run": a check that reports a problem succeeded as a command and
243
- failed as a check, so it exits 1 with its findings on stdout.
244
+ they are, and `doctor` prints a table unless `--json` asks for the object
245
+ behind it. `--json` is refused rather than ignored on the commands that have
246
+ only one form, since a flag that quietly does nothing reads as one that
247
+ worked; `--` ends flag parsing, for the verbs that end in free prose. Errors
248
+ go to stderr and exit 1. `validate` and `doctor --strict` are the commands
249
+ whose exit code is not just "did it run": a check that reports a problem
250
+ succeeded as a command and failed as a check, so it exits 1 with its findings
251
+ on stdout.
244
252
 
245
253
  ```bash
246
254
  strauss-kb --bundle .strauss/kb write fact <<'JSON'
@@ -262,7 +270,8 @@ strauss-kb validate || echo "problems above"
262
270
  `strauss-kb-mcp` speaks stdio and takes no API key and no required environment.
263
271
  Every CLI verb is a tool: `kb_write`, `kb_write_decision`, `kb_no_decision`,
264
272
  `kb_status`, `kb_supersede`, `kb_answer`, `kb_verify`, `kb_load`, `kb_pack`, `kb_query`,
265
- `kb_trace`, `kb_list`, `kb_index`, `kb_log`, `kb_validate`, `kb_schema`, `kb_types`,
273
+ `kb_trace`, `kb_list`, `kb_index`, `kb_log`, `kb_validate`, `kb_doctor`,
274
+ `kb_schema`, `kb_types`,
266
275
  `kb_pin`, `kb_unpin`, `kb_pins`, `kb_context`. Most tools take a `bundlePath`;
267
276
  `kb_schema` and `kb_types` describe the format rather than any one base, and
268
277
  `kb_pins` and `kb_context` read the workspace pin manifests instead. The one
@@ -401,6 +410,83 @@ fact; a missing replacement is `broken-chain` with no head — the case that nee
401
410
  the most care, because returning the stale record unmarked looks exactly like
402
411
  success.
403
412
 
413
+ ## Health
414
+
415
+ `doctor` sweeps a whole base and reports what has decayed. It is read-only —
416
+ nothing is re-dated, re-verified, superseded, or deleted — because every
417
+ finding is a judgment somebody has to make: whether a claim still holds, which
418
+ question is worth answering, which island to link or drop.
419
+
420
+ It exists because decay is invisible from inside a single record. A stale
421
+ record reads exactly like a live one, a question nobody answered reads exactly
422
+ like one nobody asked, and a record nothing links to is reachable only by
423
+ someone who already knows it is there. `validate` is the narrower neighbour:
424
+ it asks only whether pointers between records agree.
425
+
426
+ | Check | Reports |
427
+ | ---------------------- | ------------------------------------------------------------------------------ |
428
+ | `expired` | `stale_after` is in the past — or is not a readable date, which is no better. |
429
+ | `expiring` | `stale_after` falls inside the next `--expiring-days` (30). |
430
+ | `unverified` | `verified[]` is empty and the record is over `--unverified-days` (90) old. |
431
+ | `aging` | Still `open` or `proposed` after `--aging-days` (90). |
432
+ | `orphaned` | No other record links to it, by body link or supersession. |
433
+ | `broken-supersession` | A chain that does not resolve: no replacement, a missing one, a cycle, a fork. |
434
+ | `superseded-but-cited` | A record that still holds, whose body links to one that does not. |
435
+
436
+ The last check's name is for its common case: a rejected target counts too, and
437
+ is the worse half — a superseded record at least names its replacement, while a
438
+ rejected one is a well-formed assertion of what someone decided _not_ to do,
439
+ cited by a record the reader trusts.
440
+
441
+ ```bash
442
+ strauss-kb doctor # the table
443
+ strauss-kb doctor --json # the object behind it
444
+ strauss-kb doctor --strict # exit 1 if anything has expired
445
+ strauss-kb doctor --unverified-days 30 # a stricter confirmation window
446
+ ```
447
+
448
+ All seven groups are reported even when empty. A check that found nothing and
449
+ a check that never ran look identical in a report that only lists findings,
450
+ which is the whole value of a sweep.
451
+
452
+ Judgments the checks make, worth knowing before reading a report:
453
+
454
+ - **Superseded and rejected records sit out the freshness checks.** A replaced
455
+ record whose date has passed needs no repair, and reporting it would bury the
456
+ records that do. They stay in the graph checks, where standing is not the
457
+ question.
458
+ - **A date-only `stale_after` expires at UTC midnight.** `2026-09-01` parses as
459
+ `2026-09-01T00:00:00Z`, so a record goes stale at the start of its date: a
460
+ sweep run at exactly that instant still calls it expiring, and one a minute
461
+ later calls it expired. That is `adjudicate`'s comparison rather than a
462
+ second one — two readings of the same field disagreeing about the day would
463
+ be worse than either.
464
+ - **Age is read from `generated.at`, exclusively.** A record carrying no
465
+ timestamp is not reported as aging or unverified — without a start there is
466
+ no duration, and inventing one would flag every foreign record as overdue
467
+ (adjudication still warns `unverified` on it at read time). Exactly N days
468
+ old is not yet "older than N".
469
+ - **`orphaned` counts incoming links only, and reads supersession one way.** A
470
+ record that cites five others and is cited by none is precisely the island:
471
+ reachable if you already know it exists. The replacement references what it
472
+ replaced, never the reverse — taken symmetrically, a dead record would vouch
473
+ for its own replacement and an old→new pair nothing else touches would rescue
474
+ itself. Shared anchors and shared sources are co-location rather than
475
+ reference, so they do not rescue a record either.
476
+ - **A record citing the one it replaced is not superseded-but-cited.** That
477
+ link is the history working as designed, and reporting it would put a finding
478
+ on every correctly performed supersession.
479
+ - **A replacement pointer is checked whatever the status says.** The store
480
+ writes `strauss_status` and `strauss_superseded_by` in one mutation, so a
481
+ record left `accepted` while naming a replacement was hand-edited — and
482
+ adjudication reads it as current no matter what the pointer says, which is
483
+ what makes it worth naming.
484
+
485
+ `--strict` gates on expiry alone. The other six report debt a reader decides
486
+ about; an expired record is the base itself saying it would stop standing
487
+ behind something, which is the one finding a pipeline can act on without a
488
+ judgment call.
489
+
404
490
  ## Living in an agent session
405
491
 
406
492
  Long sessions lose a knowledge base twice over: attention decays, and
@@ -4,13 +4,14 @@ import {
4
4
  KB_DIR,
5
5
  KbStore,
6
6
  VERSION
7
- } from "./chunk-GKUQOJEK.js";
7
+ } from "./chunk-YJK7KGHN.js";
8
8
 
9
9
  // src/cli.ts
10
10
  import { join } from "path";
11
11
  async function runKbCli(argv) {
12
- const { bundle, rest } = takeBundle(argv);
13
- const name = rest[0] ?? "";
12
+ const { flags, literal } = takeLiteral(argv);
13
+ const { bundle, rest: withFlags } = takeBundle(flags);
14
+ const name = withFlags[0] ?? "";
14
15
  if (!name || name === "-h" || name === "--help") {
15
16
  process.stdout.write(usage());
16
17
  return;
@@ -22,6 +23,14 @@ async function runKbCli(argv) {
22
23
  }
23
24
  const command = KB_COMMANDS_BY_NAME.get(name);
24
25
  if (!command) die(`unknown command ${name}`);
26
+ const json = withFlags.includes("--json");
27
+ if (json && !command.render) {
28
+ die(`${name} takes no --json: its result is already the machine shape`);
29
+ }
30
+ const rest = [
31
+ ...json ? withFlags.filter((argument) => argument !== "--json") : withFlags,
32
+ ...literal
33
+ ];
25
34
  const raw = await command.fromArgv(rest, bundle, readStdin);
26
35
  const parsed = command.input.safeParse(raw);
27
36
  if (!parsed.success) {
@@ -41,13 +50,16 @@ async function runKbCli(argv) {
41
50
  },
42
51
  parsed.data
43
52
  );
44
- if (command.failsWhen?.(result)) process.exitCode = 1;
53
+ if (command.failsWhen?.(result, parsed.data)) process.exitCode = 1;
45
54
  if (result === "") return;
46
- process.stdout.write(
47
- typeof result === "string" ? result.endsWith("\n") ? result : `${result}
48
- ` : `${JSON.stringify(result, null, 2)}
49
- `
50
- );
55
+ const text = command.render && !json ? command.render(result) : typeof result === "string" ? result : JSON.stringify(result, null, 2);
56
+ process.stdout.write(text.endsWith("\n") ? text : `${text}
57
+ `);
58
+ }
59
+ function takeLiteral(argv) {
60
+ const at = argv.indexOf("--");
61
+ if (at === -1) return { flags: argv, literal: [] };
62
+ return { flags: argv.slice(0, at), literal: argv.slice(at + 1) };
51
63
  }
52
64
  function takeBundle(argv) {
53
65
  const at = argv.indexOf("--bundle");
@@ -89,6 +101,8 @@ function usage() {
89
101
  ),
90
102
  "",
91
103
  ` --bundle PATH defaults to ./${KB_DIR}`,
104
+ " --json the machine shape, where a command prints a table",
105
+ " -- everything after it is text, not flags",
92
106
  " --version the installed package version",
93
107
  " STRAUSS_KB_ACTOR names the writer in the log",
94
108
  ""
@@ -98,4 +112,4 @@ function usage() {
98
112
  export {
99
113
  runKbCli
100
114
  };
101
- //# sourceMappingURL=chunk-GKCG4P3L.js.map
115
+ //# sourceMappingURL=chunk-MGPYUZOM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts"],"sourcesContent":["/**\n * strauss-kb — a knowledge base's command line.\n *\n * A dispatcher over `KB_COMMANDS`, which the MCP server also projects. Nothing\n * command-specific lives here beyond turning argv into the object both\n * surfaces pass.\n */\nimport { join } from \"node:path\";\nimport { KB_COMMANDS, KB_COMMANDS_BY_NAME } from \"./commands/index.js\";\nimport { KB_DIR, KbStore } from \"./kb-store.js\";\nimport { VERSION } from \"./version.js\";\n\nexport async function runKbCli(argv: string[]): Promise<void> {\n const { flags, literal } = takeLiteral(argv);\n const { bundle, rest: withFlags } = takeBundle(flags);\n const name = withFlags[0] ?? \"\";\n\n if (!name || name === \"-h\" || name === \"--help\") {\n process.stdout.write(usage());\n return;\n }\n\n // The plugin in front of this CLI updates from a marketplace while the CLI\n // updates from npm, and neither prompts for the other. Answering \"which one\n // is installed\" is what makes that skew diagnosable instead of mysterious.\n if (name === \"--version\" || name === \"-v\") {\n process.stdout.write(`${VERSION}\\n`);\n return;\n }\n\n const command = KB_COMMANDS_BY_NAME.get(name);\n if (!command) die(`unknown command ${name}`);\n\n // Output shape, not an argument: stripped before the command sees argv, so a\n // positional adapter never has to know the flag exists. Refused rather than\n // ignored where a command has only one form — a flag that silently does\n // nothing teaches a caller that it worked.\n const json = withFlags.includes(\"--json\");\n if (json && !command.render) {\n die(`${name} takes no --json: its result is already the machine shape`);\n }\n const rest = [\n ...(json\n ? withFlags.filter((argument) => argument !== \"--json\")\n : withFlags),\n ...literal,\n ];\n\n const raw = await command.fromArgv(rest, bundle, readStdin);\n const parsed = command.input.safeParse(raw);\n if (!parsed.success) {\n die(\n `${name}: ${parsed.error.issues\n .map((issue) => `${issue.path.join(\".\") || \"(root)\"}: ${issue.message}`)\n .join(\"; \")}`,\n );\n }\n\n const store = new KbStore({\n warn: (entry) => process.stderr.write(`${JSON.stringify(entry)}\\n`),\n });\n const result = await command.run(\n {\n store,\n actor: process.env.STRAUSS_KB_ACTOR ?? \"unknown\",\n now: () => new Date().toISOString(),\n },\n parsed.data,\n );\n\n // A check reporting a problem succeeded as a command and failed as a check;\n // the command says which, rather than the dispatcher knowing their names.\n if (command.failsWhen?.(result, parsed.data)) process.exitCode = 1;\n // An empty string is deliberate silence — `context` with nothing pinned\n // runs from hooks at every session start, and even a bare newline is noise\n // injected into a fresh context.\n if (result === \"\") return;\n const text =\n command.render && !json\n ? command.render(result)\n : typeof result === \"string\"\n ? result\n : JSON.stringify(result, null, 2);\n process.stdout.write(text.endsWith(\"\\n\") ? text : `${text}\\n`);\n}\n\n/**\n * Everything after a bare `--` is text, never flags.\n *\n * Several verbs end in free prose — `no-decision`, `answer`, `query` — and a\n * reason that happens to contain `--json` or `--bundle` would otherwise lose a\n * word to the flag scan, or two. The sentinel is the usual escape, and the\n * token itself is dropped while the order of everything else is kept, so the\n * positional adapters still index the same way.\n */\nfunction takeLiteral(argv: string[]): { flags: string[]; literal: string[] } {\n const at = argv.indexOf(\"--\");\n if (at === -1) return { flags: argv, literal: [] };\n return { flags: argv.slice(0, at), literal: argv.slice(at + 1) };\n}\n\n/**\n * `--bundle` addresses a base directly; without it the command works on the\n * one under the current directory. A base belongs to whatever prompted it, so\n * the default cannot be the only option.\n */\nfunction takeBundle(argv: string[]): { bundle: string; rest: string[] } {\n const at = argv.indexOf(\"--bundle\");\n if (at === -1) {\n return { bundle: join(process.cwd(), KB_DIR), rest: argv };\n }\n const bundle = argv[at + 1];\n if (!bundle) die(\"--bundle requires a path\");\n return { bundle, rest: [...argv.slice(0, at), ...argv.slice(at + 2)] };\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve, reject) => {\n let text = \"\";\n process.stdin.setEncoding(\"utf8\");\n process.stdin.on(\"data\", (chunk) => (text += chunk));\n process.stdin.on(\"end\", () => resolve(text));\n process.stdin.on(\"error\", reject);\n });\n}\n\nfunction die(message: string): never {\n process.stderr.write(`strauss-kb: error: ${message}\\n`);\n process.exit(1);\n}\n\n/** First sentence, capped — the full text is what an MCP client shows. */\nfunction summarise(description: string): string {\n const first = description.split(\"\\n\")[0] ?? \"\";\n const sentence = first.includes(\". \")\n ? `${first.slice(0, first.indexOf(\". \"))}.`\n : first;\n return sentence.length > 78 ? `${sentence.slice(0, 75)}…` : sentence;\n}\n\nfunction usage(): string {\n const width = Math.max(...KB_COMMANDS.map((command) => command.usage.length));\n return [\n \"strauss-kb — knowledge base commands\",\n \"\",\n \"Usage: strauss-kb [--bundle PATH] <command> [args]\",\n \"\",\n ...KB_COMMANDS.map(\n (command) =>\n ` ${command.usage.padEnd(width)} ${summarise(command.description)}`,\n ),\n \"\",\n ` --bundle PATH defaults to ./${KB_DIR}`,\n \" --json the machine shape, where a command prints a table\",\n \" -- everything after it is text, not flags\",\n \" --version the installed package version\",\n \" STRAUSS_KB_ACTOR names the writer in the log\",\n \"\",\n ].join(\"\\n\");\n}\n"],"mappings":"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","names":[]}