@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
|
@@ -934,55 +934,6 @@ ${CONTEXT_END}` : null;
|
|
|
934
934
|
return { file, action: "appended" };
|
|
935
935
|
}
|
|
936
936
|
|
|
937
|
-
// src/kb-log.ts
|
|
938
|
-
import { z as z5 } from "zod";
|
|
939
|
-
var LOG_FILE = "log.jsonl";
|
|
940
|
-
var kbLogEntrySchema = z5.object({
|
|
941
|
-
at: z5.string().min(1),
|
|
942
|
-
by: z5.string().min(1),
|
|
943
|
-
operation: z5.string().min(1),
|
|
944
|
-
conceptId: z5.string().min(1),
|
|
945
|
-
/** Second concept id, where the operation relates two — supersession. */
|
|
946
|
-
target: z5.string().min(1).optional()
|
|
947
|
-
}).strict();
|
|
948
|
-
function renderLogEntry(entry) {
|
|
949
|
-
return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
|
|
950
|
-
`;
|
|
951
|
-
}
|
|
952
|
-
function parseLog(raw) {
|
|
953
|
-
const entries = [];
|
|
954
|
-
const malformed = [];
|
|
955
|
-
raw.split("\n").forEach((text, index) => {
|
|
956
|
-
if (!text.trim()) return;
|
|
957
|
-
let value;
|
|
958
|
-
try {
|
|
959
|
-
value = JSON.parse(text);
|
|
960
|
-
} catch {
|
|
961
|
-
malformed.push({ line: index + 1, text });
|
|
962
|
-
return;
|
|
963
|
-
}
|
|
964
|
-
const parsed = kbLogEntrySchema.safeParse(value);
|
|
965
|
-
if (!parsed.success) {
|
|
966
|
-
malformed.push({ line: index + 1, text });
|
|
967
|
-
return;
|
|
968
|
-
}
|
|
969
|
-
entries.push(parsed.data);
|
|
970
|
-
});
|
|
971
|
-
return { entries, malformed };
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// src/json-schema.ts
|
|
975
|
-
import { z as z6 } from "zod";
|
|
976
|
-
function kbJsonSchemas() {
|
|
977
|
-
return {
|
|
978
|
-
recordFrontmatter: z6.toJSONSchema(kbRecordFrontmatterSchema, {
|
|
979
|
-
io: "input"
|
|
980
|
-
}),
|
|
981
|
-
composeInput: z6.toJSONSchema(composeInputSchema, { io: "input" }),
|
|
982
|
-
logEntry: z6.toJSONSchema(kbLogEntrySchema, { io: "input" })
|
|
983
|
-
};
|
|
984
|
-
}
|
|
985
|
-
|
|
986
937
|
// src/kb-edges.ts
|
|
987
938
|
var KB_EDGE_KINDS = [
|
|
988
939
|
"body-link",
|
|
@@ -1059,6 +1010,357 @@ function anchorsTouch(left, right) {
|
|
|
1059
1010
|
return left.symbol === right.symbol;
|
|
1060
1011
|
}
|
|
1061
1012
|
|
|
1013
|
+
// src/validate.ts
|
|
1014
|
+
function validateBundle(records) {
|
|
1015
|
+
const byId = new Map(records.map((record) => [record.conceptId, record]));
|
|
1016
|
+
const problems = [];
|
|
1017
|
+
const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
|
|
1018
|
+
for (const record of records) {
|
|
1019
|
+
const { conceptId: conceptId2, frontmatter: fm } = record;
|
|
1020
|
+
if (!isKbRecordType(fm.type)) {
|
|
1021
|
+
report("type", conceptId2, `unrecognised type "${fm.type}"`);
|
|
1022
|
+
}
|
|
1023
|
+
if (fm.strauss_status === "superseded") {
|
|
1024
|
+
const by = fm.strauss_superseded_by;
|
|
1025
|
+
if (!by) {
|
|
1026
|
+
report("superseded_by", conceptId2, "superseded with no replacement");
|
|
1027
|
+
} else if (!byId.has(by)) {
|
|
1028
|
+
report("superseded_by", conceptId2, `replacement ${by} is missing`);
|
|
1029
|
+
} else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
|
|
1030
|
+
report("backlink", by, `does not list ${conceptId2} in supersedes`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
for (const old of fm.strauss_supersedes ?? []) {
|
|
1034
|
+
const previous = byId.get(old);
|
|
1035
|
+
if (!previous) {
|
|
1036
|
+
report("supersedes", conceptId2, `target ${old} is missing`);
|
|
1037
|
+
} else if (previous.frontmatter.strauss_status !== "superseded") {
|
|
1038
|
+
report("supersedes", conceptId2, `${old} is not marked superseded`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
if (fm.strauss_assumption && fm.sources?.length) {
|
|
1042
|
+
report("assumption", conceptId2, "marked an assumption but cites sources");
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return problems;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/doctor.ts
|
|
1049
|
+
var DEFAULT_EXPIRING_DAYS = 30;
|
|
1050
|
+
var DEFAULT_UNVERIFIED_DAYS = 90;
|
|
1051
|
+
var DEFAULT_AGING_DAYS = 90;
|
|
1052
|
+
var KB_DOCTOR_CHECKS = [
|
|
1053
|
+
"expired",
|
|
1054
|
+
"expiring",
|
|
1055
|
+
"unverified",
|
|
1056
|
+
"aging",
|
|
1057
|
+
"orphaned",
|
|
1058
|
+
"broken-supersession",
|
|
1059
|
+
"superseded-but-cited"
|
|
1060
|
+
];
|
|
1061
|
+
var CHECK_HEADLINES = {
|
|
1062
|
+
expired: "past its stale_after date",
|
|
1063
|
+
expiring: "stale_after falls within the window",
|
|
1064
|
+
unverified: "nobody has ever confirmed it, and it is old enough to matter",
|
|
1065
|
+
aging: "still open or still proposed long after it was written",
|
|
1066
|
+
orphaned: "no other record links to it",
|
|
1067
|
+
"broken-supersession": "the supersession pointers do not resolve",
|
|
1068
|
+
"superseded-but-cited": "a live record's body links to one that no longer holds"
|
|
1069
|
+
};
|
|
1070
|
+
var DAY_MS = 864e5;
|
|
1071
|
+
function doctor(bundle, options = {}) {
|
|
1072
|
+
const thresholds = {
|
|
1073
|
+
expiringDays: options.expiringDays ?? DEFAULT_EXPIRING_DAYS,
|
|
1074
|
+
unverifiedDays: options.unverifiedDays ?? DEFAULT_UNVERIFIED_DAYS,
|
|
1075
|
+
agingDays: options.agingDays ?? DEFAULT_AGING_DAYS
|
|
1076
|
+
};
|
|
1077
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1078
|
+
const adjudicated = adjudicate(bundle, bundle, now);
|
|
1079
|
+
const standings = new Map(
|
|
1080
|
+
adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
|
|
1081
|
+
);
|
|
1082
|
+
const inForce = adjudicated.filter(
|
|
1083
|
+
(hit) => hit.standing !== "superseded" && hit.standing !== "rejected"
|
|
1084
|
+
);
|
|
1085
|
+
const groups = [
|
|
1086
|
+
group("expired", expired(inForce, now)),
|
|
1087
|
+
group("expiring", expiring(inForce, now, thresholds.expiringDays)),
|
|
1088
|
+
group("unverified", unverified(inForce, now, thresholds.unverifiedDays)),
|
|
1089
|
+
group("aging", aging(inForce, now, thresholds.agingDays)),
|
|
1090
|
+
group("orphaned", orphaned(bundle)),
|
|
1091
|
+
group("broken-supersession", brokenSupersession(bundle, adjudicated)),
|
|
1092
|
+
group("superseded-but-cited", supersededButCited(bundle, standings))
|
|
1093
|
+
];
|
|
1094
|
+
const counts = Object.fromEntries(
|
|
1095
|
+
groups.map((entry) => [entry.check, entry.count])
|
|
1096
|
+
);
|
|
1097
|
+
const findingCount = groups.reduce((total, entry) => total + entry.count, 0);
|
|
1098
|
+
return {
|
|
1099
|
+
recordCount: bundle.length,
|
|
1100
|
+
thresholds,
|
|
1101
|
+
counts,
|
|
1102
|
+
groups,
|
|
1103
|
+
findingCount,
|
|
1104
|
+
healthy: findingCount === 0
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
function group(check, findings) {
|
|
1108
|
+
return {
|
|
1109
|
+
check,
|
|
1110
|
+
headline: CHECK_HEADLINES[check],
|
|
1111
|
+
count: findings.length,
|
|
1112
|
+
findings
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
function expired(hits, now) {
|
|
1116
|
+
const findings = [];
|
|
1117
|
+
for (const hit of hits) {
|
|
1118
|
+
const raw = hit.record.frontmatter.stale_after;
|
|
1119
|
+
if (!raw) continue;
|
|
1120
|
+
const at = Date.parse(raw);
|
|
1121
|
+
if (Number.isNaN(at)) {
|
|
1122
|
+
findings.push(
|
|
1123
|
+
finding(hit.record, `stale_after "${raw}" is not a readable date`)
|
|
1124
|
+
);
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
if (at < now.getTime()) {
|
|
1128
|
+
findings.push(
|
|
1129
|
+
finding(
|
|
1130
|
+
hit.record,
|
|
1131
|
+
`stale since ${raw} (${daysBetween(at, now.getTime())} days ago)`
|
|
1132
|
+
)
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return findings;
|
|
1137
|
+
}
|
|
1138
|
+
function expiring(hits, now, withinDays) {
|
|
1139
|
+
const horizon = now.getTime() + withinDays * DAY_MS;
|
|
1140
|
+
const findings = [];
|
|
1141
|
+
for (const hit of hits) {
|
|
1142
|
+
const raw = hit.record.frontmatter.stale_after;
|
|
1143
|
+
if (!raw) continue;
|
|
1144
|
+
const at = Date.parse(raw);
|
|
1145
|
+
if (Number.isNaN(at) || at < now.getTime() || at > horizon) continue;
|
|
1146
|
+
findings.push(
|
|
1147
|
+
finding(
|
|
1148
|
+
hit.record,
|
|
1149
|
+
`goes stale ${raw} (in ${daysBetween(now.getTime(), at)} days)`
|
|
1150
|
+
)
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
return findings;
|
|
1154
|
+
}
|
|
1155
|
+
function unverified(hits, now, olderThanDays) {
|
|
1156
|
+
const findings = [];
|
|
1157
|
+
for (const hit of hits) {
|
|
1158
|
+
if (hit.record.frontmatter.verified?.length) continue;
|
|
1159
|
+
const age = ageInDays(hit.record, now);
|
|
1160
|
+
if (age === null || age <= olderThanDays) continue;
|
|
1161
|
+
findings.push(
|
|
1162
|
+
finding(hit.record, `never verified, written ${age} days ago`)
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
return findings;
|
|
1166
|
+
}
|
|
1167
|
+
function aging(hits, now, olderThanDays) {
|
|
1168
|
+
const findings = [];
|
|
1169
|
+
for (const hit of hits) {
|
|
1170
|
+
const status = hit.record.frontmatter.strauss_status;
|
|
1171
|
+
if (status !== "open" && status !== "proposed") continue;
|
|
1172
|
+
const age = ageInDays(hit.record, now);
|
|
1173
|
+
if (age === null || age <= olderThanDays) continue;
|
|
1174
|
+
findings.push(
|
|
1175
|
+
finding(
|
|
1176
|
+
hit.record,
|
|
1177
|
+
status === "open" ? `open for ${age} days` : `proposed ${age} days ago and still unsettled`
|
|
1178
|
+
)
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
return findings.sort(
|
|
1182
|
+
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
function orphaned(bundle) {
|
|
1186
|
+
const present = new Set(bundle.map((record) => record.conceptId));
|
|
1187
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
1188
|
+
for (const record of bundle) {
|
|
1189
|
+
for (const neighbour of edgeNeighbours(record, bundle, "body-link")) {
|
|
1190
|
+
referenced.add(neighbour.conceptId);
|
|
1191
|
+
}
|
|
1192
|
+
for (const replaced of record.frontmatter.strauss_supersedes ?? []) {
|
|
1193
|
+
referenced.add(replaced);
|
|
1194
|
+
}
|
|
1195
|
+
const replacement = record.frontmatter.strauss_superseded_by;
|
|
1196
|
+
if (replacement && present.has(replacement)) {
|
|
1197
|
+
referenced.add(record.conceptId);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return bundle.filter((record) => !referenced.has(record.conceptId)).map((record) => finding(record, "no other record links to it"));
|
|
1201
|
+
}
|
|
1202
|
+
var SUPERSESSION_CHECKS = /* @__PURE__ */ new Set([
|
|
1203
|
+
"superseded_by",
|
|
1204
|
+
"supersedes",
|
|
1205
|
+
"backlink"
|
|
1206
|
+
]);
|
|
1207
|
+
function brokenSupersession(bundle, adjudicated) {
|
|
1208
|
+
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
1209
|
+
const findings = [];
|
|
1210
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1211
|
+
const add = (record, note) => {
|
|
1212
|
+
const key = `${record.conceptId}\0${note}`;
|
|
1213
|
+
if (seen.has(key)) return;
|
|
1214
|
+
seen.add(key);
|
|
1215
|
+
findings.push(finding(record, note));
|
|
1216
|
+
};
|
|
1217
|
+
for (const problem of validateBundle(bundle)) {
|
|
1218
|
+
if (!SUPERSESSION_CHECKS.has(problem.check)) continue;
|
|
1219
|
+
const record = byId.get(problem.conceptId);
|
|
1220
|
+
if (record) add(record, problem.note);
|
|
1221
|
+
}
|
|
1222
|
+
for (const record of bundle) {
|
|
1223
|
+
const replacement = record.frontmatter.strauss_superseded_by;
|
|
1224
|
+
if (!replacement) continue;
|
|
1225
|
+
if (!byId.has(replacement)) {
|
|
1226
|
+
add(record, `replacement ${replacement} is missing`);
|
|
1227
|
+
} else if (record.frontmatter.strauss_status !== "superseded") {
|
|
1228
|
+
add(
|
|
1229
|
+
record,
|
|
1230
|
+
`names ${replacement} as its replacement but is not marked superseded`
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
for (const hit of adjudicated) {
|
|
1235
|
+
for (const warning of hit.warnings) {
|
|
1236
|
+
if (warning.kind === "broken-chain") {
|
|
1237
|
+
add(hit.record, `replacement ${warning.missing} is missing`);
|
|
1238
|
+
} else if (warning.kind === "chain-cycle") {
|
|
1239
|
+
add(
|
|
1240
|
+
hit.record,
|
|
1241
|
+
`supersession chain cycles through ${warning.through.join(" \u2192 ")}`
|
|
1242
|
+
);
|
|
1243
|
+
} else if (warning.kind === "forked-chain") {
|
|
1244
|
+
add(
|
|
1245
|
+
hit.record,
|
|
1246
|
+
`two records claim to replace it: ${warning.heads.join(", ")}`
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return findings.sort(
|
|
1252
|
+
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
function supersededButCited(bundle, standings) {
|
|
1256
|
+
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
1257
|
+
const findings = [];
|
|
1258
|
+
for (const record of bundle) {
|
|
1259
|
+
const standing = standings.get(record.conceptId);
|
|
1260
|
+
if (standing === "superseded" || standing === "rejected") continue;
|
|
1261
|
+
for (const target of edgeNeighbours(record, bundle, "body-link")) {
|
|
1262
|
+
const targetStanding = standings.get(target.conceptId);
|
|
1263
|
+
if (targetStanding !== "superseded" && targetStanding !== "rejected") {
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
if (replaces(record, target)) continue;
|
|
1267
|
+
const replacement = target.frontmatter.strauss_superseded_by;
|
|
1268
|
+
findings.push(
|
|
1269
|
+
finding(
|
|
1270
|
+
record,
|
|
1271
|
+
`cites ${targetStanding} ${target.conceptId}${targetStanding === "superseded" && replacement && byId.has(replacement) ? ` \u2014 replaced by ${replacement}` : ""}`
|
|
1272
|
+
)
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return findings;
|
|
1277
|
+
}
|
|
1278
|
+
function replaces(later, earlier) {
|
|
1279
|
+
return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
|
|
1280
|
+
}
|
|
1281
|
+
function finding(record, note) {
|
|
1282
|
+
return {
|
|
1283
|
+
conceptId: record.conceptId,
|
|
1284
|
+
title: record.frontmatter.title ?? null,
|
|
1285
|
+
status: record.frontmatter.strauss_status,
|
|
1286
|
+
note
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
function daysBetween(from, to) {
|
|
1290
|
+
return Math.max(0, Math.floor((to - from) / DAY_MS));
|
|
1291
|
+
}
|
|
1292
|
+
function ageInDays(record, now) {
|
|
1293
|
+
const at = record.frontmatter.generated?.at;
|
|
1294
|
+
if (!at) return null;
|
|
1295
|
+
const written = Date.parse(at);
|
|
1296
|
+
if (Number.isNaN(written)) return null;
|
|
1297
|
+
return daysBetween(written, now.getTime());
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// src/kb-log.ts
|
|
1301
|
+
import { z as z5 } from "zod";
|
|
1302
|
+
var LOG_FILE = "log.jsonl";
|
|
1303
|
+
var kbLogEntrySchema = z5.object({
|
|
1304
|
+
// Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
|
|
1305
|
+
// below), and a value that isn't actually chronological — a Unix
|
|
1306
|
+
// timestamp, a human-typed date, garbage — would sort wrong without
|
|
1307
|
+
// ever failing to parse. `z.iso.datetime()` accepts exactly what
|
|
1308
|
+
// `record()` writes (`Date#toISOString()`: full precision, `Z` offset)
|
|
1309
|
+
// and rejects everything else, including a non-`Z` offset — so a
|
|
1310
|
+
// malformed `at` is reported the same way a malformed line already is,
|
|
1311
|
+
// rather than silently sorting into the wrong place.
|
|
1312
|
+
at: z5.iso.datetime(),
|
|
1313
|
+
by: z5.string().min(1),
|
|
1314
|
+
operation: z5.string().min(1),
|
|
1315
|
+
conceptId: z5.string().min(1),
|
|
1316
|
+
/** Second concept id, where the operation relates two — supersession. */
|
|
1317
|
+
target: z5.string().min(1).optional()
|
|
1318
|
+
}).strict();
|
|
1319
|
+
function renderLogEntry(entry) {
|
|
1320
|
+
return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
|
|
1321
|
+
`;
|
|
1322
|
+
}
|
|
1323
|
+
function parseLog(raw) {
|
|
1324
|
+
const entries = [];
|
|
1325
|
+
const malformed = [];
|
|
1326
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1327
|
+
raw.split("\n").forEach((text, index) => {
|
|
1328
|
+
if (!text.trim()) return;
|
|
1329
|
+
let value;
|
|
1330
|
+
try {
|
|
1331
|
+
value = JSON.parse(text);
|
|
1332
|
+
} catch {
|
|
1333
|
+
malformed.push({ line: index + 1, text });
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
const parsed = kbLogEntrySchema.safeParse(value);
|
|
1337
|
+
if (!parsed.success) {
|
|
1338
|
+
malformed.push({ line: index + 1, text });
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
const key = JSON.stringify(parsed.data);
|
|
1342
|
+
if (seen.has(key)) return;
|
|
1343
|
+
seen.add(key);
|
|
1344
|
+
entries.push(parsed.data);
|
|
1345
|
+
});
|
|
1346
|
+
entries.sort(
|
|
1347
|
+
(left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
|
|
1348
|
+
);
|
|
1349
|
+
return { entries, malformed };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// src/json-schema.ts
|
|
1353
|
+
import { z as z6 } from "zod";
|
|
1354
|
+
function kbJsonSchemas() {
|
|
1355
|
+
return {
|
|
1356
|
+
recordFrontmatter: z6.toJSONSchema(kbRecordFrontmatterSchema, {
|
|
1357
|
+
io: "input"
|
|
1358
|
+
}),
|
|
1359
|
+
composeInput: z6.toJSONSchema(composeInputSchema, { io: "input" }),
|
|
1360
|
+
logEntry: z6.toJSONSchema(kbLogEntrySchema, { io: "input" })
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1062
1364
|
// src/trace.ts
|
|
1063
1365
|
var TRACE_EDGES = ["supersession", "anchor", "source"];
|
|
1064
1366
|
function trace(seedId, bundle, options = {}) {
|
|
@@ -1097,41 +1399,6 @@ function byGeneratedAt(left, right) {
|
|
|
1097
1399
|
return at(left).localeCompare(at(right)) || left.depth - right.depth;
|
|
1098
1400
|
}
|
|
1099
1401
|
|
|
1100
|
-
// src/validate.ts
|
|
1101
|
-
function validateBundle(records) {
|
|
1102
|
-
const byId = new Map(records.map((record) => [record.conceptId, record]));
|
|
1103
|
-
const problems = [];
|
|
1104
|
-
const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
|
|
1105
|
-
for (const record of records) {
|
|
1106
|
-
const { conceptId: conceptId2, frontmatter: fm } = record;
|
|
1107
|
-
if (!isKbRecordType(fm.type)) {
|
|
1108
|
-
report("type", conceptId2, `unrecognised type "${fm.type}"`);
|
|
1109
|
-
}
|
|
1110
|
-
if (fm.strauss_status === "superseded") {
|
|
1111
|
-
const by = fm.strauss_superseded_by;
|
|
1112
|
-
if (!by) {
|
|
1113
|
-
report("superseded_by", conceptId2, "superseded with no replacement");
|
|
1114
|
-
} else if (!byId.has(by)) {
|
|
1115
|
-
report("superseded_by", conceptId2, `replacement ${by} is missing`);
|
|
1116
|
-
} else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
|
|
1117
|
-
report("backlink", by, `does not list ${conceptId2} in supersedes`);
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
for (const old of fm.strauss_supersedes ?? []) {
|
|
1121
|
-
const previous = byId.get(old);
|
|
1122
|
-
if (!previous) {
|
|
1123
|
-
report("supersedes", conceptId2, `target ${old} is missing`);
|
|
1124
|
-
} else if (previous.frontmatter.strauss_status !== "superseded") {
|
|
1125
|
-
report("supersedes", conceptId2, `${old} is not marked superseded`);
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
if (fm.strauss_assumption && fm.sources?.length) {
|
|
1129
|
-
report("assumption", conceptId2, "marked an assumption but cites sources");
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
return problems;
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
1402
|
// src/commands/answer.ts
|
|
1136
1403
|
import { z as z8 } from "zod";
|
|
1137
1404
|
|
|
@@ -1221,14 +1488,104 @@ var contextCommand = define({
|
|
|
1221
1488
|
}
|
|
1222
1489
|
});
|
|
1223
1490
|
|
|
1224
|
-
// src/commands/
|
|
1491
|
+
// src/commands/doctor.ts
|
|
1225
1492
|
import { z as z10 } from "zod";
|
|
1493
|
+
var days = (what, fallback) => z10.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
|
|
1494
|
+
var doctorCommand = define({
|
|
1495
|
+
name: "doctor",
|
|
1496
|
+
tool: "kb_doctor",
|
|
1497
|
+
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]",
|
|
1498
|
+
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.",
|
|
1499
|
+
input: z10.object({
|
|
1500
|
+
bundlePath,
|
|
1501
|
+
expiringDays: days(
|
|
1502
|
+
"How far ahead `expiring` looks, in days.",
|
|
1503
|
+
DEFAULT_EXPIRING_DAYS
|
|
1504
|
+
),
|
|
1505
|
+
unverifiedDays: days(
|
|
1506
|
+
"How old an unconfirmed record must be before `unverified` reports it, in days.",
|
|
1507
|
+
DEFAULT_UNVERIFIED_DAYS
|
|
1508
|
+
),
|
|
1509
|
+
agingDays: days(
|
|
1510
|
+
"How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
|
|
1511
|
+
DEFAULT_AGING_DAYS
|
|
1512
|
+
),
|
|
1513
|
+
strict: z10.boolean().optional().describe(
|
|
1514
|
+
"Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
|
|
1515
|
+
)
|
|
1516
|
+
}),
|
|
1517
|
+
// Presence, not truthiness: `--expiring-days ""` is a caller who meant
|
|
1518
|
+
// something and mistyped it, and a falsy test would answer by quietly
|
|
1519
|
+
// sweeping at the default. Passed through as given, the schema rejects it
|
|
1520
|
+
// and says which field.
|
|
1521
|
+
fromArgv: (argv, path) => {
|
|
1522
|
+
const expiring2 = argvFlag(argv, "--expiring-days");
|
|
1523
|
+
const unverified2 = argvFlag(argv, "--unverified-days");
|
|
1524
|
+
const agingDays = argvFlag(argv, "--aging-days");
|
|
1525
|
+
return {
|
|
1526
|
+
bundlePath: path,
|
|
1527
|
+
...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
|
|
1528
|
+
...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
|
|
1529
|
+
...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
|
|
1530
|
+
...argv.includes("--strict") ? { strict: true } : {}
|
|
1531
|
+
};
|
|
1532
|
+
},
|
|
1533
|
+
run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays }) => {
|
|
1534
|
+
const checkedAt = now();
|
|
1535
|
+
const report = doctor(await store.list(path), {
|
|
1536
|
+
...expiringDays !== void 0 ? { expiringDays } : {},
|
|
1537
|
+
...unverifiedDays !== void 0 ? { unverifiedDays } : {},
|
|
1538
|
+
...agingDays !== void 0 ? { agingDays } : {},
|
|
1539
|
+
now: new Date(checkedAt)
|
|
1540
|
+
});
|
|
1541
|
+
return { bundlePath: path, checkedAt, ...report };
|
|
1542
|
+
},
|
|
1543
|
+
render: (result) => render(result),
|
|
1544
|
+
// Only expiry, and only under --strict. The other six checks report debt a
|
|
1545
|
+
// reader decides about; an expired record is the base asserting something it
|
|
1546
|
+
// already said it would stop standing behind, which is the one finding a
|
|
1547
|
+
// pipeline can act on without a judgment call.
|
|
1548
|
+
failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
|
|
1549
|
+
});
|
|
1550
|
+
function render(result) {
|
|
1551
|
+
const { thresholds } = result;
|
|
1552
|
+
const lines = [
|
|
1553
|
+
`# KB Doctor \u2014 ${result.bundlePath}`,
|
|
1554
|
+
`records: ${result.recordCount}`,
|
|
1555
|
+
`thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
|
|
1556
|
+
`checked: ${result.checkedAt}`,
|
|
1557
|
+
""
|
|
1558
|
+
];
|
|
1559
|
+
const width = Math.max(...result.groups.map((group2) => group2.check.length));
|
|
1560
|
+
for (const group2 of result.groups) {
|
|
1561
|
+
lines.push(
|
|
1562
|
+
` ${group2.check.padEnd(width)} ${String(group2.count).padStart(3)} ${group2.headline}`
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
for (const group2 of result.groups) {
|
|
1566
|
+
if (!group2.count) continue;
|
|
1567
|
+
lines.push("", `## ${group2.check} (${group2.count})`);
|
|
1568
|
+
for (const found of group2.findings) {
|
|
1569
|
+
lines.push(
|
|
1570
|
+
`- ${found.conceptId}${found.title ? ` \u2014 ${found.title}` : ""}: ${found.note}`
|
|
1571
|
+
);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
lines.push(
|
|
1575
|
+
"",
|
|
1576
|
+
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.`
|
|
1577
|
+
);
|
|
1578
|
+
return lines.join("\n");
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
// src/commands/list.ts
|
|
1582
|
+
import { z as z11 } from "zod";
|
|
1226
1583
|
var listCommand = define({
|
|
1227
1584
|
name: "list",
|
|
1228
1585
|
tool: "kb_list",
|
|
1229
1586
|
usage: "list [type]",
|
|
1230
1587
|
description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
|
|
1231
|
-
input:
|
|
1588
|
+
input: z11.object({ bundlePath, type: z11.enum(KB_RECORD_TYPES).optional() }),
|
|
1232
1589
|
fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
|
|
1233
1590
|
run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
|
|
1234
1591
|
conceptId: record.conceptId,
|
|
@@ -1240,17 +1597,17 @@ var listCommand = define({
|
|
|
1240
1597
|
});
|
|
1241
1598
|
|
|
1242
1599
|
// src/commands/load.ts
|
|
1243
|
-
import { z as
|
|
1600
|
+
import { z as z12 } from "zod";
|
|
1244
1601
|
var loadCommand = define({
|
|
1245
1602
|
name: "load",
|
|
1246
1603
|
tool: "kb_load",
|
|
1247
1604
|
usage: "load [type] [--budget N | --all]",
|
|
1248
1605
|
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.",
|
|
1249
|
-
input:
|
|
1606
|
+
input: z12.object({
|
|
1250
1607
|
bundlePath,
|
|
1251
|
-
type:
|
|
1252
|
-
budgetTokens:
|
|
1253
|
-
all:
|
|
1608
|
+
type: z12.enum(KB_RECORD_TYPES).optional(),
|
|
1609
|
+
budgetTokens: z12.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
|
|
1610
|
+
all: z12.boolean().optional().describe(
|
|
1254
1611
|
"Load the entire base regardless of size. The deliberate-operator escape hatch; mutually exclusive with budgetTokens."
|
|
1255
1612
|
)
|
|
1256
1613
|
}).refine((value) => !(value.all && value.budgetTokens !== void 0), {
|
|
@@ -1288,25 +1645,25 @@ var loadCommand = define({
|
|
|
1288
1645
|
});
|
|
1289
1646
|
|
|
1290
1647
|
// src/commands/log.ts
|
|
1291
|
-
import { z as
|
|
1648
|
+
import { z as z13 } from "zod";
|
|
1292
1649
|
var logCommand = define({
|
|
1293
1650
|
name: "log",
|
|
1294
1651
|
tool: "kb_log",
|
|
1295
1652
|
usage: "log",
|
|
1296
1653
|
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.",
|
|
1297
|
-
input:
|
|
1654
|
+
input: z13.object({ bundlePath }),
|
|
1298
1655
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1299
1656
|
run: ({ store }, { bundlePath: path }) => store.readLog(path)
|
|
1300
1657
|
});
|
|
1301
1658
|
|
|
1302
1659
|
// src/commands/no-decision.ts
|
|
1303
|
-
import { z as
|
|
1660
|
+
import { z as z14 } from "zod";
|
|
1304
1661
|
var noDecisionCommand = define({
|
|
1305
1662
|
name: "no-decision",
|
|
1306
1663
|
tool: "kb_no_decision",
|
|
1307
1664
|
usage: "no-decision <reason...>",
|
|
1308
1665
|
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.',
|
|
1309
|
-
input:
|
|
1666
|
+
input: z14.object({ bundlePath, reason: z14.string().min(1) }),
|
|
1310
1667
|
fromArgv: (argv, path) => ({
|
|
1311
1668
|
bundlePath: path,
|
|
1312
1669
|
reason: argv.slice(1).join(" ").trim()
|
|
@@ -1323,20 +1680,20 @@ var noDecisionCommand = define({
|
|
|
1323
1680
|
});
|
|
1324
1681
|
|
|
1325
1682
|
// src/commands/pack.ts
|
|
1326
|
-
import { z as
|
|
1683
|
+
import { z as z15 } from "zod";
|
|
1327
1684
|
var packCommand = define({
|
|
1328
1685
|
name: "pack",
|
|
1329
1686
|
tool: "kb_pack",
|
|
1330
1687
|
usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
|
|
1331
1688
|
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.",
|
|
1332
|
-
input:
|
|
1689
|
+
input: z15.object({
|
|
1333
1690
|
bundlePath,
|
|
1334
1691
|
conceptId,
|
|
1335
|
-
hops:
|
|
1336
|
-
maxNodes:
|
|
1692
|
+
hops: z15.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
|
|
1693
|
+
maxNodes: z15.number().int().positive().optional().describe(
|
|
1337
1694
|
"How many records the pack may hold, root included. Defaults to 20."
|
|
1338
1695
|
),
|
|
1339
|
-
budgetTokens:
|
|
1696
|
+
budgetTokens: z15.number().int().positive().optional().describe(
|
|
1340
1697
|
"Approximate token ceiling over what is actually emitted. Defaults to 25000."
|
|
1341
1698
|
)
|
|
1342
1699
|
}),
|
|
@@ -1358,10 +1715,10 @@ var packCommand = define({
|
|
|
1358
1715
|
...maxNodes !== void 0 ? { maxNodes } : {},
|
|
1359
1716
|
...budgetTokens !== void 0 ? { budgetTokens } : {}
|
|
1360
1717
|
});
|
|
1361
|
-
return
|
|
1718
|
+
return render2(result, path, now());
|
|
1362
1719
|
}
|
|
1363
1720
|
});
|
|
1364
|
-
function
|
|
1721
|
+
function render2(result, bundle, at) {
|
|
1365
1722
|
const lines = [
|
|
1366
1723
|
`# KB Pack \u2014 ${result.root}`,
|
|
1367
1724
|
`bundle: ${bundle}`,
|
|
@@ -1423,22 +1780,22 @@ function warningLabel(warning) {
|
|
|
1423
1780
|
}
|
|
1424
1781
|
|
|
1425
1782
|
// src/commands/pin.ts
|
|
1426
|
-
import { z as
|
|
1783
|
+
import { z as z16 } from "zod";
|
|
1427
1784
|
var pinCommand = define({
|
|
1428
1785
|
name: "pin",
|
|
1429
1786
|
tool: "kb_pin",
|
|
1430
1787
|
usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
|
|
1431
1788
|
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.",
|
|
1432
|
-
input:
|
|
1789
|
+
input: z16.object({
|
|
1433
1790
|
bundlePath,
|
|
1434
|
-
mode:
|
|
1791
|
+
mode: z16.enum(["full", "index"]).optional().describe(
|
|
1435
1792
|
"full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
|
|
1436
1793
|
),
|
|
1437
|
-
profiles:
|
|
1438
|
-
layer:
|
|
1794
|
+
profiles: z16.array(z16.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
|
|
1795
|
+
layer: z16.enum(["project", "local", "user"]).optional().describe(
|
|
1439
1796
|
"Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
|
|
1440
1797
|
),
|
|
1441
|
-
frozen:
|
|
1798
|
+
frozen: z16.boolean().optional().describe(
|
|
1442
1799
|
"true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
|
|
1443
1800
|
)
|
|
1444
1801
|
}),
|
|
@@ -1467,29 +1824,29 @@ var pinCommand = define({
|
|
|
1467
1824
|
});
|
|
1468
1825
|
|
|
1469
1826
|
// src/commands/pins.ts
|
|
1470
|
-
import { z as
|
|
1827
|
+
import { z as z17 } from "zod";
|
|
1471
1828
|
var pinsCommand = define({
|
|
1472
1829
|
name: "pins",
|
|
1473
1830
|
tool: "kb_pins",
|
|
1474
1831
|
usage: "pins",
|
|
1475
1832
|
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.",
|
|
1476
|
-
input:
|
|
1833
|
+
input: z17.object({}),
|
|
1477
1834
|
fromArgv: () => ({}),
|
|
1478
1835
|
run: ({ store }) => listPins(store, process.cwd())
|
|
1479
1836
|
});
|
|
1480
1837
|
|
|
1481
1838
|
// src/commands/query.ts
|
|
1482
|
-
import { z as
|
|
1839
|
+
import { z as z18 } from "zod";
|
|
1483
1840
|
var queryCommand = define({
|
|
1484
1841
|
name: "query",
|
|
1485
1842
|
tool: "kb_query",
|
|
1486
1843
|
usage: "query <text...>",
|
|
1487
1844
|
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.",
|
|
1488
|
-
input:
|
|
1845
|
+
input: z18.object({
|
|
1489
1846
|
bundlePath,
|
|
1490
|
-
text:
|
|
1491
|
-
type:
|
|
1492
|
-
includeNonCurrent:
|
|
1847
|
+
text: z18.string().optional(),
|
|
1848
|
+
type: z18.enum(KB_RECORD_TYPES).optional(),
|
|
1849
|
+
includeNonCurrent: z18.boolean().optional()
|
|
1493
1850
|
}),
|
|
1494
1851
|
fromArgv: (argv, path) => ({
|
|
1495
1852
|
bundlePath: path,
|
|
@@ -1511,40 +1868,40 @@ var queryCommand = define({
|
|
|
1511
1868
|
});
|
|
1512
1869
|
|
|
1513
1870
|
// src/commands/read-index.ts
|
|
1514
|
-
import { z as
|
|
1871
|
+
import { z as z19 } from "zod";
|
|
1515
1872
|
var readIndexCommand = define({
|
|
1516
1873
|
name: "index",
|
|
1517
1874
|
tool: "kb_index",
|
|
1518
1875
|
usage: "index",
|
|
1519
1876
|
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.",
|
|
1520
|
-
input:
|
|
1877
|
+
input: z19.object({ bundlePath }),
|
|
1521
1878
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1522
1879
|
run: ({ store }, { bundlePath: path }) => store.readIndex(path)
|
|
1523
1880
|
});
|
|
1524
1881
|
|
|
1525
1882
|
// src/commands/schema.ts
|
|
1526
|
-
import { z as
|
|
1883
|
+
import { z as z20 } from "zod";
|
|
1527
1884
|
var schemaCommand = define({
|
|
1528
1885
|
name: "schema",
|
|
1529
1886
|
tool: "kb_schema",
|
|
1530
1887
|
usage: "schema",
|
|
1531
1888
|
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.",
|
|
1532
|
-
input:
|
|
1889
|
+
input: z20.object({}),
|
|
1533
1890
|
fromArgv: () => ({}),
|
|
1534
1891
|
run: () => Promise.resolve(kbJsonSchemas())
|
|
1535
1892
|
});
|
|
1536
1893
|
|
|
1537
1894
|
// src/commands/status.ts
|
|
1538
|
-
import { z as
|
|
1895
|
+
import { z as z21 } from "zod";
|
|
1539
1896
|
var statusCommand = define({
|
|
1540
1897
|
name: "status",
|
|
1541
1898
|
tool: "kb_status",
|
|
1542
1899
|
usage: "status <concept-id> <status>",
|
|
1543
1900
|
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.",
|
|
1544
|
-
input:
|
|
1901
|
+
input: z21.object({
|
|
1545
1902
|
bundlePath,
|
|
1546
1903
|
conceptId,
|
|
1547
|
-
status:
|
|
1904
|
+
status: z21.enum(KB_RECORD_STATUSES)
|
|
1548
1905
|
}),
|
|
1549
1906
|
fromArgv: (argv, path) => ({
|
|
1550
1907
|
bundlePath: path,
|
|
@@ -1559,13 +1916,13 @@ var statusCommand = define({
|
|
|
1559
1916
|
});
|
|
1560
1917
|
|
|
1561
1918
|
// src/commands/supersede.ts
|
|
1562
|
-
import { z as
|
|
1919
|
+
import { z as z22 } from "zod";
|
|
1563
1920
|
var supersedeCommand = define({
|
|
1564
1921
|
name: "supersede",
|
|
1565
1922
|
tool: "kb_supersede",
|
|
1566
1923
|
usage: "supersede <concept-id> <replacement-id>",
|
|
1567
1924
|
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.",
|
|
1568
|
-
input:
|
|
1925
|
+
input: z22.object({ bundlePath, conceptId, replacementId: conceptId }),
|
|
1569
1926
|
fromArgv: (argv, path) => ({
|
|
1570
1927
|
bundlePath: path,
|
|
1571
1928
|
conceptId: argv[1],
|
|
@@ -1579,16 +1936,16 @@ var supersedeCommand = define({
|
|
|
1579
1936
|
});
|
|
1580
1937
|
|
|
1581
1938
|
// src/commands/sync-instructions.ts
|
|
1582
|
-
import { z as
|
|
1939
|
+
import { z as z23 } from "zod";
|
|
1583
1940
|
var syncInstructionsCommand = define({
|
|
1584
1941
|
name: "sync-instructions",
|
|
1585
1942
|
usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
|
|
1586
1943
|
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.",
|
|
1587
|
-
input:
|
|
1588
|
-
file:
|
|
1589
|
-
budgetTokens:
|
|
1590
|
-
fullUnderTokens:
|
|
1591
|
-
profile:
|
|
1944
|
+
input: z23.object({
|
|
1945
|
+
file: z23.string().min(1).describe("The instruction file to edit in place."),
|
|
1946
|
+
budgetTokens: z23.number().int().positive().optional(),
|
|
1947
|
+
fullUnderTokens: z23.number().int().positive().optional(),
|
|
1948
|
+
profile: z23.string().optional()
|
|
1592
1949
|
}),
|
|
1593
1950
|
fromArgv: (argv) => {
|
|
1594
1951
|
const budget = argvFlag(argv, "--budget");
|
|
@@ -1614,17 +1971,17 @@ var syncInstructionsCommand = define({
|
|
|
1614
1971
|
});
|
|
1615
1972
|
|
|
1616
1973
|
// src/commands/trace.ts
|
|
1617
|
-
import { z as
|
|
1974
|
+
import { z as z24 } from "zod";
|
|
1618
1975
|
var traceCommand = define({
|
|
1619
1976
|
name: "trace",
|
|
1620
1977
|
tool: "kb_trace",
|
|
1621
1978
|
usage: "trace <concept-id> [edges...]",
|
|
1622
1979
|
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.',
|
|
1623
|
-
input:
|
|
1980
|
+
input: z24.object({
|
|
1624
1981
|
bundlePath,
|
|
1625
1982
|
conceptId,
|
|
1626
|
-
edges:
|
|
1627
|
-
depth:
|
|
1983
|
+
edges: z24.array(z24.enum(TRACE_EDGES)).optional(),
|
|
1984
|
+
depth: z24.number().int().positive().optional()
|
|
1628
1985
|
}),
|
|
1629
1986
|
fromArgv: (argv, path) => ({
|
|
1630
1987
|
bundlePath: path,
|
|
@@ -1646,53 +2003,53 @@ var traceCommand = define({
|
|
|
1646
2003
|
});
|
|
1647
2004
|
|
|
1648
2005
|
// src/commands/types.ts
|
|
1649
|
-
import { z as
|
|
2006
|
+
import { z as z25 } from "zod";
|
|
1650
2007
|
var typesCommand = define({
|
|
1651
2008
|
name: "types",
|
|
1652
2009
|
tool: "kb_types",
|
|
1653
2010
|
usage: "types",
|
|
1654
2011
|
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.",
|
|
1655
|
-
input:
|
|
2012
|
+
input: z25.object({}),
|
|
1656
2013
|
fromArgv: () => ({}),
|
|
1657
2014
|
run: () => Promise.resolve(RECORD_TYPES)
|
|
1658
2015
|
});
|
|
1659
2016
|
|
|
1660
2017
|
// src/commands/unpin.ts
|
|
1661
|
-
import { z as
|
|
2018
|
+
import { z as z26 } from "zod";
|
|
1662
2019
|
var unpinCommand = define({
|
|
1663
2020
|
name: "unpin",
|
|
1664
2021
|
tool: "kb_unpin",
|
|
1665
2022
|
usage: "unpin [bundle-path]",
|
|
1666
2023
|
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.",
|
|
1667
|
-
input:
|
|
2024
|
+
input: z26.object({ bundlePath }),
|
|
1668
2025
|
fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
|
|
1669
2026
|
run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
|
|
1670
2027
|
});
|
|
1671
2028
|
|
|
1672
2029
|
// src/commands/validate.ts
|
|
1673
|
-
import { z as
|
|
2030
|
+
import { z as z27 } from "zod";
|
|
1674
2031
|
var validateCommand = define({
|
|
1675
2032
|
name: "validate",
|
|
1676
2033
|
tool: "kb_validate",
|
|
1677
2034
|
usage: "validate",
|
|
1678
2035
|
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.",
|
|
1679
|
-
input:
|
|
2036
|
+
input: z27.object({ bundlePath }),
|
|
1680
2037
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1681
2038
|
run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
|
|
1682
2039
|
failsWhen: (result) => Array.isArray(result) && result.length > 0
|
|
1683
2040
|
});
|
|
1684
2041
|
|
|
1685
2042
|
// src/commands/verify.ts
|
|
1686
|
-
import { z as
|
|
2043
|
+
import { z as z28 } from "zod";
|
|
1687
2044
|
var verifyCommand = define({
|
|
1688
2045
|
name: "verify",
|
|
1689
2046
|
tool: "kb_verify",
|
|
1690
2047
|
usage: "verify <concept-id> --note <text>",
|
|
1691
2048
|
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.",
|
|
1692
|
-
input:
|
|
2049
|
+
input: z28.object({
|
|
1693
2050
|
bundlePath,
|
|
1694
2051
|
conceptId,
|
|
1695
|
-
note:
|
|
2052
|
+
note: z28.string().refine((s) => s.trim().length > 0, {
|
|
1696
2053
|
message: "note must say what the check found"
|
|
1697
2054
|
})
|
|
1698
2055
|
}),
|
|
@@ -1712,7 +2069,7 @@ var verifyCommand = define({
|
|
|
1712
2069
|
});
|
|
1713
2070
|
|
|
1714
2071
|
// src/commands/write.ts
|
|
1715
|
-
import { z as
|
|
2072
|
+
import { z as z29 } from "zod";
|
|
1716
2073
|
var writeCommand = define({
|
|
1717
2074
|
name: "write",
|
|
1718
2075
|
tool: "kb_write",
|
|
@@ -1726,9 +2083,9 @@ var writeCommand = define({
|
|
|
1726
2083
|
"- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
|
|
1727
2084
|
"- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
|
|
1728
2085
|
].join("\n"),
|
|
1729
|
-
input:
|
|
2086
|
+
input: z29.object({
|
|
1730
2087
|
bundlePath,
|
|
1731
|
-
type:
|
|
2088
|
+
type: z29.enum(KB_RECORD_TYPES),
|
|
1732
2089
|
input: composeInputSchema
|
|
1733
2090
|
}),
|
|
1734
2091
|
fromArgv: async (argv, path, stdin) => ({
|
|
@@ -1752,7 +2109,7 @@ var writeCommand = define({
|
|
|
1752
2109
|
});
|
|
1753
2110
|
|
|
1754
2111
|
// src/commands/write-decision.ts
|
|
1755
|
-
import { z as
|
|
2112
|
+
import { z as z30 } from "zod";
|
|
1756
2113
|
var writeDecisionCommand = define({
|
|
1757
2114
|
name: "write-decision",
|
|
1758
2115
|
tool: "kb_write_decision",
|
|
@@ -1765,7 +2122,7 @@ var writeDecisionCommand = define({
|
|
|
1765
2122
|
"- `alternative` is what you turned down and why, not a list of everything considered.",
|
|
1766
2123
|
"- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
|
|
1767
2124
|
].join("\n"),
|
|
1768
|
-
input:
|
|
2125
|
+
input: z30.object({ bundlePath, input: decisionInputSchema }),
|
|
1769
2126
|
fromArgv: async (_argv, path, stdin) => ({
|
|
1770
2127
|
bundlePath: path,
|
|
1771
2128
|
input: JSON.parse(await stdin())
|
|
@@ -1802,6 +2159,7 @@ var KB_COMMANDS = [
|
|
|
1802
2159
|
readIndexCommand,
|
|
1803
2160
|
logCommand,
|
|
1804
2161
|
validateCommand,
|
|
2162
|
+
doctorCommand,
|
|
1805
2163
|
schemaCommand,
|
|
1806
2164
|
pinCommand,
|
|
1807
2165
|
unpinCommand,
|
|
@@ -2073,6 +2431,32 @@ import {
|
|
|
2073
2431
|
writeFile as writeFile3
|
|
2074
2432
|
} from "fs/promises";
|
|
2075
2433
|
import { join as join4, resolve as resolve4, sep as sep2 } from "path";
|
|
2434
|
+
|
|
2435
|
+
// src/kb-gitattributes.ts
|
|
2436
|
+
var GITATTRIBUTES_FILE = ".gitattributes";
|
|
2437
|
+
var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
|
|
2438
|
+
function parseLine(line) {
|
|
2439
|
+
const trimmed = line.trim();
|
|
2440
|
+
if (!trimmed || trimmed.startsWith("#")) return null;
|
|
2441
|
+
const [pattern, ...attrs] = trimmed.split(/\s+/);
|
|
2442
|
+
return pattern === void 0 ? null : { pattern, attrs };
|
|
2443
|
+
}
|
|
2444
|
+
function hasMergeDeclaration(contents) {
|
|
2445
|
+
return contents.split("\n").some((line) => {
|
|
2446
|
+
const parsed = parseLine(line);
|
|
2447
|
+
if (!parsed || parsed.pattern !== LOG_FILE) return false;
|
|
2448
|
+
return parsed.attrs.some(
|
|
2449
|
+
(attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
|
|
2450
|
+
);
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
function appendUnionMergeLine(contents) {
|
|
2454
|
+
const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
|
|
2455
|
+
return `${separator}${UNION_MERGE_LINE}
|
|
2456
|
+
`;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
// src/kb-store.ts
|
|
2076
2460
|
var KB_DIR = join4(".strauss", "kb");
|
|
2077
2461
|
var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
|
|
2078
2462
|
var DEFAULT_LOAD_BUDGET = 25e3;
|
|
@@ -2497,8 +2881,87 @@ ${answer}
|
|
|
2497
2881
|
await unlink(staging).catch(() => void 0);
|
|
2498
2882
|
}
|
|
2499
2883
|
}
|
|
2884
|
+
/**
|
|
2885
|
+
* Declares union merge for the log, so two worktrees writing the same
|
|
2886
|
+
* bundle interleave their `log.jsonl` lines on merge rather than one
|
|
2887
|
+
* side's appends silently losing to git's ordinary line-level merge.
|
|
2888
|
+
*
|
|
2889
|
+
* Called from `record` — every path that appends a log line, not just
|
|
2890
|
+
* `write` — so a bundle only ever mutated through `setStatus`/`verify`/
|
|
2891
|
+
* `supersede` still gets it. There is no cheaper reliable signal for
|
|
2892
|
+
* "first write" than checking the file itself, and after the first call
|
|
2893
|
+
* the check is a no-op `readFile`.
|
|
2894
|
+
*
|
|
2895
|
+
* A missing `.gitattributes` is created outright, with `wx` (exclusive
|
|
2896
|
+
* create) rather than a plain write: if another process's `write()` won a
|
|
2897
|
+
* race and created the file between the `readFile` below and this call,
|
|
2898
|
+
* `wx` fails instead of truncating what that writer just wrote, and the
|
|
2899
|
+
* failure is swallowed by the catch below same as any other best-effort
|
|
2900
|
+
* miss. A file that exists but declares no merge strategy for the log
|
|
2901
|
+
* gets the line appended, never a wholesale rewrite; one that already
|
|
2902
|
+
* declares any merge strategy — this one or a user's own — is left alone
|
|
2903
|
+
* entirely (see `hasMergeDeclaration`).
|
|
2904
|
+
*
|
|
2905
|
+
* `readFile` failing is `existing === null` only for `ENOENT` — genuinely
|
|
2906
|
+
* missing. Any other error (a permission problem, a transient `EMFILE`,
|
|
2907
|
+
* the path being a directory) is *not* "missing" and must not fall into
|
|
2908
|
+
* the create branch, which would truncate whatever is actually there with
|
|
2909
|
+
* just the union-merge line: that is the file-destroying bug this
|
|
2910
|
+
* function exists to avoid, not commit. An unreadable existing file is
|
|
2911
|
+
* therefore left untouched and reported as a failure like any other.
|
|
2912
|
+
*
|
|
2913
|
+
* Two processes racing the append branch — both read a file without the
|
|
2914
|
+
* line, both append it — is possible and left unguarded: `appendFile` is
|
|
2915
|
+
* `O_APPEND`, so the result is two copies of the same line rather than a
|
|
2916
|
+
* torn write, and `hasMergeDeclaration` sees a duplicate declaration as
|
|
2917
|
+
* "already declared" on the next call. A cheap-to-detect, harmless-to-
|
|
2918
|
+
* leave residue, not a reason to add a cross-process lock (see
|
|
2919
|
+
* `ARCHITECTURE.md`'s rejection of one for the same trade on records).
|
|
2920
|
+
*
|
|
2921
|
+
* Best-effort, like the log append it precedes: failing to write this
|
|
2922
|
+
* file must not fail the mutation it guards.
|
|
2923
|
+
*/
|
|
2924
|
+
async ensureGitattributes(root) {
|
|
2925
|
+
const target = join4(root, GITATTRIBUTES_FILE);
|
|
2926
|
+
try {
|
|
2927
|
+
let existing;
|
|
2928
|
+
try {
|
|
2929
|
+
existing = await readFile3(target, "utf8");
|
|
2930
|
+
} catch (error) {
|
|
2931
|
+
if (error.code !== "ENOENT") throw error;
|
|
2932
|
+
existing = null;
|
|
2933
|
+
}
|
|
2934
|
+
if (existing === null) {
|
|
2935
|
+
await writeFile3(target, appendUnionMergeLine(""), {
|
|
2936
|
+
encoding: "utf8",
|
|
2937
|
+
flag: "wx"
|
|
2938
|
+
});
|
|
2939
|
+
this.logger.info?.({
|
|
2940
|
+
operation: "kb.gitattributes.ensure",
|
|
2941
|
+
bundlePath: root,
|
|
2942
|
+
outcome: "created"
|
|
2943
|
+
});
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2946
|
+
if (!hasMergeDeclaration(existing)) {
|
|
2947
|
+
await appendFile(target, appendUnionMergeLine(existing), "utf8");
|
|
2948
|
+
this.logger.info?.({
|
|
2949
|
+
operation: "kb.gitattributes.ensure",
|
|
2950
|
+
bundlePath: root,
|
|
2951
|
+
outcome: "appended"
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
} catch (error) {
|
|
2955
|
+
this.logger.warn?.({
|
|
2956
|
+
operation: "kb.gitattributes.ensure",
|
|
2957
|
+
outcome: "failed",
|
|
2958
|
+
error: error instanceof Error ? error.message : "unknown"
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2500
2962
|
/** Appends one log line. Failing to log must not fail the mutation. */
|
|
2501
2963
|
async record(root, entry) {
|
|
2964
|
+
await this.ensureGitattributes(root);
|
|
2502
2965
|
const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
2503
2966
|
await appendFile(join4(root, LOG_FILE), line, "utf8").catch((error) => {
|
|
2504
2967
|
this.logger.warn?.({
|
|
@@ -2656,7 +3119,7 @@ function typeRank(record) {
|
|
|
2656
3119
|
}
|
|
2657
3120
|
|
|
2658
3121
|
// src/version.ts
|
|
2659
|
-
var VERSION = true ? "0.1.
|
|
3122
|
+
var VERSION = true ? "0.1.9" : "0.0.0-dev";
|
|
2660
3123
|
|
|
2661
3124
|
export {
|
|
2662
3125
|
kbSourceSchema,
|
|
@@ -2708,17 +3171,22 @@ export {
|
|
|
2708
3171
|
CONTEXT_BEGIN,
|
|
2709
3172
|
CONTEXT_END,
|
|
2710
3173
|
syncInstructions,
|
|
3174
|
+
KB_EDGE_KINDS,
|
|
3175
|
+
neighbours,
|
|
3176
|
+
edgeNeighbours,
|
|
3177
|
+
validateBundle,
|
|
3178
|
+
DEFAULT_EXPIRING_DAYS,
|
|
3179
|
+
DEFAULT_UNVERIFIED_DAYS,
|
|
3180
|
+
DEFAULT_AGING_DAYS,
|
|
3181
|
+
KB_DOCTOR_CHECKS,
|
|
3182
|
+
doctor,
|
|
2711
3183
|
LOG_FILE,
|
|
2712
3184
|
kbLogEntrySchema,
|
|
2713
3185
|
renderLogEntry,
|
|
2714
3186
|
parseLog,
|
|
2715
3187
|
kbJsonSchemas,
|
|
2716
|
-
KB_EDGE_KINDS,
|
|
2717
|
-
neighbours,
|
|
2718
|
-
edgeNeighbours,
|
|
2719
3188
|
TRACE_EDGES,
|
|
2720
3189
|
trace,
|
|
2721
|
-
validateBundle,
|
|
2722
3190
|
KB_COMMANDS,
|
|
2723
3191
|
KB_COMMANDS_BY_NAME,
|
|
2724
3192
|
stringifyMarkdownWithFrontmatter,
|
|
@@ -2745,4 +3213,4 @@ export {
|
|
|
2745
3213
|
KbStore,
|
|
2746
3214
|
VERSION
|
|
2747
3215
|
};
|
|
2748
|
-
//# sourceMappingURL=chunk-
|
|
3216
|
+
//# sourceMappingURL=chunk-OFDWRMY6.js.map
|