git-coco 0.42.0 → 0.44.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.
@@ -5,6 +5,8 @@ import yargs from 'yargs';
5
5
  import chalk from 'chalk';
6
6
  import * as fs from 'fs';
7
7
  import fs__default, { promises, existsSync, readFileSync, readdirSync } from 'fs';
8
+ import * as crypto from 'node:crypto';
9
+ import { createHash } from 'node:crypto';
8
10
  import * as ini from 'ini';
9
11
  import * as os from 'os';
10
12
  import os__default, { tmpdir } from 'os';
@@ -14,11 +16,12 @@ import Ajv from 'ajv';
14
16
  import ora from 'ora';
15
17
  import now from 'performance-now';
16
18
  import prettyMilliseconds from 'pretty-ms';
19
+ import * as fs$1 from 'node:fs';
20
+ import * as os$1 from 'node:os';
21
+ import * as path$1 from 'node:path';
17
22
  import { ChatAnthropic } from '@langchain/anthropic';
18
23
  import { ChatOllama } from '@langchain/ollama';
19
24
  import { ChatOpenAI } from '@langchain/openai';
20
- import * as fs$1 from 'node:fs';
21
- import * as path$1 from 'node:path';
22
25
  import { StructuredOutputParser, BaseOutputParser, StringOutputParser } from '@langchain/core/output_parsers';
23
26
  import { minimatch } from 'minimatch';
24
27
  import { simpleGit, GitError } from 'simple-git';
@@ -40,8 +43,6 @@ import '@langchain/core/utils/async_caller';
40
43
  import { encoding_for_model } from 'tiktoken';
41
44
  import { spawn, exec, execFile } from 'child_process';
42
45
  import { spawnSync } from 'node:child_process';
43
- import * as os$1 from 'node:os';
44
- import * as crypto from 'node:crypto';
45
46
  import * as readline from 'readline';
46
47
  import readline__default from 'readline';
47
48
  import { promisify } from 'util';
@@ -53,7 +54,7 @@ import { pathToFileURL } from 'url';
53
54
  /**
54
55
  * Current build version from package.json
55
56
  */
56
- const BUILD_VERSION = "0.42.0";
57
+ const BUILD_VERSION = "0.44.0";
57
58
 
58
59
  const isInteractive = (config) => {
59
60
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -229,6 +230,19 @@ const SUMMARIZE_PROMPT = new PromptTemplate({
229
230
  inputVariables: inputVariables$4,
230
231
  template: template$4,
231
232
  });
233
+ /**
234
+ * Stable fingerprint of the active summarization template (#845, PR 5).
235
+ *
236
+ * The diff-summary cache keys include this hash so any prompt edit
237
+ * invalidates prior cache entries automatically — no manual bumps,
238
+ * no stale outputs that no longer reflect the current prompt's voice
239
+ * or rules. Only the template body matters; whitespace differences
240
+ * still re-key the cache, which is the safe default.
241
+ */
242
+ const SUMMARIZE_PROMPT_HASH = createHash('sha256')
243
+ .update(template$4)
244
+ .digest('hex')
245
+ .slice(0, 16);
232
246
 
233
247
  /**
234
248
  * Base class for all LangChain-related errors
@@ -412,10 +426,21 @@ function getDefaultServiceApiKey(config) {
412
426
  }
413
427
  const DEFAULT_OPENAI_LLM_SERVICE = {
414
428
  provider: 'openai',
415
- model: 'gpt-4o-mini',
429
+ // Bumped from `gpt-4o-mini` to `gpt-4.1-nano` (#854). Diff
430
+ // condensing is bounded summarization — the cheaper / faster
431
+ // tier is the right default for it; quality is on par for this
432
+ // class of task. Users who want the older 4o-mini can still
433
+ // override via service config.
434
+ model: 'gpt-4.1-nano',
416
435
  tokenLimit: 4096,
417
436
  temperature: 0.32,
418
- maxConcurrent: 12,
437
+ // Bumped 12 → 24 (#845, PR 3). The OpenAI fast tier comfortably
438
+ // handles ~30 concurrent on the per-key default rate limit; 24
439
+ // leaves headroom for retries while still doubling throughput.
440
+ // The summarize chain has a 429-aware backoff (`summarize`
441
+ // helper) so a temporary rate-limit hit no longer kills the
442
+ // whole pipeline.
443
+ maxConcurrent: 24,
419
444
  minTokensForSummary: 800,
420
445
  maxFileTokens: 2000,
421
446
  authentication: {
@@ -427,10 +452,20 @@ const DEFAULT_OPENAI_LLM_SERVICE = {
427
452
  };
428
453
  const DEFAULT_ANTHROPIC_LLM_SERVICE = {
429
454
  provider: 'anthropic',
430
- model: 'claude-3-5-sonnet-20240620',
455
+ // Bumped from `claude-3-5-sonnet-20240620` to
456
+ // `claude-haiku-4-5-20251001` (#854). The Sonnet 3.5 default
457
+ // was nearly two model generations stale; Haiku 4.5 is the
458
+ // current fast tier and the right fit for diff summarization.
459
+ // Users who want Sonnet for quality-sensitive runs can still
460
+ // override via service config (recommended: `claude-sonnet-4-6`).
461
+ model: 'claude-haiku-4-5-20251001',
431
462
  temperature: 0.32,
432
463
  tokenLimit: 4096,
433
- maxConcurrent: 12,
464
+ // Bumped 12 → 24 (#845, PR 3). Matches the OpenAI default;
465
+ // Anthropic's per-key concurrency on Haiku is generous enough
466
+ // that 24 stays under the rate ceiling for typical fast-model
467
+ // request shapes. Backoff in `summarize` handles spikes.
468
+ maxConcurrent: 24,
434
469
  minTokensForSummary: 800,
435
470
  maxFileTokens: 2000,
436
471
  authentication: {
@@ -1414,6 +1449,10 @@ const schema$1 = {
1414
1449
  "AnthropicModel": {
1415
1450
  "type": "string",
1416
1451
  "enum": [
1452
+ "claude-sonnet-4-6",
1453
+ "claude-haiku-4-5-20251001",
1454
+ "claude-haiku-4-5",
1455
+ "claude-opus-4-7",
1417
1456
  "claude-sonnet-4-0",
1418
1457
  "claude-3-7-sonnet-latest",
1419
1458
  "claude-3-5-haiku-latest",
@@ -1947,6 +1986,44 @@ function parseServiceConfig(service) {
1947
1986
  }
1948
1987
  }
1949
1988
 
1989
+ /**
1990
+ * Ensure the canonical default ignore lists are always present in
1991
+ * the resolved config (#851). User-provided `ignoredFiles` /
1992
+ * `ignoredExtensions` arrays from XDG / git / project / env config
1993
+ * sources used to *replace* the defaults wholesale via the shallow
1994
+ * spread in each loader, which silently dropped lockfile + node_modules
1995
+ * entries the moment a user provided their own list. The reported
1996
+ * symptom: `pnpm-lock.yaml` reaching the diff-condensing pipeline
1997
+ * after a user added `.coco.config.json` for unrelated overrides.
1998
+ *
1999
+ * Now: user values are *unioned* with the defaults. Order is preserved
2000
+ * (defaults first, then user-only additions in their original order).
2001
+ * Duplicates are de-duped. The defaults can no longer be opted out of —
2002
+ * the cost of accidentally summarizing a lockfile (minutes of LLM time
2003
+ * per commit) outweighs the niche case of intentionally excluding a
2004
+ * default lockfile pattern.
2005
+ */
2006
+ function unionPreservingOrder(base, extras) {
2007
+ if (!extras || extras.length === 0)
2008
+ return [...base];
2009
+ const seen = new Set(base);
2010
+ const merged = [...base];
2011
+ for (const value of extras) {
2012
+ if (!seen.has(value)) {
2013
+ seen.add(value);
2014
+ merged.push(value);
2015
+ }
2016
+ }
2017
+ return merged;
2018
+ }
2019
+ function mergeIgnoreLists(config) {
2020
+ return {
2021
+ ...config,
2022
+ ignoredFiles: unionPreservingOrder(DEFAULT_IGNORED_FILES$1, config.ignoredFiles),
2023
+ ignoredExtensions: unionPreservingOrder(DEFAULT_IGNORED_EXTENSIONS$1, config.ignoredExtensions),
2024
+ };
2025
+ }
2026
+
1950
2027
  /**
1951
2028
  * Tracked config sources populated during the last loadConfig call.
1952
2029
  * Useful for diagnostics (e.g. `coco doctor`).
@@ -1997,6 +2074,13 @@ function loadConfig(argv = {}) {
1997
2074
  config = envConfig;
1998
2075
  if (envActive)
1999
2076
  sources.push({ source: 'env' });
2077
+ // Re-apply the canonical default ignore lists after every loader has
2078
+ // had a chance to override (#851). Each loader replaces ignoredFiles
2079
+ // / ignoredExtensions wholesale via shallow spread, which used to
2080
+ // silently drop the lockfile + node_modules defaults the moment a
2081
+ // user provided their own list. The merge is a union — defaults first,
2082
+ // user-only entries appended.
2083
+ config = mergeIgnoreLists(config);
2000
2084
  _lastConfigSources = sources;
2001
2085
  return { ...config, ...argv };
2002
2086
  }
@@ -2171,6 +2255,232 @@ function commandExecutor(handler) {
2171
2255
  };
2172
2256
  }
2173
2257
 
2258
+ const command$8 = 'cache <subcommand>';
2259
+ const builder$8 = (yargs) => {
2260
+ return yargs
2261
+ .positional('subcommand', {
2262
+ describe: 'Cache action to run (clear, info)',
2263
+ type: 'string',
2264
+ choices: ['clear', 'info'],
2265
+ })
2266
+ .usage(getCommandUsageHeader(command$8));
2267
+ };
2268
+
2269
+ /**
2270
+ * Per-repo disk cache of LLM-summarized diffs (#845, PR 5). On a
2271
+ * re-run of `coco commit` after a small change, most files have
2272
+ * unchanged content and unchanged diffs — caching their summaries
2273
+ * by content hash means the second run skips the LLM entirely for
2274
+ * those files and only pays for what's actually different.
2275
+ *
2276
+ * Strict best-effort: read failures fall back to "no cache" (the
2277
+ * pipeline runs the LLM as before), and write failures are
2278
+ * swallowed silently. The cache is never load-bearing.
2279
+ *
2280
+ * Repos are keyed by a short hash of their absolute path. No PII
2281
+ * in the cache filename, and re-creating a repo at the same path
2282
+ * keeps the same cache.
2283
+ *
2284
+ * Cache key: `sha256(diff + ':' + model + ':' + promptHash)`.
2285
+ * - diff: the literal diff text being summarized
2286
+ * - model: switching models invalidates (different summaries)
2287
+ * - promptHash: editing the SUMMARIZE_PROMPT template invalidates
2288
+ *
2289
+ * Cap: 500 entries per repo. LRU eviction on overflow keeps the
2290
+ * cache file under ~500 KB on a typical repo (each entry is a
2291
+ * sha256 hash + 200-500-byte summary).
2292
+ */
2293
+ const CACHE_SCHEMA_VERSION$1 = 1;
2294
+ const CACHE_DIR_NAME$1 = 'diff-summaries';
2295
+ const CACHE_ENTRY_HARD_CAP = 500;
2296
+ function resolveCacheDir$4() {
2297
+ const xdg = process.env.XDG_CACHE_HOME;
2298
+ if (xdg && xdg.trim().length > 0) {
2299
+ return path$1.join(xdg, 'coco', CACHE_DIR_NAME$1);
2300
+ }
2301
+ return path$1.join(os$1.homedir(), '.cache', 'coco', CACHE_DIR_NAME$1);
2302
+ }
2303
+ function repoKey$3(repoPath) {
2304
+ // sha256 here is a non-security cache-key derivation — deterministic
2305
+ // short identifier for the cache filename so two repos at different
2306
+ // paths never collide. We truncate to 16 chars; collision-resistance
2307
+ // against an adversary is not required.
2308
+ return crypto.createHash('sha256').update(repoPath).digest('hex').slice(0, 16);
2309
+ }
2310
+ function getDiffSummaryCachePath(repoPath) {
2311
+ return path$1.join(resolveCacheDir$4(), `summaries.${repoKey$3(repoPath)}.json`);
2312
+ }
2313
+ /**
2314
+ * Build the cache key for a (diff, model, prompt) tuple. sha256
2315
+ * because we want a strong content-hash; the per-entry storage cost
2316
+ * is dominated by the summary text anyway.
2317
+ */
2318
+ function diffSummaryKey(diff, model, promptHash) {
2319
+ return crypto
2320
+ .createHash('sha256')
2321
+ .update(`${diff}\x1f${model}\x1f${promptHash}`)
2322
+ .digest('hex');
2323
+ }
2324
+ function readEnvelope(filePath) {
2325
+ try {
2326
+ const raw = fs$1.readFileSync(filePath, 'utf8');
2327
+ const parsed = JSON.parse(raw);
2328
+ if (parsed.version !== CACHE_SCHEMA_VERSION$1)
2329
+ return undefined;
2330
+ if (!parsed.entries || typeof parsed.entries !== 'object')
2331
+ return undefined;
2332
+ return parsed;
2333
+ }
2334
+ catch {
2335
+ return undefined;
2336
+ }
2337
+ }
2338
+ function readDiffSummary(repoPath, key) {
2339
+ const envelope = readEnvelope(getDiffSummaryCachePath(repoPath));
2340
+ if (!envelope)
2341
+ return undefined;
2342
+ const entry = envelope.entries[key];
2343
+ if (!entry)
2344
+ return undefined;
2345
+ return entry;
2346
+ }
2347
+ function writeDiffSummary(repoPath, key, entry) {
2348
+ const filePath = getDiffSummaryCachePath(repoPath);
2349
+ const existing = readEnvelope(filePath) || {
2350
+ version: CACHE_SCHEMA_VERSION$1,
2351
+ savedAt: new Date().toISOString(),
2352
+ entries: {},
2353
+ };
2354
+ existing.entries[key] = { ...entry, lastAccessedAt: new Date().toISOString() };
2355
+ existing.savedAt = new Date().toISOString();
2356
+ const evictedEntries = enforceHardCap(existing.entries);
2357
+ if (evictedEntries.length > 0) {
2358
+ for (const evicted of evictedEntries) {
2359
+ delete existing.entries[evicted];
2360
+ }
2361
+ }
2362
+ try {
2363
+ fs$1.mkdirSync(path$1.dirname(filePath), { recursive: true });
2364
+ fs$1.writeFileSync(filePath, JSON.stringify(existing));
2365
+ }
2366
+ catch {
2367
+ // Best-effort persistence; swallow.
2368
+ }
2369
+ }
2370
+ /**
2371
+ * Touch an existing entry's lastAccessedAt so LRU eviction prefers
2372
+ * dropping older / unused entries. Caller is expected to know the
2373
+ * entry exists (read returned a hit).
2374
+ */
2375
+ function touchDiffSummary(repoPath, key) {
2376
+ const filePath = getDiffSummaryCachePath(repoPath);
2377
+ const envelope = readEnvelope(filePath);
2378
+ if (!envelope || !envelope.entries[key])
2379
+ return;
2380
+ envelope.entries[key] = {
2381
+ ...envelope.entries[key],
2382
+ lastAccessedAt: new Date().toISOString(),
2383
+ };
2384
+ envelope.savedAt = new Date().toISOString();
2385
+ try {
2386
+ fs$1.writeFileSync(filePath, JSON.stringify(envelope));
2387
+ }
2388
+ catch {
2389
+ // Swallow.
2390
+ }
2391
+ }
2392
+ function enforceHardCap(entries) {
2393
+ const keys = Object.keys(entries);
2394
+ if (keys.length <= CACHE_ENTRY_HARD_CAP)
2395
+ return [];
2396
+ // Sort by lastAccessedAt ascending (oldest first), drop the
2397
+ // oldest (keys.length - CACHE_ENTRY_HARD_CAP) entries.
2398
+ const sorted = keys
2399
+ .map((key) => ({ key, accessed: Date.parse(entries[key].lastAccessedAt) || 0 }))
2400
+ .sort((a, b) => a.accessed - b.accessed);
2401
+ const toEvict = sorted.slice(0, keys.length - CACHE_ENTRY_HARD_CAP).map((entry) => entry.key);
2402
+ return toEvict;
2403
+ }
2404
+ /** Remove the entire cache file for the repo. Used by `coco cache:clear`. */
2405
+ function clearDiffSummaryCache(repoPath) {
2406
+ const filePath = getDiffSummaryCachePath(repoPath);
2407
+ if (!fs$1.existsSync(filePath)) {
2408
+ return { ok: true, removed: false };
2409
+ }
2410
+ try {
2411
+ fs$1.unlinkSync(filePath);
2412
+ return { ok: true, removed: true };
2413
+ }
2414
+ catch {
2415
+ return { ok: false, removed: false };
2416
+ }
2417
+ }
2418
+
2419
+ function readEnvelopeOrUndefined(filePath) {
2420
+ try {
2421
+ if (!fs$1.existsSync(filePath))
2422
+ return undefined;
2423
+ const raw = fs$1.readFileSync(filePath, 'utf8');
2424
+ return JSON.parse(raw);
2425
+ }
2426
+ catch {
2427
+ return undefined;
2428
+ }
2429
+ }
2430
+ function formatBytes(bytes) {
2431
+ if (bytes < 1024)
2432
+ return `${bytes} B`;
2433
+ if (bytes < 1024 * 1024)
2434
+ return `${(bytes / 1024).toFixed(1)} KB`;
2435
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
2436
+ }
2437
+ const handler$8 = async (argv, logger) => {
2438
+ const subcommand = argv.subcommand;
2439
+ const repoPath = process.cwd();
2440
+ const cachePath = getDiffSummaryCachePath(repoPath);
2441
+ if (subcommand === 'clear') {
2442
+ const result = clearDiffSummaryCache(repoPath);
2443
+ if (!result.ok) {
2444
+ logger.log(chalk.red(`Failed to clear diff-summary cache at ${cachePath}`));
2445
+ process.exitCode = 1;
2446
+ return;
2447
+ }
2448
+ if (result.removed) {
2449
+ logger.log(chalk.green(`Cleared diff-summary cache at ${cachePath}`));
2450
+ }
2451
+ else {
2452
+ logger.log(chalk.dim(`No diff-summary cache to clear (${cachePath})`));
2453
+ }
2454
+ return;
2455
+ }
2456
+ if (subcommand === 'info') {
2457
+ const envelope = readEnvelopeOrUndefined(cachePath);
2458
+ if (!envelope) {
2459
+ logger.log(chalk.dim(`No diff-summary cache for this repo (${cachePath})`));
2460
+ return;
2461
+ }
2462
+ const stat = fs$1.statSync(cachePath);
2463
+ const entryCount = Object.keys(envelope.entries).length;
2464
+ const totalSummaryTokens = Object.values(envelope.entries).reduce((sum, entry) => sum + entry.tokens, 0);
2465
+ logger.log(chalk.bold('Diff-summary cache') + ` ${chalk.dim(cachePath)}`);
2466
+ logger.log(` ${chalk.green('entries')} ${entryCount}`);
2467
+ logger.log(` ${chalk.green('on-disk size')} ${formatBytes(stat.size)}`);
2468
+ logger.log(` ${chalk.green('summary tokens')} ${totalSummaryTokens}`);
2469
+ logger.log(` ${chalk.green('last saved')} ${envelope.savedAt}`);
2470
+ return;
2471
+ }
2472
+ logger.log(chalk.red(`Unknown cache subcommand: ${subcommand}`));
2473
+ logger.log(chalk.dim('Use one of: clear, info'));
2474
+ process.exitCode = 1;
2475
+ };
2476
+
2477
+ var cache = {
2478
+ command: command$8,
2479
+ desc: 'Manage the diff-summary cache (clear, info)',
2480
+ builder: builder$8,
2481
+ handler: commandExecutor(handler$8),
2482
+ };
2483
+
2174
2484
  var util;
2175
2485
  (function (util) {
2176
2486
  util.assertEqual = (_) => { };
@@ -7505,6 +7815,56 @@ function getPathFromFilePath(filePath) {
7505
7815
  return filePath.split('/').slice(0, -1).join('/');
7506
7816
  }
7507
7817
 
7818
+ /**
7819
+ * Adaptive backoff (#845, PR 3). Wraps the chain invocation so a
7820
+ * transient 429 (rate limit) or 5xx no longer kills the whole
7821
+ * pipeline — instead we wait briefly and retry up to N times
7822
+ * before surfacing the failure.
7823
+ *
7824
+ * Cap is intentionally short. Diff condensing fans out to many
7825
+ * concurrent calls; if rate limits hit hard, queueing requests
7826
+ * indefinitely just makes the user wait longer for a result the
7827
+ * pipeline ultimately handles via fewer concurrent passes anyway.
7828
+ * 3 retries with 1s/2s/4s waits trade ~7s of worst-case extra
7829
+ * latency for resilience to brief rate-limit blips.
7830
+ */
7831
+ const BACKOFF_RETRIES = 3;
7832
+ const BACKOFF_BASE_MS = 1000;
7833
+ const BACKOFF_CAP_MS = 5000;
7834
+ function isRetryableError(error) {
7835
+ if (!error || typeof error !== 'object')
7836
+ return false;
7837
+ const err = error;
7838
+ if (err.status === 429 || err.status === 503 || err.status === 502 || err.status === 504) {
7839
+ return true;
7840
+ }
7841
+ if (err.code === 429 || err.code === 'rate_limit_exceeded' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
7842
+ return true;
7843
+ }
7844
+ if (typeof err.message === 'string' && /(rate.?limit|429|too many requests|timeout|temporarily unavailable)/i.test(err.message)) {
7845
+ return true;
7846
+ }
7847
+ return false;
7848
+ }
7849
+ async function invokeWithBackoff(chain, input, logger) {
7850
+ let lastError;
7851
+ for (let attempt = 0; attempt <= BACKOFF_RETRIES; attempt++) {
7852
+ try {
7853
+ return await chain.invoke(input);
7854
+ }
7855
+ catch (error) {
7856
+ lastError = error;
7857
+ if (!isRetryableError(error) || attempt === BACKOFF_RETRIES) {
7858
+ throw error;
7859
+ }
7860
+ const wait = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * Math.pow(2, attempt));
7861
+ logger?.verbose(`[summarize] retryable error (attempt ${attempt + 1}/${BACKOFF_RETRIES}); backing off ${wait}ms`, { color: 'yellow' });
7862
+ await new Promise((resolve) => setTimeout(resolve, wait));
7863
+ }
7864
+ }
7865
+ // Unreachable — the loop either returns or rethrows above.
7866
+ throw lastError;
7867
+ }
7508
7868
  async function summarize(documents, { chain, textSplitter, options, logger, tokenizer, metadata }) {
7509
7869
  const { returnIntermediateSteps = false } = options || {};
7510
7870
  const docs = await textSplitter.splitDocuments(documents.map((doc) => new Document(doc)));
@@ -7512,10 +7872,10 @@ async function summarize(documents, { chain, textSplitter, options, logger, toke
7512
7872
  ? docs.reduce((sum, doc) => sum + tokenizer(doc.pageContent), 0)
7513
7873
  : undefined;
7514
7874
  const startedAt = Date.now();
7515
- const res = await chain.invoke({
7875
+ const res = await invokeWithBackoff(chain, {
7516
7876
  input_documents: docs,
7517
7877
  returnIntermediateSteps,
7518
- });
7878
+ }, logger);
7519
7879
  const elapsedMs = Date.now() - startedAt;
7520
7880
  logLlmCall(logger, {
7521
7881
  task: 'summarize',
@@ -7530,10 +7890,175 @@ async function summarize(documents, { chain, textSplitter, options, logger, toke
7530
7890
  return res.text && res.text.trim();
7531
7891
  }
7532
7892
 
7893
+ /**
7894
+ * Inspect a unified-diff string and report its shape, or undefined
7895
+ * if the diff isn't trivial (mixed +/- lines, weird headers, etc.).
7896
+ *
7897
+ * Detection rules (cheap on purpose — we're called per-file and the
7898
+ * goal is to skip work, not be exhaustive):
7899
+ *
7900
+ * - `Binary files ... differ` header → 'binary'
7901
+ * - `rename from`/`rename to` headers and no `+`/`-` content
7902
+ * lines → 'rename'
7903
+ * - All content lines are `+` (and at least one is) → 'addition'
7904
+ * - All content lines are `-` (and at least one is) → 'deletion'
7905
+ * - Otherwise → undefined (let the LLM handle it)
7906
+ */
7907
+ function detectTrivialDiffShape(diff) {
7908
+ if (!diff)
7909
+ return undefined;
7910
+ // Binary marker is unambiguous and short-circuits early.
7911
+ if (/^Binary files .+ and .+ differ$/m.test(diff)) {
7912
+ return 'binary';
7913
+ }
7914
+ // Pure rename: git emits `rename from` / `rename to` and no body.
7915
+ // We require BOTH markers AND no `+`/`-` content lines. Some
7916
+ // renames-with-edit show rename headers AND a hunk; those should
7917
+ // fall through to the LLM path.
7918
+ const hasRenameFrom = /^rename from /m.test(diff);
7919
+ const hasRenameTo = /^rename to /m.test(diff);
7920
+ if (hasRenameFrom && hasRenameTo) {
7921
+ const hasContentChange = diff
7922
+ .split('\n')
7923
+ .some((line) => isContentChangeLine(line));
7924
+ if (!hasContentChange) {
7925
+ return 'rename';
7926
+ }
7927
+ }
7928
+ // Walk the body once classifying content lines. We skip header
7929
+ // lines (diff --git, index, ---, +++, @@, etc.) and only inspect
7930
+ // the lines that represent actual change content.
7931
+ let plus = 0;
7932
+ let minus = 0;
7933
+ for (const line of diff.split('\n')) {
7934
+ if (isHeaderLine(line))
7935
+ continue;
7936
+ if (line.startsWith('+'))
7937
+ plus++;
7938
+ else if (line.startsWith('-'))
7939
+ minus++;
7940
+ // Context lines (' ' prefix) are ignored for shape classification:
7941
+ // a pure addition can still have surrounding context if a hunk
7942
+ // anchors at line 0, though `git diff` for a brand-new file
7943
+ // typically has none.
7944
+ }
7945
+ if (plus > 0 && minus === 0)
7946
+ return 'addition';
7947
+ if (minus > 0 && plus === 0)
7948
+ return 'deletion';
7949
+ return undefined;
7950
+ }
7951
+ /**
7952
+ * Build a deterministic summary string for a trivial diff. Returns
7953
+ * undefined when the shape can't be templated (caller should fall
7954
+ * back to the LLM path).
7955
+ */
7956
+ function summarizeTrivialDiff(fileDiff) {
7957
+ const shape = detectTrivialDiffShape(fileDiff.diff);
7958
+ if (!shape)
7959
+ return undefined;
7960
+ const lineCount = countContentLines(fileDiff.diff, shape);
7961
+ switch (shape) {
7962
+ case 'addition':
7963
+ return `Added \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
7964
+ case 'deletion':
7965
+ return `Removed \`${fileDiff.file}\` (${lineCount} line${lineCount === 1 ? '' : 's'}).`;
7966
+ case 'rename': {
7967
+ const oldPath = extractRenameOldPath(fileDiff.diff);
7968
+ return oldPath
7969
+ ? `Renamed \`${oldPath}\` → \`${fileDiff.file}\`.`
7970
+ : `Renamed file to \`${fileDiff.file}\`.`;
7971
+ }
7972
+ case 'binary':
7973
+ return `Updated binary file \`${fileDiff.file}\`.`;
7974
+ }
7975
+ }
7976
+ function isHeaderLine(line) {
7977
+ return (line.startsWith('diff --git') ||
7978
+ line.startsWith('index ') ||
7979
+ line.startsWith('--- ') ||
7980
+ line.startsWith('+++ ') ||
7981
+ line.startsWith('@@') ||
7982
+ line.startsWith('new file mode') ||
7983
+ line.startsWith('deleted file mode') ||
7984
+ line.startsWith('similarity index') ||
7985
+ line.startsWith('rename from ') ||
7986
+ line.startsWith('rename to ') ||
7987
+ line.startsWith('Binary files '));
7988
+ }
7989
+ function isContentChangeLine(line) {
7990
+ if (isHeaderLine(line))
7991
+ return false;
7992
+ return line.startsWith('+') || line.startsWith('-');
7993
+ }
7994
+ function countContentLines(diff, shape) {
7995
+ if (shape === 'binary' || shape === 'rename')
7996
+ return 0;
7997
+ const prefix = shape === 'addition' ? '+' : '-';
7998
+ let count = 0;
7999
+ for (const line of diff.split('\n')) {
8000
+ if (isHeaderLine(line))
8001
+ continue;
8002
+ if (line.startsWith(prefix))
8003
+ count++;
8004
+ }
8005
+ return count;
8006
+ }
8007
+ function extractRenameOldPath(diff) {
8008
+ const match = diff.match(/^rename from (.+)$/m);
8009
+ return match ? match[1].trim() : undefined;
8010
+ }
8011
+
8012
+ /**
8013
+ * Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
8014
+ * for the diff-summary cache (#845, PR 5). Default is enabled.
8015
+ */
8016
+ function isCacheEnabled$1() {
8017
+ return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
8018
+ }
7533
8019
  /**
7534
8020
  * Summarize a single file diff that exceeds the token threshold.
8021
+ *
8022
+ * Trivial-shape short-circuit (#845, PR 2): pure additions / deletions
8023
+ * / renames / binary changes have no information content beyond the
8024
+ * diff's shape, so we templated-summarize them instead of paying for
8025
+ * an LLM call. On initial-commit fixtures (lots of pure adds) this
8026
+ * collapses the per-file summary phase entirely; the resulting tiny
8027
+ * synthetic summaries usually drop the directory token totals under
8028
+ * budget so wave consolidation skips too.
7535
8029
  */
7536
8030
  async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, logger, metadata, }) {
8031
+ const trivialSummary = summarizeTrivialDiff(fileDiff);
8032
+ if (trivialSummary !== undefined) {
8033
+ logger.verbose(` - ${fileDiff.file}: trivial-shape skip (no LLM call)`, { color: 'gray' });
8034
+ return {
8035
+ ...fileDiff,
8036
+ diff: trivialSummary,
8037
+ tokenCount: tokenizer(trivialSummary),
8038
+ };
8039
+ }
8040
+ // Cache lookup (#845, PR 5). Keyed on the file's literal diff
8041
+ // content + the active model + the summarization prompt hash.
8042
+ // A hit returns the prior summary instantly; on iterative
8043
+ // `coco commit` re-runs after small edits, the unchanged files
8044
+ // never go to the LLM.
8045
+ const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
8046
+ const cacheRepo = process.cwd();
8047
+ const cacheKey = isCacheEnabled$1() && cacheModel
8048
+ ? diffSummaryKey(fileDiff.diff, cacheModel, SUMMARIZE_PROMPT_HASH)
8049
+ : undefined;
8050
+ if (cacheKey) {
8051
+ const cached = readDiffSummary(cacheRepo, cacheKey);
8052
+ if (cached) {
8053
+ logger.verbose(` - ${fileDiff.file}: cache hit (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
8054
+ touchDiffSummary(cacheRepo, cacheKey);
8055
+ return {
8056
+ ...fileDiff,
8057
+ diff: cached.summary,
8058
+ tokenCount: cached.tokens,
8059
+ };
8060
+ }
8061
+ }
7537
8062
  try {
7538
8063
  const fileSummary = await summarize([
7539
8064
  {
@@ -7557,6 +8082,13 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
7557
8082
  },
7558
8083
  });
7559
8084
  const newTokenCount = tokenizer(fileSummary);
8085
+ if (cacheKey && cacheModel) {
8086
+ writeDiffSummary(cacheRepo, cacheKey, {
8087
+ summary: fileSummary,
8088
+ model: cacheModel,
8089
+ tokens: newTokenCount,
8090
+ });
8091
+ }
7560
8092
  return {
7561
8093
  ...fileDiff,
7562
8094
  diff: fileSummary,
@@ -7570,16 +8102,41 @@ async function summarizeFileDiff(fileDiff, { chain, textSplitter, tokenizer, log
7570
8102
  }
7571
8103
  }
7572
8104
  /**
7573
- * Process files in waves to respect concurrency limits.
8105
+ * Continuous-queue scheduler (#845, PR 4). Mirrors the directory-
8106
+ * level scheduler in `summarizeDiffs.ts` and replaces the previous
8107
+ * fixed-wave Promise.all loop, which made the slowest call in
8108
+ * each wave block the next wave from starting. With realistic LLM
8109
+ * tail variance, that wave-locking adds dead time at every wave
8110
+ * boundary; continuous queue fills slots as in-flight calls
8111
+ * resolve, so the wall-clock tracks the slowest *call*, not the
8112
+ * sum of slowest-per-wave.
7574
8113
  */
7575
8114
  async function processInWaves$1(items, processor, maxConcurrent) {
7576
- const results = [];
7577
- for (let i = 0; i < items.length; i += maxConcurrent) {
7578
- const wave = items.slice(i, i + maxConcurrent);
7579
- const waveResults = await Promise.all(wave.map(processor));
7580
- results.push(...waveResults);
7581
- }
7582
- return results;
8115
+ const limit = createLimit$2(maxConcurrent);
8116
+ return Promise.all(items.map((item) => limit(() => processor(item))));
8117
+ }
8118
+ function createLimit$2(maxConcurrent) {
8119
+ const limit = Math.max(1, maxConcurrent);
8120
+ let active = 0;
8121
+ const queue = [];
8122
+ const runNext = () => {
8123
+ active--;
8124
+ const next = queue.shift();
8125
+ if (next)
8126
+ next();
8127
+ };
8128
+ return async (operation) => {
8129
+ if (active >= limit) {
8130
+ await new Promise((resolve) => queue.push(resolve));
8131
+ }
8132
+ active++;
8133
+ try {
8134
+ return await operation();
8135
+ }
8136
+ finally {
8137
+ runNext();
8138
+ }
8139
+ };
7583
8140
  }
7584
8141
  /**
7585
8142
  * Pre-summarize individual files that exceed the maxFileTokens threshold.
@@ -7644,6 +8201,13 @@ async function preprocessLargeFiles(rootNode, options) {
7644
8201
  return rebuildNode(rootNode);
7645
8202
  }
7646
8203
 
8204
+ /**
8205
+ * Cache opt-out: COCO_NO_CACHE=1 disables both reads and writes
8206
+ * for the diff-summary cache (#845, PR 5). Default is enabled.
8207
+ */
8208
+ function isCacheEnabled() {
8209
+ return !process.env.COCO_NO_CACHE || process.env.COCO_NO_CACHE === '0';
8210
+ }
7647
8211
  /**
7648
8212
  * Create groups from a given node info.
7649
8213
  * @param {DiffNode} node - The node info to start grouping.
@@ -7669,6 +8233,32 @@ function createDirectoryDiffs(node) {
7669
8233
  * Summarize a directory diff asynchronously.
7670
8234
  */
7671
8235
  async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenizer, logger, metadata }) {
8236
+ // Cache lookup (#845, PR 5). Joined per-file diffs become the
8237
+ // payload signature; if every file in the directory is unchanged
8238
+ // since the last run (and the model + prompt match), the prior
8239
+ // directory-level summary is reused instead of paying for another
8240
+ // map_reduce pass.
8241
+ const cacheModel = typeof metadata?.model === 'string' ? metadata.model : undefined;
8242
+ const cacheRepo = process.cwd();
8243
+ const cachePayload = directory.diffs
8244
+ .map((diff) => `${diff.file}\x1e${diff.diff}`)
8245
+ .join('\x1d');
8246
+ const cacheKey = isCacheEnabled() && cacheModel
8247
+ ? diffSummaryKey(cachePayload, cacheModel, SUMMARIZE_PROMPT_HASH)
8248
+ : undefined;
8249
+ if (cacheKey) {
8250
+ const cached = readDiffSummary(cacheRepo, cacheKey);
8251
+ if (cached) {
8252
+ logger?.verbose?.(` • Cache hit for "/${directory.path}" (skipped LLM, ${cached.tokens} tokens)`, { color: 'cyan' });
8253
+ touchDiffSummary(cacheRepo, cacheKey);
8254
+ return {
8255
+ diffs: directory.diffs,
8256
+ path: directory.path,
8257
+ summary: cached.summary,
8258
+ tokenCount: cached.tokens,
8259
+ };
8260
+ }
8261
+ }
7672
8262
  try {
7673
8263
  const directorySummary = await summarize(directory.diffs.map((diff) => ({
7674
8264
  pageContent: diff.diff,
@@ -7690,6 +8280,13 @@ async function summarizeDirectoryDiff(directory, { chain, textSplitter, tokenize
7690
8280
  },
7691
8281
  });
7692
8282
  const newTokenTotal = tokenizer(directorySummary);
8283
+ if (cacheKey && cacheModel) {
8284
+ writeDiffSummary(cacheRepo, cacheKey, {
8285
+ summary: directorySummary,
8286
+ model: cacheModel,
8287
+ tokens: newTokenTotal,
8288
+ });
8289
+ }
7693
8290
  return {
7694
8291
  diffs: directory.diffs,
7695
8292
  path: directory.path,
@@ -7724,66 +8321,99 @@ const defaultOutputCallback = (group) => {
7724
8321
  return output;
7725
8322
  };
7726
8323
  /**
7727
- * Process directory summarization in waves to respect concurrency limits
7728
- * while maintaining predictable behavior.
8324
+ * Continuous-queue scheduler for the directory summarization pass
8325
+ * (#845, PR 4). The previous wave-by-wave Promise.all forced the
8326
+ * scheduler to wait for the slowest call in a wave before starting
8327
+ * the next wave; on a fixture like `refactor` (20 directories, mixed
8328
+ * sizes) one big directory could pin the wave at ~its own latency
8329
+ * even though the other 19 calls finished long before.
8330
+ *
8331
+ * The continuous queue dispatches all eligible directories through
8332
+ * a `createLimit(maxConcurrent)` semaphore — same primitive
8333
+ * `collectDiffs` already uses. As soon as any in-flight summary
8334
+ * resolves, the next eligible directory takes its slot. Each
8335
+ * scheduled call also re-checks the budget at the moment it would
8336
+ * fire; if the budget is already met (because earlier completions
8337
+ * dropped the total under maxTokens), it returns the original
8338
+ * directory without an LLM call. So the work scales with what's
8339
+ * actually needed, not with the worst-case wave count.
8340
+ *
8341
+ * Order discipline is preserved: directories are sorted by token
8342
+ * count descending and dispatched in that order. The biggest
8343
+ * candidates land in the first batch of in-flight calls; as smaller
8344
+ * candidates reach the queue front, the budget is more likely to
8345
+ * already be met and they short-circuit.
7729
8346
  */
7730
8347
  async function summarizeInWaves(directories, options) {
7731
8348
  const { totalTokenCount: initialTotal, maxTokens, minTokensForSummary, maxConcurrent, logger, chain, textSplitter, tokenizer, metadata, } = options;
7732
8349
  let totalTokenCount = initialTotal;
7733
8350
  const results = [...directories];
7734
- // Create sorted indices by token count (descending) for prioritized processing
7735
- const sortedIndices = directories
8351
+ // Pick eligible directories upfront, sorted big-first.
8352
+ const eligibleIndices = directories
7736
8353
  .map((d, i) => ({ index: i, tokens: d.tokenCount }))
7737
- .sort((a, b) => b.tokens - a.tokens);
7738
- let cursor = 0;
7739
- while (totalTokenCount > maxTokens && cursor < sortedIndices.length) {
7740
- // Select wave candidates: directories that exceed minTokensForSummary
7741
- const wave = [];
7742
- for (let i = cursor; i < sortedIndices.length && wave.length < maxConcurrent; i++) {
7743
- const { index, tokens } = sortedIndices[i];
7744
- // Skip directories below the minimum threshold
7745
- if (tokens < minTokensForSummary) {
7746
- cursor = i + 1;
7747
- continue;
7748
- }
7749
- // Skip directories that have already been summarized
7750
- if (results[index].summary) {
7751
- cursor = i + 1;
7752
- continue;
7753
- }
7754
- wave.push(index);
7755
- cursor = i + 1;
7756
- }
7757
- // No more eligible candidates
7758
- if (wave.length === 0) {
7759
- break;
8354
+ .filter((entry) => entry.tokens >= minTokensForSummary && !results[entry.index].summary)
8355
+ .sort((a, b) => b.tokens - a.tokens)
8356
+ .map((entry) => entry.index);
8357
+ if (eligibleIndices.length === 0 || totalTokenCount <= maxTokens) {
8358
+ return { directories: results, totalTokenCount };
8359
+ }
8360
+ const limit = createLimit$1(maxConcurrent);
8361
+ logger.verbose(`\nProcessing ${eligibleIndices.length} directories with continuous queue (concurrency ${maxConcurrent})...`, { color: 'blue' });
8362
+ await Promise.all(eligibleIndices.map((idx) => limit(async () => {
8363
+ // Re-check the budget at dispatch time. Earlier completions
8364
+ // may have already dropped the total under the cap; in that
8365
+ // case skip the LLM call entirely.
8366
+ if (totalTokenCount <= maxTokens) {
8367
+ return;
7760
8368
  }
7761
- logger.verbose(`\nProcessing wave of ${wave.length} directories...`, { color: 'blue' });
7762
- // Process wave in parallel
7763
- const waveResults = await Promise.all(wave.map((idx) => summarizeDirectoryDiff(results[idx], { chain, textSplitter, tokenizer, logger, metadata })));
7764
- // Update results and recalculate total
7765
- waveResults.forEach((result, i) => {
7766
- const idx = wave[i];
7767
- const originalTokens = results[idx].tokenCount;
7768
- const newTokens = result.tokenCount;
7769
- const reduction = originalTokens - newTokens;
7770
- totalTokenCount -= reduction;
7771
- results[idx] = result;
7772
- logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
7773
- color: 'magenta',
7774
- });
8369
+ const result = await summarizeDirectoryDiff(results[idx], {
8370
+ chain,
8371
+ textSplitter,
8372
+ tokenizer,
8373
+ logger,
8374
+ metadata,
7775
8375
  });
7776
- logger.verbose(`Total token count: ${totalTokenCount}`, {
7777
- color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8376
+ const originalTokens = results[idx].tokenCount;
8377
+ const newTokens = result.tokenCount;
8378
+ totalTokenCount -= (originalTokens - newTokens);
8379
+ results[idx] = result;
8380
+ logger.verbose(` • Summarized "/${result.path}": ${originalTokens} -> ${newTokens} tokens`, {
8381
+ color: 'magenta',
7778
8382
  });
7779
- // Check if we're now under budget
7780
- if (totalTokenCount <= maxTokens) {
7781
- logger.verbose(`Under token budget, stopping summarization.`, { color: 'green' });
7782
- break;
7783
- }
7784
- }
8383
+ })));
8384
+ logger.verbose(`Total token count after continuous queue: ${totalTokenCount}`, {
8385
+ color: totalTokenCount > maxTokens ? 'yellow' : 'green',
8386
+ });
7785
8387
  return { directories: results, totalTokenCount };
7786
8388
  }
8389
+ /**
8390
+ * Tiny semaphore mirroring `collectDiffs.createLimit` (kept private
8391
+ * here to avoid a cross-module import for one helper). Schedules at
8392
+ * most `maxConcurrent` operations concurrently; the rest queue FIFO.
8393
+ */
8394
+ function createLimit$1(maxConcurrent) {
8395
+ const limit = Math.max(1, maxConcurrent);
8396
+ let active = 0;
8397
+ const queue = [];
8398
+ const runNext = () => {
8399
+ active--;
8400
+ const next = queue.shift();
8401
+ if (next)
8402
+ next();
8403
+ };
8404
+ return async (operation) => {
8405
+ if (active >= limit) {
8406
+ await new Promise((resolve) => queue.push(resolve));
8407
+ }
8408
+ active++;
8409
+ try {
8410
+ return await operation();
8411
+ }
8412
+ finally {
8413
+ runNext();
8414
+ }
8415
+ };
8416
+ }
7787
8417
  /**
7788
8418
  * Summarize diffs using a three-phase approach:
7789
8419
  *
@@ -7797,7 +8427,16 @@ async function summarizeInWaves(directories, options) {
7797
8427
  * - Efficient parallel processing with predictable behavior
7798
8428
  * - Early exit when under token budget
7799
8429
  */
7800
- async function summarizeDiffs(rootDiffNode, { tokenizer, logger, maxTokens = 2048, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
8430
+ async function summarizeDiffs(rootDiffNode, { tokenizer, logger,
8431
+ // Default raised to 4096 (#845) so the budget matches the
8432
+ // canonical service configs in `langchain/utils.ts`. The
8433
+ // previous 2048 default came from an earlier era when 4k
8434
+ // context was a stretch for fast models; today every shipped
8435
+ // service overrides it to 4096 anyway. Keeping this in sync
8436
+ // with the service defaults means a caller that omits
8437
+ // `maxTokens` doesn't accidentally fall into a tighter budget
8438
+ // than the rest of the system assumes.
8439
+ maxTokens = 4096, minTokensForSummary = 400, maxFileTokens, maxConcurrent = 6, textSplitter, chain, metadata, handleOutput = defaultOutputCallback, }) {
7801
8440
  // Calculate maxFileTokens as 25% of maxTokens if not specified
7802
8441
  const effectiveMaxFileTokens = maxFileTokens ?? Math.floor(maxTokens * 0.25);
7803
8442
  // PHASE 1: Directory grouping & assessment
@@ -10816,10 +11455,17 @@ async function fileChangeParser({ changes, commit, options: { tokenizer, git, ll
10816
11455
  // 1. Pre-process large files to prevent bias
10817
11456
  // 2. Group by directory and assess token count
10818
11457
  // 3. Wave-based parallel summarization until under budget
11458
+ //
11459
+ // The 4096 fallback (#845) matches the default service configs
11460
+ // for openai / anthropic / ollama (`langchain/utils.ts`). It's a
11461
+ // safety net for users with custom service definitions that omit
11462
+ // `tokenLimit` — without it those users hit a degenerate 2048
11463
+ // budget that triggers needless pre-summarization on diffs the
11464
+ // model could absorb whole.
10819
11465
  logger.startTimer();
10820
11466
  const summary = await summarizeDiffs(diffs, {
10821
11467
  tokenizer,
10822
- maxTokens: maxTokens || 2048,
11468
+ maxTokens: maxTokens || 4096,
10823
11469
  minTokensForSummary,
10824
11470
  maxFileTokens,
10825
11471
  maxConcurrent,
@@ -11769,6 +12415,164 @@ const CommitSplitPlanSchema = objectType({
11769
12415
  }))
11770
12416
  .min(1),
11771
12417
  });
12418
+
12419
+ const getGroupFiles$1 = (group) => group.files || [];
12420
+ const getGroupHunks$1 = (group) => group.hunks || [];
12421
+ function getPlanValidationIssues(plan, staged, hunkInventory) {
12422
+ const stagedFiles = new Set(staged.map((change) => change.filePath));
12423
+ const seen = new Set();
12424
+ const seenHunks = new Set();
12425
+ const unknownFiles = [];
12426
+ const duplicateFiles = [];
12427
+ const unknownHunks = [];
12428
+ const duplicateHunks = [];
12429
+ plan.groups.forEach((group) => {
12430
+ getGroupFiles$1(group).forEach((file) => {
12431
+ if (!stagedFiles.has(file)) {
12432
+ unknownFiles.push(file);
12433
+ return;
12434
+ }
12435
+ if (seen.has(file)) {
12436
+ duplicateFiles.push(file);
12437
+ return;
12438
+ }
12439
+ seen.add(file);
12440
+ });
12441
+ getGroupHunks$1(group).forEach((hunkId) => {
12442
+ const hunk = hunkInventory?.byId.get(hunkId);
12443
+ if (!hunk) {
12444
+ unknownHunks.push(hunkId);
12445
+ return;
12446
+ }
12447
+ if (seenHunks.has(hunkId)) {
12448
+ duplicateHunks.push(hunkId);
12449
+ return;
12450
+ }
12451
+ seenHunks.add(hunkId);
12452
+ });
12453
+ });
12454
+ const hunkCoveredFiles = new Set([...seenHunks].map((hunkId) => hunkInventory?.byId.get(hunkId)?.filePath));
12455
+ const mixedFiles = [...seen].filter((file) => hunkCoveredFiles.has(file));
12456
+ const partiallyCoveredFiles = [...hunkCoveredFiles]
12457
+ .filter((file) => Boolean(file))
12458
+ .filter((file) => {
12459
+ const fileHunks = hunkInventory?.byFile.get(file) || [];
12460
+ return fileHunks.some((hunk) => !seenHunks.has(hunk.id));
12461
+ });
12462
+ const missingFiles = [...stagedFiles].filter((file) => !seen.has(file) && !hunkCoveredFiles.has(file));
12463
+ return {
12464
+ unknownFiles,
12465
+ duplicateFiles,
12466
+ unknownHunks,
12467
+ duplicateHunks,
12468
+ mixedFiles,
12469
+ partiallyCoveredFiles,
12470
+ missingFiles,
12471
+ };
12472
+ }
12473
+ function hasPlanValidationIssues(issues) {
12474
+ return (issues.unknownFiles.length > 0 ||
12475
+ issues.duplicateFiles.length > 0 ||
12476
+ issues.unknownHunks.length > 0 ||
12477
+ issues.duplicateHunks.length > 0 ||
12478
+ issues.mixedFiles.length > 0 ||
12479
+ issues.partiallyCoveredFiles.length > 0 ||
12480
+ issues.missingFiles.length > 0);
12481
+ }
12482
+ function formatPlanValidationIssuesError(issues) {
12483
+ return [
12484
+ issues.unknownFiles.length ? `unknown files: ${issues.unknownFiles.join(', ')}` : undefined,
12485
+ issues.duplicateFiles.length
12486
+ ? `duplicate files: ${issues.duplicateFiles.join(', ')}`
12487
+ : undefined,
12488
+ issues.unknownHunks.length ? `unknown hunks: ${issues.unknownHunks.join(', ')}` : undefined,
12489
+ issues.duplicateHunks.length
12490
+ ? `duplicate hunks: ${issues.duplicateHunks.join(', ')}`
12491
+ : undefined,
12492
+ issues.mixedFiles.length
12493
+ ? `files assigned both as whole files and hunks: ${issues.mixedFiles.join(', ')}`
12494
+ : undefined,
12495
+ issues.partiallyCoveredFiles.length
12496
+ ? `files with only some hunks assigned: ${issues.partiallyCoveredFiles.join(', ')}`
12497
+ : undefined,
12498
+ issues.missingFiles.length ? `missing files: ${issues.missingFiles.join(', ')}` : undefined,
12499
+ ]
12500
+ .filter(Boolean)
12501
+ .join('; ');
12502
+ }
12503
+ function formatPlanValidationFeedback(issues) {
12504
+ const sections = [];
12505
+ if (issues.unknownFiles.length) {
12506
+ sections.push(`Files referenced that are NOT in the staged file inventory (remove or replace): ${issues.unknownFiles.join(', ')}`);
12507
+ }
12508
+ if (issues.duplicateFiles.length) {
12509
+ sections.push(`Files assigned to more than one group (each file may appear at most once): ${issues.duplicateFiles.join(', ')}`);
12510
+ }
12511
+ if (issues.unknownHunks.length) {
12512
+ sections.push(`Hunk IDs referenced that are NOT in the staged hunk inventory: ${issues.unknownHunks.join(', ')}`);
12513
+ }
12514
+ if (issues.duplicateHunks.length) {
12515
+ sections.push(`Hunk IDs assigned to more than one group (each hunk may appear at most once): ${issues.duplicateHunks.join(', ')}`);
12516
+ }
12517
+ if (issues.mixedFiles.length) {
12518
+ sections.push(`Files assigned BOTH as whole files and via hunks (pick one mode per file): ${issues.mixedFiles.join(', ')}`);
12519
+ }
12520
+ if (issues.partiallyCoveredFiles.length) {
12521
+ sections.push(`Files with only some hunks assigned (every hunk for these files must be covered): ${issues.partiallyCoveredFiles.join(', ')}`);
12522
+ }
12523
+ if (issues.missingFiles.length) {
12524
+ sections.push(`Staged files missing from every group (must appear exactly once): ${issues.missingFiles.join(', ')}`);
12525
+ }
12526
+ return sections.map((section) => `- ${section}`).join('\n');
12527
+ }
12528
+
12529
+ const NO_PREVIOUS_FEEDBACK_PLACEHOLDER = 'None — this is the first attempt.';
12530
+ const DEFAULT_MAX_PLAN_ATTEMPTS = 3;
12531
+ /**
12532
+ * Generate a commit-split plan with self-correcting retries on validator failures.
12533
+ *
12534
+ * The first attempt runs as normal. If `validatePlanForStagedFiles` rejects the result,
12535
+ * the validator's complaints are formatted as natural-language feedback and fed back
12536
+ * into the same prompt template (`previous_attempt_feedback` slot) so the model can
12537
+ * fix its own mistakes without re-running pre-processing.
12538
+ */
12539
+ async function generateValidatedCommitSplitPlan({ llm, prompt, variables, staged, hunkInventory, logger, tokenizer, metadata = {}, maxAttempts = DEFAULT_MAX_PLAN_ATTEMPTS, }) {
12540
+ let lastIssues = null;
12541
+ let attempt = 0;
12542
+ while (attempt < maxAttempts) {
12543
+ attempt++;
12544
+ const previousFeedback = lastIssues
12545
+ ? formatPlanValidationFeedback(lastIssues)
12546
+ : NO_PREVIOUS_FEEDBACK_PLACEHOLDER;
12547
+ const plan = await executeChainWithSchema(CommitSplitPlanSchema, llm, prompt, {
12548
+ ...variables,
12549
+ previous_attempt_feedback: previousFeedback,
12550
+ }, {
12551
+ logger,
12552
+ tokenizer,
12553
+ metadata: {
12554
+ task: 'commit-split-plan',
12555
+ ...metadata,
12556
+ planAttempt: attempt,
12557
+ },
12558
+ });
12559
+ const issues = getPlanValidationIssues(plan, staged, hunkInventory);
12560
+ if (!hasPlanValidationIssues(issues)) {
12561
+ if (attempt > 1 && logger) {
12562
+ logger.verbose(`Plan validated after ${attempt} attempts.`, { color: 'green' });
12563
+ }
12564
+ return { plan, attempts: attempt };
12565
+ }
12566
+ lastIssues = issues;
12567
+ if (logger) {
12568
+ logger.verbose(`Plan attempt ${attempt}/${maxAttempts} failed validation: ${formatPlanValidationIssuesError(issues)}`, { color: 'yellow' });
12569
+ }
12570
+ }
12571
+ throw new Error(lastIssues
12572
+ ? `Failed to produce a valid commit-split plan after ${maxAttempts} attempts. Final validator issues: ${formatPlanValidationIssuesError(lastIssues)}`
12573
+ : `Failed to produce a valid commit-split plan after ${maxAttempts} attempts.`);
12574
+ }
12575
+
11772
12576
  const COMMIT_SPLIT_PROMPT = PromptTemplate.fromTemplate(`You are helping split staged git changes into a small sequence of coherent commits.
11773
12577
 
11774
12578
  Return ONLY valid JSON matching this schema:
@@ -11785,14 +12589,13 @@ Return ONLY valid JSON matching this schema:
11785
12589
  }}
11786
12590
 
11787
12591
  Rules:
11788
- - Use each staged file exactly once.
11789
- - If a file has hunk IDs and contains unrelated changes, assign every hunk ID exactly once instead of assigning the whole file.
11790
- - Do not list the same file in "files" when assigning that file through "hunks".
11791
- - Only use file paths listed in the staged file inventory.
11792
- - Only use hunk IDs listed in the staged hunk inventory.
12592
+ - Every staged file MUST be assigned exactly once across all groups, either via "files" OR via every one of its hunk IDs (never both).
12593
+ - If you assign any hunk for a file, you MUST assign EVERY hunk for that file across the groups partial coverage is invalid.
12594
+ - Do not list the same file in "files" of more than one group, and do not assign the same hunk ID to more than one group.
12595
+ - Only use file paths listed in the staged file inventory. Do not invent files.
12596
+ - Only use hunk IDs listed in the staged hunk inventory. Do not invent hunk IDs.
11793
12597
  - Prefer 2-5 commits unless the changes are truly all one topic.
11794
12598
  - Keep commit titles concise and understandable.
11795
- - Do not invent files.
11796
12599
 
11797
12600
  Staged file inventory:
11798
12601
  {file_inventory}
@@ -11804,7 +12607,10 @@ Condensed staged diff:
11804
12607
  {summary}
11805
12608
 
11806
12609
  Additional context:
11807
- {additional_context}`);
12610
+ {additional_context}
12611
+
12612
+ Feedback on previous attempt (fix every item before responding):
12613
+ {previous_attempt_feedback}`);
11808
12614
  function isCommitSplitCommand(argv) {
11809
12615
  return Boolean(argv.split || argv.plan || argv.apply || argv._.includes('split'));
11810
12616
  }
@@ -11823,9 +12629,6 @@ function formatCommitSplitPlan(plan) {
11823
12629
  })
11824
12630
  .join('\n\n---\n\n');
11825
12631
  }
11826
- function getStagedFileSet(changes) {
11827
- return new Set(changes.map((change) => change.filePath));
11828
- }
11829
12632
  function getGroupFiles(group) {
11830
12633
  return group.files || [];
11831
12634
  }
@@ -11882,67 +12685,9 @@ function formatHunkInventory(inventory) {
11882
12685
  .join('\n');
11883
12686
  }
11884
12687
  function validatePlanForStagedFiles(plan, staged, hunkInventory) {
11885
- const stagedFiles = getStagedFileSet(staged);
11886
- const seen = new Set();
11887
- const seenHunks = new Set();
11888
- const unknown = [];
11889
- const duplicate = [];
11890
- const unknownHunks = [];
11891
- const duplicateHunks = [];
11892
- plan.groups.forEach((group) => {
11893
- getGroupFiles(group).forEach((file) => {
11894
- if (!stagedFiles.has(file)) {
11895
- unknown.push(file);
11896
- return;
11897
- }
11898
- if (seen.has(file)) {
11899
- duplicate.push(file);
11900
- return;
11901
- }
11902
- seen.add(file);
11903
- });
11904
- getGroupHunks(group).forEach((hunkId) => {
11905
- const hunk = hunkInventory?.byId.get(hunkId);
11906
- if (!hunk) {
11907
- unknownHunks.push(hunkId);
11908
- return;
11909
- }
11910
- if (seenHunks.has(hunkId)) {
11911
- duplicateHunks.push(hunkId);
11912
- return;
11913
- }
11914
- seenHunks.add(hunkId);
11915
- });
11916
- });
11917
- const hunkCoveredFiles = new Set([...seenHunks].map((hunkId) => hunkInventory?.byId.get(hunkId)?.filePath));
11918
- const mixedFiles = [...seen].filter((file) => hunkCoveredFiles.has(file));
11919
- const partiallyCoveredFiles = [...hunkCoveredFiles]
11920
- .filter((file) => Boolean(file))
11921
- .filter((file) => {
11922
- const fileHunks = hunkInventory?.byFile.get(file) || [];
11923
- return fileHunks.some((hunk) => !seenHunks.has(hunk.id));
11924
- });
11925
- const missing = [...stagedFiles].filter((file) => !seen.has(file) && !hunkCoveredFiles.has(file));
11926
- if (unknown.length ||
11927
- duplicate.length ||
11928
- unknownHunks.length ||
11929
- duplicateHunks.length ||
11930
- mixedFiles.length ||
11931
- partiallyCoveredFiles.length ||
11932
- missing.length) {
11933
- throw new Error([
11934
- unknown.length ? `unknown files: ${unknown.join(', ')}` : undefined,
11935
- duplicate.length ? `duplicate files: ${duplicate.join(', ')}` : undefined,
11936
- unknownHunks.length ? `unknown hunks: ${unknownHunks.join(', ')}` : undefined,
11937
- duplicateHunks.length ? `duplicate hunks: ${duplicateHunks.join(', ')}` : undefined,
11938
- mixedFiles.length ? `files assigned both as whole files and hunks: ${mixedFiles.join(', ')}` : undefined,
11939
- partiallyCoveredFiles.length
11940
- ? `files with only some hunks assigned: ${partiallyCoveredFiles.join(', ')}`
11941
- : undefined,
11942
- missing.length ? `missing files: ${missing.join(', ')}` : undefined,
11943
- ]
11944
- .filter(Boolean)
11945
- .join('; '));
12688
+ const issues = getPlanValidationIssues(plan, staged, hunkInventory);
12689
+ if (hasPlanValidationIssues(issues)) {
12690
+ throw new Error(formatPlanValidationIssuesError(issues));
11946
12691
  }
11947
12692
  }
11948
12693
  function assertNoUnstagedOverlap(plan, changes, hunkInventory) {
@@ -12046,22 +12791,26 @@ async function handleCommitSplit({ argv, config, git, logger, tokenizer, llm, })
12046
12791
  .map((change) => `- ${change.filePath}: ${change.status} - ${change.summary}`)
12047
12792
  .join('\n');
12048
12793
  const hunkInventoryText = formatHunkInventory(hunkInventory);
12049
- const plan = await executeChainWithSchema(CommitSplitPlanSchema, llm, COMMIT_SPLIT_PROMPT, {
12050
- file_inventory: fileInventory,
12051
- hunk_inventory: hunkInventoryText,
12052
- summary,
12053
- additional_context: argv.additional || '',
12054
- }, {
12794
+ const { plan } = await generateValidatedCommitSplitPlan({
12795
+ llm,
12796
+ prompt: COMMIT_SPLIT_PROMPT,
12797
+ variables: {
12798
+ file_inventory: fileInventory,
12799
+ hunk_inventory: hunkInventoryText,
12800
+ summary,
12801
+ additional_context: argv.additional || '',
12802
+ },
12803
+ staged: changes.staged,
12804
+ hunkInventory,
12055
12805
  logger,
12056
12806
  tokenizer,
12057
12807
  metadata: {
12058
- task: 'commit-split-plan',
12059
12808
  command: 'commit',
12060
12809
  provider: config.service.provider,
12061
12810
  model: String(config.service.model),
12062
12811
  },
12812
+ maxAttempts: DEFAULT_MAX_PLAN_ATTEMPTS,
12063
12813
  });
12064
- validatePlanForStagedFiles(plan, changes.staged, hunkInventory);
12065
12814
  if (argv.apply) {
12066
12815
  return await applyCommitSplitPlan({
12067
12816
  plan,
@@ -29235,6 +29984,7 @@ y.command(init.command, init.desc, init.builder, init.handler);
29235
29984
  y.command(doctor.command, doctor.desc, doctor.builder, doctor.handler);
29236
29985
  y.command(log.command, log.desc, log.builder, log.handler);
29237
29986
  y.command(ui.command, ui.desc, ui.builder, ui.handler);
29987
+ y.command(cache.command, cache.desc, cache.builder, cache.handler);
29238
29988
  y.help().parse(process.argv.slice(2));
29239
29989
 
29240
29990
  /**
@@ -29686,4 +30436,4 @@ var commitValidationHandler = /*#__PURE__*/Object.freeze({
29686
30436
  handleValidationErrors: handleValidationErrors
29687
30437
  });
29688
30438
 
29689
- export { changelog, commit, doctor, init, log, recap, types, ui };
30439
+ export { cache, changelog, commit, doctor, init, log, recap, types, ui };