@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/mcp-main.cjs
CHANGED
|
@@ -1056,14 +1056,460 @@ var contextCommand = define({
|
|
|
1056
1056
|
}
|
|
1057
1057
|
});
|
|
1058
1058
|
|
|
1059
|
-
// src/commands/
|
|
1059
|
+
// src/commands/doctor.ts
|
|
1060
1060
|
var import_zod8 = require("zod");
|
|
1061
|
+
|
|
1062
|
+
// src/kb-edges.ts
|
|
1063
|
+
var KB_EDGE_KINDS = [
|
|
1064
|
+
"body-link",
|
|
1065
|
+
"supersession",
|
|
1066
|
+
"anchor",
|
|
1067
|
+
"source"
|
|
1068
|
+
];
|
|
1069
|
+
var BODY_LINK_TARGET = new RegExp(
|
|
1070
|
+
`\\]\\((${KB_CONCEPT_ID_PATTERN.source.replace(/^\^|\$$/g, "")})\\.md\\)`,
|
|
1071
|
+
"g"
|
|
1072
|
+
);
|
|
1073
|
+
function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
|
|
1074
|
+
const found = /* @__PURE__ */ new Map();
|
|
1075
|
+
for (const kind of kinds) {
|
|
1076
|
+
for (const record of edgeNeighbours(from, bundle, kind)) {
|
|
1077
|
+
const existing = found.get(record.conceptId);
|
|
1078
|
+
if (existing) {
|
|
1079
|
+
if (!existing.via.includes(kind)) existing.via.push(kind);
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
found.set(record.conceptId, { record, via: [kind] });
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return [...found.values()];
|
|
1086
|
+
}
|
|
1087
|
+
function edgeNeighbours(from, bundle, kind) {
|
|
1088
|
+
switch (kind) {
|
|
1089
|
+
// A link whose target is not in the bundle is legal per compose.ts —
|
|
1090
|
+
// records are routinely written before the ones they point at exist — so
|
|
1091
|
+
// missing targets are skipped, never an error.
|
|
1092
|
+
case "body-link": {
|
|
1093
|
+
const targets = new Set(
|
|
1094
|
+
[...from.body.matchAll(BODY_LINK_TARGET)].map((match) => match[1])
|
|
1095
|
+
);
|
|
1096
|
+
if (!targets.size) return [];
|
|
1097
|
+
return bundle.filter(
|
|
1098
|
+
(candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
// Both directions and both pointers: `supersede()` writes the pair, but a
|
|
1102
|
+
// hand-edit can leave one side behind, and a walk trusting one pointer
|
|
1103
|
+
// would miss a replacement the bundle openly declares.
|
|
1104
|
+
case "supersession":
|
|
1105
|
+
return bundle.filter(
|
|
1106
|
+
(candidate) => candidate.conceptId !== from.conceptId && (candidate.conceptId === from.frontmatter.strauss_superseded_by || from.frontmatter.strauss_supersedes?.includes(
|
|
1107
|
+
candidate.conceptId
|
|
1108
|
+
) || candidate.frontmatter.strauss_superseded_by === from.conceptId || candidate.frontmatter.strauss_supersedes?.includes(from.conceptId))
|
|
1109
|
+
);
|
|
1110
|
+
// The edge that answers "why is this code shaped this way": every record
|
|
1111
|
+
// attached to the same file or symbol, whatever its standing.
|
|
1112
|
+
case "anchor": {
|
|
1113
|
+
const mine = from.frontmatter.strauss_anchors ?? [];
|
|
1114
|
+
if (!mine.length) return [];
|
|
1115
|
+
return bundle.filter(
|
|
1116
|
+
(candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.strauss_anchors ?? []).some(
|
|
1117
|
+
(theirs) => mine.some((ours) => anchorsTouch(ours, theirs))
|
|
1118
|
+
)
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
case "source": {
|
|
1122
|
+
const mine = new Set((from.frontmatter.sources ?? []).map((s) => s.id));
|
|
1123
|
+
if (!mine.size) return [];
|
|
1124
|
+
return bundle.filter(
|
|
1125
|
+
(candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.sources ?? []).some(
|
|
1126
|
+
(source) => mine.has(source.id)
|
|
1127
|
+
)
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function anchorsTouch(left, right) {
|
|
1133
|
+
if (left.file !== right.file) return false;
|
|
1134
|
+
if (!left.symbol || !right.symbol) return true;
|
|
1135
|
+
return left.symbol === right.symbol;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// src/validate.ts
|
|
1139
|
+
function validateBundle(records) {
|
|
1140
|
+
const byId = new Map(records.map((record) => [record.conceptId, record]));
|
|
1141
|
+
const problems = [];
|
|
1142
|
+
const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
|
|
1143
|
+
for (const record of records) {
|
|
1144
|
+
const { conceptId: conceptId2, frontmatter: fm } = record;
|
|
1145
|
+
if (!isKbRecordType(fm.type)) {
|
|
1146
|
+
report("type", conceptId2, `unrecognised type "${fm.type}"`);
|
|
1147
|
+
}
|
|
1148
|
+
if (fm.strauss_status === "superseded") {
|
|
1149
|
+
const by = fm.strauss_superseded_by;
|
|
1150
|
+
if (!by) {
|
|
1151
|
+
report("superseded_by", conceptId2, "superseded with no replacement");
|
|
1152
|
+
} else if (!byId.has(by)) {
|
|
1153
|
+
report("superseded_by", conceptId2, `replacement ${by} is missing`);
|
|
1154
|
+
} else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
|
|
1155
|
+
report("backlink", by, `does not list ${conceptId2} in supersedes`);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
for (const old of fm.strauss_supersedes ?? []) {
|
|
1159
|
+
const previous = byId.get(old);
|
|
1160
|
+
if (!previous) {
|
|
1161
|
+
report("supersedes", conceptId2, `target ${old} is missing`);
|
|
1162
|
+
} else if (previous.frontmatter.strauss_status !== "superseded") {
|
|
1163
|
+
report("supersedes", conceptId2, `${old} is not marked superseded`);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (fm.strauss_assumption && fm.sources?.length) {
|
|
1167
|
+
report("assumption", conceptId2, "marked an assumption but cites sources");
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
return problems;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/doctor.ts
|
|
1174
|
+
var DEFAULT_EXPIRING_DAYS = 30;
|
|
1175
|
+
var DEFAULT_UNVERIFIED_DAYS = 90;
|
|
1176
|
+
var DEFAULT_AGING_DAYS = 90;
|
|
1177
|
+
var CHECK_HEADLINES = {
|
|
1178
|
+
expired: "past its stale_after date",
|
|
1179
|
+
expiring: "stale_after falls within the window",
|
|
1180
|
+
unverified: "nobody has ever confirmed it, and it is old enough to matter",
|
|
1181
|
+
aging: "still open or still proposed long after it was written",
|
|
1182
|
+
orphaned: "no other record links to it",
|
|
1183
|
+
"broken-supersession": "the supersession pointers do not resolve",
|
|
1184
|
+
"superseded-but-cited": "a live record's body links to one that no longer holds"
|
|
1185
|
+
};
|
|
1186
|
+
var DAY_MS = 864e5;
|
|
1187
|
+
function doctor(bundle, options = {}) {
|
|
1188
|
+
const thresholds = {
|
|
1189
|
+
expiringDays: options.expiringDays ?? DEFAULT_EXPIRING_DAYS,
|
|
1190
|
+
unverifiedDays: options.unverifiedDays ?? DEFAULT_UNVERIFIED_DAYS,
|
|
1191
|
+
agingDays: options.agingDays ?? DEFAULT_AGING_DAYS
|
|
1192
|
+
};
|
|
1193
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1194
|
+
const adjudicated = adjudicate(bundle, bundle, now);
|
|
1195
|
+
const standings = new Map(
|
|
1196
|
+
adjudicated.map((hit) => [hit.record.conceptId, hit.standing])
|
|
1197
|
+
);
|
|
1198
|
+
const inForce = adjudicated.filter(
|
|
1199
|
+
(hit) => hit.standing !== "superseded" && hit.standing !== "rejected"
|
|
1200
|
+
);
|
|
1201
|
+
const groups = [
|
|
1202
|
+
group("expired", expired(inForce, now)),
|
|
1203
|
+
group("expiring", expiring(inForce, now, thresholds.expiringDays)),
|
|
1204
|
+
group("unverified", unverified(inForce, now, thresholds.unverifiedDays)),
|
|
1205
|
+
group("aging", aging(inForce, now, thresholds.agingDays)),
|
|
1206
|
+
group("orphaned", orphaned(bundle)),
|
|
1207
|
+
group("broken-supersession", brokenSupersession(bundle, adjudicated)),
|
|
1208
|
+
group("superseded-but-cited", supersededButCited(bundle, standings))
|
|
1209
|
+
];
|
|
1210
|
+
const counts = Object.fromEntries(
|
|
1211
|
+
groups.map((entry) => [entry.check, entry.count])
|
|
1212
|
+
);
|
|
1213
|
+
const findingCount = groups.reduce((total, entry) => total + entry.count, 0);
|
|
1214
|
+
return {
|
|
1215
|
+
recordCount: bundle.length,
|
|
1216
|
+
thresholds,
|
|
1217
|
+
counts,
|
|
1218
|
+
groups,
|
|
1219
|
+
findingCount,
|
|
1220
|
+
healthy: findingCount === 0
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
function group(check, findings) {
|
|
1224
|
+
return {
|
|
1225
|
+
check,
|
|
1226
|
+
headline: CHECK_HEADLINES[check],
|
|
1227
|
+
count: findings.length,
|
|
1228
|
+
findings
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
function expired(hits, now) {
|
|
1232
|
+
const findings = [];
|
|
1233
|
+
for (const hit of hits) {
|
|
1234
|
+
const raw = hit.record.frontmatter.stale_after;
|
|
1235
|
+
if (!raw) continue;
|
|
1236
|
+
const at = Date.parse(raw);
|
|
1237
|
+
if (Number.isNaN(at)) {
|
|
1238
|
+
findings.push(
|
|
1239
|
+
finding(hit.record, `stale_after "${raw}" is not a readable date`)
|
|
1240
|
+
);
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
if (at < now.getTime()) {
|
|
1244
|
+
findings.push(
|
|
1245
|
+
finding(
|
|
1246
|
+
hit.record,
|
|
1247
|
+
`stale since ${raw} (${daysBetween(at, now.getTime())} days ago)`
|
|
1248
|
+
)
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return findings;
|
|
1253
|
+
}
|
|
1254
|
+
function expiring(hits, now, withinDays) {
|
|
1255
|
+
const horizon = now.getTime() + withinDays * DAY_MS;
|
|
1256
|
+
const findings = [];
|
|
1257
|
+
for (const hit of hits) {
|
|
1258
|
+
const raw = hit.record.frontmatter.stale_after;
|
|
1259
|
+
if (!raw) continue;
|
|
1260
|
+
const at = Date.parse(raw);
|
|
1261
|
+
if (Number.isNaN(at) || at < now.getTime() || at > horizon) continue;
|
|
1262
|
+
findings.push(
|
|
1263
|
+
finding(
|
|
1264
|
+
hit.record,
|
|
1265
|
+
`goes stale ${raw} (in ${daysBetween(now.getTime(), at)} days)`
|
|
1266
|
+
)
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
return findings;
|
|
1270
|
+
}
|
|
1271
|
+
function unverified(hits, now, olderThanDays) {
|
|
1272
|
+
const findings = [];
|
|
1273
|
+
for (const hit of hits) {
|
|
1274
|
+
if (hit.record.frontmatter.verified?.length) continue;
|
|
1275
|
+
const age = ageInDays(hit.record, now);
|
|
1276
|
+
if (age === null || age <= olderThanDays) continue;
|
|
1277
|
+
findings.push(
|
|
1278
|
+
finding(hit.record, `never verified, written ${age} days ago`)
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
return findings;
|
|
1282
|
+
}
|
|
1283
|
+
function aging(hits, now, olderThanDays) {
|
|
1284
|
+
const findings = [];
|
|
1285
|
+
for (const hit of hits) {
|
|
1286
|
+
const status = hit.record.frontmatter.strauss_status;
|
|
1287
|
+
if (status !== "open" && status !== "proposed") continue;
|
|
1288
|
+
const age = ageInDays(hit.record, now);
|
|
1289
|
+
if (age === null || age <= olderThanDays) continue;
|
|
1290
|
+
findings.push(
|
|
1291
|
+
finding(
|
|
1292
|
+
hit.record,
|
|
1293
|
+
status === "open" ? `open for ${age} days` : `proposed ${age} days ago and still unsettled`
|
|
1294
|
+
)
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
return findings.sort(
|
|
1298
|
+
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
function orphaned(bundle) {
|
|
1302
|
+
const present = new Set(bundle.map((record) => record.conceptId));
|
|
1303
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
1304
|
+
for (const record of bundle) {
|
|
1305
|
+
for (const neighbour of edgeNeighbours(record, bundle, "body-link")) {
|
|
1306
|
+
referenced.add(neighbour.conceptId);
|
|
1307
|
+
}
|
|
1308
|
+
for (const replaced of record.frontmatter.strauss_supersedes ?? []) {
|
|
1309
|
+
referenced.add(replaced);
|
|
1310
|
+
}
|
|
1311
|
+
const replacement = record.frontmatter.strauss_superseded_by;
|
|
1312
|
+
if (replacement && present.has(replacement)) {
|
|
1313
|
+
referenced.add(record.conceptId);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return bundle.filter((record) => !referenced.has(record.conceptId)).map((record) => finding(record, "no other record links to it"));
|
|
1317
|
+
}
|
|
1318
|
+
var SUPERSESSION_CHECKS = /* @__PURE__ */ new Set([
|
|
1319
|
+
"superseded_by",
|
|
1320
|
+
"supersedes",
|
|
1321
|
+
"backlink"
|
|
1322
|
+
]);
|
|
1323
|
+
function brokenSupersession(bundle, adjudicated) {
|
|
1324
|
+
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
1325
|
+
const findings = [];
|
|
1326
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1327
|
+
const add = (record, note) => {
|
|
1328
|
+
const key = `${record.conceptId}\0${note}`;
|
|
1329
|
+
if (seen.has(key)) return;
|
|
1330
|
+
seen.add(key);
|
|
1331
|
+
findings.push(finding(record, note));
|
|
1332
|
+
};
|
|
1333
|
+
for (const problem of validateBundle(bundle)) {
|
|
1334
|
+
if (!SUPERSESSION_CHECKS.has(problem.check)) continue;
|
|
1335
|
+
const record = byId.get(problem.conceptId);
|
|
1336
|
+
if (record) add(record, problem.note);
|
|
1337
|
+
}
|
|
1338
|
+
for (const record of bundle) {
|
|
1339
|
+
const replacement = record.frontmatter.strauss_superseded_by;
|
|
1340
|
+
if (!replacement) continue;
|
|
1341
|
+
if (!byId.has(replacement)) {
|
|
1342
|
+
add(record, `replacement ${replacement} is missing`);
|
|
1343
|
+
} else if (record.frontmatter.strauss_status !== "superseded") {
|
|
1344
|
+
add(
|
|
1345
|
+
record,
|
|
1346
|
+
`names ${replacement} as its replacement but is not marked superseded`
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
for (const hit of adjudicated) {
|
|
1351
|
+
for (const warning of hit.warnings) {
|
|
1352
|
+
if (warning.kind === "broken-chain") {
|
|
1353
|
+
add(hit.record, `replacement ${warning.missing} is missing`);
|
|
1354
|
+
} else if (warning.kind === "chain-cycle") {
|
|
1355
|
+
add(
|
|
1356
|
+
hit.record,
|
|
1357
|
+
`supersession chain cycles through ${warning.through.join(" \u2192 ")}`
|
|
1358
|
+
);
|
|
1359
|
+
} else if (warning.kind === "forked-chain") {
|
|
1360
|
+
add(
|
|
1361
|
+
hit.record,
|
|
1362
|
+
`two records claim to replace it: ${warning.heads.join(", ")}`
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return findings.sort(
|
|
1368
|
+
(left, right) => left.conceptId.localeCompare(right.conceptId)
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
function supersededButCited(bundle, standings) {
|
|
1372
|
+
const byId = new Map(bundle.map((record) => [record.conceptId, record]));
|
|
1373
|
+
const findings = [];
|
|
1374
|
+
for (const record of bundle) {
|
|
1375
|
+
const standing = standings.get(record.conceptId);
|
|
1376
|
+
if (standing === "superseded" || standing === "rejected") continue;
|
|
1377
|
+
for (const target of edgeNeighbours(record, bundle, "body-link")) {
|
|
1378
|
+
const targetStanding = standings.get(target.conceptId);
|
|
1379
|
+
if (targetStanding !== "superseded" && targetStanding !== "rejected") {
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
if (replaces(record, target)) continue;
|
|
1383
|
+
const replacement = target.frontmatter.strauss_superseded_by;
|
|
1384
|
+
findings.push(
|
|
1385
|
+
finding(
|
|
1386
|
+
record,
|
|
1387
|
+
`cites ${targetStanding} ${target.conceptId}${targetStanding === "superseded" && replacement && byId.has(replacement) ? ` \u2014 replaced by ${replacement}` : ""}`
|
|
1388
|
+
)
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return findings;
|
|
1393
|
+
}
|
|
1394
|
+
function replaces(later, earlier) {
|
|
1395
|
+
return (later.frontmatter.strauss_supersedes ?? []).includes(earlier.conceptId) || earlier.frontmatter.strauss_superseded_by === later.conceptId;
|
|
1396
|
+
}
|
|
1397
|
+
function finding(record, note) {
|
|
1398
|
+
return {
|
|
1399
|
+
conceptId: record.conceptId,
|
|
1400
|
+
title: record.frontmatter.title ?? null,
|
|
1401
|
+
status: record.frontmatter.strauss_status,
|
|
1402
|
+
note
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
function daysBetween(from, to) {
|
|
1406
|
+
return Math.max(0, Math.floor((to - from) / DAY_MS));
|
|
1407
|
+
}
|
|
1408
|
+
function ageInDays(record, now) {
|
|
1409
|
+
const at = record.frontmatter.generated?.at;
|
|
1410
|
+
if (!at) return null;
|
|
1411
|
+
const written = Date.parse(at);
|
|
1412
|
+
if (Number.isNaN(written)) return null;
|
|
1413
|
+
return daysBetween(written, now.getTime());
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// src/commands/doctor.ts
|
|
1417
|
+
var days = (what, fallback) => import_zod8.z.number().int().positive().optional().describe(`${what} Defaults to ${fallback}.`);
|
|
1418
|
+
var doctorCommand = define({
|
|
1419
|
+
name: "doctor",
|
|
1420
|
+
tool: "kb_doctor",
|
|
1421
|
+
usage: "doctor [--expiring-days N] [--unverified-days N] [--aging-days N] [--strict]",
|
|
1422
|
+
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.",
|
|
1423
|
+
input: import_zod8.z.object({
|
|
1424
|
+
bundlePath,
|
|
1425
|
+
expiringDays: days(
|
|
1426
|
+
"How far ahead `expiring` looks, in days.",
|
|
1427
|
+
DEFAULT_EXPIRING_DAYS
|
|
1428
|
+
),
|
|
1429
|
+
unverifiedDays: days(
|
|
1430
|
+
"How old an unconfirmed record must be before `unverified` reports it, in days.",
|
|
1431
|
+
DEFAULT_UNVERIFIED_DAYS
|
|
1432
|
+
),
|
|
1433
|
+
agingDays: days(
|
|
1434
|
+
"How long a record may stay `open` or `proposed` before `aging` reports it, in days.",
|
|
1435
|
+
DEFAULT_AGING_DAYS
|
|
1436
|
+
),
|
|
1437
|
+
strict: import_zod8.z.boolean().optional().describe(
|
|
1438
|
+
"Turn an expired record into a non-zero exit for the CLI. No effect on the report itself."
|
|
1439
|
+
)
|
|
1440
|
+
}),
|
|
1441
|
+
// Presence, not truthiness: `--expiring-days ""` is a caller who meant
|
|
1442
|
+
// something and mistyped it, and a falsy test would answer by quietly
|
|
1443
|
+
// sweeping at the default. Passed through as given, the schema rejects it
|
|
1444
|
+
// and says which field.
|
|
1445
|
+
fromArgv: (argv, path) => {
|
|
1446
|
+
const expiring2 = argvFlag(argv, "--expiring-days");
|
|
1447
|
+
const unverified2 = argvFlag(argv, "--unverified-days");
|
|
1448
|
+
const agingDays = argvFlag(argv, "--aging-days");
|
|
1449
|
+
return {
|
|
1450
|
+
bundlePath: path,
|
|
1451
|
+
...expiring2 !== void 0 ? { expiringDays: Number(expiring2) } : {},
|
|
1452
|
+
...unverified2 !== void 0 ? { unverifiedDays: Number(unverified2) } : {},
|
|
1453
|
+
...agingDays !== void 0 ? { agingDays: Number(agingDays) } : {},
|
|
1454
|
+
...argv.includes("--strict") ? { strict: true } : {}
|
|
1455
|
+
};
|
|
1456
|
+
},
|
|
1457
|
+
run: async ({ store, now }, { bundlePath: path, expiringDays, unverifiedDays, agingDays }) => {
|
|
1458
|
+
const checkedAt = now();
|
|
1459
|
+
const report = doctor(await store.list(path), {
|
|
1460
|
+
...expiringDays !== void 0 ? { expiringDays } : {},
|
|
1461
|
+
...unverifiedDays !== void 0 ? { unverifiedDays } : {},
|
|
1462
|
+
...agingDays !== void 0 ? { agingDays } : {},
|
|
1463
|
+
now: new Date(checkedAt)
|
|
1464
|
+
});
|
|
1465
|
+
return { bundlePath: path, checkedAt, ...report };
|
|
1466
|
+
},
|
|
1467
|
+
render: (result) => render(result),
|
|
1468
|
+
// Only expiry, and only under --strict. The other six checks report debt a
|
|
1469
|
+
// reader decides about; an expired record is the base asserting something it
|
|
1470
|
+
// already said it would stop standing behind, which is the one finding a
|
|
1471
|
+
// pipeline can act on without a judgment call.
|
|
1472
|
+
failsWhen: (result, input) => input.strict === true && result.counts.expired > 0
|
|
1473
|
+
});
|
|
1474
|
+
function render(result) {
|
|
1475
|
+
const { thresholds } = result;
|
|
1476
|
+
const lines = [
|
|
1477
|
+
`# KB Doctor \u2014 ${result.bundlePath}`,
|
|
1478
|
+
`records: ${result.recordCount}`,
|
|
1479
|
+
`thresholds: expiring within ${thresholds.expiringDays}d, unverified over ${thresholds.unverifiedDays}d, aging over ${thresholds.agingDays}d`,
|
|
1480
|
+
`checked: ${result.checkedAt}`,
|
|
1481
|
+
""
|
|
1482
|
+
];
|
|
1483
|
+
const width = Math.max(...result.groups.map((group2) => group2.check.length));
|
|
1484
|
+
for (const group2 of result.groups) {
|
|
1485
|
+
lines.push(
|
|
1486
|
+
` ${group2.check.padEnd(width)} ${String(group2.count).padStart(3)} ${group2.headline}`
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
for (const group2 of result.groups) {
|
|
1490
|
+
if (!group2.count) continue;
|
|
1491
|
+
lines.push("", `## ${group2.check} (${group2.count})`);
|
|
1492
|
+
for (const found of group2.findings) {
|
|
1493
|
+
lines.push(
|
|
1494
|
+
`- ${found.conceptId}${found.title ? ` \u2014 ${found.title}` : ""}: ${found.note}`
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
lines.push(
|
|
1499
|
+
"",
|
|
1500
|
+
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.`
|
|
1501
|
+
);
|
|
1502
|
+
return lines.join("\n");
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// src/commands/list.ts
|
|
1506
|
+
var import_zod9 = require("zod");
|
|
1061
1507
|
var listCommand = define({
|
|
1062
1508
|
name: "list",
|
|
1063
1509
|
tool: "kb_list",
|
|
1064
1510
|
usage: "list [type]",
|
|
1065
1511
|
description: "Every record, optionally narrowed to one type. Use kb_query when you have a question; this is for enumerating.",
|
|
1066
|
-
input:
|
|
1512
|
+
input: import_zod9.z.object({ bundlePath, type: import_zod9.z.enum(KB_RECORD_TYPES).optional() }),
|
|
1067
1513
|
fromArgv: (argv, path) => ({ bundlePath: path, type: argv[1] }),
|
|
1068
1514
|
run: async ({ store }, { bundlePath: path, type }) => (await store.list(path, type)).map((record) => ({
|
|
1069
1515
|
conceptId: record.conceptId,
|
|
@@ -1075,17 +1521,17 @@ var listCommand = define({
|
|
|
1075
1521
|
});
|
|
1076
1522
|
|
|
1077
1523
|
// src/commands/load.ts
|
|
1078
|
-
var
|
|
1524
|
+
var import_zod10 = require("zod");
|
|
1079
1525
|
var loadCommand = define({
|
|
1080
1526
|
name: "load",
|
|
1081
1527
|
tool: "kb_load",
|
|
1082
1528
|
usage: "load [type] [--budget N | --all]",
|
|
1083
1529
|
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.",
|
|
1084
|
-
input:
|
|
1530
|
+
input: import_zod10.z.object({
|
|
1085
1531
|
bundlePath,
|
|
1086
|
-
type:
|
|
1087
|
-
budgetTokens:
|
|
1088
|
-
all:
|
|
1532
|
+
type: import_zod10.z.enum(KB_RECORD_TYPES).optional(),
|
|
1533
|
+
budgetTokens: import_zod10.z.number().int().positive().optional().describe("Approximate token ceiling. Defaults to 25000."),
|
|
1534
|
+
all: import_zod10.z.boolean().optional().describe(
|
|
1089
1535
|
"Load the entire base regardless of size. The deliberate-operator escape hatch; mutually exclusive with budgetTokens."
|
|
1090
1536
|
)
|
|
1091
1537
|
}).refine((value) => !(value.all && value.budgetTokens !== void 0), {
|
|
@@ -1123,25 +1569,25 @@ var loadCommand = define({
|
|
|
1123
1569
|
});
|
|
1124
1570
|
|
|
1125
1571
|
// src/commands/log.ts
|
|
1126
|
-
var
|
|
1572
|
+
var import_zod11 = require("zod");
|
|
1127
1573
|
var logCommand = define({
|
|
1128
1574
|
name: "log",
|
|
1129
1575
|
tool: "kb_log",
|
|
1130
1576
|
usage: "log",
|
|
1131
1577
|
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.",
|
|
1132
|
-
input:
|
|
1578
|
+
input: import_zod11.z.object({ bundlePath }),
|
|
1133
1579
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1134
1580
|
run: ({ store }, { bundlePath: path }) => store.readLog(path)
|
|
1135
1581
|
});
|
|
1136
1582
|
|
|
1137
1583
|
// src/commands/no-decision.ts
|
|
1138
|
-
var
|
|
1584
|
+
var import_zod12 = require("zod");
|
|
1139
1585
|
var noDecisionCommand = define({
|
|
1140
1586
|
name: "no-decision",
|
|
1141
1587
|
tool: "kb_no_decision",
|
|
1142
1588
|
usage: "no-decision <reason...>",
|
|
1143
1589
|
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.',
|
|
1144
|
-
input:
|
|
1590
|
+
input: import_zod12.z.object({ bundlePath, reason: import_zod12.z.string().min(1) }),
|
|
1145
1591
|
fromArgv: (argv, path) => ({
|
|
1146
1592
|
bundlePath: path,
|
|
1147
1593
|
reason: argv.slice(1).join(" ").trim()
|
|
@@ -1158,20 +1604,20 @@ var noDecisionCommand = define({
|
|
|
1158
1604
|
});
|
|
1159
1605
|
|
|
1160
1606
|
// src/commands/pack.ts
|
|
1161
|
-
var
|
|
1607
|
+
var import_zod13 = require("zod");
|
|
1162
1608
|
var packCommand = define({
|
|
1163
1609
|
name: "pack",
|
|
1164
1610
|
tool: "kb_pack",
|
|
1165
1611
|
usage: "pack <conceptId> [--hops N] [--max-nodes N] [--budget N]",
|
|
1166
1612
|
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.",
|
|
1167
|
-
input:
|
|
1613
|
+
input: import_zod13.z.object({
|
|
1168
1614
|
bundlePath,
|
|
1169
1615
|
conceptId,
|
|
1170
|
-
hops:
|
|
1171
|
-
maxNodes:
|
|
1616
|
+
hops: import_zod13.z.number().int().positive().optional().describe("How far from the root the walk may reach. Defaults to 2."),
|
|
1617
|
+
maxNodes: import_zod13.z.number().int().positive().optional().describe(
|
|
1172
1618
|
"How many records the pack may hold, root included. Defaults to 20."
|
|
1173
1619
|
),
|
|
1174
|
-
budgetTokens:
|
|
1620
|
+
budgetTokens: import_zod13.z.number().int().positive().optional().describe(
|
|
1175
1621
|
"Approximate token ceiling over what is actually emitted. Defaults to 25000."
|
|
1176
1622
|
)
|
|
1177
1623
|
}),
|
|
@@ -1193,10 +1639,10 @@ var packCommand = define({
|
|
|
1193
1639
|
...maxNodes !== void 0 ? { maxNodes } : {},
|
|
1194
1640
|
...budgetTokens !== void 0 ? { budgetTokens } : {}
|
|
1195
1641
|
});
|
|
1196
|
-
return
|
|
1642
|
+
return render2(result, path, now());
|
|
1197
1643
|
}
|
|
1198
1644
|
});
|
|
1199
|
-
function
|
|
1645
|
+
function render2(result, bundle, at) {
|
|
1200
1646
|
const lines = [
|
|
1201
1647
|
`# KB Pack \u2014 ${result.root}`,
|
|
1202
1648
|
`bundle: ${bundle}`,
|
|
@@ -1258,22 +1704,22 @@ function warningLabel(warning) {
|
|
|
1258
1704
|
}
|
|
1259
1705
|
|
|
1260
1706
|
// src/commands/pin.ts
|
|
1261
|
-
var
|
|
1707
|
+
var import_zod14 = require("zod");
|
|
1262
1708
|
var pinCommand = define({
|
|
1263
1709
|
name: "pin",
|
|
1264
1710
|
tool: "kb_pin",
|
|
1265
1711
|
usage: "pin [bundle-path] [--mode full|index] [--profiles a,b] [--local|--user] [--frozen|--unfreeze]",
|
|
1266
1712
|
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.",
|
|
1267
|
-
input:
|
|
1713
|
+
input: import_zod14.z.object({
|
|
1268
1714
|
bundlePath,
|
|
1269
|
-
mode:
|
|
1715
|
+
mode: import_zod14.z.enum(["full", "index"]).optional().describe(
|
|
1270
1716
|
"full: always emit this base's records whole (still under the block budget); index: never upgrade. Absent: the profile's full-under threshold decides."
|
|
1271
1717
|
),
|
|
1272
|
-
profiles:
|
|
1273
|
-
layer:
|
|
1718
|
+
profiles: import_zod14.z.array(import_zod14.z.string()).optional().describe("Context profiles this pin surfaces in. Absent: all of them."),
|
|
1719
|
+
layer: import_zod14.z.enum(["project", "local", "user"]).optional().describe(
|
|
1274
1720
|
"Which manifest to write: project (committed, default), local (personal, gitignored), user (~/.strauss, every workspace)."
|
|
1275
1721
|
),
|
|
1276
|
-
frozen:
|
|
1722
|
+
frozen: import_zod14.z.boolean().optional().describe(
|
|
1277
1723
|
"true: the base is concluded \u2014 writes against it refuse while pinned. false: lift a freeze."
|
|
1278
1724
|
)
|
|
1279
1725
|
}),
|
|
@@ -1302,29 +1748,29 @@ var pinCommand = define({
|
|
|
1302
1748
|
});
|
|
1303
1749
|
|
|
1304
1750
|
// src/commands/pins.ts
|
|
1305
|
-
var
|
|
1751
|
+
var import_zod15 = require("zod");
|
|
1306
1752
|
var pinsCommand = define({
|
|
1307
1753
|
name: "pins",
|
|
1308
1754
|
tool: "kb_pins",
|
|
1309
1755
|
usage: "pins",
|
|
1310
1756
|
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.",
|
|
1311
|
-
input:
|
|
1757
|
+
input: import_zod15.z.object({}),
|
|
1312
1758
|
fromArgv: () => ({}),
|
|
1313
1759
|
run: ({ store }) => listPins(store, process.cwd())
|
|
1314
1760
|
});
|
|
1315
1761
|
|
|
1316
1762
|
// src/commands/query.ts
|
|
1317
|
-
var
|
|
1763
|
+
var import_zod16 = require("zod");
|
|
1318
1764
|
var queryCommand = define({
|
|
1319
1765
|
name: "query",
|
|
1320
1766
|
tool: "kb_query",
|
|
1321
1767
|
usage: "query <text...>",
|
|
1322
1768
|
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.",
|
|
1323
|
-
input:
|
|
1769
|
+
input: import_zod16.z.object({
|
|
1324
1770
|
bundlePath,
|
|
1325
|
-
text:
|
|
1326
|
-
type:
|
|
1327
|
-
includeNonCurrent:
|
|
1771
|
+
text: import_zod16.z.string().optional(),
|
|
1772
|
+
type: import_zod16.z.enum(KB_RECORD_TYPES).optional(),
|
|
1773
|
+
includeNonCurrent: import_zod16.z.boolean().optional()
|
|
1328
1774
|
}),
|
|
1329
1775
|
fromArgv: (argv, path) => ({
|
|
1330
1776
|
bundlePath: path,
|
|
@@ -1346,33 +1792,41 @@ var queryCommand = define({
|
|
|
1346
1792
|
});
|
|
1347
1793
|
|
|
1348
1794
|
// src/commands/read-index.ts
|
|
1349
|
-
var
|
|
1795
|
+
var import_zod17 = require("zod");
|
|
1350
1796
|
var readIndexCommand = define({
|
|
1351
1797
|
name: "index",
|
|
1352
1798
|
tool: "kb_index",
|
|
1353
1799
|
usage: "index",
|
|
1354
1800
|
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.",
|
|
1355
|
-
input:
|
|
1801
|
+
input: import_zod17.z.object({ bundlePath }),
|
|
1356
1802
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1357
1803
|
run: ({ store }, { bundlePath: path }) => store.readIndex(path)
|
|
1358
1804
|
});
|
|
1359
1805
|
|
|
1360
1806
|
// src/commands/schema.ts
|
|
1361
|
-
var
|
|
1807
|
+
var import_zod20 = require("zod");
|
|
1362
1808
|
|
|
1363
1809
|
// src/json-schema.ts
|
|
1364
|
-
var
|
|
1810
|
+
var import_zod19 = require("zod");
|
|
1365
1811
|
|
|
1366
1812
|
// src/kb-log.ts
|
|
1367
|
-
var
|
|
1813
|
+
var import_zod18 = require("zod");
|
|
1368
1814
|
var LOG_FILE = "log.jsonl";
|
|
1369
|
-
var kbLogEntrySchema =
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1815
|
+
var kbLogEntrySchema = import_zod18.z.object({
|
|
1816
|
+
// Validated, not just `min(1)`: `at` is a sort key (see `parseLog`
|
|
1817
|
+
// below), and a value that isn't actually chronological — a Unix
|
|
1818
|
+
// timestamp, a human-typed date, garbage — would sort wrong without
|
|
1819
|
+
// ever failing to parse. `z.iso.datetime()` accepts exactly what
|
|
1820
|
+
// `record()` writes (`Date#toISOString()`: full precision, `Z` offset)
|
|
1821
|
+
// and rejects everything else, including a non-`Z` offset — so a
|
|
1822
|
+
// malformed `at` is reported the same way a malformed line already is,
|
|
1823
|
+
// rather than silently sorting into the wrong place.
|
|
1824
|
+
at: import_zod18.z.iso.datetime(),
|
|
1825
|
+
by: import_zod18.z.string().min(1),
|
|
1826
|
+
operation: import_zod18.z.string().min(1),
|
|
1827
|
+
conceptId: import_zod18.z.string().min(1),
|
|
1374
1828
|
/** Second concept id, where the operation relates two — supersession. */
|
|
1375
|
-
target:
|
|
1829
|
+
target: import_zod18.z.string().min(1).optional()
|
|
1376
1830
|
}).strict();
|
|
1377
1831
|
function renderLogEntry(entry) {
|
|
1378
1832
|
return `${JSON.stringify(kbLogEntrySchema.parse(entry))}
|
|
@@ -1381,6 +1835,7 @@ function renderLogEntry(entry) {
|
|
|
1381
1835
|
function parseLog(raw) {
|
|
1382
1836
|
const entries = [];
|
|
1383
1837
|
const malformed = [];
|
|
1838
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1384
1839
|
raw.split("\n").forEach((text, index) => {
|
|
1385
1840
|
if (!text.trim()) return;
|
|
1386
1841
|
let value;
|
|
@@ -1395,19 +1850,25 @@ function parseLog(raw) {
|
|
|
1395
1850
|
malformed.push({ line: index + 1, text });
|
|
1396
1851
|
return;
|
|
1397
1852
|
}
|
|
1853
|
+
const key = JSON.stringify(parsed.data);
|
|
1854
|
+
if (seen.has(key)) return;
|
|
1855
|
+
seen.add(key);
|
|
1398
1856
|
entries.push(parsed.data);
|
|
1399
1857
|
});
|
|
1858
|
+
entries.sort(
|
|
1859
|
+
(left, right) => left.at < right.at ? -1 : left.at > right.at ? 1 : 0
|
|
1860
|
+
);
|
|
1400
1861
|
return { entries, malformed };
|
|
1401
1862
|
}
|
|
1402
1863
|
|
|
1403
1864
|
// src/json-schema.ts
|
|
1404
1865
|
function kbJsonSchemas() {
|
|
1405
1866
|
return {
|
|
1406
|
-
recordFrontmatter:
|
|
1867
|
+
recordFrontmatter: import_zod19.z.toJSONSchema(kbRecordFrontmatterSchema, {
|
|
1407
1868
|
io: "input"
|
|
1408
1869
|
}),
|
|
1409
|
-
composeInput:
|
|
1410
|
-
logEntry:
|
|
1870
|
+
composeInput: import_zod19.z.toJSONSchema(composeInputSchema, { io: "input" }),
|
|
1871
|
+
logEntry: import_zod19.z.toJSONSchema(kbLogEntrySchema, { io: "input" })
|
|
1411
1872
|
};
|
|
1412
1873
|
}
|
|
1413
1874
|
|
|
@@ -1417,22 +1878,22 @@ var schemaCommand = define({
|
|
|
1417
1878
|
tool: "kb_schema",
|
|
1418
1879
|
usage: "schema",
|
|
1419
1880
|
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.",
|
|
1420
|
-
input:
|
|
1881
|
+
input: import_zod20.z.object({}),
|
|
1421
1882
|
fromArgv: () => ({}),
|
|
1422
1883
|
run: () => Promise.resolve(kbJsonSchemas())
|
|
1423
1884
|
});
|
|
1424
1885
|
|
|
1425
1886
|
// src/commands/status.ts
|
|
1426
|
-
var
|
|
1887
|
+
var import_zod21 = require("zod");
|
|
1427
1888
|
var statusCommand = define({
|
|
1428
1889
|
name: "status",
|
|
1429
1890
|
tool: "kb_status",
|
|
1430
1891
|
usage: "status <concept-id> <status>",
|
|
1431
1892
|
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.",
|
|
1432
|
-
input:
|
|
1893
|
+
input: import_zod21.z.object({
|
|
1433
1894
|
bundlePath,
|
|
1434
1895
|
conceptId,
|
|
1435
|
-
status:
|
|
1896
|
+
status: import_zod21.z.enum(KB_RECORD_STATUSES)
|
|
1436
1897
|
}),
|
|
1437
1898
|
fromArgv: (argv, path) => ({
|
|
1438
1899
|
bundlePath: path,
|
|
@@ -1447,13 +1908,13 @@ var statusCommand = define({
|
|
|
1447
1908
|
});
|
|
1448
1909
|
|
|
1449
1910
|
// src/commands/supersede.ts
|
|
1450
|
-
var
|
|
1911
|
+
var import_zod22 = require("zod");
|
|
1451
1912
|
var supersedeCommand = define({
|
|
1452
1913
|
name: "supersede",
|
|
1453
1914
|
tool: "kb_supersede",
|
|
1454
1915
|
usage: "supersede <concept-id> <replacement-id>",
|
|
1455
1916
|
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.",
|
|
1456
|
-
input:
|
|
1917
|
+
input: import_zod22.z.object({ bundlePath, conceptId, replacementId: conceptId }),
|
|
1457
1918
|
fromArgv: (argv, path) => ({
|
|
1458
1919
|
bundlePath: path,
|
|
1459
1920
|
conceptId: argv[1],
|
|
@@ -1467,16 +1928,16 @@ var supersedeCommand = define({
|
|
|
1467
1928
|
});
|
|
1468
1929
|
|
|
1469
1930
|
// src/commands/sync-instructions.ts
|
|
1470
|
-
var
|
|
1931
|
+
var import_zod23 = require("zod");
|
|
1471
1932
|
var syncInstructionsCommand = define({
|
|
1472
1933
|
name: "sync-instructions",
|
|
1473
1934
|
usage: "sync-instructions <file> [--profile NAME] [--budget N] [--full-under N]",
|
|
1474
1935
|
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.",
|
|
1475
|
-
input:
|
|
1476
|
-
file:
|
|
1477
|
-
budgetTokens:
|
|
1478
|
-
fullUnderTokens:
|
|
1479
|
-
profile:
|
|
1936
|
+
input: import_zod23.z.object({
|
|
1937
|
+
file: import_zod23.z.string().min(1).describe("The instruction file to edit in place."),
|
|
1938
|
+
budgetTokens: import_zod23.z.number().int().positive().optional(),
|
|
1939
|
+
fullUnderTokens: import_zod23.z.number().int().positive().optional(),
|
|
1940
|
+
profile: import_zod23.z.string().optional()
|
|
1480
1941
|
}),
|
|
1481
1942
|
fromArgv: (argv) => {
|
|
1482
1943
|
const budget = argvFlag(argv, "--budget");
|
|
@@ -1502,83 +1963,7 @@ var syncInstructionsCommand = define({
|
|
|
1502
1963
|
});
|
|
1503
1964
|
|
|
1504
1965
|
// src/commands/trace.ts
|
|
1505
|
-
var
|
|
1506
|
-
|
|
1507
|
-
// src/kb-edges.ts
|
|
1508
|
-
var KB_EDGE_KINDS = [
|
|
1509
|
-
"body-link",
|
|
1510
|
-
"supersession",
|
|
1511
|
-
"anchor",
|
|
1512
|
-
"source"
|
|
1513
|
-
];
|
|
1514
|
-
var BODY_LINK_TARGET = new RegExp(
|
|
1515
|
-
`\\]\\((${KB_CONCEPT_ID_PATTERN.source.replace(/^\^|\$$/g, "")})\\.md\\)`,
|
|
1516
|
-
"g"
|
|
1517
|
-
);
|
|
1518
|
-
function neighbours(from, bundle, kinds = KB_EDGE_KINDS) {
|
|
1519
|
-
const found = /* @__PURE__ */ new Map();
|
|
1520
|
-
for (const kind of kinds) {
|
|
1521
|
-
for (const record of edgeNeighbours(from, bundle, kind)) {
|
|
1522
|
-
const existing = found.get(record.conceptId);
|
|
1523
|
-
if (existing) {
|
|
1524
|
-
if (!existing.via.includes(kind)) existing.via.push(kind);
|
|
1525
|
-
continue;
|
|
1526
|
-
}
|
|
1527
|
-
found.set(record.conceptId, { record, via: [kind] });
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
return [...found.values()];
|
|
1531
|
-
}
|
|
1532
|
-
function edgeNeighbours(from, bundle, kind) {
|
|
1533
|
-
switch (kind) {
|
|
1534
|
-
// A link whose target is not in the bundle is legal per compose.ts —
|
|
1535
|
-
// records are routinely written before the ones they point at exist — so
|
|
1536
|
-
// missing targets are skipped, never an error.
|
|
1537
|
-
case "body-link": {
|
|
1538
|
-
const targets = new Set(
|
|
1539
|
-
[...from.body.matchAll(BODY_LINK_TARGET)].map((match) => match[1])
|
|
1540
|
-
);
|
|
1541
|
-
if (!targets.size) return [];
|
|
1542
|
-
return bundle.filter(
|
|
1543
|
-
(candidate) => candidate.conceptId !== from.conceptId && targets.has(candidate.conceptId)
|
|
1544
|
-
);
|
|
1545
|
-
}
|
|
1546
|
-
// Both directions and both pointers: `supersede()` writes the pair, but a
|
|
1547
|
-
// hand-edit can leave one side behind, and a walk trusting one pointer
|
|
1548
|
-
// would miss a replacement the bundle openly declares.
|
|
1549
|
-
case "supersession":
|
|
1550
|
-
return bundle.filter(
|
|
1551
|
-
(candidate) => candidate.conceptId !== from.conceptId && (candidate.conceptId === from.frontmatter.strauss_superseded_by || from.frontmatter.strauss_supersedes?.includes(
|
|
1552
|
-
candidate.conceptId
|
|
1553
|
-
) || candidate.frontmatter.strauss_superseded_by === from.conceptId || candidate.frontmatter.strauss_supersedes?.includes(from.conceptId))
|
|
1554
|
-
);
|
|
1555
|
-
// The edge that answers "why is this code shaped this way": every record
|
|
1556
|
-
// attached to the same file or symbol, whatever its standing.
|
|
1557
|
-
case "anchor": {
|
|
1558
|
-
const mine = from.frontmatter.strauss_anchors ?? [];
|
|
1559
|
-
if (!mine.length) return [];
|
|
1560
|
-
return bundle.filter(
|
|
1561
|
-
(candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.strauss_anchors ?? []).some(
|
|
1562
|
-
(theirs) => mine.some((ours) => anchorsTouch(ours, theirs))
|
|
1563
|
-
)
|
|
1564
|
-
);
|
|
1565
|
-
}
|
|
1566
|
-
case "source": {
|
|
1567
|
-
const mine = new Set((from.frontmatter.sources ?? []).map((s) => s.id));
|
|
1568
|
-
if (!mine.size) return [];
|
|
1569
|
-
return bundle.filter(
|
|
1570
|
-
(candidate) => candidate.conceptId !== from.conceptId && (candidate.frontmatter.sources ?? []).some(
|
|
1571
|
-
(source) => mine.has(source.id)
|
|
1572
|
-
)
|
|
1573
|
-
);
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
function anchorsTouch(left, right) {
|
|
1578
|
-
if (left.file !== right.file) return false;
|
|
1579
|
-
if (!left.symbol || !right.symbol) return true;
|
|
1580
|
-
return left.symbol === right.symbol;
|
|
1581
|
-
}
|
|
1966
|
+
var import_zod24 = require("zod");
|
|
1582
1967
|
|
|
1583
1968
|
// src/trace.ts
|
|
1584
1969
|
var TRACE_EDGES = ["supersession", "anchor", "source"];
|
|
@@ -1624,11 +2009,11 @@ var traceCommand = define({
|
|
|
1624
2009
|
tool: "kb_trace",
|
|
1625
2010
|
usage: "trace <concept-id> [edges...]",
|
|
1626
2011
|
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.',
|
|
1627
|
-
input:
|
|
2012
|
+
input: import_zod24.z.object({
|
|
1628
2013
|
bundlePath,
|
|
1629
2014
|
conceptId,
|
|
1630
|
-
edges:
|
|
1631
|
-
depth:
|
|
2015
|
+
edges: import_zod24.z.array(import_zod24.z.enum(TRACE_EDGES)).optional(),
|
|
2016
|
+
depth: import_zod24.z.number().int().positive().optional()
|
|
1632
2017
|
}),
|
|
1633
2018
|
fromArgv: (argv, path) => ({
|
|
1634
2019
|
bundlePath: path,
|
|
@@ -1650,90 +2035,53 @@ var traceCommand = define({
|
|
|
1650
2035
|
});
|
|
1651
2036
|
|
|
1652
2037
|
// src/commands/types.ts
|
|
1653
|
-
var
|
|
2038
|
+
var import_zod25 = require("zod");
|
|
1654
2039
|
var typesCommand = define({
|
|
1655
2040
|
name: "types",
|
|
1656
2041
|
tool: "kb_types",
|
|
1657
2042
|
usage: "types",
|
|
1658
2043
|
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.",
|
|
1659
|
-
input:
|
|
2044
|
+
input: import_zod25.z.object({}),
|
|
1660
2045
|
fromArgv: () => ({}),
|
|
1661
2046
|
run: () => Promise.resolve(RECORD_TYPES)
|
|
1662
2047
|
});
|
|
1663
2048
|
|
|
1664
2049
|
// src/commands/unpin.ts
|
|
1665
|
-
var
|
|
2050
|
+
var import_zod26 = require("zod");
|
|
1666
2051
|
var unpinCommand = define({
|
|
1667
2052
|
name: "unpin",
|
|
1668
2053
|
tool: "kb_unpin",
|
|
1669
2054
|
usage: "unpin [bundle-path]",
|
|
1670
2055
|
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.",
|
|
1671
|
-
input:
|
|
2056
|
+
input: import_zod26.z.object({ bundlePath }),
|
|
1672
2057
|
fromArgv: (argv, path) => ({ bundlePath: argv[1] ?? path }),
|
|
1673
2058
|
run: (_ctx, { bundlePath: path }) => unpinBase(process.cwd(), path)
|
|
1674
2059
|
});
|
|
1675
2060
|
|
|
1676
2061
|
// src/commands/validate.ts
|
|
1677
|
-
var
|
|
1678
|
-
|
|
1679
|
-
// src/validate.ts
|
|
1680
|
-
function validateBundle(records) {
|
|
1681
|
-
const byId = new Map(records.map((record) => [record.conceptId, record]));
|
|
1682
|
-
const problems = [];
|
|
1683
|
-
const report = (check, conceptId2, note) => problems.push({ check, conceptId: conceptId2, note });
|
|
1684
|
-
for (const record of records) {
|
|
1685
|
-
const { conceptId: conceptId2, frontmatter: fm } = record;
|
|
1686
|
-
if (!isKbRecordType(fm.type)) {
|
|
1687
|
-
report("type", conceptId2, `unrecognised type "${fm.type}"`);
|
|
1688
|
-
}
|
|
1689
|
-
if (fm.strauss_status === "superseded") {
|
|
1690
|
-
const by = fm.strauss_superseded_by;
|
|
1691
|
-
if (!by) {
|
|
1692
|
-
report("superseded_by", conceptId2, "superseded with no replacement");
|
|
1693
|
-
} else if (!byId.has(by)) {
|
|
1694
|
-
report("superseded_by", conceptId2, `replacement ${by} is missing`);
|
|
1695
|
-
} else if (!byId.get(by)?.frontmatter.strauss_supersedes?.includes(conceptId2)) {
|
|
1696
|
-
report("backlink", by, `does not list ${conceptId2} in supersedes`);
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
for (const old of fm.strauss_supersedes ?? []) {
|
|
1700
|
-
const previous = byId.get(old);
|
|
1701
|
-
if (!previous) {
|
|
1702
|
-
report("supersedes", conceptId2, `target ${old} is missing`);
|
|
1703
|
-
} else if (previous.frontmatter.strauss_status !== "superseded") {
|
|
1704
|
-
report("supersedes", conceptId2, `${old} is not marked superseded`);
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
if (fm.strauss_assumption && fm.sources?.length) {
|
|
1708
|
-
report("assumption", conceptId2, "marked an assumption but cites sources");
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
return problems;
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
// src/commands/validate.ts
|
|
2062
|
+
var import_zod27 = require("zod");
|
|
1715
2063
|
var validateCommand = define({
|
|
1716
2064
|
name: "validate",
|
|
1717
2065
|
tool: "kb_validate",
|
|
1718
2066
|
usage: "validate",
|
|
1719
2067
|
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.",
|
|
1720
|
-
input:
|
|
2068
|
+
input: import_zod27.z.object({ bundlePath }),
|
|
1721
2069
|
fromArgv: (_argv, path) => ({ bundlePath: path }),
|
|
1722
2070
|
run: async ({ store }, { bundlePath: path }) => validateBundle(await store.list(path)),
|
|
1723
2071
|
failsWhen: (result) => Array.isArray(result) && result.length > 0
|
|
1724
2072
|
});
|
|
1725
2073
|
|
|
1726
2074
|
// src/commands/verify.ts
|
|
1727
|
-
var
|
|
2075
|
+
var import_zod28 = require("zod");
|
|
1728
2076
|
var verifyCommand = define({
|
|
1729
2077
|
name: "verify",
|
|
1730
2078
|
tool: "kb_verify",
|
|
1731
2079
|
usage: "verify <concept-id> --note <text>",
|
|
1732
2080
|
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.",
|
|
1733
|
-
input:
|
|
2081
|
+
input: import_zod28.z.object({
|
|
1734
2082
|
bundlePath,
|
|
1735
2083
|
conceptId,
|
|
1736
|
-
note:
|
|
2084
|
+
note: import_zod28.z.string().refine((s) => s.trim().length > 0, {
|
|
1737
2085
|
message: "note must say what the check found"
|
|
1738
2086
|
})
|
|
1739
2087
|
}),
|
|
@@ -1753,7 +2101,7 @@ var verifyCommand = define({
|
|
|
1753
2101
|
});
|
|
1754
2102
|
|
|
1755
2103
|
// src/commands/write.ts
|
|
1756
|
-
var
|
|
2104
|
+
var import_zod29 = require("zod");
|
|
1757
2105
|
var writeCommand = define({
|
|
1758
2106
|
name: "write",
|
|
1759
2107
|
tool: "kb_write",
|
|
@@ -1767,9 +2115,9 @@ var writeCommand = define({
|
|
|
1767
2115
|
"- Prefer a new record over overloading an existing one, and keep each short. A record nobody finishes reading is not durable memory.",
|
|
1768
2116
|
"- Records are never deleted; supersede instead, so the earlier reasoning stays inspectable."
|
|
1769
2117
|
].join("\n"),
|
|
1770
|
-
input:
|
|
2118
|
+
input: import_zod29.z.object({
|
|
1771
2119
|
bundlePath,
|
|
1772
|
-
type:
|
|
2120
|
+
type: import_zod29.z.enum(KB_RECORD_TYPES),
|
|
1773
2121
|
input: composeInputSchema
|
|
1774
2122
|
}),
|
|
1775
2123
|
fromArgv: async (argv, path, stdin) => ({
|
|
@@ -1793,7 +2141,7 @@ var writeCommand = define({
|
|
|
1793
2141
|
});
|
|
1794
2142
|
|
|
1795
2143
|
// src/commands/write-decision.ts
|
|
1796
|
-
var
|
|
2144
|
+
var import_zod30 = require("zod");
|
|
1797
2145
|
var writeDecisionCommand = define({
|
|
1798
2146
|
name: "write-decision",
|
|
1799
2147
|
tool: "kb_write_decision",
|
|
@@ -1806,7 +2154,7 @@ var writeDecisionCommand = define({
|
|
|
1806
2154
|
"- `alternative` is what you turned down and why, not a list of everything considered.",
|
|
1807
2155
|
"- A reference to material you read goes in `sources`; a reference to code goes in `anchors`; a reference to another record goes in `relatedConceptIds`."
|
|
1808
2156
|
].join("\n"),
|
|
1809
|
-
input:
|
|
2157
|
+
input: import_zod30.z.object({ bundlePath, input: decisionInputSchema }),
|
|
1810
2158
|
fromArgv: async (_argv, path, stdin) => ({
|
|
1811
2159
|
bundlePath: path,
|
|
1812
2160
|
input: JSON.parse(await stdin())
|
|
@@ -1843,6 +2191,7 @@ var KB_COMMANDS = [
|
|
|
1843
2191
|
readIndexCommand,
|
|
1844
2192
|
logCommand,
|
|
1845
2193
|
validateCommand,
|
|
2194
|
+
doctorCommand,
|
|
1846
2195
|
schemaCommand,
|
|
1847
2196
|
pinCommand,
|
|
1848
2197
|
unpinCommand,
|
|
@@ -2175,6 +2524,30 @@ function typeRank(record) {
|
|
|
2175
2524
|
return index === -1 ? TYPE_PRIORITY.length : index;
|
|
2176
2525
|
}
|
|
2177
2526
|
|
|
2527
|
+
// src/kb-gitattributes.ts
|
|
2528
|
+
var GITATTRIBUTES_FILE = ".gitattributes";
|
|
2529
|
+
var UNION_MERGE_LINE = `${LOG_FILE} text eol=lf merge=union`;
|
|
2530
|
+
function parseLine(line) {
|
|
2531
|
+
const trimmed = line.trim();
|
|
2532
|
+
if (!trimmed || trimmed.startsWith("#")) return null;
|
|
2533
|
+
const [pattern, ...attrs] = trimmed.split(/\s+/);
|
|
2534
|
+
return pattern === void 0 ? null : { pattern, attrs };
|
|
2535
|
+
}
|
|
2536
|
+
function hasMergeDeclaration(contents) {
|
|
2537
|
+
return contents.split("\n").some((line) => {
|
|
2538
|
+
const parsed = parseLine(line);
|
|
2539
|
+
if (!parsed || parsed.pattern !== LOG_FILE) return false;
|
|
2540
|
+
return parsed.attrs.some(
|
|
2541
|
+
(attr) => attr === "merge" || attr === "-merge" || attr.startsWith("merge=")
|
|
2542
|
+
);
|
|
2543
|
+
});
|
|
2544
|
+
}
|
|
2545
|
+
function appendUnionMergeLine(contents) {
|
|
2546
|
+
const separator = contents.length === 0 || contents.endsWith("\n") ? "" : "\n";
|
|
2547
|
+
return `${separator}${UNION_MERGE_LINE}
|
|
2548
|
+
`;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2178
2551
|
// src/kb-store.ts
|
|
2179
2552
|
var KB_DIR = (0, import_node_path6.join)(".strauss", "kb");
|
|
2180
2553
|
var STORE_OWNED = /* @__PURE__ */ new Set([INDEX_FILE, LOG_FILE, SEARCH_INDEX_FILE]);
|
|
@@ -2600,8 +2973,87 @@ ${answer}
|
|
|
2600
2973
|
await (0, import_promises4.unlink)(staging).catch(() => void 0);
|
|
2601
2974
|
}
|
|
2602
2975
|
}
|
|
2976
|
+
/**
|
|
2977
|
+
* Declares union merge for the log, so two worktrees writing the same
|
|
2978
|
+
* bundle interleave their `log.jsonl` lines on merge rather than one
|
|
2979
|
+
* side's appends silently losing to git's ordinary line-level merge.
|
|
2980
|
+
*
|
|
2981
|
+
* Called from `record` — every path that appends a log line, not just
|
|
2982
|
+
* `write` — so a bundle only ever mutated through `setStatus`/`verify`/
|
|
2983
|
+
* `supersede` still gets it. There is no cheaper reliable signal for
|
|
2984
|
+
* "first write" than checking the file itself, and after the first call
|
|
2985
|
+
* the check is a no-op `readFile`.
|
|
2986
|
+
*
|
|
2987
|
+
* A missing `.gitattributes` is created outright, with `wx` (exclusive
|
|
2988
|
+
* create) rather than a plain write: if another process's `write()` won a
|
|
2989
|
+
* race and created the file between the `readFile` below and this call,
|
|
2990
|
+
* `wx` fails instead of truncating what that writer just wrote, and the
|
|
2991
|
+
* failure is swallowed by the catch below same as any other best-effort
|
|
2992
|
+
* miss. A file that exists but declares no merge strategy for the log
|
|
2993
|
+
* gets the line appended, never a wholesale rewrite; one that already
|
|
2994
|
+
* declares any merge strategy — this one or a user's own — is left alone
|
|
2995
|
+
* entirely (see `hasMergeDeclaration`).
|
|
2996
|
+
*
|
|
2997
|
+
* `readFile` failing is `existing === null` only for `ENOENT` — genuinely
|
|
2998
|
+
* missing. Any other error (a permission problem, a transient `EMFILE`,
|
|
2999
|
+
* the path being a directory) is *not* "missing" and must not fall into
|
|
3000
|
+
* the create branch, which would truncate whatever is actually there with
|
|
3001
|
+
* just the union-merge line: that is the file-destroying bug this
|
|
3002
|
+
* function exists to avoid, not commit. An unreadable existing file is
|
|
3003
|
+
* therefore left untouched and reported as a failure like any other.
|
|
3004
|
+
*
|
|
3005
|
+
* Two processes racing the append branch — both read a file without the
|
|
3006
|
+
* line, both append it — is possible and left unguarded: `appendFile` is
|
|
3007
|
+
* `O_APPEND`, so the result is two copies of the same line rather than a
|
|
3008
|
+
* torn write, and `hasMergeDeclaration` sees a duplicate declaration as
|
|
3009
|
+
* "already declared" on the next call. A cheap-to-detect, harmless-to-
|
|
3010
|
+
* leave residue, not a reason to add a cross-process lock (see
|
|
3011
|
+
* `ARCHITECTURE.md`'s rejection of one for the same trade on records).
|
|
3012
|
+
*
|
|
3013
|
+
* Best-effort, like the log append it precedes: failing to write this
|
|
3014
|
+
* file must not fail the mutation it guards.
|
|
3015
|
+
*/
|
|
3016
|
+
async ensureGitattributes(root) {
|
|
3017
|
+
const target = (0, import_node_path6.join)(root, GITATTRIBUTES_FILE);
|
|
3018
|
+
try {
|
|
3019
|
+
let existing;
|
|
3020
|
+
try {
|
|
3021
|
+
existing = await (0, import_promises4.readFile)(target, "utf8");
|
|
3022
|
+
} catch (error) {
|
|
3023
|
+
if (error.code !== "ENOENT") throw error;
|
|
3024
|
+
existing = null;
|
|
3025
|
+
}
|
|
3026
|
+
if (existing === null) {
|
|
3027
|
+
await (0, import_promises4.writeFile)(target, appendUnionMergeLine(""), {
|
|
3028
|
+
encoding: "utf8",
|
|
3029
|
+
flag: "wx"
|
|
3030
|
+
});
|
|
3031
|
+
this.logger.info?.({
|
|
3032
|
+
operation: "kb.gitattributes.ensure",
|
|
3033
|
+
bundlePath: root,
|
|
3034
|
+
outcome: "created"
|
|
3035
|
+
});
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
if (!hasMergeDeclaration(existing)) {
|
|
3039
|
+
await (0, import_promises4.appendFile)(target, appendUnionMergeLine(existing), "utf8");
|
|
3040
|
+
this.logger.info?.({
|
|
3041
|
+
operation: "kb.gitattributes.ensure",
|
|
3042
|
+
bundlePath: root,
|
|
3043
|
+
outcome: "appended"
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
} catch (error) {
|
|
3047
|
+
this.logger.warn?.({
|
|
3048
|
+
operation: "kb.gitattributes.ensure",
|
|
3049
|
+
outcome: "failed",
|
|
3050
|
+
error: error instanceof Error ? error.message : "unknown"
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
2603
3054
|
/** Appends one log line. Failing to log must not fail the mutation. */
|
|
2604
3055
|
async record(root, entry) {
|
|
3056
|
+
await this.ensureGitattributes(root);
|
|
2605
3057
|
const line = renderLogEntry({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry });
|
|
2606
3058
|
await (0, import_promises4.appendFile)((0, import_node_path6.join)(root, LOG_FILE), line, "utf8").catch((error) => {
|
|
2607
3059
|
this.logger.warn?.({
|
|
@@ -2675,7 +3127,7 @@ function digest(contents) {
|
|
|
2675
3127
|
}
|
|
2676
3128
|
|
|
2677
3129
|
// src/version.ts
|
|
2678
|
-
var VERSION = true ? "0.1.
|
|
3130
|
+
var VERSION = true ? "0.1.9" : "0.0.0-dev";
|
|
2679
3131
|
|
|
2680
3132
|
// src/mcp.ts
|
|
2681
3133
|
function createKbMcpServer() {
|