@saasontools/strauss-kb 0.1.7 → 0.1.9
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/ARCHITECTURE.md +89 -0
- package/README.md +132 -5
- package/dist/{chunk-GKCG4P3L.js → chunk-KVEEISYQ.js} +24 -10
- package/dist/chunk-KVEEISYQ.js.map +1 -0
- package/dist/{chunk-LCQKARFK.js → chunk-MWWDD23L.js} +2 -2
- package/dist/{chunk-GKUQOJEK.js → chunk-OFDWRMY6.js} +622 -154
- package/dist/chunk-OFDWRMY6.js.map +1 -0
- package/dist/cli-main.cjs +663 -197
- package/dist/cli-main.cjs.map +1 -1
- package/dist/cli-main.js +2 -2
- package/dist/index.cjs +559 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +120 -3
- package/dist/index.d.ts +120 -3
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-main.cjs +641 -189
- package/dist/mcp-main.cjs.map +1 -1
- package/dist/mcp-main.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-GKCG4P3L.js.map +0 -1
- package/dist/chunk-GKUQOJEK.js.map +0 -1
- /package/dist/{chunk-LCQKARFK.js.map → chunk-MWWDD23L.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -35,9 +35,12 @@ __export(index_exports, {
|
|
|
35
35
|
CONTEXT_END: () => CONTEXT_END,
|
|
36
36
|
CONTEXT_PROFILES: () => CONTEXT_PROFILES,
|
|
37
37
|
DECISION_TYPE: () => DECISION_TYPE,
|
|
38
|
+
DEFAULT_AGING_DAYS: () => DEFAULT_AGING_DAYS,
|
|
39
|
+
DEFAULT_EXPIRING_DAYS: () => DEFAULT_EXPIRING_DAYS,
|
|
38
40
|
DEFAULT_LOAD_BUDGET: () => DEFAULT_LOAD_BUDGET,
|
|
39
41
|
DEFAULT_PACK_HOPS: () => DEFAULT_PACK_HOPS,
|
|
40
42
|
DEFAULT_PACK_MAX_NODES: () => DEFAULT_PACK_MAX_NODES,
|
|
43
|
+
DEFAULT_UNVERIFIED_DAYS: () => DEFAULT_UNVERIFIED_DAYS,
|
|
41
44
|
ErrorTypes: () => ErrorTypes,
|
|
42
45
|
Fault: () => Fault,
|
|
43
46
|
INDEX_FILE: () => INDEX_FILE,
|
|
@@ -46,6 +49,7 @@ __export(index_exports, {
|
|
|
46
49
|
KB_CONCEPT_ID_PATTERN: () => KB_CONCEPT_ID_PATTERN,
|
|
47
50
|
KB_CONFIDENCES: () => KB_CONFIDENCES,
|
|
48
51
|
KB_DIR: () => KB_DIR,
|
|
52
|
+
KB_DOCTOR_CHECKS: () => KB_DOCTOR_CHECKS,
|
|
49
53
|
KB_EDGE_KINDS: () => KB_EDGE_KINDS,
|
|
50
54
|
KB_MATERIALITIES: () => KB_MATERIALITIES,
|
|
51
55
|
KB_RECORD_STATUSES: () => KB_RECORD_STATUSES,
|
|
@@ -78,6 +82,7 @@ __export(index_exports, {
|
|
|
78
82
|
contextProfileBudgets: () => contextProfileBudgets,
|
|
79
83
|
createKbMcpServer: () => createKbMcpServer,
|
|
80
84
|
decisionInputSchema: () => decisionInputSchema,
|
|
85
|
+
doctor: () => doctor,
|
|
81
86
|
edgeNeighbours: () => edgeNeighbours,
|
|
82
87
|
indexIsStale: () => indexIsStale,
|
|
83
88
|
isKbRecordType: () => isKbRecordType,
|
|
@@ -498,7 +503,15 @@ var import_node_path = require("path");
|
|
|
498
503
|
var import_zod2 = require("zod");
|
|
499
504
|
var LOG_FILE = "log.jsonl";
|
|
500
505
|
var kbLogEntrySchema = import_zod2.z.object({
|
|
501
|
-
|
|
506
|
+
// Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
|
|
507
|
+
// below), and a value that isn't actually chronological — a Unix
|
|
508
|
+
// timestamp, a human-typed date, garbage — would sort wrong without
|
|
509
|
+
// ever failing to parse. `z.iso.datetime()` accepts exactly what
|
|
510
|
+
// `record()` writes (`Date#toISOString()`: full precision, `Z` offset)
|
|
511
|
+
// and rejects everything else, including a non-`Z` offset — so a
|
|
512
|
+
// malformed `at` is reported the same way a malformed line already is,
|
|
513
|
+
// rather than silently sorting into the wrong place.
|
|
514
|
+
at: import_zod2.z.iso.datetime(),
|
|
502
515
|
by: import_zod2.z.string().min(1),
|
|
503
516
|
operation: import_zod2.z.string().min(1),
|
|
504
517
|
conceptId: import_zod2.z.string().min(1),
|
|
@@ -512,6 +525,7 @@ function renderLogEntry(entry) {
|
|
|
512
525
|
function parseLog(raw) {
|
|
513
526
|
const entries = [];
|
|
514
527
|
const malformed = [];
|
|
528
|
+
const seen = /* @__PURE__ */ new Set();
|
|
515
529
|
raw.split("\n").forEach((text, index) => {
|
|
516
530
|
if (!text.trim()) return;
|
|
517
531
|
let value;
|
|
@@ -526,8 +540,14 @@ function parseLog(raw) {
|
|
|
526
540
|
malformed.push({ line: index + 1, text });
|
|
527
541
|
return;
|
|
528
542
|
}
|
|
543
|
+
const key2 = JSON.stringify(parsed.data);
|
|
544
|
+
if (seen.has(key2)) return;
|
|
545
|
+
seen.add(key2);
|
|
529
546
|
entries.push(parsed.data);
|
|
530
547
|
});
|
|
548
|
+
entries.sort(
|
|
549
|
+
(left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
|
|
550
|
+
);
|
|
531
551
|
return { entries, malformed };
|
|
532
552
|
}
|
|
533
553
|
|
|
@@ -813,6 +833,30 @@ function typeRank(record) {
|
|
|
813
833
|
return index === -1 ? TYPE_PRIORITY.length : index;
|
|
814
834
|
}
|
|
815
835
|
|
|
836
|
+
// src/kb-gitattributes.ts
|
|
837
|
+
var GITATTRIBUTES_FILE = ".gitattributes";
|
|
838
|
+
var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
|
|
839
|
+
function parseLine(line) {
|
|
840
|
+
const trimmed = line.trim();
|
|
841
|
+
if (!trimmed || trimmed.startsWith("#")) return null;
|
|
842
|
+
const [pattern, ...attrs] = trimmed.split(/\s+/);
|
|
843
|
+
return pattern === void 0 ? null : { pattern, attrs };
|
|
844
|
+
}
|
|
845
|
+
function hasMergeDeclaration(contents) {
|
|
846
|
+
return contents.split("\n").some((line) => {
|
|
847
|
+
const parsed = parseLine(line);
|
|
848
|
+
if (!parsed || parsed.pattern !== LOG_FILE) return false;
|
|
849
|
+
return parsed.attrs.some(
|
|
850
|
+
(attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
|
|
851
|
+
);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
function appendUnionMergeLine(contents) {
|
|
855
|
+
const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
|
|
856
|
+
return `${separator}${UNION_MERGE_LINE}
|
|
857
|
+
`;
|
|
858
|
+
}
|
|
859
|
+
|
|
816
860
|
// src/kb-store.ts
|
|
817
861
|
var KB_DIR = (0, import_node_path2.join)(".strauss", "kb");
|
|
818
862
|
var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
|
|
@@ -1238,8 +1282,87 @@ ${answer}
|
|
|
1238
1282
|
await (0, import_promises2.unlink)(staging).catch(() => void 0);
|
|
1239
1283
|
}
|
|
1240
1284
|
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Declares union merge for the log, so two worktrees writing the same
|
|
1287
|
+
* bundle interleave their `log.jsonl` lines on merge rather than one
|
|
1288
|
+
* side's appends silently losing to git's ordinary line-level merge.
|
|
1289
|
+
*
|
|
1290
|
+
* Called from `record` — every path that appends a log line, not just
|
|
1291
|
+
* `write` — so a bundle only ever mutated through `setStatus`/`verify`/
|
|
1292
|
+
* `supersede` still gets it. There is no cheaper reliable signal for
|
|
1293
|
+
* "first write" than checking the file itself, and after the first call
|
|
1294
|
+
* the check is a no-op `readFile`.
|
|
1295
|
+
*
|
|
1296
|
+
* A missing `.gitattributes` is created outright, with `wx` (exclusive
|
|
1297
|
+
* create) rather than a plain write: if another process's `write()` won a
|
|
1298
|
+
* race and created the file between the `readFile` below and this call,
|
|
1299
|
+
* `wx` fails instead of truncating what that writer just wrote, and the
|
|
1300
|
+
* failure is swallowed by the catch below same as any other best-effort
|
|
1301
|
+
* miss. A file that exists but declares no merge strategy for the log
|
|
1302
|
+
* gets the line appended, never a wholesale rewrite; one that already
|
|
1303
|
+
* declares any merge strategy — this one or a user's own — is left alone
|
|
1304
|
+
* entirely (see `hasMergeDeclaration`).
|
|
1305
|
+
*
|
|
1306
|
+
* `readFile` failing is `existing === null` only for `ENOENT` — genuinely
|
|
1307
|
+
* missing. Any other error (a permission problem, a transient `EMFILE`,
|
|
1308
|
+
* the path being a directory) is *not* "missing" and must not fall into
|
|
1309
|
+
* the create branch, which would truncate whatever is actually there with
|
|
1310
|
+
* just the union-merge line: that is the file-destroying bug this
|
|
1311
|
+
* function exists to avoid, not commit. An unreadable existing file is
|
|
1312
|
+
* therefore left untouched and reported as a failure like any other.
|
|
1313
|
+
*
|
|
1314
|
+
* Two processes racing the append branch — both read a file without the
|
|
1315
|
+
* line, both append it — is possible and left unguarded: `appendFile` is
|
|
1316
|
+
* `O_APPEND`, so the result is two copies of the same line rather than a
|
|
1317
|
+
* torn write, and `hasMergeDeclaration` sees a duplicate declaration as
|
|
1318
|
+
* "already declared" on the next call. A cheap-to-detect, harmless-to-
|
|
1319
|
+
* leave residue, not a reason to add a cross-process lock (see
|
|
1320
|
+
* `ARCHITECTURE.md`'s rejection of one for the same trade on records).
|
|
1321
|
+
*
|
|
1322
|
+
* Best-effort, like the log append it precedes: failing to write this
|
|
1323
|
+
* file must not fail the mutation it guards.
|
|
1324
|
+
*/
|
|
1325
|
+
async ensureGitattributes(root) {
|
|
1326
|
+
const target = (0, import_node_path2.join)(root, GITATTRIBUTES_FILE);
|
|
1327
|
+
try {
|
|
1328
|
+
let existing;
|
|
1329
|
+
try {
|
|
1330
|
+
existing = await (0, import_promises2.readFile)(target, "utf8");
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
if (error.code !== "ENOENT") throw error;
|
|
1333
|
+
existing = null;
|
|
1334
|
+
}
|
|
1335
|
+
if (existing === null) {
|
|
1336
|
+
await (0, import_promises2.writeFile)(target, appendUnionMergeLine(""), {
|
|
1337
|
+
encoding: "utf8",
|
|
1338
|
+
flag: "wx"
|
|
1339
|
+
});
|
|
1340
|
+
this.logger.info?.({
|
|
1341
|
+
operation: "kb.gitattributes.ensure",
|
|
1342
|
+
bundlePath: root,
|
|
1343
|
+
outcome: "created"
|
|
1344
|
+
});
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
if (!hasMergeDeclaration(existing)) {
|
|
1348
|
+
await (0, import_promises2.appendFile)(target, appendUnionMergeLine(existing), "utf8");
|
|
1349
|
+
this.logger.info?.({
|
|
1350
|
+
operation: "kb.gitattributes.ensure",
|
|
1351
|
+
bundlePath: root,
|
|
1352
|
+
outcome: "appended"
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
} catch (error) {
|
|
1356
|
+
this.logger.warn?.({
|
|
1357
|
+
operation: "kb.gitattributes.ensure",
|
|
1358
|
+
outcome: "failed",
|
|
1359
|
+
error: error instanceof Error ? error.message : "unknown"
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1241
1363
|
/** Appends one log line. Failing to log must not fail the mutation. */
|
|
1242
1364
|
async record(root, entry) {
|
|
1365
|
+
await this.ensureGitattributes(root);
|
|
1243
1366
|
const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
1244
1367
|
await (0, import_promises2.appendFile)((0, import_node_path2.join)(root, LOG_FILE), line, "utf8").catch((error) => {
|
|
1245
1368
|
this.logger.warn?.({
|
|
@@ -2128,6 +2251,258 @@ function validateBundle(records) {
|
|
|
2128
2251
|
return problems;
|
|
2129
2252
|
}
|
|
2130
2253
|
|
|
2254
|
+
// src/doctor.ts
|
|
2255
|
+
var DEFAULT_EXPIRING_DAYS = 30;
|
|
2256
|
+
var DEFAULT_UNVERIFIED_DAYS = 90;
|
|
2257
|
+
var DEFAULT_AGING_DAYS = 90;
|
|
2258
|
+
var KB_DOCTOR_CHECKS = [
|
|
2259
|
+
"expired",
|
|
2260
|
+
"expiring",
|
|
2261
|
+
"unverified",
|
|
2262
|
+
"aging",
|
|
2263
|
+
"orphaned",
|
|
2264
|
+
"broken-supersession",
|
|
2265
|
+
"superseded-but-cited"
|
|
2266
|
+
];
|
|
2267
|
+
var CHECK_HEADLINES = {
|
|
2268
|
+
expired: "past its stale_after date",
|
|
2269
|
+
expiring: "stale_after falls within the window",
|
|
2270
|
+
unverified: "nobody has ever confirmed it, and it is old enough to matter",
|
|
2271
|
+
aging: "still open or still proposed long after it was written",
|
|
2272
|
+
orphaned: "no other record links to it",
|
|
2273
|
+
"broken-supersession": "the supersession pointers do not resolve",
|
|
2274
|
+
"superseded-but-cited": "a live record's body links to one that no longer holds"
|
|
2275
|
+
};
|
|
2276
|
+
var DAY_MS = 864e5;
|
|
2277
|
+
function doctor(bundle, options = {}) {
|
|
2278
|
+
const thresholds = {
|
|
2279
|
+
expiringDays: options.expiringDays ?? DEFAULT_EXPIRING_DAYS,
|
|
2280
|
+
unverifiedDays: options.unverifiedDays ?? DEFAULT_UNVERIFIED_DAYS,
|
|
2281
|
+
agingDays: options.agingDays ?? DEFAULT_AGING_DAYS
|
|
2282
|
+
};
|
|
2283
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
2284
|
+
const adjudicated = adjudicate(bundle, bundle, now);
|
|
2285
|
+
const standings = new Map(
|
|
2286
|
+
adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
|
|
2287
|
+
);
|
|
2288
|
+
const inForce = adjudicated.filter(
|
|
2289
|
+
(hit) => hit.standing !== "superseded" && hit.standing !== "rejected"
|
|
2290
|
+
);
|
|
2291
|
+
const groups = [
|
|
2292
|
+
group("expired", expired(inForce, now)),
|
|
2293
|
+
group("expiring", expiring(inForce, now, thresholds.expiringDays)),
|
|
2294
|
+
group("unverified", unverified(inForce, now, thresholds.unverifiedDays)),
|
|
2295
|
+
group("aging", aging(inForce, now, thresholds.agingDays)),
|
|
2296
|
+
group("orphaned", orphaned(bundle)),
|
|
2297
|
+
group("broken-supersession", brokenSupersession(bundle, adjudicated)),
|
|
2298
|
+
group("superseded-but-cited", supersededButCited(bundle, standings))
|
|
2299
|
+
];
|
|
2300
|
+
const counts = Object.fromEntries(
|
|
2301
|
+
groups.map((entry) => [entry.check, entry.count])
|
|
2302
|
+
);
|
|
2303
|
+
const findingCount = groups.reduce((total, entry) => total + entry.count, 0);
|
|
2304
|
+
return {
|
|
2305
|
+
recordCount: bundle.length,
|
|
2306
|
+
thresholds,
|
|
2307
|
+
counts,
|
|
2308
|
+
groups,
|
|
2309
|
+
findingCount,
|
|
2310
|
+
healthy: findingCount === 0
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
function group(check, findings) {
|
|
2314
|
+
return {
|
|
2315
|
+
check,
|
|
2316
|
+
headline: CHECK_HEADLINES[check],
|
|
2317
|
+
count: findings.length,
|
|
2318
|
+
findings
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
function expired(hits, now) {
|
|
2322
|
+
const findings = [];
|
|
2323
|
+
for (const hit of hits) {
|
|
2324
|
+
const raw = hit.record.frontmatter.stale_after;
|
|
2325
|
+
if (!raw) continue;
|
|
2326
|
+
const at = Date.parse(raw);
|
|
2327
|
+
if (Number.isNaN(at)) {
|
|
2328
|
+
findings.push(
|
|
2329
|
+
finding(hit.record, `stale_after "${raw}" is not a readable date`)
|
|
2330
|
+
);
|
|
2331
|
+
continue;
|
|
2332
|
+
}
|
|
2333
|
+
if (at < now.getTime()) {
|
|
2334
|
+
findings.push(
|
|
2335
|
+
finding(
|
|
2336
|
+
hit.record,
|
|
2337
|
+
`stale since ${raw} (${daysBetween(at, now.getTime())} days ago)`
|
|
2338
|
+
)
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
return findings;
|
|
2343
|
+
}
|
|
2344
|
+
function expiring(hits, now, withinDays) {
|
|
2345
|
+
const horizon = now.getTime() + withinDays * DAY_MS;
|
|
2346
|
+
const findings = [];
|
|
2347
|
+
for (const hit of hits) {
|
|
2348
|
+
const raw = hit.record.frontmatter.stale_after;
|
|
2349
|
+
if (!raw) continue;
|
|
2350
|
+
const at = Date.parse(raw);
|
|
2351
|
+
if (Number.isNaN(at) || at < now.getTime() || at > horizon) continue;
|
|
2352
|
+
findings.push(
|
|
2353
|
+
finding(
|
|
2354
|
+
hit.record,
|
|
2355
|
+
`goes stale ${raw} (in ${daysBetween(now.getTime(), at)} days)`
|
|
2356
|
+
)
|
|
2357
|
+
);
|
|
2358
|
+
}
|
|
2359
|
+
return findings;
|
|
2360
|
+
}
|
|
2361
|
+
function unverified(hits, now, olderThanDays) {
|
|
2362
|
+
const findings = [];
|
|
2363
|
+
for (const hit of hits) {
|
|
2364
|
+
if (hit.record.frontmatter.verified?.length) continue;
|
|
2365
|
+
const age = ageInDays(hit.record, now);
|
|
2366
|
+
if (age === null || age <= olderThanDays) continue;
|
|
2367
|
+
findings.push(
|
|
2368
|
+
finding(hit.record, `never verified, written ${age} days ago`)
|
|
2369
|
+
);
|
|
2370
|
+
}
|
|
2371
|
+
return findings;
|
|
2372
|
+
}
|
|
2373
|
+
function aging(hits, now, olderThanDays) {
|
|
2374
|
+
const findings = [];
|
|
2375
|
+
for (const hit of hits) {
|
|
2376
|
+
const status = hit.record.frontmatter.strauss_status;
|
|
2377
|
+
if (status !== "open" && status !== "proposed") continue;
|
|
2378
|
+
const age = ageInDays(hit.record, now);
|
|
2379
|
+
if (age === null || age <= olderThanDays) continue;
|
|
2380
|
+
findings.push(
|
|
2381
|
+
finding(
|
|
2382
|
+
hit.record,
|
|
2383
|
+
status === "open" ? `open for ${age} days` : `proposed ${age} days ago and still unsettled`
|
|
2384
|
+
)
|
|
2385
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
return findings.sort(
|
|
2388
|
+
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
2389
|
+
);
|
|
2390
|
+
}
|
|
2391
|
+
function orphaned(bundle) {
|
|
2392
|
+
const present = new Set(bundle.map((record) => record.conceptId));
|
|
2393
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
2394
|
+
for (const record of bundle) {
|
|
2395
|
+
for (const neighbour of edgeNeighbours(record, bundle, "body-link")) {
|
|
2396
|
+
referenced.add(neighbour.conceptId);
|
|
2397
|
+
}
|
|
2398
|
+
for (const replaced of record.frontmatter.strauss_supersedes ?? []) {
|
|
2399
|
+
referenced.add(replaced);
|
|
2400
|
+
}
|
|
2401
|
+
const replacement = record.frontmatter.strauss_superseded_by;
|
|
2402
|
+
if (replacement && present.has(replacement)) {
|
|
2403
|
+
referenced.add(record.conceptId);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
return bundle.filter((record) => !referenced.has(record.conceptId)).map((record) => finding(record, "no other record links to it"));
|
|
2407
|
+
}
|
|
2408
|
+
var SUPERSESSION_CHECKS = /* @__PURE__ */ new Set([
|
|
2409
|
+
"superseded_by",
|
|
2410
|
+
"supersedes",
|
|
2411
|
+
"backlink"
|
|
2412
|
+
]);
|
|
2413
|
+
function brokenSupersession(bundle, adjudicated) {
|
|
2414
|
+
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
2415
|
+
const findings = [];
|
|
2416
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2417
|
+
const add = (record, note) => {
|
|
2418
|
+
const key2 = `${record.conceptId}\0${note}`;
|
|
2419
|
+
if (seen.has(key2)) return;
|
|
2420
|
+
seen.add(key2);
|
|
2421
|
+
findings.push(finding(record, note));
|
|
2422
|
+
};
|
|
2423
|
+
for (const problem of validateBundle(bundle)) {
|
|
2424
|
+
if (!SUPERSESSION_CHECKS.has(problem.check)) continue;
|
|
2425
|
+
const record = byId.get(problem.conceptId);
|
|
2426
|
+
if (record) add(record, problem.note);
|
|
2427
|
+
}
|
|
2428
|
+
for (const record of bundle) {
|
|
2429
|
+
const replacement = record.frontmatter.strauss_superseded_by;
|
|
2430
|
+
if (!replacement) continue;
|
|
2431
|
+
if (!byId.has(replacement)) {
|
|
2432
|
+
add(record, `replacement ${replacement} is missing`);
|
|
2433
|
+
} else if (record.frontmatter.strauss_status !== "superseded") {
|
|
2434
|
+
add(
|
|
2435
|
+
record,
|
|
2436
|
+
`names ${replacement} as its replacement but is not marked superseded`
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
for (const hit of adjudicated) {
|
|
2441
|
+
for (const warning of hit.warnings) {
|
|
2442
|
+
if (warning.kind === "broken-chain") {
|
|
2443
|
+
add(hit.record, `replacement ${warning.missing} is missing`);
|
|
2444
|
+
} else if (warning.kind === "chain-cycle") {
|
|
2445
|
+
add(
|
|
2446
|
+
hit.record,
|
|
2447
|
+
`supersession chain cycles through ${warning.through.join(" \u2192 ")}`
|
|
2448
|
+
);
|
|
2449
|
+
} else if (warning.kind === "forked-chain") {
|
|
2450
|
+
add(
|
|
2451
|
+
hit.record,
|
|
2452
|
+
`two records claim to replace it: ${warning.heads.join(", ")}`
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
return findings.sort(
|
|
2458
|
+
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
function supersededButCited(bundle, standings) {
|
|
2462
|
+
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
2463
|
+
const findings = [];
|
|
2464
|
+
for (const record of bundle) {
|
|
2465
|
+
const standing = standings.get(record.conceptId);
|
|
2466
|
+
if (standing === "superseded" || standing === "rejected") continue;
|
|
2467
|
+
for (const target of edgeNeighbours(record, bundle, "body-link")) {
|
|
2468
|
+
const targetStanding = standings.get(target.conceptId);
|
|
2469
|
+
if (targetStanding !== "superseded" && targetStanding !== "rejected") {
|
|
2470
|
+
continue;
|
|
2471
|
+
}
|
|
2472
|
+
if (replaces(record, target)) continue;
|
|
2473
|
+
const replacement = target.frontmatter.strauss_superseded_by;
|
|
2474
|
+
findings.push(
|
|
2475
|
+
finding(
|
|
2476
|
+
record,
|
|
2477
|
+
`cites ${targetStanding} ${target.conceptId}${targetStanding === "superseded" && replacement && byId.has(replacement) ? ` \u2014 replaced by ${replacement}` : ""}`
|
|
2478
|
+
)
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
return findings;
|
|
2483
|
+
}
|
|
2484
|
+
function replaces(later, earlier) {
|
|
2485
|
+
return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
|
|
2486
|
+
}
|
|
2487
|
+
function finding(record, note) {
|
|
2488
|
+
return {
|
|
2489
|
+
conceptId: record.conceptId,
|
|
2490
|
+
title: record.frontmatter.title ?? null,
|
|
2491
|
+
status: record.frontmatter.strauss_status,
|
|
2492
|
+
note
|
|
2493
|
+
};
|
|
2494
|
+
}
|
|
2495
|
+
function daysBetween(from, to) {
|
|
2496
|
+
return Math.max(0, Math.floor((to - from) / DAY_MS));
|
|
2497
|
+
}
|
|
2498
|
+
function ageInDays(record, now) {
|
|
2499
|
+
const at = record.frontmatter.generated?.at;
|
|
2500
|
+
if (!at) return null;
|
|
2501
|
+
const written = Date.parse(at);
|
|
2502
|
+
if (Number.isNaN(written)) return null;
|
|
2503
|
+
return daysBetween(written, now.getTime());
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2131
2506
|
// src/decision-record.ts
|
|
2132
2507
|
var import_zod6 = require("zod");
|
|
2133
2508
|
var DECISION_TYPE = "decision";
|
|
@@ -2264,14 +2639,104 @@ var contextCommand = define({
|
|
|
2264
2639
|
}
|
|
2265
2640
|
});
|
|
2266
2641
|
|
|
2267
|
-
// src/commands/
|
|
2642
|
+
// src/commands/doctor.ts
|
|
2268
2643
|
var import_zod10 = require("zod");
|
|
2644
|
+
var days = (what, fallback) => import_zod10.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
|
|
2645
|
+
var doctorCommand = define({
|
|
2646
|
+
name: "doctor",
|
|
2647
|
+
tool: "kb_doctor",
|
|
2648
|
+
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]",
|
|
2649
|
+
description: "A health sweep over a whole base: what the calendar has already retired, what nobody ever confirmed, what has been open or proposed long enough that the status is now the answer, and what the graph has dropped on the floor. Read-only \u2014 it never writes, never supersedes, and never re-dates anything; every finding names a record for a person to repair. Seven checks, grouped and counted: expired (past `stale_after`), expiring (inside the window), unverified (an empty `verified[]` on a record old enough to matter), aging (still `open` or `proposed`), orphaned (no other record links to it), broken supersession (a chain that does not resolve), and superseded-but-cited (a live record whose body links to a record that no longer holds). Every group is reported even when empty, because a check that found nothing and a check that never ran look identical in a report that only lists findings.\n\nThis is the question no reader thinks to ask, which is why it needs a command: decay is invisible from inside a single record \u2014 a stale one reads exactly like a live one, and a question nobody answered reads exactly like one nobody asked. Reach for it when picking up a base someone else kept, before trusting a base you have not touched in months, or on a schedule; kb_validate is the narrower neighbour, checking only whether pointers between records agree.",
|
|
2650
|
+
input: import_zod10.z.object({
|
|
2651
|
+
bundlePath,
|
|
2652
|
+
expiringDays: days(
|
|
2653
|
+
"How far ahead `expiring` looks, in days.",
|
|
2654
|
+
DEFAULT_EXPIRING_DAYS
|
|
2655
|
+
),
|
|
2656
|
+
unverifiedDays: days(
|
|
2657
|
+
"How old an unconfirmed record must be before `unverified` reports it, in days.",
|
|
2658
|
+
DEFAULT_UNVERIFIED_DAYS
|
|
2659
|
+
),
|
|
2660
|
+
agingDays: days(
|
|
2661
|
+
"How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
|
|
2662
|
+
DEFAULT_AGING_DAYS
|
|
2663
|
+
),
|
|
2664
|
+
strict: import_zod10.z.boolean().optional().describe(
|
|
2665
|
+
"Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
|
|
2666
|
+
)
|
|
2667
|
+
}),
|
|
2668
|
+
// Presence, not truthiness: `--expiring-days ""` is a caller who meant
|
|
2669
|
+
// something and mistyped it, and a falsy test would answer by quietly
|
|
2670
|
+
// sweeping at the default. Passed through as given, the schema rejects it
|
|
2671
|
+
// and says which field.
|
|
2672
|
+
fromArgv: (argv, path) => {
|
|
2673
|
+
const expiring2 = argvFlag(argv, "--expiring-days");
|
|
2674
|
+
const unverified2 = argvFlag(argv, "--unverified-days");
|
|
2675
|
+
const agingDays = argvFlag(argv, "--aging-days");
|
|
2676
|
+
return {
|
|
2677
|
+
bundlePath: path,
|
|
2678
|
+
...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
|
|
2679
|
+
...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
|
|
2680
|
+
...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
|
|
2681
|
+
...argv.includes("--strict") ? { strict: true } : {}
|
|
2682
|
+
};
|
|
2683
|
+
},
|
|
2684
|
+
run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays }) => {
|
|
2685
|
+
const checkedAt = now();
|
|
2686
|
+
const report = doctor(await store.list(path), {
|
|
2687
|
+
...expiringDays !== void 0 ? { expiringDays } : {},
|
|
2688
|
+
...unverifiedDays !== void 0 ? { unverifiedDays } : {},
|
|
2689
|
+
...agingDays !== void 0 ? { agingDays } : {},
|
|
2690
|
+
now: new Date(checkedAt)
|
|
2691
|
+
});
|
|
2692
|
+
return { bundlePath: path, checkedAt, ...report };
|
|
2693
|
+
},
|
|
2694
|
+
render: (result) => render(result),
|
|
2695
|
+
// Only expiry, and only under --strict. The other six checks report debt a
|
|
2696
|
+
// reader decides about; an expired record is the base asserting something it
|
|
2697
|
+
// already said it would stop standing behind, which is the one finding a
|
|
2698
|
+
// pipeline can act on without a judgment call.
|
|
2699
|
+
failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
|
|
2700
|
+
});
|
|
2701
|
+
function render(result) {
|
|
2702
|
+
const { thresholds } = result;
|
|
2703
|
+
const lines = [
|
|
2704
|
+
`# KB Doctor \u2014 ${result.bundlePath}`,
|
|
2705
|
+
`records: ${result.recordCount}`,
|
|
2706
|
+
`thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
|
|
2707
|
+
`checked: ${result.checkedAt}`,
|
|
2708
|
+
""
|
|
2709
|
+
];
|
|
2710
|
+
const width = Math.max(...result.groups.map((group2) => group2.check.length));
|
|
2711
|
+
for (const group2 of result.groups) {
|
|
2712
|
+
lines.push(
|
|
2713
|
+
` ${group2.check.padEnd(width)} ${String(group2.count).padStart(3)} ${group2.headline}`
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
for (const group2 of result.groups) {
|
|
2717
|
+
if (!group2.count) continue;
|
|
2718
|
+
lines.push("", `## ${group2.check} (${group2.count})`);
|
|
2719
|
+
for (const found of group2.findings) {
|
|
2720
|
+
lines.push(
|
|
2721
|
+
`- ${found.conceptId}${found.title ? ` \u2014 ${found.title}` : ""}: ${found.note}`
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
lines.push(
|
|
2726
|
+
"",
|
|
2727
|
+
result.healthy ? "Nothing to repair." : `${result.findingCount} finding${result.findingCount === 1 ? "" : "s"} across ${result.groups.filter((group2) => group2.count).length} of ${result.groups.length} checks.`
|
|
2728
|
+
);
|
|
2729
|
+
return lines.join("\n");
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
// src/commands/list.ts
|
|
2733
|
+
var import_zod11 = require("zod");
|
|
2269
2734
|
var listCommand = define({
|
|
2270
2735
|
name: "list",
|
|
2271
2736
|
tool: "kb_list",
|
|
2272
2737
|
usage: "list [type]",
|
|
2273
2738
|
description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
|
|
2274
|
-
input:
|
|
2739
|
+
input: import_zod11.z.object({ bundlePath, type: import_zod11.z.enum(KB_RECORD_TYPES).optional() }),
|
|
2275
2740
|
fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
|
|
2276
2741
|
run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
|
|
2277
2742
|
conceptId: record.conceptId,
|
|
@@ -2283,17 +2748,17 @@ var listCommand = define({
|
|
|
2283
2748
|
});
|
|
2284
2749
|
|
|
2285
2750
|
// src/commands/load.ts
|
|
2286
|
-
var
|
|
2751
|
+
var import_zod12 = require("zod");
|
|
2287
2752
|
var loadCommand = define({
|
|
2288
2753
|
name: "load",
|
|
2289
2754
|
tool: "kb_load",
|
|
2290
2755
|
usage: "load [type] [--budget N | --all]",
|
|
2291
2756
|
description: "Load the whole knowledge base at once, each record with its standing. Prefer this over searching: these bases run to a few thousand tokens, and a reader holding all of it has perfect recall and knows why it is asking, which no ranker does. Superseded records arrive under `superseded` as name, replacement and date only \u2014 their bodies no longer hold, and reading one later in a long session is the mistake this prevents; pass the id to kb_trace when you need the history. Rejected and unresolved records arrive whole: what was turned down, and what is still open, is the part a diff cannot show you. Refuses with a count rather than truncating when the base is too large \u2014 a truncated base is indistinguishable from a complete one, and would have you conclude something was never decided from a slice you did not know was a slice. Call at the point of use, not once per session: a base loaded early is summarised away by compaction, so if the visible context holds no records from this base and the question at hand is one it might govern, load before answering \u2014 never conclude nothing was decided from a context with no KB content in it. This tool (with kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.\n\nThat refusal is the default guardrail, meant for an agent that would otherwise burn its whole context on one call. `all` bypasses it and loads everything regardless of size: a deliberate operator with the budget to spend, not something to reach for automatically. It is mutually exclusive with `budgetTokens`. When the reader does not need everything, kb_query or a narrower `type` filter is the better fit than either.",
|
|
2292
|
-
input:
|
|
2757
|
+
input: import_zod12.z.object({
|
|
2293
2758
|
bundlePath,
|
|
2294
|
-
type:
|
|
2295
|
-
budgetTokens:
|
|
2296
|
-
all:
|
|
2759
|
+
type: import_zod12.z.enum(KB_RECORD_TYPES).optional(),
|
|
2760
|
+
budgetTokens: import_zod12.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
|
|
2761
|
+
all: import_zod12.z.boolean().optional().describe(
|
|
2297
2762
|
"Load the entire base regardless of size. The deliberate-operator escape hatch; mutually exclusive with budgetTokens."
|
|
2298
2763
|
)
|
|
2299
2764
|
}).refine((value) => !(value.all && value.budgetTokens !== void 0), {
|
|
@@ -2331,25 +2796,25 @@ var loadCommand = define({
|
|
|
2331
2796
|
});
|
|
2332
2797
|
|
|
2333
2798
|
// src/commands/log.ts
|
|
2334
|
-
var
|
|
2799
|
+
var import_zod13 = require("zod");
|
|
2335
2800
|
var logCommand = define({
|
|
2336
2801
|
name: "log",
|
|
2337
2802
|
tool: "kb_log",
|
|
2338
2803
|
usage: "log",
|
|
2339
2804
|
description: "What touched what, and when. The only artifact here that cannot be reconstructed from the records, so malformed lines are reported rather than repaired.",
|
|
2340
|
-
input:
|
|
2805
|
+
input: import_zod13.z.object({ bundlePath }),
|
|
2341
2806
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
2342
2807
|
run: ({ store }, { bundlePath: path }) => store.readLog(path)
|
|
2343
2808
|
});
|
|
2344
2809
|
|
|
2345
2810
|
// src/commands/no-decision.ts
|
|
2346
|
-
var
|
|
2811
|
+
var import_zod14 = require("zod");
|
|
2347
2812
|
var noDecisionCommand = define({
|
|
2348
2813
|
name: "no-decision",
|
|
2349
2814
|
tool: "kb_no_decision",
|
|
2350
2815
|
usage: "no-decision <reason...>",
|
|
2351
2816
|
description: 'Claim in one sentence that there was nothing to decide. Gating on "did you write a decision?" rewards writing a junk one; gating on "did you answer?" does not, so silence has to be expressible. Idempotent \u2014 restating it is not a collision.',
|
|
2352
|
-
input:
|
|
2817
|
+
input: import_zod14.z.object({ bundlePath, reason: import_zod14.z.string().min(1) }),
|
|
2353
2818
|
fromArgv: (argv, path) => ({
|
|
2354
2819
|
bundlePath: path,
|
|
2355
2820
|
reason: argv.slice(1).join(" ").trim()
|
|
@@ -2366,20 +2831,20 @@ var noDecisionCommand = define({
|
|
|
2366
2831
|
});
|
|
2367
2832
|
|
|
2368
2833
|
// src/commands/pack.ts
|
|
2369
|
-
var
|
|
2834
|
+
var import_zod15 = require("zod");
|
|
2370
2835
|
var packCommand = define({
|
|
2371
2836
|
name: "pack",
|
|
2372
2837
|
tool: "kb_pack",
|
|
2373
2838
|
usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
|
|
2374
2839
|
description: "The bounded neighbourhood around one record: everything within `hops` of the root, ranked and cut to `maxNodes`, with every cut record named under Excluded \u2014 a named gap is knowable, a silent one is not. Prefer this over kb_load when the base is too large to hold whole and the work centres on one record; prefer it over kb_query when the question needs the governed neighbourhood \u2014 what was settled and what binds near this record \u2014 rather than a lookup by wording. Superseded records arrive as name, replacement and date stubs exactly as kb_load emits them: their bodies no longer hold, and kb_trace has the history. Refuses outright rather than truncating when the pack would exceed its token budget \u2014 a partial pack is indistinguishable from a complete one \u2014 reporting the record count and every already-cut id so the caller can lower hops or maxNodes, or raise the budget. The header carries the bundle, root, budget and a timestamp; everything below the header is byte-identical across runs over an unchanged base, so two packs can be diffed and a changed byte means changed knowledge. This tool (with kb_load, kb_query and kb_trace) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.",
|
|
2375
|
-
input:
|
|
2840
|
+
input: import_zod15.z.object({
|
|
2376
2841
|
bundlePath,
|
|
2377
2842
|
conceptId,
|
|
2378
|
-
hops:
|
|
2379
|
-
maxNodes:
|
|
2843
|
+
hops: import_zod15.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
|
|
2844
|
+
maxNodes: import_zod15.z.number().int().positive().optional().describe(
|
|
2380
2845
|
"How many records the pack may hold, root included. Defaults to 20."
|
|
2381
2846
|
),
|
|
2382
|
-
budgetTokens:
|
|
2847
|
+
budgetTokens: import_zod15.z.number().int().positive().optional().describe(
|
|
2383
2848
|
"Approximate token ceiling over what is actually emitted. Defaults to 25000."
|
|
2384
2849
|
)
|
|
2385
2850
|
}),
|
|
@@ -2401,10 +2866,10 @@ var packCommand = define({
|
|
|
2401
2866
|
...maxNodes !== void 0 ? { maxNodes } : {},
|
|
2402
2867
|
...budgetTokens !== void 0 ? { budgetTokens } : {}
|
|
2403
2868
|
});
|
|
2404
|
-
return
|
|
2869
|
+
return render2(result, path, now());
|
|
2405
2870
|
}
|
|
2406
2871
|
});
|
|
2407
|
-
function
|
|
2872
|
+
function render2(result, bundle, at) {
|
|
2408
2873
|
const lines = [
|
|
2409
2874
|
`# KB Pack \u2014 ${result.root}`,
|
|
2410
2875
|
`bundle: ${bundle}`,
|
|
@@ -2466,22 +2931,22 @@ function warningLabel(warning) {
|
|
|
2466
2931
|
}
|
|
2467
2932
|
|
|
2468
2933
|
// src/commands/pin.ts
|
|
2469
|
-
var
|
|
2934
|
+
var import_zod16 = require("zod");
|
|
2470
2935
|
var pinCommand = define({
|
|
2471
2936
|
name: "pin",
|
|
2472
2937
|
tool: "kb_pin",
|
|
2473
2938
|
usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
|
|
2474
2939
|
description: "Pin a base into a workspace pin manifest, so `context` surfaces it at every context birth. Three layers, nearest wins: the committed project manifest (.strauss/kb-pins.json, the default), `--local` (.strauss/kb-pins.local.json, personal and gitignored), and `--user` (~/.strauss/kb-pins.json, every workspace). Idempotent \u2014 re-pinning changes nothing unless --mode, --profiles, or --frozen/--unfreeze are given, which update just those fields. `--mode full` preloads the whole base into the block regardless of the full-under threshold; `--mode index` never upgrades. `--profiles` scopes the pin to named context profiles. `--frozen` marks the base concluded: write commands against it refuse and `context` labels it read-only. A path with no records yet succeeds with a warning; bases are routinely pinned before they are populated. Pins are workspace state: the pinned base itself is never touched.",
|
|
2475
|
-
input:
|
|
2940
|
+
input: import_zod16.z.object({
|
|
2476
2941
|
bundlePath,
|
|
2477
|
-
mode:
|
|
2942
|
+
mode: import_zod16.z.enum(["full", "index"]).optional().describe(
|
|
2478
2943
|
"full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
|
|
2479
2944
|
),
|
|
2480
|
-
profiles:
|
|
2481
|
-
layer:
|
|
2945
|
+
profiles: import_zod16.z.array(import_zod16.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
|
|
2946
|
+
layer: import_zod16.z.enum(["project", "local", "user"]).optional().describe(
|
|
2482
2947
|
"Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
|
|
2483
2948
|
),
|
|
2484
|
-
frozen:
|
|
2949
|
+
frozen: import_zod16.z.boolean().optional().describe(
|
|
2485
2950
|
"true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
|
|
2486
2951
|
)
|
|
2487
2952
|
}),
|
|
@@ -2510,29 +2975,29 @@ var pinCommand = define({
|
|
|
2510
2975
|
});
|
|
2511
2976
|
|
|
2512
2977
|
// src/commands/pins.ts
|
|
2513
|
-
var
|
|
2978
|
+
var import_zod17 = require("zod");
|
|
2514
2979
|
var pinsCommand = define({
|
|
2515
2980
|
name: "pins",
|
|
2516
2981
|
tool: "kb_pins",
|
|
2517
2982
|
usage: "pins",
|
|
2518
2983
|
description: "Every pinned base across the manifest layers, each with its layer and whether it currently resolves to readable records. Reads the workspace manifests rather than any one base, like kb_context.",
|
|
2519
|
-
input:
|
|
2984
|
+
input: import_zod17.z.object({}),
|
|
2520
2985
|
fromArgv: () => ({}),
|
|
2521
2986
|
run: ({ store }) => listPins(store, process.cwd())
|
|
2522
2987
|
});
|
|
2523
2988
|
|
|
2524
2989
|
// src/commands/query.ts
|
|
2525
|
-
var
|
|
2990
|
+
var import_zod18 = require("zod");
|
|
2526
2991
|
var queryCommand = define({
|
|
2527
2992
|
name: "query",
|
|
2528
2993
|
tool: "kb_query",
|
|
2529
2994
|
usage: "query <text...>",
|
|
2530
2995
|
description: "Search and return each match with its standing. Results are flagged, never filtered: a superseded record comes back alongside whatever replaced it, and a rejected one is marked as something explicitly not adopted. Prefer kb_load when the base fits its budget: on this package's measurements, a reader holding the whole base answered eight of nine questions whose wording appears in no record, where embedding search answered four. Never read record files directly \u2014 this tool (with kb_load and kb_trace) is the only supported way to read a base; a file read bypasses supersession resolution and returns replaced records as if current.",
|
|
2531
|
-
input:
|
|
2996
|
+
input: import_zod18.z.object({
|
|
2532
2997
|
bundlePath,
|
|
2533
|
-
text:
|
|
2534
|
-
type:
|
|
2535
|
-
includeNonCurrent:
|
|
2998
|
+
text: import_zod18.z.string().optional(),
|
|
2999
|
+
type: import_zod18.z.enum(KB_RECORD_TYPES).optional(),
|
|
3000
|
+
includeNonCurrent: import_zod18.z.boolean().optional()
|
|
2536
3001
|
}),
|
|
2537
3002
|
fromArgv: (argv, path) => ({
|
|
2538
3003
|
bundlePath: path,
|
|
@@ -2554,40 +3019,40 @@ var queryCommand = define({
|
|
|
2554
3019
|
});
|
|
2555
3020
|
|
|
2556
3021
|
// src/commands/read-index.ts
|
|
2557
|
-
var
|
|
3022
|
+
var import_zod19 = require("zod");
|
|
2558
3023
|
var readIndexCommand = define({
|
|
2559
3024
|
name: "index",
|
|
2560
3025
|
tool: "kb_index",
|
|
2561
3026
|
usage: "index",
|
|
2562
3027
|
description: "The index, rebuilt if it disagrees with the records. One call gives the whole shape of the base: title, type, status, and description per record. The cheap re-orientation call after compaction or deep in a long session \u2014 a few hundred tokens; call it (or kb_context, when bases are pinned) first, then kb_load or fetch by concept id.",
|
|
2563
|
-
input:
|
|
3028
|
+
input: import_zod19.z.object({ bundlePath }),
|
|
2564
3029
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
2565
3030
|
run: ({ store }, { bundlePath: path }) => store.readIndex(path)
|
|
2566
3031
|
});
|
|
2567
3032
|
|
|
2568
3033
|
// src/commands/schema.ts
|
|
2569
|
-
var
|
|
3034
|
+
var import_zod20 = require("zod");
|
|
2570
3035
|
var schemaCommand = define({
|
|
2571
3036
|
name: "schema",
|
|
2572
3037
|
tool: "kb_schema",
|
|
2573
3038
|
usage: "schema",
|
|
2574
3039
|
description: "JSON Schema for the frontmatter, the write input, and log entries \u2014 generated from the code that enforces them, so it cannot drift from what a write will accept.",
|
|
2575
|
-
input:
|
|
3040
|
+
input: import_zod20.z.object({}),
|
|
2576
3041
|
fromArgv: () => ({}),
|
|
2577
3042
|
run: () => Promise.resolve(kbJsonSchemas())
|
|
2578
3043
|
});
|
|
2579
3044
|
|
|
2580
3045
|
// src/commands/status.ts
|
|
2581
|
-
var
|
|
3046
|
+
var import_zod21 = require("zod");
|
|
2582
3047
|
var statusCommand = define({
|
|
2583
3048
|
name: "status",
|
|
2584
3049
|
tool: "kb_status",
|
|
2585
3050
|
usage: "status <concept-id> <status>",
|
|
2586
3051
|
description: "Move a record's status, leaving everything else alone. Uses a compare-and-swap, so a concurrent change fails loudly rather than being overwritten.",
|
|
2587
|
-
input:
|
|
3052
|
+
input: import_zod21.z.object({
|
|
2588
3053
|
bundlePath,
|
|
2589
3054
|
conceptId,
|
|
2590
|
-
status:
|
|
3055
|
+
status: import_zod21.z.enum(KB_RECORD_STATUSES)
|
|
2591
3056
|
}),
|
|
2592
3057
|
fromArgv: (argv, path) => ({
|
|
2593
3058
|
bundlePath: path,
|
|
@@ -2602,13 +3067,13 @@ var statusCommand = define({
|
|
|
2602
3067
|
});
|
|
2603
3068
|
|
|
2604
3069
|
// src/commands/supersede.ts
|
|
2605
|
-
var
|
|
3070
|
+
var import_zod22 = require("zod");
|
|
2606
3071
|
var supersedeCommand = define({
|
|
2607
3072
|
name: "supersede",
|
|
2608
3073
|
tool: "kb_supersede",
|
|
2609
3074
|
usage: "supersede <concept-id> <replacement-id>",
|
|
2610
3075
|
description: "Mark a record superseded by another, linking both directions. Use this rather than editing a record whose meaning changed \u2014 a record that quietly becomes something else invalidates every reference to it, and the earlier understanding is what a later trace needs.",
|
|
2611
|
-
input:
|
|
3076
|
+
input: import_zod22.z.object({ bundlePath, conceptId, replacementId: conceptId }),
|
|
2612
3077
|
fromArgv: (argv, path) => ({
|
|
2613
3078
|
bundlePath: path,
|
|
2614
3079
|
conceptId: argv[1],
|
|
@@ -2622,16 +3087,16 @@ var supersedeCommand = define({
|
|
|
2622
3087
|
});
|
|
2623
3088
|
|
|
2624
3089
|
// src/commands/sync-instructions.ts
|
|
2625
|
-
var
|
|
3090
|
+
var import_zod23 = require("zod");
|
|
2626
3091
|
var syncInstructionsCommand = define({
|
|
2627
3092
|
name: "sync-instructions",
|
|
2628
3093
|
usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
|
|
2629
3094
|
description: "Idempotently plant the `context` block between sentinel comments in an instruction file (AGENTS.md, CLAUDE.md), creating the block when absent and leaving everything outside the sentinels alone. CLI-only: this is file plumbing for runtimes whose instruction files are re-read where their conversations are not, not an agent capability \u2014 the capability is kb_context.",
|
|
2630
|
-
input:
|
|
2631
|
-
file:
|
|
2632
|
-
budgetTokens:
|
|
2633
|
-
fullUnderTokens:
|
|
2634
|
-
profile:
|
|
3095
|
+
input: import_zod23.z.object({
|
|
3096
|
+
file: import_zod23.z.string().min(1).describe("The instruction file to edit in place."),
|
|
3097
|
+
budgetTokens: import_zod23.z.number().int().positive().optional(),
|
|
3098
|
+
fullUnderTokens: import_zod23.z.number().int().positive().optional(),
|
|
3099
|
+
profile: import_zod23.z.string().optional()
|
|
2635
3100
|
}),
|
|
2636
3101
|
fromArgv: (argv) => {
|
|
2637
3102
|
const budget = argvFlag(argv, "--budget");
|
|
@@ -2657,17 +3122,17 @@ var syncInstructionsCommand = define({
|
|
|
2657
3122
|
});
|
|
2658
3123
|
|
|
2659
3124
|
// src/commands/trace.ts
|
|
2660
|
-
var
|
|
3125
|
+
var import_zod24 = require("zod");
|
|
2661
3126
|
var traceCommand = define({
|
|
2662
3127
|
name: "trace",
|
|
2663
3128
|
tool: "kb_trace",
|
|
2664
3129
|
usage: "trace <concept-id> [edges...]",
|
|
2665
3130
|
description: 'How a position was arrived at, as a timeline ordered by when each record was written. Deliberately includes rejected, draft, and superseded records \u2014 in a history those are the content, not noise. Follows supersession, shared code anchors, and shared sources. Use when the question is "why is this the way it is" rather than "what do we hold now". This tool (with kb_load and kb_query) is the only supported way to read a base; a raw file read bypasses supersession resolution and returns replaced records as if current.',
|
|
2666
|
-
input:
|
|
3131
|
+
input: import_zod24.z.object({
|
|
2667
3132
|
bundlePath,
|
|
2668
3133
|
conceptId,
|
|
2669
|
-
edges:
|
|
2670
|
-
depth:
|
|
3134
|
+
edges: import_zod24.z.array(import_zod24.z.enum(TRACE_EDGES)).optional(),
|
|
3135
|
+
depth: import_zod24.z.number().int().positive().optional()
|
|
2671
3136
|
}),
|
|
2672
3137
|
fromArgv: (argv, path) => ({
|
|
2673
3138
|
bundlePath: path,
|
|
@@ -2689,53 +3154,53 @@ var traceCommand = define({
|
|
|
2689
3154
|
});
|
|
2690
3155
|
|
|
2691
3156
|
// src/commands/types.ts
|
|
2692
|
-
var
|
|
3157
|
+
var import_zod25 = require("zod");
|
|
2693
3158
|
var typesCommand = define({
|
|
2694
3159
|
name: "types",
|
|
2695
3160
|
tool: "kb_types",
|
|
2696
3161
|
usage: "types",
|
|
2697
3162
|
description: "The twelve record types with their purpose, body sections, and starting status. Read this before writing rather than guessing headings \u2014 a section the type does not define is rejected.",
|
|
2698
|
-
input:
|
|
3163
|
+
input: import_zod25.z.object({}),
|
|
2699
3164
|
fromArgv: () => ({}),
|
|
2700
3165
|
run: () => Promise.resolve(RECORD_TYPES)
|
|
2701
3166
|
});
|
|
2702
3167
|
|
|
2703
3168
|
// src/commands/unpin.ts
|
|
2704
|
-
var
|
|
3169
|
+
var import_zod26 = require("zod");
|
|
2705
3170
|
var unpinCommand = define({
|
|
2706
3171
|
name: "unpin",
|
|
2707
3172
|
tool: "kb_unpin",
|
|
2708
3173
|
usage: "unpin [bundle-path]",
|
|
2709
3174
|
description: "Remove a base from every pin manifest layer that holds it \u2014 project, local, and user \u2014 because unpinned means gone, not still injected from another file. Reports which layers were touched.",
|
|
2710
|
-
input:
|
|
3175
|
+
input: import_zod26.z.object({ bundlePath }),
|
|
2711
3176
|
fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
|
|
2712
3177
|
run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
|
|
2713
3178
|
});
|
|
2714
3179
|
|
|
2715
3180
|
// src/commands/validate.ts
|
|
2716
|
-
var
|
|
3181
|
+
var import_zod27 = require("zod");
|
|
2717
3182
|
var validateCommand = define({
|
|
2718
3183
|
name: "validate",
|
|
2719
3184
|
tool: "kb_validate",
|
|
2720
3185
|
usage: "validate",
|
|
2721
3186
|
description: "Check pointers no single record can see: supersession links that disagree between the two records, and assumptions that cite sources. Per-record shape is enforced on every read, so a problem here means someone edited a file by hand.",
|
|
2722
|
-
input:
|
|
3187
|
+
input: import_zod27.z.object({ bundlePath }),
|
|
2723
3188
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
2724
3189
|
run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
|
|
2725
3190
|
failsWhen: (result) => Array.isArray(result) && result.length > 0
|
|
2726
3191
|
});
|
|
2727
3192
|
|
|
2728
3193
|
// src/commands/verify.ts
|
|
2729
|
-
var
|
|
3194
|
+
var import_zod28 = require("zod");
|
|
2730
3195
|
var verifyCommand = define({
|
|
2731
3196
|
name: "verify",
|
|
2732
3197
|
tool: "kb_verify",
|
|
2733
3198
|
usage: "verify <concept-id> --note <text>",
|
|
2734
3199
|
description: "Append one verified[] event \u2014 who checked the record, when, and what the check found. Appends only; prior events are never rewritten. A record's own generator is refused unless the actor is human: re-reading your own output is not an independent check.",
|
|
2735
|
-
input:
|
|
3200
|
+
input: import_zod28.z.object({
|
|
2736
3201
|
bundlePath,
|
|
2737
3202
|
conceptId,
|
|
2738
|
-
note:
|
|
3203
|
+
note: import_zod28.z.string().refine((s) => s.trim().length > 0, {
|
|
2739
3204
|
message: "note must say what the check found"
|
|
2740
3205
|
})
|
|
2741
3206
|
}),
|
|
@@ -2755,7 +3220,7 @@ var verifyCommand = define({
|
|
|
2755
3220
|
});
|
|
2756
3221
|
|
|
2757
3222
|
// src/commands/write.ts
|
|
2758
|
-
var
|
|
3223
|
+
var import_zod29 = require("zod");
|
|
2759
3224
|
var writeCommand = define({
|
|
2760
3225
|
name: "write",
|
|
2761
3226
|
tool: "kb_write",
|
|
@@ -2769,9 +3234,9 @@ var writeCommand = define({
|
|
|
2769
3234
|
"- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
|
|
2770
3235
|
"- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
|
|
2771
3236
|
].join("\n"),
|
|
2772
|
-
input:
|
|
3237
|
+
input: import_zod29.z.object({
|
|
2773
3238
|
bundlePath,
|
|
2774
|
-
type:
|
|
3239
|
+
type: import_zod29.z.enum(KB_RECORD_TYPES),
|
|
2775
3240
|
input: composeInputSchema
|
|
2776
3241
|
}),
|
|
2777
3242
|
fromArgv: async (argv, path, stdin) => ({
|
|
@@ -2795,7 +3260,7 @@ var writeCommand = define({
|
|
|
2795
3260
|
});
|
|
2796
3261
|
|
|
2797
3262
|
// src/commands/write-decision.ts
|
|
2798
|
-
var
|
|
3263
|
+
var import_zod30 = require("zod");
|
|
2799
3264
|
var writeDecisionCommand = define({
|
|
2800
3265
|
name: "write-decision",
|
|
2801
3266
|
tool: "kb_write_decision",
|
|
@@ -2808,7 +3273,7 @@ var writeDecisionCommand = define({
|
|
|
2808
3273
|
"- `alternative` is what you turned down and why, not a list of everything considered.",
|
|
2809
3274
|
"- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
|
|
2810
3275
|
].join("\n"),
|
|
2811
|
-
input:
|
|
3276
|
+
input: import_zod30.z.object({ bundlePath, input: decisionInputSchema }),
|
|
2812
3277
|
fromArgv: async (_argv, path, stdin) => ({
|
|
2813
3278
|
bundlePath: path,
|
|
2814
3279
|
input: JSON.parse(await stdin())
|
|
@@ -2845,6 +3310,7 @@ var KB_COMMANDS = [
|
|
|
2845
3310
|
readIndexCommand,
|
|
2846
3311
|
logCommand,
|
|
2847
3312
|
validateCommand,
|
|
3313
|
+
doctorCommand,
|
|
2848
3314
|
schemaCommand,
|
|
2849
3315
|
pinCommand,
|
|
2850
3316
|
unpinCommand,
|
|
@@ -2862,7 +3328,7 @@ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
2862
3328
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
2863
3329
|
|
|
2864
3330
|
// src/version.ts
|
|
2865
|
-
var VERSION = true ? "0.1.
|
|
3331
|
+
var VERSION = true ? "0.1.9" : "0.0.0-dev";
|
|
2866
3332
|
|
|
2867
3333
|
// src/mcp.ts
|
|
2868
3334
|
function createKbMcpServer() {
|
|
@@ -2903,8 +3369,9 @@ async function runKbMcpServer() {
|
|
|
2903
3369
|
// src/cli.ts
|
|
2904
3370
|
var import_node_path7 = require("path");
|
|
2905
3371
|
async function runKbCli(argv) {
|
|
2906
|
-
const {
|
|
2907
|
-
const
|
|
3372
|
+
const { flags, literal } = takeLiteral(argv);
|
|
3373
|
+
const { bundle, rest: withFlags } = takeBundle(flags);
|
|
3374
|
+
const name = withFlags[0] ?? "";
|
|
2908
3375
|
if (!name || name === "-h" || name === "--help") {
|
|
2909
3376
|
process.stdout.write(usage());
|
|
2910
3377
|
return;
|
|
@@ -2916,6 +3383,14 @@ async function runKbCli(argv) {
|
|
|
2916
3383
|
}
|
|
2917
3384
|
const command = KB_COMMANDS_BY_NAME.get(name);
|
|
2918
3385
|
if (!command) die(`unknown command ${name}`);
|
|
3386
|
+
const json = withFlags.includes("--json");
|
|
3387
|
+
if (json && !command.render) {
|
|
3388
|
+
die(`${name} takes no --json: its result is already the machine shape`);
|
|
3389
|
+
}
|
|
3390
|
+
const rest = [
|
|
3391
|
+
...json ? withFlags.filter((argument) => argument !== "--json") : withFlags,
|
|
3392
|
+
...literal
|
|
3393
|
+
];
|
|
2919
3394
|
const raw = await command.fromArgv(rest, bundle, readStdin);
|
|
2920
3395
|
const parsed = command.input.safeParse(raw);
|
|
2921
3396
|
if (!parsed.success) {
|
|
@@ -2935,13 +3410,16 @@ async function runKbCli(argv) {
|
|
|
2935
3410
|
},
|
|
2936
3411
|
parsed.data
|
|
2937
3412
|
);
|
|
2938
|
-
if (command.failsWhen?.(result)) process.exitCode = 1;
|
|
3413
|
+
if (command.failsWhen?.(result, parsed.data)) process.exitCode = 1;
|
|
2939
3414
|
if (result === "") return;
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
`
|
|
2943
|
-
|
|
2944
|
-
|
|
3415
|
+
const text = command.render && !json ? command.render(result) : typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
3416
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}
|
|
3417
|
+
`);
|
|
3418
|
+
}
|
|
3419
|
+
function takeLiteral(argv) {
|
|
3420
|
+
const at = argv.indexOf("--");
|
|
3421
|
+
if (at === -1) return { flags: argv, literal: [] };
|
|
3422
|
+
return { flags: argv.slice(0, at), literal: argv.slice(at + 1) };
|
|
2945
3423
|
}
|
|
2946
3424
|
function takeBundle(argv) {
|
|
2947
3425
|
const at = argv.indexOf("--bundle");
|
|
@@ -2983,6 +3461,8 @@ function usage() {
|
|
|
2983
3461
|
),
|
|
2984
3462
|
"",
|
|
2985
3463
|
` --bundle PATH defaults to ./${KB_DIR}`,
|
|
3464
|
+
" --json the machine shape, where a command prints a table",
|
|
3465
|
+
" -- everything after it is text, not flags",
|
|
2986
3466
|
" --version the installed package version",
|
|
2987
3467
|
" STRAUSS_KB_ACTOR names the writer in the log",
|
|
2988
3468
|
""
|
|
@@ -2995,9 +3475,12 @@ function usage() {
|
|
|
2995
3475
|
CONTEXT_END,
|
|
2996
3476
|
CONTEXT_PROFILES,
|
|
2997
3477
|
DECISION_TYPE,
|
|
3478
|
+
DEFAULT_AGING_DAYS,
|
|
3479
|
+
DEFAULT_EXPIRING_DAYS,
|
|
2998
3480
|
DEFAULT_LOAD_BUDGET,
|
|
2999
3481
|
DEFAULT_PACK_HOPS,
|
|
3000
3482
|
DEFAULT_PACK_MAX_NODES,
|
|
3483
|
+
DEFAULT_UNVERIFIED_DAYS,
|
|
3001
3484
|
ErrorTypes,
|
|
3002
3485
|
Fault,
|
|
3003
3486
|
INDEX_FILE,
|
|
@@ -3006,6 +3489,7 @@ function usage() {
|
|
|
3006
3489
|
KB_CONCEPT_ID_PATTERN,
|
|
3007
3490
|
KB_CONFIDENCES,
|
|
3008
3491
|
KB_DIR,
|
|
3492
|
+
KB_DOCTOR_CHECKS,
|
|
3009
3493
|
KB_EDGE_KINDS,
|
|
3010
3494
|
KB_MATERIALITIES,
|
|
3011
3495
|
KB_RECORD_STATUSES,
|
|
@@ -3038,6 +3522,7 @@ function usage() {
|
|
|
3038
3522
|
contextProfileBudgets,
|
|
3039
3523
|
createKbMcpServer,
|
|
3040
3524
|
decisionInputSchema,
|
|
3525
|
+
doctor,
|
|
3041
3526
|
edgeNeighbours,
|
|
3042
3527
|
indexIsStale,
|
|
3043
3528
|
isKbRecordType,
|