@danielblomma/cortex-mcp 2.2.4 → 2.4.0

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.
@@ -11,6 +11,7 @@ import {
11
11
  DEFAULT_SCHEDULER_OPTIONS,
12
12
  groupDuplicates,
13
13
  packWorkUnits,
14
+ resolveEffectiveTokenBudget,
14
15
  resolveInFlightTokens,
15
16
  resolveMemoryHeadroom,
16
17
  resolveModelMaxTokens,
@@ -30,6 +31,31 @@ const MODEL_CACHE_DIR = PATHS.embeddingsModelCache;
30
31
  const EMBEDDINGS_DIR = path.dirname(EMBEDDINGS_PATH);
31
32
 
32
33
  export const DEFAULT_MODEL_ID = "jinaai/jina-embeddings-v2-base-code";
34
+ export const COMPACT_FILE_TEXT_STRATEGY = "compact_files_v1";
35
+ export const COMPACT_FILE_TEXT_THRESHOLD_CHARS = 32768;
36
+ export const COMPACT_FILE_TEXT_TARGET_CHARS = 16000;
37
+
38
+ const COMPACT_FILE_SIGNAL_BUDGET_CHARS = 4096;
39
+ const COMPACT_FILE_SIGNAL_MAX_LINE_CHARS = 512;
40
+ const COMPACT_FILE_MIN_HEAD_CHARS = 4096;
41
+ const COMPACT_FILE_MIN_TAIL_CHARS = 2048;
42
+
43
+ export type EmbedTextProfile = "full" | "compact-files";
44
+
45
+ type FileEmbeddingTextResult = {
46
+ text: string;
47
+ profile: EmbedTextProfile;
48
+ compacted: boolean;
49
+ original_chars: number;
50
+ text_chars: number;
51
+ omitted_chars: number;
52
+ };
53
+
54
+ type ParseFileEntitiesOptions = {
55
+ textProfile?: EmbedTextProfile;
56
+ };
57
+
58
+ type SignatureEntityType = "File" | "Rule" | "ADR" | "Module" | "Project" | "Chunk";
33
59
 
34
60
  export function resolveModelId(): string {
35
61
  return (process.env.CORTEX_EMBED_MODEL ?? DEFAULT_MODEL_ID).trim() || DEFAULT_MODEL_ID;
@@ -47,6 +73,11 @@ type FileEntity = {
47
73
  updated_at: string;
48
74
  text: string;
49
75
  signature: string;
76
+ text_profile: EmbedTextProfile;
77
+ text_compacted: boolean;
78
+ text_original_chars: number;
79
+ text_chars: number;
80
+ text_omitted_chars: number;
50
81
  };
51
82
 
52
83
  type RuleEntity = {
@@ -154,6 +185,129 @@ function normalizeText(value: string): string {
154
185
  return value.replace(/\s+/g, " ").trim();
155
186
  }
156
187
 
188
+ export function resolveEmbedTextProfile(raw = process.env.CORTEX_EMBED_TEXT_PROFILE): EmbedTextProfile {
189
+ const value = (raw ?? "").trim().toLowerCase();
190
+ if (!value || value === "full") {
191
+ return "full";
192
+ }
193
+ if (value === "compact-files") {
194
+ return "compact-files";
195
+ }
196
+ throw new Error(
197
+ `Unsupported CORTEX_EMBED_TEXT_PROFILE=${JSON.stringify(raw)}; expected "full" or "compact-files"`
198
+ );
199
+ }
200
+
201
+ function isSignalLine(line: string): boolean {
202
+ const trimmed = line.trim();
203
+ if (!trimmed) {
204
+ return false;
205
+ }
206
+ return (
207
+ /^(import|export)\b/.test(trimmed) ||
208
+ /^(abstract\s+|async\s+|public\s+|private\s+|protected\s+|static\s+|readonly\s+|override\s+)*(class|interface|type|enum|function)\b/.test(trimmed) ||
209
+ /^(const|let|var)\s+[$A-Z_a-z][$\w]*\s*=/.test(trimmed) ||
210
+ /^(describe|it|test)\s*\(/.test(trimmed) ||
211
+ /^(@[A-Z_a-z][$\w]*|#[#\s])/.test(trimmed) ||
212
+ /^```[A-Za-z0-9_-]+/.test(trimmed) ||
213
+ /\b(route|router|endpoint|controller|handler|middleware|permission|auth|token|secret|security|todo|fixme)\b/i.test(trimmed)
214
+ );
215
+ }
216
+
217
+ function collectSignalLines(content: string, budgetChars: number): string {
218
+ const lines: string[] = [];
219
+ const seen = new Set<string>();
220
+ let used = 0;
221
+
222
+ for (const line of content.split(/\r?\n/)) {
223
+ if (!isSignalLine(line)) {
224
+ continue;
225
+ }
226
+ const normalized = line.trimEnd();
227
+ const signalLine = normalized.length > COMPACT_FILE_SIGNAL_MAX_LINE_CHARS
228
+ ? `${normalized.slice(0, COMPACT_FILE_SIGNAL_MAX_LINE_CHARS)} [cortex ${COMPACT_FILE_TEXT_STRATEGY} signal_line_truncated_chars=${normalized.length - COMPACT_FILE_SIGNAL_MAX_LINE_CHARS}]`
229
+ : normalized;
230
+ if (seen.has(signalLine)) {
231
+ continue;
232
+ }
233
+ const next = used + signalLine.length + 1;
234
+ if (next > budgetChars) {
235
+ continue;
236
+ }
237
+ lines.push(signalLine);
238
+ seen.add(signalLine);
239
+ used = next;
240
+ }
241
+
242
+ return lines.join("\n");
243
+ }
244
+
245
+ export function buildFileEmbeddingText(
246
+ filePath: string,
247
+ excerpt: string,
248
+ content: string,
249
+ profile: EmbedTextProfile = "full"
250
+ ): FileEmbeddingTextResult {
251
+ const fullText = `${filePath}\n${excerpt}\n${content}`;
252
+ if (profile === "full" || fullText.length <= COMPACT_FILE_TEXT_THRESHOLD_CHARS) {
253
+ return {
254
+ text: fullText,
255
+ profile,
256
+ compacted: false,
257
+ original_chars: fullText.length,
258
+ text_chars: fullText.length,
259
+ omitted_chars: 0
260
+ };
261
+ }
262
+
263
+ const signalText = collectSignalLines(content, COMPACT_FILE_SIGNAL_BUDGET_CHARS);
264
+ const markerPrefix = `[cortex ${COMPACT_FILE_TEXT_STRATEGY} omitted_chars=`;
265
+ const staticChars =
266
+ filePath.length +
267
+ excerpt.length +
268
+ signalText.length +
269
+ markerPrefix.length +
270
+ 64;
271
+ const available = Math.max(
272
+ COMPACT_FILE_MIN_HEAD_CHARS + COMPACT_FILE_MIN_TAIL_CHARS,
273
+ COMPACT_FILE_TEXT_TARGET_CHARS - staticChars
274
+ );
275
+ const headChars = Math.max(COMPACT_FILE_MIN_HEAD_CHARS, Math.floor(available * 0.62));
276
+ const tailChars = Math.max(COMPACT_FILE_MIN_TAIL_CHARS, available - headChars);
277
+ const head = content.slice(0, headChars);
278
+ const tail = content.slice(-tailChars);
279
+ const omittedChars = Math.max(0, content.length - head.length - tail.length);
280
+ const marker = `${markerPrefix}${omittedChars}]`;
281
+ const compactText = [
282
+ filePath,
283
+ excerpt,
284
+ head,
285
+ marker,
286
+ signalText ? `[cortex ${COMPACT_FILE_TEXT_STRATEGY} signal_lines]\n${signalText}` : "",
287
+ tail
288
+ ].filter((part) => part.length > 0).join("\n");
289
+
290
+ if (compactText.length >= fullText.length) {
291
+ return {
292
+ text: fullText,
293
+ profile,
294
+ compacted: false,
295
+ original_chars: fullText.length,
296
+ text_chars: fullText.length,
297
+ omitted_chars: 0
298
+ };
299
+ }
300
+
301
+ return {
302
+ text: compactText,
303
+ profile,
304
+ compacted: true,
305
+ original_chars: fullText.length,
306
+ text_chars: compactText.length,
307
+ omitted_chars: fullText.length - compactText.length
308
+ };
309
+ }
310
+
157
311
  function ensureRequiredFiles(): void {
158
312
  const required = [
159
313
  path.join(CACHE_DIR, "documents.jsonl"),
@@ -168,7 +322,8 @@ function ensureRequiredFiles(): void {
168
322
  }
169
323
  }
170
324
 
171
- export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
325
+ export function parseFileEntities(raw: JsonObject[], options: ParseFileEntitiesOptions = {}): FileEntity[] {
326
+ const textProfile = options.textProfile ?? "full";
172
327
  return raw
173
328
  .map((item) => {
174
329
  const id = asString(item.id);
@@ -181,7 +336,7 @@ export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
181
336
  const excerpt = asString(item.excerpt);
182
337
  const updatedAt = asString(item.updated_at);
183
338
  const checksum = asString(item.checksum, hashText(content));
184
- const text = `${filePath}\n${excerpt}\n${content}`;
339
+ const embeddingText = buildFileEmbeddingText(filePath, excerpt, content, textProfile);
185
340
 
186
341
  return {
187
342
  id,
@@ -193,8 +348,13 @@ export function parseFileEntities(raw: JsonObject[]): FileEntity[] {
193
348
  source_of_truth: asBoolean(item.source_of_truth, false),
194
349
  trust_level: asNumber(item.trust_level, 50),
195
350
  updated_at: updatedAt,
196
- text,
197
- signature: hashText(`file|${checksum}|${updatedAt}|${hashText(text)}`)
351
+ text: embeddingText.text,
352
+ signature: hashText(`file|${checksum}|${updatedAt}|${hashText(embeddingText.text)}`),
353
+ text_profile: embeddingText.profile,
354
+ text_compacted: embeddingText.compacted,
355
+ text_original_chars: embeddingText.original_chars,
356
+ text_chars: embeddingText.text_chars,
357
+ text_omitted_chars: embeddingText.omitted_chars
198
358
  };
199
359
  })
200
360
  .filter((value): value is FileEntity => value !== null);
@@ -407,8 +567,24 @@ function roundVector(values: number[]): number[] {
407
567
  return values.map((value) => Number(value.toFixed(6)));
408
568
  }
409
569
 
410
- function resolveSignatureProfile(maxTokenCap: number | null): string {
411
- return maxTokenCap ? `embed|max_tokens=${maxTokenCap}` : "";
570
+ export function resolveSignatureProfile(
571
+ maxTokenCap: number | null,
572
+ textProfile: EmbedTextProfile = "full",
573
+ entityType?: SignatureEntityType
574
+ ): string {
575
+ const parts: string[] = [];
576
+ if (maxTokenCap) {
577
+ parts.push(`max_tokens=${maxTokenCap}`);
578
+ }
579
+ if (textProfile === "compact-files" && (entityType === undefined || entityType === "File")) {
580
+ parts.push(
581
+ "text_profile=compact-files",
582
+ COMPACT_FILE_TEXT_STRATEGY,
583
+ `threshold_chars=${COMPACT_FILE_TEXT_THRESHOLD_CHARS}`,
584
+ `target_chars=${COMPACT_FILE_TEXT_TARGET_CHARS}`
585
+ );
586
+ }
587
+ return parts.length ? `embed|${parts.join("|")}` : "";
412
588
  }
413
589
 
414
590
  function embeddingSignature(entitySignature: string, profile: string): string {
@@ -433,8 +609,19 @@ async function main(): Promise<void> {
433
609
  fs.mkdirSync(MODEL_CACHE_DIR, { recursive: true });
434
610
 
435
611
  const modelId = resolveModelId();
436
-
437
- const documents = parseFileEntities(readJsonl(path.join(CACHE_DIR, "documents.jsonl")));
612
+ const textProfile = resolveEmbedTextProfile();
613
+
614
+ const documents = parseFileEntities(readJsonl(path.join(CACHE_DIR, "documents.jsonl")), { textProfile });
615
+ const textProfileStats = {
616
+ strategy: textProfile === "compact-files" ? COMPACT_FILE_TEXT_STRATEGY : null,
617
+ threshold_chars: textProfile === "compact-files" ? COMPACT_FILE_TEXT_THRESHOLD_CHARS : null,
618
+ target_chars: textProfile === "compact-files" ? COMPACT_FILE_TEXT_TARGET_CHARS : null,
619
+ file_entities: documents.length,
620
+ compacted_files: documents.filter((doc) => doc.text_compacted).length,
621
+ original_chars: documents.reduce((total, doc) => total + doc.text_original_chars, 0),
622
+ text_chars: documents.reduce((total, doc) => total + doc.text_chars, 0),
623
+ saved_chars: documents.reduce((total, doc) => total + doc.text_omitted_chars, 0)
624
+ };
438
625
  const rules = parseRuleEntities(readJsonl(path.join(CACHE_DIR, "entities.rule.jsonl")));
439
626
  const adrs = parseAdrEntities(readJsonl(path.join(CACHE_DIR, "entities.adr.jsonl")));
440
627
  const modules = parseModuleEntities(readJsonl(path.join(CACHE_DIR, "entities.module.jsonl")));
@@ -453,11 +640,7 @@ async function main(): Promise<void> {
453
640
  uniqueSignatures.add(entity.signature);
454
641
  }
455
642
  const uniqueTextCount = uniqueSignatures.size;
456
- const tokenBudget = resolveTokenBudgetChoice(process.env.CORTEX_EMBED_MAX_TOKENS, uniqueTextCount);
457
643
  uniqueSignatures.clear();
458
- const signatureProfile = resolveSignatureProfile(tokenBudget.cap);
459
-
460
- const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
461
644
 
462
645
  env.cacheDir = MODEL_CACHE_DIR;
463
646
  // Total thread budget for embedding. CORTEX_EMBED_THREADS caps it so
@@ -466,6 +649,32 @@ async function main(): Promise<void> {
466
649
  const threadsRaw = Number(process.env.CORTEX_EMBED_THREADS);
467
650
  const threadBudget =
468
651
  Number.isFinite(threadsRaw) && threadsRaw >= 1 ? Math.floor(threadsRaw) : os.cpus().length;
652
+ const readHeadroom = () =>
653
+ resolveMemoryHeadroom({
654
+ freeMemory: os.freemem(),
655
+ totalMemory: os.totalmem(),
656
+ constrainedMemory: process.constrainedMemory?.() ?? null,
657
+ availableMemory: process.availableMemory?.() ?? null
658
+ });
659
+ const memoryHeadroom = readHeadroom();
660
+ const previewPoolConfig = resolvePoolConfig({
661
+ threadBudget,
662
+ uniqueCount: uniqueTextCount,
663
+ memoryBytes: memoryHeadroom
664
+ });
665
+ const requestedTokenBudget = resolveTokenBudgetChoice(process.env.CORTEX_EMBED_MAX_TOKENS, uniqueTextCount);
666
+ const tokenBudget = resolveEffectiveTokenBudget({
667
+ choice: requestedTokenBudget,
668
+ modelMaxTokens: resolveModelMaxTokens(undefined, requestedTokenBudget.cap ?? undefined),
669
+ memoryBytes: memoryHeadroom,
670
+ sessions: previewPoolConfig.sessions
671
+ });
672
+ const defaultSignatureProfile = resolveSignatureProfile(tokenBudget.cap, "full");
673
+ const fileSignatureProfile = resolveSignatureProfile(tokenBudget.cap, textProfile, "File");
674
+ const signatureProfileForEntity = (entity: SearchEntity) =>
675
+ entity.type === "File" ? fileSignatureProfile : defaultSignatureProfile;
676
+
677
+ const existing = parseExistingEmbeddings(readJsonlRecords(EMBEDDINGS_PATH), modelId);
469
678
 
470
679
  let reused = 0;
471
680
  // Slot per entity keeps output in entity order; failed slots stay null.
@@ -474,6 +683,7 @@ async function main(): Promise<void> {
474
683
  let dimensions = 0;
475
684
 
476
685
  entities.forEach((entity, index) => {
686
+ const signatureProfile = signatureProfileForEntity(entity);
477
687
  const signature = embeddingSignature(entity.signature, signatureProfile);
478
688
  const previous = existing.get(entity.id);
479
689
  if (previous && previous.signature === signature && previous.vector.length > 0) {
@@ -505,14 +715,6 @@ async function main(): Promise<void> {
505
715
  // adapts to the machine so no tuning is expected from users. Container
506
716
  // limits (cgroups) and platforms that under-report free memory are both
507
717
  // handled inside resolveMemoryHeadroom.
508
- const readHeadroom = () =>
509
- resolveMemoryHeadroom({
510
- freeMemory: os.freemem(),
511
- totalMemory: os.totalmem(),
512
- constrainedMemory: process.constrainedMemory?.() ?? null,
513
- availableMemory: process.availableMemory?.() ?? null
514
- });
515
- const memoryHeadroom = readHeadroom();
516
718
  const poolConfig = resolvePoolConfig({
517
719
  threadBudget,
518
720
  poolOverride: Number(process.env.CORTEX_EMBED_POOL) || null,
@@ -616,6 +818,7 @@ async function main(): Promise<void> {
616
818
  maxInFlightTokens,
617
819
  onVector(index, rawVector) {
618
820
  const entity = entities[index];
821
+ const signatureProfile = signatureProfileForEntity(entity);
619
822
  const vector = roundVector(rawVector);
620
823
  embedded += 1;
621
824
  dimensions = dimensions || vector.length;
@@ -650,6 +853,13 @@ async function main(): Promise<void> {
650
853
  mode,
651
854
  model: modelId,
652
855
  dimensions,
856
+ text_profile: textProfile,
857
+ signature_profile: fileSignatureProfile === defaultSignatureProfile ? defaultSignatureProfile : "per_entity",
858
+ signature_profiles: {
859
+ default: defaultSignatureProfile,
860
+ file: fileSignatureProfile
861
+ },
862
+ text_profile_stats: textProfileStats,
653
863
  counts: {
654
864
  entities: entities.length,
655
865
  output: outputCount,
@@ -663,7 +873,10 @@ async function main(): Promise<void> {
663
873
  fs.writeFileSync(EMBEDDINGS_MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
664
874
 
665
875
  console.log(
666
- `[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems} max_tokens<=${modelMaxTokensUsed || tokenBudget.cap || "model"} token_budget=${tokenBudget.mode}`
876
+ `[embed] mode=${mode} model=${modelId} dim=${dimensions} pool=${poolConfig.sessions}x${poolConfig.threadsPerSession} batch<=${schedulerOptions.batchMaxItems} max_tokens<=${modelMaxTokensUsed || tokenBudget.cap || "model"} token_budget=${tokenBudget.mode} reason=${tokenBudget.reason} text_profile=${textProfile}`
877
+ );
878
+ console.log(
879
+ `[embed] text_profile=${textProfile} compacted_files=${textProfileStats.compacted_files}/${textProfileStats.file_entities} saved_chars=${textProfileStats.saved_chars}`
667
880
  );
668
881
  console.log(
669
882
  `[embed] entities=${entities.length} embedded=${embedded} reused=${reused} failed=${failed}`
@@ -195,7 +195,7 @@ export function resolveModelMaxTokens(raw: unknown, overrideRaw?: unknown): numb
195
195
 
196
196
  export type TokenBudgetChoice = {
197
197
  cap: number | null;
198
- mode: "auto" | "explicit" | "model";
198
+ mode: "auto" | "auto_degraded" | "explicit" | "model";
199
199
  reason: string;
200
200
  };
201
201
 
@@ -225,6 +225,74 @@ export function resolveTokenBudgetChoice(overrideRaw: unknown, uniqueCount: numb
225
225
  };
226
226
  }
227
227
 
228
+ const AUTO_TOKEN_BUDGET_CANDIDATES = [8192, 4096, 2048];
229
+ const AUTO_TOKEN_BUDGET_MIN = 2048;
230
+ const AUTO_TOKEN_BUDGET_SESSION_BYTES = 2.4e9;
231
+ const AUTO_TOKEN_BUDGET_ACTIVATION_BYTES_AT_4096 = 3e9;
232
+ const AUTO_TOKEN_BUDGET_MEMORY_MARGIN = 0.95;
233
+
234
+ function estimateTokenBudgetMemoryBytes(maxTokens: number, sessions: number): number {
235
+ const sessionCount = Number.isFinite(sessions) && sessions >= 1 ? Math.floor(sessions) : 1;
236
+ const tokenCount = Number.isFinite(maxTokens) && maxTokens >= 1 ? Math.floor(maxTokens) : 8192;
237
+ const activationBytes =
238
+ AUTO_TOKEN_BUDGET_ACTIVATION_BYTES_AT_4096 * Math.pow(tokenCount / 4096, 2);
239
+ return sessionCount * AUTO_TOKEN_BUDGET_SESSION_BYTES + activationBytes;
240
+ }
241
+
242
+ /**
243
+ * Keeps auto quality-first but avoids choosing a token budget that is likely
244
+ * to OOM this process. Explicit numeric caps and explicit full-model modes are
245
+ * operator choices and are never degraded here.
246
+ */
247
+ export function resolveEffectiveTokenBudget({
248
+ choice,
249
+ modelMaxTokens,
250
+ memoryBytes,
251
+ sessions
252
+ }: {
253
+ choice: TokenBudgetChoice;
254
+ modelMaxTokens: number;
255
+ memoryBytes?: number;
256
+ sessions?: number;
257
+ }): TokenBudgetChoice {
258
+ if (choice.mode !== "auto" || choice.cap !== null) {
259
+ return choice;
260
+ }
261
+ const modelMax =
262
+ Number.isFinite(modelMaxTokens) && modelMaxTokens >= 1 ? Math.floor(modelMaxTokens) : 8192;
263
+ if (modelMax <= AUTO_TOKEN_BUDGET_MIN) {
264
+ return choice;
265
+ }
266
+ if (!Number.isFinite(memoryBytes) || (memoryBytes as number) <= 0) {
267
+ return choice;
268
+ }
269
+
270
+ const available = (memoryBytes as number) * AUTO_TOKEN_BUDGET_MEMORY_MARGIN;
271
+ const candidates = AUTO_TOKEN_BUDGET_CANDIDATES
272
+ .filter((candidate) => candidate <= modelMax)
273
+ .sort((a, b) => b - a);
274
+ if (!candidates.includes(AUTO_TOKEN_BUDGET_MIN)) {
275
+ candidates.push(AUTO_TOKEN_BUDGET_MIN);
276
+ }
277
+
278
+ const selected =
279
+ candidates.find((candidate) => estimateTokenBudgetMemoryBytes(candidate, sessions ?? 1) <= available) ??
280
+ AUTO_TOKEN_BUDGET_MIN;
281
+
282
+ if (selected >= modelMax) {
283
+ return choice;
284
+ }
285
+
286
+ return {
287
+ cap: selected,
288
+ mode: "auto_degraded",
289
+ reason: `memory_headroom cap=${selected} model_max=${modelMax} sessions=${Math.max(
290
+ 1,
291
+ Math.floor(sessions ?? 1)
292
+ )} headroom_mb=${Math.round((memoryBytes as number) / 1024 / 1024)}`
293
+ };
294
+ }
295
+
228
296
  /**
229
297
  * Builds a token counter from a tokenizer callable. Falls back to a
230
298
  * chars/4 estimate when the tokenizer is unavailable, misbehaves, or
@@ -21,7 +21,7 @@ import { pushWorkflowSnapshot, setWorkflowPushContext } from "./workflow/push.js
21
21
  import { hasWorkflowState, loadWorkflowState } from "./workflow/state.js";
22
22
 
23
23
  const require = createRequire(import.meta.url);
24
- const pkg = require("../package.json") as { version: string };
24
+ const pkg = require("../../package.json") as { version: string };
25
25
 
26
26
  export const name = "cortex-enterprise";
27
27
  export const version: string = pkg.version;
@@ -0,0 +1,34 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ function gitLines(projectRoot: string, args: string[]): string[] {
4
+ const output = execFileSync("git", args, {
5
+ cwd: projectRoot,
6
+ encoding: "utf8",
7
+ timeout: 5000,
8
+ stdio: ["ignore", "pipe", "ignore"],
9
+ });
10
+ return output.split("\n").map((value) => value.trim()).filter(Boolean);
11
+ }
12
+
13
+ function trackedChangedFiles(projectRoot: string): string[] {
14
+ try {
15
+ return gitLines(projectRoot, ["diff", "--name-only", "HEAD"]);
16
+ } catch {
17
+ // No HEAD yet (repo without commits): every tracked file is new.
18
+ return [
19
+ ...gitLines(projectRoot, ["diff", "--name-only"]),
20
+ ...gitLines(projectRoot, ["ls-files"]),
21
+ ];
22
+ }
23
+ }
24
+
25
+ export function resolveChangedReviewFiles(projectRoot: string): string[] | null {
26
+ try {
27
+ gitLines(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
28
+ const tracked = trackedChangedFiles(projectRoot);
29
+ const untracked = gitLines(projectRoot, ["ls-files", "--others", "--exclude-standard"]);
30
+ return [...new Set([...tracked, ...untracked])].sort();
31
+ } catch {
32
+ return null;
33
+ }
34
+ }