codebase-snapshot 0.9.9 → 0.9.12

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.
@@ -1,4 +1,4 @@
1
- import { GENERATOR_VERSION, MIN_MAX_PART_BYTES, SCHEMA_VERSION, GENERATOR_NAME, ARTIFACT_NAMES, PRESETS, generateSnapshot, validateSnapshot, explainPath, renderExplainText, describeManifestShapeProblem, loadConfigFileDetailed, parseByteSize, resolveConfig } from './chunk-KUVFTJHK.js';
1
+ import { GENERATOR_VERSION, anchorForPath, displayPath, MIN_MAX_PART_BYTES, SCHEMA_VERSION, GENERATOR_NAME, ARTIFACT_NAMES, PRESETS, generateSnapshot, generateInWorker, validateSnapshot, buildImportResolver, resolveSpecifier, truncateToBytes, explainPath, renderExplainText, describeManifestShapeProblem, loadConfigFileDetailed, parseByteSize, resolveConfig } from './chunk-3H45YMBG.js';
2
2
  import * as fs from 'fs';
3
3
  import * as path2 from 'path';
4
4
 
@@ -520,6 +520,8 @@ function createMcpServer(options) {
520
520
  // src/mcp/part.ts
521
521
  var FILE_HEADING = "### FILE: ";
522
522
  var CONTENT_MARKERS = /* @__PURE__ */ new Set(["#### Content", "#### Bounded Preview"]);
523
+ var ANCHOR_LINE = /^<a id="([^"]+)"><\/a>$/;
524
+ var INCLUSION_MODES = /* @__PURE__ */ new Set(["full", "preview", "signatures"]);
523
525
  function parsePartSections(partText) {
524
526
  const lines = partText.split("\n");
525
527
  const sections = [];
@@ -530,16 +532,26 @@ function parsePartSections(partText) {
530
532
  continue;
531
533
  }
532
534
  const heading = lines[index].slice(FILE_HEADING.length);
535
+ const anchorMatch = index > 0 ? ANCHOR_LINE.exec(lines[index - 1]) : null;
533
536
  let cursor = index + 1;
534
537
  let inclusion = null;
535
- let sha256 = null;
536
538
  let includedLines = null;
537
539
  while (cursor < lines.length && !CONTENT_MARKERS.has(lines[cursor])) {
538
540
  const line = lines[cursor];
539
541
  if (line.startsWith(FILE_HEADING)) break;
540
- if (line.startsWith("- Inclusion: ")) inclusion = line.slice("- Inclusion: ".length).trim();
541
- else if (line.startsWith("- SHA-256: ")) sha256 = line.slice("- SHA-256: ".length).trim();
542
- else if (line.startsWith("- Included Lines: ")) {
542
+ if (line.startsWith("- Meta: ")) {
543
+ for (const field of line.slice("- Meta: ".length).split("|")) {
544
+ const value = field.trim();
545
+ if (INCLUSION_MODES.has(value)) inclusion = value;
546
+ }
547
+ const counts = /(-?\d+) of (-?\d+) lines|(\d+) lines/.exec(line);
548
+ if (counts) {
549
+ const emitted = counts[1] ?? counts[3];
550
+ includedLines = Number.isFinite(Number(emitted)) ? Number(emitted) : null;
551
+ }
552
+ } else if (line.startsWith("- Inclusion: ")) {
553
+ inclusion = line.slice("- Inclusion: ".length).trim();
554
+ } else if (line.startsWith("- Included Lines: ")) {
543
555
  const value = Number(line.slice("- Included Lines: ".length).trim());
544
556
  includedLines = Number.isFinite(value) ? value : null;
545
557
  }
@@ -563,40 +575,42 @@ function parsePartSections(partText) {
563
575
  body.push(lines[bodyLine]);
564
576
  bodyLine += 1;
565
577
  }
566
- sections.push({ path: heading, body: body.join("\n"), inclusion, sha256, includedLines });
578
+ sections.push({ path: heading, anchor: anchorMatch?.[1] ?? null, body: body.join("\n"), inclusion, includedLines });
567
579
  index = bodyLine + 1;
568
580
  }
569
581
  return sections;
570
582
  }
571
- function findSection(sections, relativePath, sha256) {
572
- const exact = sections.find((section) => section.path === relativePath);
583
+ function findSection(sections, relativePath) {
584
+ const anchor = anchorForPath(relativePath);
585
+ const byAnchor = sections.find((section) => section.anchor === anchor);
586
+ if (byAnchor) return byAnchor;
587
+ const rendered = displayPath(relativePath);
588
+ const samePath = (section) => section.path === relativePath || section.path === rendered;
589
+ const exact = sections.find(samePath);
573
590
  if (exact) return exact;
574
- if (sha256) {
575
- const byHash = sections.filter((section) => section.sha256 === sha256);
576
- if (byHash.length === 1) return byHash[0];
577
- if (byHash.length > 1) {
578
- const byPath = byHash.find(
579
- (section) => section.path.toLowerCase() === relativePath.toLowerCase()
580
- );
581
- if (byPath) return byPath;
582
- }
583
- }
584
- const folded = sections.filter((section) => section.path.toLowerCase() === relativePath.toLowerCase());
591
+ const foldedPath = rendered.toLowerCase();
592
+ const folded = sections.filter((section) => {
593
+ const path4 = section.path.toLowerCase();
594
+ return path4 === relativePath.toLowerCase() || path4 === foldedPath;
595
+ });
585
596
  return folded.length === 1 ? folded[0] : null;
586
597
  }
587
598
  function windowText(body, fromLine, lineCount) {
588
599
  const lines = body.split("\n");
589
- const total = lines.length;
590
- const start = Math.min(Math.max(1, Math.trunc(fromLine)), Math.max(1, total));
600
+ const total = body.length === 0 ? 0 : lines.length;
601
+ const requested = Math.max(1, Math.trunc(fromLine));
602
+ const pastEnd = total > 0 && requested > total;
603
+ const start = pastEnd ? requested : Math.min(requested, Math.max(1, total));
591
604
  const end = Math.min(total, start + Math.max(1, Math.trunc(lineCount)) - 1);
592
- const slice = lines.slice(start - 1, end);
605
+ const slice = pastEnd ? [] : lines.slice(start - 1, Math.max(start - 1, end));
593
606
  return {
594
607
  text: slice.join("\n"),
595
608
  fromLine: start,
596
609
  returnedLines: slice.length,
597
610
  totalLines: total,
598
- truncated: end < total,
599
- nextFromLine: end < total ? end + 1 : null
611
+ truncated: !pastEnd && end < total,
612
+ nextFromLine: !pastEnd && end < total ? end + 1 : null,
613
+ pastEnd
600
614
  };
601
615
  }
602
616
  function foldForComparison(value) {
@@ -667,7 +681,7 @@ async function openWorkspace(root, overrides = {}) {
667
681
  }
668
682
  try {
669
683
  const config = resolveConfig(loaded.config, cliOptions, root);
670
- return { ok: true, workspace: { root, config, configFile: loaded.file } };
684
+ return { ok: true, workspace: { root, config, configFile: loaded.file, cliOptions } };
671
685
  } catch (error) {
672
686
  return { ok: false, message: error instanceof Error ? error.message : String(error) };
673
687
  }
@@ -741,6 +755,11 @@ var ROOT_PROPERTY = {
741
755
  type: "string",
742
756
  description: "Repository root. Defaults to the directory this server was started in."
743
757
  };
758
+ var OUT_DIR_PROPERTY = {
759
+ type: "string",
760
+ description: "Snapshot directory, relative to the root. Defaults to the project configuration's outDir."
761
+ };
762
+ var outDirOverride = (args) => ({ outDir: args.outDir });
744
763
  function failure(message) {
745
764
  return { text: message, isError: true };
746
765
  }
@@ -781,9 +800,39 @@ function toRelativePath(root, value) {
781
800
  }
782
801
  function symbolsOf(entry) {
783
802
  const summary = entry.summary;
784
- if (!summary || typeof summary !== "object" || !("exports" in summary)) return [];
785
- const source = summary;
786
- return [...source.exports, ...source.reactComponents, ...source.routeHandlers];
803
+ if (!summary || typeof summary !== "object") return [];
804
+ const names = [];
805
+ const collect = (value) => {
806
+ if (!Array.isArray(value)) return;
807
+ for (const item of value) {
808
+ if (typeof item === "string") names.push(item);
809
+ }
810
+ };
811
+ const record = summary;
812
+ for (const field of [
813
+ "exports",
814
+ "reactComponents",
815
+ "routeHandlers",
816
+ "functions",
817
+ "classes",
818
+ "interfaces",
819
+ "types",
820
+ "enums",
821
+ "hooks",
822
+ "headings",
823
+ // The rest of the same records. `variables` is where a `const`, a Python constant, a Go
824
+ // `var`, a Rust `mod`, a C `#define` and a Terraform `variable` block all land, so leaving it
825
+ // out made `snapshot_locate` miss most of what a non-JavaScript file declares; `directives`
826
+ // carries `"use client"`-style markers, and `frontmatterKeys`/`keys` are the headings and
827
+ // top-level names of a Markdown or JSON file.
828
+ "variables",
829
+ "directives",
830
+ "frontmatterKeys",
831
+ "keys"
832
+ ]) {
833
+ collect(record[field]);
834
+ }
835
+ return names;
787
836
  }
788
837
  async function prepare(args, context, overrides = {}) {
789
838
  const rootOutcome = resolveRoot(args.root, context);
@@ -798,7 +847,7 @@ var statusTool = {
798
847
  description: "Report whether a snapshot exists for this repository, when it was built, and what it covers. Reads the manifest rather than the repository, so it is cheap. Call it before deciding whether a snapshot needs to be generated.",
799
848
  inputSchema: {
800
849
  type: "object",
801
- properties: { root: ROOT_PROPERTY },
850
+ properties: { root: ROOT_PROPERTY, outDir: OUT_DIR_PROPERTY },
802
851
  additionalProperties: false
803
852
  },
804
853
  outputSchema: {
@@ -819,7 +868,7 @@ var statusTool = {
819
868
  },
820
869
  annotations: READ_ONLY,
821
870
  async handler(args, context) {
822
- const prepared = await prepare(args, context);
871
+ const prepared = await prepare(args, context, outDirOverride(args));
823
872
  if (!prepared.ok) return prepared.outcome;
824
873
  const workspace = prepared.workspace;
825
874
  const read = readManifest(workspace);
@@ -878,6 +927,7 @@ var planTool = {
878
927
  type: "object",
879
928
  properties: {
880
929
  root: ROOT_PROPERTY,
930
+ outDir: OUT_DIR_PROPERTY,
881
931
  maxPartBytes: {
882
932
  type: "string",
883
933
  description: 'Byte budget per part, for example 307200 or "300k". Defaults to the project configuration.'
@@ -898,7 +948,7 @@ var planTool = {
898
948
  },
899
949
  annotations: READ_ONLY,
900
950
  async handler(args, context) {
901
- const prepared = await prepare(args, context, { maxPartBytes: args.maxPartBytes });
951
+ const prepared = await prepare(args, context, { maxPartBytes: args.maxPartBytes, outDir: args.outDir });
902
952
  if (!prepared.ok) return prepared.outcome;
903
953
  const workspace = prepared.workspace;
904
954
  let manifest;
@@ -952,13 +1002,10 @@ var generateTool = {
952
1002
  type: "object",
953
1003
  properties: {
954
1004
  root: ROOT_PROPERTY,
1005
+ outDir: OUT_DIR_PROPERTY,
955
1006
  maxPartBytes: {
956
1007
  type: "string",
957
1008
  description: 'Byte budget per part, for example 307200 or "300k". Defaults to the project configuration.'
958
- },
959
- outDir: {
960
- type: "string",
961
- description: "Output directory relative to the root. Defaults to the project configuration."
962
1009
  }
963
1010
  },
964
1011
  additionalProperties: false
@@ -981,35 +1028,32 @@ var generateTool = {
981
1028
  });
982
1029
  if (!prepared.ok) return prepared.outcome;
983
1030
  const workspace = prepared.workspace;
984
- let manifest;
985
- try {
986
- manifest = generateSnapshot(workspace.config);
987
- } catch (error) {
988
- return failure(error instanceof Error ? error.message : String(error));
989
- }
1031
+ const result = await generateInWorker(workspace.root, workspace.cliOptions);
1032
+ if (!result.ok) return failure(result.message);
1033
+ const summary = result.summary;
990
1034
  forgetManifest(manifestPathFor(workspace));
991
1035
  const written = relativeFor(workspace, path2.resolve(workspace.root, workspace.config.outDir));
992
1036
  const lines = [
993
1037
  `Wrote ${written}/`,
994
- `Files: ${manifest.counts.totalFiles} included, ${manifest.counts.skippedCount} skipped, ${plural(manifest.counts.partsCount, "part")}`,
995
- `Parts: ${manifest.parts.map((part) => `${part.filename} (${formatBytes(part.byteSize)})`).join(", ")}`,
996
- `Fingerprint: ${manifest.fingerprint}`
1038
+ `Files: ${summary.totalFiles} included, ${summary.skippedCount} skipped, ${plural(summary.partsCount, "part")}`,
1039
+ `Parts: ${summary.parts.map((part) => `${part.filename} (${formatBytes(part.byteSize)})`).join(", ")}`,
1040
+ `Fingerprint: ${summary.fingerprint}`
997
1041
  ];
998
- if (manifest.warnings.length > 0) {
999
- lines.push(`Warnings (${manifest.warnings.length}):`);
1000
- for (const warning of manifest.warnings.slice(0, 5)) lines.push(` - ${warning}`);
1042
+ if (summary.warnings.length > 0) {
1043
+ lines.push(`Warnings (${summary.warnings.length}):`);
1044
+ for (const warning of summary.warnings.slice(0, 5)) lines.push(` - ${warning}`);
1001
1045
  }
1002
1046
  return {
1003
1047
  text: lines.join("\n"),
1004
1048
  structured: {
1005
- fingerprint: manifest.fingerprint,
1006
- files: manifest.counts.totalFiles,
1007
- skipped: manifest.counts.skippedCount,
1008
- parts: manifest.counts.partsCount,
1009
- emittedBytes: manifest.totals.totalContentBytes,
1049
+ fingerprint: summary.fingerprint,
1050
+ files: summary.totalFiles,
1051
+ skipped: summary.skippedCount,
1052
+ parts: summary.partsCount,
1053
+ emittedBytes: summary.totalContentBytes,
1010
1054
  outDir: written,
1011
- partFiles: manifest.parts.map((part) => part.filename),
1012
- warningCount: manifest.warnings.length
1055
+ partFiles: summary.parts.map((part) => part.filename),
1056
+ warningCount: summary.warnings.length
1013
1057
  }
1014
1058
  };
1015
1059
  }
@@ -1020,7 +1064,7 @@ var validateTool = {
1020
1064
  description: "Re-read the repository and report whether the snapshot on disk still matches it. Use it before trusting context that came from an earlier session, or after changing the config.",
1021
1065
  inputSchema: {
1022
1066
  type: "object",
1023
- properties: { root: ROOT_PROPERTY },
1067
+ properties: { root: ROOT_PROPERTY, outDir: OUT_DIR_PROPERTY },
1024
1068
  additionalProperties: false
1025
1069
  },
1026
1070
  outputSchema: {
@@ -1032,7 +1076,7 @@ var validateTool = {
1032
1076
  },
1033
1077
  annotations: READ_ONLY,
1034
1078
  async handler(args, context) {
1035
- const prepared = await prepare(args, context);
1079
+ const prepared = await prepare(args, context, outDirOverride(args));
1036
1080
  if (!prepared.ok) return prepared.outcome;
1037
1081
  const workspace = prepared.workspace;
1038
1082
  let result;
@@ -1099,7 +1143,8 @@ var locateTool = {
1099
1143
  default: 20,
1100
1144
  description: "Maximum matches to return."
1101
1145
  },
1102
- root: ROOT_PROPERTY
1146
+ root: ROOT_PROPERTY,
1147
+ outDir: OUT_DIR_PROPERTY
1103
1148
  },
1104
1149
  required: ["query"],
1105
1150
  additionalProperties: false
@@ -1110,7 +1155,7 @@ var locateTool = {
1110
1155
  },
1111
1156
  annotations: READ_ONLY,
1112
1157
  async handler(args, context) {
1113
- const prepared = await prepare(args, context);
1158
+ const prepared = await prepare(args, context, outDirOverride(args));
1114
1159
  if (!prepared.ok) return prepared.outcome;
1115
1160
  const workspace = prepared.workspace;
1116
1161
  const read = readManifest(workspace);
@@ -1145,12 +1190,13 @@ var locateTool = {
1145
1190
  continue;
1146
1191
  }
1147
1192
  }
1148
- if (wants("layer") && entry.layer.toLowerCase() === needle) add(entry, "layer");
1149
- else if (wants("kind") && entry.kind.toLowerCase() === needle) add(entry, "kind");
1193
+ if (wants("layer") && entry.layer.toLowerCase().includes(needle)) add(entry, "layer");
1194
+ else if (wants("kind") && entry.kind.toLowerCase().includes(needle)) add(entry, "kind");
1150
1195
  }
1151
1196
  if (wants("route")) {
1152
1197
  for (const route of manifest.indexes.routes) {
1153
- if (!route.path.toLowerCase().includes(needle)) continue;
1198
+ const method = route.methods.find((name) => name.toLowerCase().includes(needle));
1199
+ if (!route.path.toLowerCase().includes(needle) && method === void 0) continue;
1154
1200
  if (matches.some((match) => match.path === route.path)) continue;
1155
1201
  matches.push({
1156
1202
  path: route.path,
@@ -1185,6 +1231,127 @@ var locateTool = {
1185
1231
  };
1186
1232
  }
1187
1233
  };
1234
+ var dependentsTool = {
1235
+ name: "snapshot_dependents",
1236
+ title: "Follow the import graph",
1237
+ description: 'For one file in the snapshot: what it imports, and which files import it. Answers "what breaks if I change this?" without reading the parts.',
1238
+ inputSchema: {
1239
+ type: "object",
1240
+ properties: {
1241
+ path: {
1242
+ type: "string",
1243
+ description: "Snapshot-relative path, exactly as snapshot_locate or manifest.files[] reports it."
1244
+ },
1245
+ direction: {
1246
+ type: "string",
1247
+ enum: ["both", "imports", "imported-by"],
1248
+ default: "both",
1249
+ description: "Which edges to return."
1250
+ },
1251
+ limit: {
1252
+ type: "integer",
1253
+ minimum: 1,
1254
+ maximum: 200,
1255
+ default: 50,
1256
+ description: "Maximum entries per direction."
1257
+ },
1258
+ root: ROOT_PROPERTY,
1259
+ outDir: OUT_DIR_PROPERTY
1260
+ },
1261
+ required: ["path"],
1262
+ additionalProperties: false
1263
+ },
1264
+ outputSchema: {
1265
+ type: "object",
1266
+ properties: {
1267
+ path: { type: "string" },
1268
+ imports: { type: "array" },
1269
+ importedBy: { type: "array" },
1270
+ unresolved: { type: "array" }
1271
+ }
1272
+ },
1273
+ annotations: READ_ONLY,
1274
+ async handler(args, context) {
1275
+ const prepared = await prepare(args, context, outDirOverride(args));
1276
+ if (!prepared.ok) return prepared.outcome;
1277
+ const read = readManifest(prepared.workspace);
1278
+ if (!read.ok) return failure(read.message);
1279
+ const manifest = read.manifest;
1280
+ const requested = String(args.path ?? "").trim().replace(/\\/g, "/");
1281
+ if (requested.length === 0) return failure('"path" must not be empty.');
1282
+ const filesByPath = new Map(manifest.files.map((file) => [file.relativePath, file]));
1283
+ const entry = filesByPath.get(requested) ?? manifest.files.find((file) => file.relativePath.toLowerCase() === requested.toLowerCase());
1284
+ if (!entry) {
1285
+ return failure(
1286
+ `${requested} is not in the snapshot. Use snapshot_locate to find its path, or snapshot_explain to see whether it was excluded.`
1287
+ );
1288
+ }
1289
+ const direction = args.direction === "imports" || args.direction === "imported-by" ? String(args.direction) : "both";
1290
+ const limit = typeof args.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : 50;
1291
+ const resolver = buildImportResolver(manifest.files);
1292
+ const imports = [];
1293
+ const unresolved = [];
1294
+ if (direction !== "imported-by") {
1295
+ const own = manifest.indexes.imports.byFile.find((file) => file.path === entry.relativePath);
1296
+ for (const specifier of own?.imports ?? []) {
1297
+ const resolved = resolveSpecifier(entry.relativePath, specifier, resolver);
1298
+ if (resolved === null) {
1299
+ unresolved.push(specifier);
1300
+ continue;
1301
+ }
1302
+ const target = filesByPath.get(resolved);
1303
+ if (target) imports.push({ path: resolved, specifier, part: target.part });
1304
+ }
1305
+ }
1306
+ const importedBy = [];
1307
+ if (direction !== "imports") {
1308
+ for (const candidate of manifest.indexes.imports.byFile) {
1309
+ if (candidate.path === entry.relativePath) continue;
1310
+ const edge = candidate.imports.some(
1311
+ (specifier) => resolveSpecifier(candidate.path, specifier, resolver) === entry.relativePath
1312
+ );
1313
+ if (!edge) continue;
1314
+ const source = filesByPath.get(candidate.path);
1315
+ importedBy.push({ path: candidate.path, part: source?.part ?? "" });
1316
+ }
1317
+ }
1318
+ const shownImports = imports.slice(0, limit);
1319
+ const shownImportedBy = importedBy.slice(0, limit);
1320
+ const lines = [
1321
+ `${entry.relativePath} \u2014 ${importedBy.length} importer(s), ${imports.length} import(s), ${unresolved.length} outside the snapshot.`
1322
+ ];
1323
+ if (direction !== "imports") {
1324
+ lines.push("", "Imported by:");
1325
+ if (shownImportedBy.length === 0) lines.push(" (nothing in the snapshot imports it)");
1326
+ for (const item of shownImportedBy) lines.push(` ${item.path} [${item.part}]`);
1327
+ if (importedBy.length > shownImportedBy.length) {
1328
+ lines.push(` \u2026 ${importedBy.length - shownImportedBy.length} more; raise \`limit\`.`);
1329
+ }
1330
+ }
1331
+ if (direction !== "imported-by") {
1332
+ lines.push("", "Imports:");
1333
+ if (shownImports.length === 0) lines.push(" (none recorded for this file)");
1334
+ for (const item of shownImports) {
1335
+ lines.push(` ${item.path} (from "${item.specifier}") [${item.part}]`);
1336
+ }
1337
+ if (imports.length > shownImports.length) {
1338
+ lines.push(` \u2026 ${imports.length - shownImports.length} more; raise \`limit\`.`);
1339
+ }
1340
+ if (unresolved.length > 0) {
1341
+ lines.push("", `Outside the snapshot (${unresolved.length}): ${unresolved.join(", ")}`);
1342
+ }
1343
+ }
1344
+ return {
1345
+ text: lines.join("\n"),
1346
+ structured: {
1347
+ path: entry.relativePath,
1348
+ imports: shownImports,
1349
+ importedBy: shownImportedBy,
1350
+ unresolved
1351
+ }
1352
+ };
1353
+ }
1354
+ };
1188
1355
  var readTool = {
1189
1356
  name: "snapshot_read",
1190
1357
  title: "Read a file from the snapshot",
@@ -1209,7 +1376,8 @@ var readTool = {
1209
1376
  default: 200,
1210
1377
  description: "How many lines to return."
1211
1378
  },
1212
- root: ROOT_PROPERTY
1379
+ root: ROOT_PROPERTY,
1380
+ outDir: OUT_DIR_PROPERTY
1213
1381
  },
1214
1382
  required: ["path"],
1215
1383
  additionalProperties: false
@@ -1227,7 +1395,7 @@ var readTool = {
1227
1395
  },
1228
1396
  annotations: READ_ONLY,
1229
1397
  async handler(args, context) {
1230
- const prepared = await prepare(args, context);
1398
+ const prepared = await prepare(args, context, outDirOverride(args));
1231
1399
  if (!prepared.ok) return prepared.outcome;
1232
1400
  const workspace = prepared.workspace;
1233
1401
  if (typeof args.path !== "string") return failure('"path" must be a string.');
@@ -1262,7 +1430,7 @@ var readTool = {
1262
1430
  `${entry.part} could not be read, so the snapshot is damaged. Run snapshot_generate to rebuild it.`
1263
1431
  );
1264
1432
  }
1265
- const section = findSection(parsePartSections(partText), entry.relativePath, entry.hashSha256);
1433
+ const section = findSection(parsePartSections(partText), entry.relativePath);
1266
1434
  if (!section) {
1267
1435
  return failure(
1268
1436
  `${relativePath} is recorded in ${entry.part} but its section could not be located there. Run snapshot_generate to rebuild the snapshot.`
@@ -1274,16 +1442,28 @@ var readTool = {
1274
1442
  let body = window.text;
1275
1443
  let truncatedByBytes = false;
1276
1444
  if (Buffer.byteLength(body, "utf8") > MAX_RESULT_BYTES) {
1277
- body = body.slice(0, MAX_RESULT_BYTES);
1445
+ const clipped = truncateToBytes(body, MAX_RESULT_BYTES);
1446
+ body = clipped.content;
1278
1447
  const lastBreak = body.lastIndexOf("\n");
1279
1448
  body = lastBreak > 0 ? body.slice(0, lastBreak) : body;
1280
1449
  truncatedByBytes = true;
1281
1450
  }
1282
1451
  const returnedLines = body.length === 0 ? 0 : body.split("\n").length;
1283
- const moreRemain = window.truncated || truncatedByBytes;
1452
+ const moreRemain = !window.pastEnd && (window.truncated || truncatedByBytes);
1284
1453
  const header = [
1285
1454
  `${entry.relativePath} \u2014 ${entry.part}, ${entry.layer}/${entry.kind}, ${entry.language}`,
1286
- section.inclusion === "preview" ? "Bounded preview: this source file is large, so the snapshot holds only its head and tail." : `Lines ${window.fromLine}\u2013${window.fromLine + returnedLines - 1} of ${window.totalLines}.`
1455
+ // The line range is the answer to "where am I in this file", and it is computed from the
1456
+ // body the window was taken from, so it is just as meaningful for a preview as for a full
1457
+ // file. It used to be replaced — not augmented — by the preview note, so a 40-line window
1458
+ // into a 300-line preview came back with no range at all and the caller could not tell
1459
+ // where the window ended. Both facts are stated now, and `signatures` is no longer
1460
+ // silently treated as a full file.
1461
+ ...section.inclusion === "preview" ? [
1462
+ "Bounded preview: the snapshot holds this file's head and tail only, so these line numbers count the preview, not the source."
1463
+ ] : section.inclusion === "signatures" ? [
1464
+ "Signatures: function and method bodies were replaced with `{ /* ... */ }` when the snapshot was generated."
1465
+ ] : [],
1466
+ window.totalLines === 0 ? "This file is empty." : window.pastEnd ? `This file has ${window.totalLines} line(s); fromLine=${window.fromLine} is past its end.` : `Lines ${window.fromLine}\u2013${window.fromLine + returnedLines - 1} of ${window.totalLines}.`
1287
1467
  ];
1288
1468
  const lines = [...header, "", body];
1289
1469
  if (moreRemain) {
@@ -1320,7 +1500,8 @@ var explainTool = {
1320
1500
  type: "string",
1321
1501
  description: "Path relative to the repository root. Directories are explained as a whole."
1322
1502
  },
1323
- root: ROOT_PROPERTY
1503
+ root: ROOT_PROPERTY,
1504
+ outDir: OUT_DIR_PROPERTY
1324
1505
  },
1325
1506
  required: ["path"],
1326
1507
  additionalProperties: false
@@ -1335,7 +1516,7 @@ var explainTool = {
1335
1516
  },
1336
1517
  annotations: READ_ONLY,
1337
1518
  async handler(args, context) {
1338
- const prepared = await prepare(args, context);
1519
+ const prepared = await prepare(args, context, outDirOverride(args));
1339
1520
  if (!prepared.ok) return prepared.outcome;
1340
1521
  const workspace = prepared.workspace;
1341
1522
  if (typeof args.path !== "string") return failure('"path" must be a string.');
@@ -1375,6 +1556,7 @@ function buildTools() {
1375
1556
  generateTool,
1376
1557
  validateTool,
1377
1558
  locateTool,
1559
+ dependentsTool,
1378
1560
  readTool,
1379
1561
  explainTool
1380
1562
  ];
@@ -1446,9 +1628,15 @@ var RESOURCE_TEMPLATE_URIS = ["snapshot://parts/{index}"];
1446
1628
 
1447
1629
  // src/agent/option-scope.ts
1448
1630
  var VALIDATE_OPTIONS = [
1631
+ "sarif",
1632
+ "skeleton",
1633
+ "changed-since",
1634
+ "staged",
1635
+ "with-dependents",
1449
1636
  "cwd",
1450
1637
  "out-dir",
1451
1638
  "max-part-bytes",
1639
+ "max-part-tokens",
1452
1640
  "large-file-bytes",
1453
1641
  "max-monolith-bytes",
1454
1642
  "include-monolith",
@@ -1465,9 +1653,15 @@ var VALIDATE_OPTIONS = [
1465
1653
  ];
1466
1654
  var COMMAND_OPTION_SCOPE = {
1467
1655
  generate: [
1656
+ "sarif",
1657
+ "skeleton",
1658
+ "changed-since",
1659
+ "staged",
1660
+ "with-dependents",
1468
1661
  "cwd",
1469
1662
  "out-dir",
1470
1663
  "max-part-bytes",
1664
+ "max-part-tokens",
1471
1665
  "large-file-bytes",
1472
1666
  "max-monolith-bytes",
1473
1667
  "include-monolith",
@@ -1505,6 +1699,12 @@ var COMMAND_OPTION_SCOPE = {
1505
1699
  "version"
1506
1700
  ],
1507
1701
  explain: [
1702
+ // The scope decides what `explain` says about a file, dependents included: without these,
1703
+ // a file that is in the snapshot only because it imports a changed one was explained as
1704
+ // `included: false, reason: "unchanged"`.
1705
+ "changed-since",
1706
+ "staged",
1707
+ "with-dependents",
1508
1708
  "cwd",
1509
1709
  "out-dir",
1510
1710
  "max-part-bytes",
@@ -1525,6 +1725,7 @@ var COMMAND_OPTION_SCOPE = {
1525
1725
  diff: ["format", "mode", "no-config", "no-update-check", "help", "version"],
1526
1726
  describe: ["format", "mode", "help", "version"],
1527
1727
  init: [
1728
+ "install-hook",
1528
1729
  "cwd",
1529
1730
  "out-dir",
1530
1731
  "max-part-bytes",
@@ -1547,13 +1748,29 @@ var COMMAND_OPTION_SCOPE = {
1547
1748
  // `mcp` reads the repository, so it takes --cwd. It has no --format: stdout carries JSON-RPC
1548
1749
  // messages, and there is no second shape to choose, so offering the flag would be a lie.
1549
1750
  mcp: ["cwd", "mode", "help", "version"],
1751
+ // `setup` generates through the `generate` command, so every generation setting it can
1752
+ // honour is in scope. Rejecting them made the one-command path unable to express a walk
1753
+ // scan, a monolith or a different large-file threshold; the alternative was to run
1754
+ // `setup` and then edit the config it had just written.
1550
1755
  setup: [
1756
+ "install-hook",
1551
1757
  "cwd",
1552
1758
  "out-dir",
1553
1759
  "max-part-bytes",
1760
+ // Persisted into the config `setup` writes, then forwarded to the generation it runs — so
1761
+ // the snapshot it produces validates against the config beside it. Accepting a setting here
1762
+ // without writing it down made `setup --large-file-bytes 100k` finish with a snapshot its
1763
+ // own `validate` called drifted, on the first run of a brand-new project.
1764
+ "max-part-tokens",
1765
+ "skeleton",
1766
+ "sarif",
1767
+ "large-file-bytes",
1768
+ "max-monolith-bytes",
1769
+ "include-monolith",
1554
1770
  "include-sensitive-files",
1555
1771
  "skip-artifacts",
1556
1772
  "only-artifacts",
1773
+ "discovery",
1557
1774
  "preset",
1558
1775
  "include",
1559
1776
  "exclude",
@@ -1839,6 +2056,13 @@ function buildAgentContract() {
1839
2056
  appliesTo: optionAppliesTo("max-part-bytes"),
1840
2057
  summary: 'Byte budget per markdown part. Accepts "120k" / "1.5mb". Minimum 4096.'
1841
2058
  },
2059
+ {
2060
+ flag: "--max-part-tokens",
2061
+ type: "string",
2062
+ default: "unset",
2063
+ appliesTo: optionAppliesTo("max-part-tokens"),
2064
+ summary: "Ceiling on a part expressed in tokens. Narrows --max-part-bytes instead of replacing it; the manifest records both units. Minimum 1024."
2065
+ },
1842
2066
  {
1843
2067
  flag: "--large-file-bytes",
1844
2068
  type: "string",
@@ -1888,6 +2112,41 @@ function buildAgentContract() {
1888
2112
  appliesTo: optionAppliesTo("only-artifacts"),
1889
2113
  summary: "Write only these artifacts. Cannot be combined with --skip-artifacts."
1890
2114
  },
2115
+ {
2116
+ flag: "--skeleton",
2117
+ type: "boolean",
2118
+ default: false,
2119
+ appliesTo: optionAppliesTo("skeleton"),
2120
+ summary: 'Replace function and method bodies with `{ /* ... */ }` in TypeScript/TSX files, keeping signatures and comments. The part records inclusionMode "signatures" for each file it touched.'
2121
+ },
2122
+ {
2123
+ flag: "--sarif",
2124
+ type: "boolean",
2125
+ default: false,
2126
+ appliesTo: optionAppliesTo("sarif"),
2127
+ summary: "Also write reports/audit.sarif (SARIF 2.1.0) so a CI security gate can read the redaction findings. The matched value is never included."
2128
+ },
2129
+ {
2130
+ flag: "--changed-since",
2131
+ type: "string",
2132
+ default: "unset",
2133
+ appliesTo: optionAppliesTo("changed-since"),
2134
+ summary: 'Snapshot only the files that differ from this git ref, plus untracked ones. Everything else is recorded in skipped[] with reason "unchanged". Requires a git checkout.'
2135
+ },
2136
+ {
2137
+ flag: "--staged",
2138
+ type: "boolean",
2139
+ default: false,
2140
+ appliesTo: optionAppliesTo("staged"),
2141
+ summary: "Snapshot only the files staged in the index. The pre-commit counterpart of --changed-since."
2142
+ },
2143
+ {
2144
+ flag: "--with-dependents",
2145
+ type: "boolean",
2146
+ default: false,
2147
+ appliesTo: optionAppliesTo("with-dependents"),
2148
+ summary: "Widen a changed-files scope by one hop: files that import a changed file are included too. Only meaningful with --changed-since or --staged."
2149
+ },
1891
2150
  {
1892
2151
  flag: "--format",
1893
2152
  type: "string",
@@ -1995,6 +2254,13 @@ function buildAgentContract() {
1995
2254
  appliesTo: optionAppliesTo("no-install"),
1996
2255
  summary: "Skip installing the dependency before generating: `setup`, or `generate --autopilot` (CI, offline, or a lockfile you do not want a package manager to touch)."
1997
2256
  },
2257
+ {
2258
+ flag: "--install-hook",
2259
+ type: "boolean",
2260
+ default: false,
2261
+ appliesTo: optionAppliesTo("install-hook"),
2262
+ summary: "After writing the config, install a pre-commit hook that runs `validate`. An existing hook this tool did not write is left alone."
2263
+ },
1998
2264
  {
1999
2265
  flag: "--no-update-check",
2000
2266
  type: "boolean",
@@ -2031,6 +2297,12 @@ function buildAgentContract() {
2031
2297
  { key: "branch", type: "string", default: "main", summary: "Branch recorded when git cannot report one." },
2032
2298
  { key: "preset", type: "string", default: "detected", summary: "Force a preset. Unknown names are an error." },
2033
2299
  { key: "maxPartBytes", type: "number | string", default: 307200, summary: "Per-part byte budget." },
2300
+ { key: "maxPartTokens", type: "number | string | null", default: null, summary: "Per-part token ceiling; narrows maxPartBytes. Minimum 1024." },
2301
+ { key: "skeleton", type: "boolean", default: false, summary: "Collapse function bodies to `{ /* ... */ }` in TypeScript files." },
2302
+ { key: "sarif", type: "boolean", default: false, summary: "Also write reports/audit.sarif." },
2303
+ { key: "changedSince", type: "string | null", default: null, summary: "Snapshot only files that differ from this git ref." },
2304
+ { key: "staged", type: "boolean", default: false, summary: "Snapshot only files staged in the index." },
2305
+ { key: "withDependents", type: "boolean", default: false, summary: "With a changed-files scope, also include one hop of importers." },
2034
2306
  { key: "largeFileBytes", type: "number | string", default: 204800, summary: "Preview threshold for large files." },
2035
2307
  { key: "previewHeadLines", type: "number", default: 80, summary: "Lines kept from the head of a large file." },
2036
2308
  { key: "previewTailLines", type: "number", default: 40, summary: "Lines kept from the tail of a large file." },
@@ -2213,14 +2485,19 @@ do not assume the repository is broken.
2213
2485
  matches the files on disk. Exit 1 means drift \u2014 re-run step 5.
2214
2486
 
2215
2487
  7. **Read the output in this order:**
2216
- - \`snapshot/parts/part-*.md\` \u2014 the code itself, each file tagged with its layer, kind,
2217
- language and, when it fits the budget, a structural summary (imports, exports,
2218
- classes, functions). A dropped summary appears as a \`Summary: (omitted \u2026)\` line.
2488
+ - \`snapshot/parts/part-*.md\` \u2014 the code itself. Each file carries one \`- Meta:\` line
2489
+ (layer/kind, language, inclusion, size) and, only when the body is **not** the whole
2490
+ file, a structural summary (imports, exports, classes, functions). A full file has no
2491
+ summary line, because the summary would repeat the code three lines below it \u2014 read
2492
+ \`manifest.json\` for it. A summary dropped to fit the budget appears as a
2493
+ \`Summary: (omitted \u2026)\` line.
2219
2494
  - \`snapshot/manifest.json\` \u2014 the index. \`files[]\` has \`relativePath\`, \`layer\`,
2220
- \`kind\`, \`language\`, \`part\`, \`inclusionMode\` and \`hashSha256\`, plus \`encoding\`
2221
- when the file was not plain UTF-8. \`summary\` is **optional**: it is absent for
2222
- languages with no structural summary, and \`summaryOmitted: true\` marks one dropped
2223
- to fit the part budget \u2014 never assume it exists.
2495
+ \`kind\`, \`language\`, \`part\`, \`inclusionMode\`, \`hashSha256\` and the structural
2496
+ \`summary\`, plus \`encoding\` when the file was not plain UTF-8. \`summary\` is
2497
+ **optional**: it is absent for languages with no structural summary, and
2498
+ \`summaryOmitted: true\` marks one dropped to fit the part budget \u2014 never assume it
2499
+ exists. The per-file \`hashSha256\` is only here and in the manifest; it is not printed
2500
+ into the parts.
2224
2501
  \`skipped[]\` records every excluded file **and why**. Prefer querying this over
2225
2502
  re-reading the parts.
2226
2503
  - \`snapshot/reports/audit.md\` \u2014 secrets found, large files, parse diagnostics, git
@@ -2274,7 +2551,7 @@ diff, and the commit, are then yours.
2274
2551
  ## When the client speaks MCP
2275
2552
 
2276
2553
  If the agent runs inside an MCP client, \`npx ${PACKAGE_NAME2}@latest mcp\` serves this repository
2277
- as seven tools over stdio instead of shelling out. Its reads come from the snapshot rather than
2554
+ as eight tools over stdio instead of shelling out. Its reads come from the snapshot rather than
2278
2555
  the working tree, so excluded files and redacted values never reach the context. Use
2279
2556
  \`snapshot_locate\` before \`snapshot_read\`, and \`snapshot_validate\` before trusting a snapshot
2280
2557
  from an earlier session.
@@ -2431,7 +2708,8 @@ var SERVER_INSTRUCTIONS = [
2431
2708
  "1. snapshot_status \u2014 is there a snapshot here, and has the tree moved since it was built?",
2432
2709
  "2. snapshot_locate \u2014 find which part holds the code you need.",
2433
2710
  "3. snapshot_read \u2014 read a window of one file. Never read a part end to end.",
2434
- "4. snapshot_validate \u2014 before trusting context that came from an earlier conversation.",
2711
+ "4. snapshot_dependents \u2014 before changing a file, see what imports it.",
2712
+ "5. snapshot_validate \u2014 before trusting context that came from an earlier conversation.",
2435
2713
  "",
2436
2714
  "Rules that matter:",
2437
2715
  "- Parts are budgeted, not small: a 12-part snapshot is hundreds of kilobytes. Use snapshot_locate first.",
@@ -2474,7 +2752,13 @@ function createLineReader(input, options) {
2474
2752
  while (index >= 0) {
2475
2753
  const line = buffer.slice(0, index);
2476
2754
  buffer = buffer.slice(index + 1);
2477
- deliver(line);
2755
+ if (discarding) {
2756
+ deliver(line);
2757
+ } else if (line.length > MAX_LINE_BYTES) {
2758
+ options.onOverflow?.(line.length);
2759
+ } else {
2760
+ deliver(line);
2761
+ }
2478
2762
  index = buffer.indexOf("\n");
2479
2763
  }
2480
2764
  };
@@ -2491,14 +2775,14 @@ function createLineReader(input, options) {
2491
2775
  return;
2492
2776
  }
2493
2777
  buffer += chunk;
2494
- if (buffer.length > MAX_LINE_BYTES && buffer.indexOf("\n") < 0) {
2778
+ drain();
2779
+ if (buffer.length > MAX_LINE_BYTES) {
2495
2780
  discarding = true;
2496
2781
  discardedBytes = buffer.length;
2497
2782
  buffer = "";
2498
2783
  options.onOverflow?.(discardedBytes);
2499
2784
  return;
2500
2785
  }
2501
- drain();
2502
2786
  };
2503
2787
  const onEnd = () => {
2504
2788
  if (buffer.length > 0) {
@@ -2598,6 +2882,4 @@ async function runStdioServer(options) {
2598
2882
  return code;
2599
2883
  }
2600
2884
 
2601
- export { COMMAND_OPTION_SCOPE, ERROR_CODES, LEGACY_PROTOCOL_VERSIONS, MAX_LINE_BYTES, METHOD, MODERN_PROTOCOL_VERSIONS, SERVER_INSTRUCTIONS, SKILL_NAME, SUPPORTED_PROTOCOL_VERSIONS, buildAgentContract, buildPrompts, buildResourceProvider, buildTools, classifyRequest, coerceNumericStrings, commandsForOption, createLineReader, createMcpServer, createServer, createSession, createWriter, inspectModernMeta, negotiateLegacyVersion, renderContractText, renderSkill, runStdioServer, validateArguments };
2602
- //# sourceMappingURL=chunk-HQBEQ67K.js.map
2603
- //# sourceMappingURL=chunk-HQBEQ67K.js.map
2885
+ export { COMMAND_OPTION_SCOPE, ERROR_CODES, LEGACY_PROTOCOL_VERSIONS, MAX_LINE_BYTES, METHOD, MODERN_PROTOCOL_VERSIONS, SERVER_INSTRUCTIONS, SKILL_NAME, SUPPORTED_PROTOCOL_VERSIONS, buildAgentContract, buildPrompts, buildResourceProvider, buildTools, classifyRequest, coerceNumericStrings, commandsForOption, createLineReader, createMcpServer, createServer, createSession, createWriter, findSection, inspectModernMeta, negotiateLegacyVersion, parsePartSections, renderContractText, renderSkill, runStdioServer, validateArguments, windowText };