@aefree/pi-plastic 0.4.0 → 0.5.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +9 -7
  3. package/extensions/bash-cm-diff-guard.ts +2 -1
  4. package/extensions/file-discovery-filter.ts +67 -0
  5. package/index.ts +18 -2
  6. package/package.json +7 -8
  7. package/skills/_shared/references/conventional-commits.md +198 -198
  8. package/skills/using-plastic/SKILL.md +3 -3
  9. package/skills/using-plastic/references/changeset-operations.md +5 -5
  10. package/skills/using-plastic/references/code-review-creation.md +30 -30
  11. package/skills/using-plastic/references/integration.md +5 -3
  12. package/skills/using-plastic/references/quick-reference.md +2 -1
  13. package/skills/using-plastic/references/resources.md +7 -7
  14. package/skills/using-plastic/references/reviewing-changes.md +9 -7
  15. package/skills/using-plastic/references/shelving.md +25 -25
  16. package/skills/using-plastic/references/troubleshooting.md +5 -3
  17. package/skills/using-plastic/references/workspaces.md +33 -33
  18. package/src/file-discovery-filter-rendezvous.ts +104 -0
  19. package/src/{repository-search-provider.ts → file-discovery-filter.ts} +21 -17
  20. package/src/plastic-core.ts +579 -170
  21. package/src/plastic-tool-loading.ts +3 -3
  22. package/evals/tool-loading/README.md +0 -38
  23. package/evals/tool-loading/RESULTS.md +0 -57
  24. package/evals/tool-loading/baseline.json +0 -34
  25. package/evals/tool-loading/cases.json +0 -70
  26. package/evals/tool-loading/config.json +0 -19
  27. package/evals/tool-loading/mutation-guard.ts +0 -30
  28. package/evals/tool-loading/provider-capture.ts +0 -22
  29. package/evals/tool-loading/results/.gitignore +0 -3
  30. package/evals/tool-loading/run-eval.ts +0 -372
  31. package/extensions/repository-search-provider.ts +0 -66
  32. package/tests/bash-cm-diff-guard-validation.ts +0 -55
  33. package/tests/fixtures/plastic.selector +0 -3
  34. package/tests/fixtures/status-compact.txt +0 -1
  35. package/tests/fixtures/status-normal.txt +0 -1
  36. package/tests/packed-copy-owner-conflict.test.ts +0 -28
  37. package/tests/pi-tool-harness.ts +0 -116
  38. package/tests/plastic-branch-create-live.test.ts +0 -78
  39. package/tests/plastic-branch-query.test.ts +0 -26
  40. package/tests/plastic-branch-status.test.ts +0 -241
  41. package/tests/plastic-checkin-path-resolution.ts +0 -219
  42. package/tests/plastic-constrained-sampling.test.ts +0 -77
  43. package/tests/plastic-diff-workflow.test.ts +0 -86
  44. package/tests/plastic-dynamic-tool-loading.test.ts +0 -224
  45. package/tests/plastic-extension-registration.test.ts +0 -34
  46. package/tests/plastic-live-smoke.test.ts +0 -37
  47. package/tests/plastic-mutation-execution.test.ts +0 -61
  48. package/tests/plastic-patch-command.test.ts +0 -118
  49. package/tests/plastic-process-lifecycle.test.ts +0 -148
  50. package/tests/plastic-tool-rendering.test.ts +0 -16
  51. package/tests/plastic-tool-validation.ts +0 -153
  52. package/tests/plastic-workspace.test.ts +0 -62
  53. package/tests/repository-search-provider-registration.test.ts +0 -37
  54. package/tests/repository-search-provider.test.ts +0 -27
@@ -28,7 +28,6 @@ const resolveExecutable = (environment: ExecutableEnvironment, overrideVariable:
28
28
  };
29
29
 
30
30
  const getCmExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_CM_EXECUTABLE", "cm");
31
- const getGitExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_GIT_EXECUTABLE", "git");
32
31
  // `diff -u` has compatible exit semantics on Windows GNU diff and macOS BSD diff.
33
32
  // Keep the executable name configurable rather than assuming a .exe suffix or package-manager path.
34
33
  const getDiffExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
@@ -45,14 +44,9 @@ const executableNotFoundError = (command: string, error: Error): Error =>
45
44
  return new Error(`Unable to launch Plastic SCM executable '${command}' (ENOENT). Set PI_PLASTIC_CM_EXECUTABLE to the full path to cm, or add cm to PATH.`);
46
45
  }
47
46
 
48
- if (command === getGitExecutable())
49
- {
50
- return new Error(`Unable to launch Git executable '${command}' (ENOENT). Set PI_PLASTIC_GIT_EXECUTABLE to the full path to git, or add git to PATH.`);
51
- }
52
-
53
47
  if (command === getDiffExecutable())
54
48
  {
55
- return new Error(`Unable to launch text diff executable '${command}' (ENOENT). Set PI_PLASTIC_DIFF_EXECUTABLE to a safe diff executable, or add diff to PATH.`);
49
+ return new Error(`Unable to launch GNU/POSIX text diff executable '${command}' (ENOENT). Set PI_PLASTIC_DIFF_EXECUTABLE to the full path of GNU/POSIX diff (paths containing spaces are supported), or install bare diff on PATH. Pi does not discover Git Bash paths automatically.`);
56
50
  }
57
51
 
58
52
  return new Error(`Unable to launch executable '${command}' (ENOENT). Verify that it is installed and available on PATH.`);
@@ -251,7 +245,7 @@ const spawnAndCollect = async (
251
245
  }
252
246
  };
253
247
 
254
- const BLOCKED_CM_DIFF_MESSAGE = "`cm diff` is blocked in Pi because it may launch GUI windows and hang the CLI. Use `plastic_status` to list changed paths, `plastic_diffFile` for a file diff, or `plastic_diffRevisions` for two explicit revision specs.";
248
+ const BLOCKED_CM_DIFF_MESSAGE = "`cm diff` is blocked in Pi because it may launch GUI windows and hang the CLI. Use `plastic_status` when only changed paths are needed. Do not diff routinely; when change-boundary evidence is necessary, use a focused `plastic_diffFile`, explicitly scoped `plastic_workspaceDiff`, or `plastic_diffRevisions`.";
255
249
 
256
250
  const ensureCmCommandAllowed = (args: string[]): void =>
257
251
  {
@@ -313,6 +307,7 @@ type PendingItem = {
313
307
  isDirectory: boolean;
314
308
  kind: PendingItemKind;
315
309
  revisionId?: string;
310
+ sourceWorkspacePath?: string;
316
311
  };
317
312
 
318
313
  type PendingItemSummary = {
@@ -591,11 +586,30 @@ const inferPendingItemKind = (statusCode: string): PendingItemKind =>
591
586
  return "other";
592
587
  };
593
588
 
589
+ // Unit separator is accepted by Plastic and cannot occur in supported workspace paths.
590
+ const STATUS_FIELD_SEPARATOR = "\x1f";
591
+
594
592
  const parseMachineReadablePendingItems = (output: string, cwd: string): PendingItem[] =>
595
593
  {
596
594
  const lines = normalizeFindOutputLines(output);
597
- const statusLinePattern = /^([A-Z+]+)\s+(.+?)\s+(True|False)\s+(.*)$/;
595
+ // Legacy records have no reliable way to distinguish the source and destination
596
+ // fields of a move, so they remain supported for normal records only.
597
+ const legacyStatusLinePattern = /^([A-Z+]+)\s+(.+)\s+(True|False)(?:\s+(.*))?$/;
598
598
  const pendingItems: PendingItem[] = [];
599
+ const addItem = (statusCode: string, workspacePath: string, isDirectory: boolean, revisionId?: string, sourceWorkspacePath?: string): void =>
600
+ {
601
+ const normalizedPath = toNormalizedAbsolutePath(workspacePath, cwd);
602
+ pendingItems.push({
603
+ statusCode,
604
+ workspacePath,
605
+ normalizedPath,
606
+ comparisonKey: toPathComparisonKeyFromAbsolutePath(normalizedPath),
607
+ isDirectory,
608
+ kind: inferPendingItemKind(statusCode),
609
+ ...(revisionId ? { revisionId } : {}),
610
+ ...(sourceWorkspacePath ? { sourceWorkspacePath } : {}),
611
+ });
612
+ };
599
613
 
600
614
  for (const line of lines)
601
615
  {
@@ -604,27 +618,57 @@ const parseMachineReadablePendingItems = (output: string, cwd: string): PendingI
604
618
  continue;
605
619
  }
606
620
 
607
- const match = line.match(statusLinePattern);
621
+ if (line.includes(STATUS_FIELD_SEPARATOR))
622
+ {
623
+ const fields = line.split(STATUS_FIELD_SEPARATOR);
624
+ const statusCode = fields[0];
625
+ if (!/^[A-Z+]+$/.test(statusCode))
626
+ {
627
+ continue;
628
+ }
629
+
630
+ const kind = inferPendingItemKind(statusCode);
631
+ if (kind === "moved")
632
+ {
633
+ // MV: status, percent, source, destination, directory, revision, metadata.
634
+ const [,, sourceWorkspacePath, workspacePath, directoryField, revisionField] = fields;
635
+ if (!sourceWorkspacePath || !workspacePath || (directoryField !== "True" && directoryField !== "False"))
636
+ {
637
+ continue;
638
+ }
639
+ addItem(statusCode, workspacePath, directoryField === "True", /^\d+$/.test(revisionField ?? "") ? revisionField : undefined, sourceWorkspacePath);
640
+ continue;
641
+ }
642
+
643
+ // Ordinary records: status, path, directory, revision, metadata.
644
+ // Only moved records include a similarity-percent field.
645
+ const [, workspacePath, directoryField, revisionField] = fields;
646
+ if (!workspacePath || (directoryField !== "True" && directoryField !== "False"))
647
+ {
648
+ continue;
649
+ }
650
+ addItem(statusCode, workspacePath, directoryField === "True", /^\d+$/.test(revisionField ?? "") ? revisionField : undefined);
651
+ continue;
652
+ }
653
+
654
+ const match = line.match(legacyStatusLinePattern);
608
655
  if (!match)
609
656
  {
610
657
  continue;
611
658
  }
612
659
 
613
660
  const statusCode = match[1];
661
+ // A whitespace-delimited MV record cannot safely reveal which portion is
662
+ // its destination. Skipping it prevents checkin/diff from targeting a
663
+ // wrong workspace path; package-owned calls always request a separator.
664
+ if (inferPendingItemKind(statusCode) === "moved")
665
+ {
666
+ continue;
667
+ }
614
668
  const workspacePath = match[2];
615
669
  const isDirectory = match[3] === "True";
616
- const remainder = match[4] ?? "";
617
- const revisionId = remainder.match(/^\s*(\d+)\b/)?.[1];
618
- const normalizedPath = toNormalizedAbsolutePath(workspacePath, cwd);
619
- pendingItems.push({
620
- statusCode,
621
- workspacePath,
622
- normalizedPath,
623
- comparisonKey: toPathComparisonKeyFromAbsolutePath(normalizedPath),
624
- isDirectory,
625
- kind: inferPendingItemKind(statusCode),
626
- ...(revisionId ? { revisionId } : {}),
627
- });
670
+ const revisionId = (match[4] ?? "").match(/^\s*(\d+)\b/)?.[1];
671
+ addItem(statusCode, workspacePath, isDirectory, revisionId);
628
672
  }
629
673
 
630
674
  return pendingItems;
@@ -633,7 +677,7 @@ const parseMachineReadablePendingItems = (output: string, cwd: string): PendingI
633
677
  const getMachineReadablePendingItems = async (workdir?: string): Promise<PendingItem[]> =>
634
678
  {
635
679
  const cwd = workdir ?? process.cwd();
636
- const output = await runCmRaw(["status", "--machinereadable", "--includeRevId"], workdir);
680
+ const output = await runCmRaw(["status", "--machinereadable", "--includeRevId", `--fieldseparator=${STATUS_FIELD_SEPARATOR}`], workdir);
637
681
  return parseMachineReadablePendingItems(output, cwd);
638
682
  };
639
683
 
@@ -856,14 +900,6 @@ const buildFallbackScopePaths = (absolutePaths: string[], cwd: string): string[]
856
900
  return dedupeAndMinimizeAbsolutePaths(fallbackAbsolutePaths).map((path) => toCommandPath(path, cwd));
857
901
  };
858
902
 
859
- const isGitUnknownOptionLabelError = (message: string): boolean =>
860
- {
861
- const normalized = message.toLowerCase();
862
- return normalized.includes("unknown option `label'")
863
- || normalized.includes("unknown option 'label'")
864
- || normalized.includes("unknown option \"label\"");
865
- };
866
-
867
903
  const isRevisionNotFoundError = (message: string): boolean =>
868
904
  {
869
905
  const normalized = message.toLowerCase();
@@ -1193,7 +1229,6 @@ const buildPatchCommandArgs = (args: PatchCommandArgs): string[] =>
1193
1229
 
1194
1230
  export const __plasticProcessInternals = {
1195
1231
  resolveCmExecutable: (environment: ExecutableEnvironment = process.env): string => resolveExecutable(environment, "PI_PLASTIC_CM_EXECUTABLE", "cm"),
1196
- resolveGitExecutable: (environment: ExecutableEnvironment = process.env): string => resolveExecutable(environment, "PI_PLASTIC_GIT_EXECUTABLE", "git"),
1197
1232
  resolveDiffExecutable: (environment: ExecutableEnvironment = process.env): string => resolveExecutable(environment, "PI_PLASTIC_DIFF_EXECUTABLE", "diff"),
1198
1233
  spawnAndCollect,
1199
1234
  runCm,
@@ -1218,7 +1253,6 @@ export const __plasticCheckinInternals = {
1218
1253
  resolveCheckinPaths,
1219
1254
  buildFallbackScopePaths,
1220
1255
  isNoChangesWorkspaceCheckinError,
1221
- isGitUnknownOptionLabelError,
1222
1256
  isRevisionNotFoundError,
1223
1257
  normalizeDiffFileRevisionSpec,
1224
1258
  resolveDiffFileRevision,
@@ -1242,84 +1276,14 @@ export const __plasticBranchInternals = {
1242
1276
  resolveBranchCreationTarget,
1243
1277
  };
1244
1278
 
1245
- let gitNoIndexSupportsLabel: boolean | null = null;
1246
-
1247
- type GitDiffResult = {
1248
- exitCode: number;
1249
- output: string;
1250
- };
1251
-
1252
- const runGitDiffNoIndexOnce = async (args: string[], cwd: string): Promise<GitDiffResult> =>
1253
- {
1254
- const { stdout, stderr, exitCode } = await spawnAndCollect(getGitExecutable(), args, cwd);
1255
- const output = [stdout, stderr].filter(Boolean).join("\n").trim();
1256
-
1257
- return {
1258
- exitCode,
1259
- output,
1260
- };
1261
- };
1262
-
1263
- const buildGitDiffNoIndexArgs = (
1264
- left: string,
1265
- right: string,
1266
- labelLeft?: string,
1267
- labelRight?: string,
1268
- includeLabels = true,
1269
- ): string[] =>
1270
- {
1271
- const cmdArgs = ["diff", "--no-index"];
1272
-
1273
- if (includeLabels && labelLeft)
1274
- {
1275
- cmdArgs.push("--label", labelLeft);
1276
- }
1277
-
1278
- if (includeLabels && labelRight)
1279
- {
1280
- cmdArgs.push("--label", labelRight);
1281
- }
1282
-
1283
- cmdArgs.push("--", left, right);
1284
- return cmdArgs;
1285
- };
1286
-
1287
- const runGitDiffNoIndex = async (
1288
- left: string,
1289
- right: string,
1290
- workdir?: string,
1291
- labelLeft?: string,
1292
- labelRight?: string,
1293
- ): Promise<string> =>
1294
- {
1295
- const cwd = workdir ?? process.cwd();
1296
- const hasLabels = Boolean(labelLeft || labelRight);
1297
- const includeLabels = hasLabels && gitNoIndexSupportsLabel !== false;
1298
- let result = await runGitDiffNoIndexOnce(
1299
- buildGitDiffNoIndexArgs(left, right, labelLeft, labelRight, includeLabels),
1300
- cwd,
1301
- );
1302
-
1303
- if (includeLabels && result.exitCode > 1 && isGitUnknownOptionLabelError(result.output))
1304
- {
1305
- gitNoIndexSupportsLabel = false;
1306
- result = await runGitDiffNoIndexOnce(buildGitDiffNoIndexArgs(left, right), cwd);
1307
- }
1308
-
1309
- if (includeLabels && result.exitCode <= 1 && gitNoIndexSupportsLabel === null)
1310
- {
1311
- gitNoIndexSupportsLabel = true;
1312
- }
1313
-
1314
- if (result.exitCode > 1)
1315
- {
1316
- throw new Error(result.output.length > 0 ? result.output : `git diff failed with exit code ${result.exitCode}`);
1317
- }
1318
-
1319
- return result.output;
1320
- };
1321
-
1279
+ // Capture enough subprocess output for diagnostics while keeping the default
1280
+ // agent-facing response intentionally small. Callers may opt into a larger
1281
+ // focused response, but never the full capture bound.
1322
1282
  const DIFF_OUTPUT_MAX_CHARS = 60_000;
1283
+ const DIFF_RESPONSE_DEFAULT_MAX_CHARS = 8_000;
1284
+ const DIFF_RESPONSE_MAX_CHARS = 20_000;
1285
+ const DIFF_RESPONSE_MIN_CHARS = 500;
1286
+ const DIFF_RESPONSE_TOTAL_MAX_CHARS = 24_000;
1323
1287
 
1324
1288
  type TextDiffResult = {
1325
1289
  backend: "diff";
@@ -1385,8 +1349,8 @@ const runPortableTextDiff = async (
1385
1349
  ["-u", leftPath, rightPath],
1386
1350
  cwd,
1387
1351
  undefined,
1388
- undefined,
1389
- { outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
1352
+ getActiveAbortSignal(),
1353
+ { ...commandExecutionStorage.getStore(), outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
1390
1354
  );
1391
1355
  if (aborted)
1392
1356
  {
@@ -1438,45 +1402,105 @@ const materializeRevision = async (revision: string, destination: string, workdi
1438
1402
  }
1439
1403
  };
1440
1404
 
1405
+ const normalizeDiffResponseMaxChars = (value: unknown): number =>
1406
+ {
1407
+ if (typeof value !== "number" || !Number.isFinite(value))
1408
+ {
1409
+ return DIFF_RESPONSE_DEFAULT_MAX_CHARS;
1410
+ }
1411
+ return Math.min(DIFF_RESPONSE_MAX_CHARS, Math.max(DIFF_RESPONSE_MIN_CHARS, Math.trunc(value)));
1412
+ };
1413
+
1441
1414
  const formatTextDiff = async (
1442
1415
  action: "diffFile" | "diffRevisions",
1443
1416
  format: OutputFormat | undefined,
1444
1417
  comparisonKind: string,
1445
- result: TextDiffResult,
1418
+ unboundedResult: TextDiffResult,
1446
1419
  workdir: string | undefined,
1420
+ comparisonMetadata: Record<string, unknown> = {},
1421
+ maxChars?: number,
1447
1422
  ): Promise<string> =>
1448
1423
  {
1424
+ const result = boundTextDiffResult(unboundedResult, normalizeDiffResponseMaxChars(maxChars));
1449
1425
  const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : "unchanged");
1450
1426
  const text = result.binary
1451
1427
  ? (result.changed ? "Binary content differs; a text diff is unavailable." : "No differences.")
1452
1428
  : (result.changed ? result.output : "No differences.");
1453
- return toStructuredResult(
1454
- action,
1455
- format ?? "text",
1456
- text,
1457
- {
1458
- comparisonKind,
1459
- backend: result.backend,
1460
- status,
1461
- changed: result.changed,
1462
- binary: result.binary,
1463
- truncated: result.truncated,
1464
- totalChars: result.totalChars,
1465
- diff: result.changed && !result.binary ? result.output : undefined,
1466
- },
1467
- workdir,
1468
- result.truncated ? ["Diff output was truncated; use a narrower file or generate a review patch for the complete result."] : undefined,
1429
+ const data = {
1430
+ comparisonKind,
1431
+ ...comparisonMetadata,
1432
+ backend: result.backend,
1433
+ status,
1434
+ changed: result.changed,
1435
+ binary: result.binary,
1436
+ truncated: result.truncated,
1437
+ totalChars: result.totalChars,
1438
+ diff: result.changed && !result.binary ? result.output : undefined,
1439
+ };
1440
+ const warnings = result.truncated ? ["Diff output was truncated; use a narrower file or generate a review patch for the complete result."] : undefined;
1441
+ if (format === "json")
1442
+ {
1443
+ return toBoundedDiffStructuredResult(action, data, workdir, warnings);
1444
+ }
1445
+ return text;
1446
+ };
1447
+
1448
+ const isNoDataError = (message: string): boolean =>
1449
+ {
1450
+ const normalized = message.toLowerCase();
1451
+ return normalized.includes("--nodata")
1452
+ || normalized.includes("no data available")
1453
+ || normalized.includes("data is not available")
1454
+ || normalized.includes("cannot retrieve data")
1455
+ || normalized.includes("could not retrieve data");
1456
+ };
1457
+
1458
+ const withWorkspaceBaseUnavailableDiagnostic = (path: string, error: unknown): Error =>
1459
+ {
1460
+ const message = error instanceof Error ? error.message : String(error);
1461
+ if (!isNoDataError(message))
1462
+ {
1463
+ return error instanceof Error ? error : new Error(message);
1464
+ }
1465
+
1466
+ return new Error(
1467
+ `Plastic cannot supply historical/base bytes for '${path}' because this workspace/status record is --nodata. `
1468
+ + "A workspace diff cannot be calculated without those bytes. Run plastic_status(machineReadable=true, includeRevId=true) to verify the item, then update/refresh the workspace or use plastic_diffRevisions with two known file-qualified revisions when historical content is available.",
1469
1469
  );
1470
1470
  };
1471
1471
 
1472
+ const boundTextDiffResult = (result: TextDiffResult, maxChars: number): TextDiffResult =>
1473
+ {
1474
+ if (!result.output || result.output.length <= maxChars)
1475
+ {
1476
+ return result;
1477
+ }
1478
+
1479
+ const suffix = `\n\n[Diff output truncated at the requested ${maxChars}-character response bound.]`;
1480
+ return {
1481
+ ...result,
1482
+ output: suffix.length >= maxChars ? suffix.slice(0, maxChars) : `${result.output.slice(0, maxChars - suffix.length)}${suffix}`,
1483
+ truncated: true,
1484
+ totalChars: result.totalChars,
1485
+ };
1486
+ };
1487
+
1472
1488
  export const __plasticDiffInternals = {
1473
1489
  DIFF_OUTPUT_MAX_CHARS,
1490
+ DIFF_RESPONSE_DEFAULT_MAX_CHARS,
1491
+ DIFF_RESPONSE_MAX_CHARS,
1492
+ DIFF_RESPONSE_MIN_CHARS,
1493
+ DIFF_RESPONSE_TOTAL_MAX_CHARS,
1494
+ normalizeDiffResponseMaxChars,
1474
1495
  isBinaryContent,
1475
1496
  stableDiffHeaders,
1476
1497
  boundDiffOutput,
1477
1498
  runPortableTextDiff,
1478
1499
  safeTempExtension,
1479
1500
  materializeRevision,
1501
+ isNoDataError,
1502
+ withWorkspaceBaseUnavailableDiagnostic,
1503
+ boundTextDiffResult,
1480
1504
  resolveDiffFileRevision,
1481
1505
  isUnscopedDiffRevisionSpec,
1482
1506
  };
@@ -1729,6 +1753,60 @@ const toStructuredResult = async (
1729
1753
  return `## ${action}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
1730
1754
  };
1731
1755
 
1756
+ const toBoundedDiffStructuredResult = async (
1757
+ action: "diffFile" | "diffRevisions",
1758
+ data: Record<string, unknown>,
1759
+ workdir?: string,
1760
+ warnings?: string[],
1761
+ ): Promise<string> =>
1762
+ {
1763
+ const wrap = (payload: Record<string, unknown>): string => `## ${action}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
1764
+ const basePayload = {
1765
+ ok: true,
1766
+ action,
1767
+ toolVersion: TOOL_VERSION,
1768
+ cliVersion: (await getCmVersion(workdir)).slice(0, 256),
1769
+ };
1770
+ const initial = wrap({ ...basePayload, data, ...(warnings?.length ? { warnings } : {}) });
1771
+ if (initial.length <= DIFF_RESPONSE_TOTAL_MAX_CHARS)
1772
+ {
1773
+ return initial;
1774
+ }
1775
+
1776
+ const originalDiff = typeof data.diff === "string" ? data.diff : "";
1777
+ const responseWarning = `Diff content was further truncated after JSON escaping to keep the complete response within ${DIFF_RESPONSE_TOTAL_MAX_CHARS} characters.`;
1778
+ const boundedWarnings = [...(warnings ?? []), responseWarning];
1779
+ const suffix = `\n\n[Diff output truncated to keep the complete JSON response within ${DIFF_RESPONSE_TOTAL_MAX_CHARS} characters.]`;
1780
+ const render = (prefixChars: number): string => wrap({
1781
+ ...basePayload,
1782
+ data: {
1783
+ ...data,
1784
+ truncated: true,
1785
+ diff: `${originalDiff.slice(0, prefixChars)}${suffix}`,
1786
+ },
1787
+ warnings: boundedWarnings,
1788
+ });
1789
+
1790
+ let low = 0;
1791
+ let high = originalDiff.length;
1792
+ let best = render(0);
1793
+ while (low <= high)
1794
+ {
1795
+ const middle = Math.floor((low + high) / 2);
1796
+ const candidate = render(middle);
1797
+ if (candidate.length <= DIFF_RESPONSE_TOTAL_MAX_CHARS)
1798
+ {
1799
+ best = candidate;
1800
+ low = middle + 1;
1801
+ }
1802
+ else
1803
+ {
1804
+ high = middle - 1;
1805
+ }
1806
+ }
1807
+ return best;
1808
+ };
1809
+
1732
1810
  type MergeConflictStrategy = "auto" | "source" | "destination";
1733
1811
 
1734
1812
  type MergeMachineReadableRecord = {
@@ -2655,6 +2733,7 @@ export const diffRevisions = tool({
2655
2733
  args: {
2656
2734
  leftRevision: tool.schema.string().min(1).describe("Left file-qualified Plastic revision spec for cm cat."),
2657
2735
  rightRevision: tool.schema.string().min(1).describe("Right file-qualified Plastic revision spec for cm cat."),
2736
+ maxChars: tool.schema.number().int().min(DIFF_RESPONSE_MIN_CHARS).max(DIFF_RESPONSE_MAX_CHARS).optional().describe(`Maximum diff-body characters returned (default ${DIFF_RESPONSE_DEFAULT_MAX_CHARS}, maximum ${DIFF_RESPONSE_MAX_CHARS}).`),
2658
2737
  format: outputFormatArg,
2659
2738
  workdir: workdirArg,
2660
2739
  },
@@ -2677,7 +2756,7 @@ export const diffRevisions = tool({
2677
2756
  await materializeRevision(args.leftRevision, leftPath, args.workdir);
2678
2757
  await materializeRevision(args.rightRevision, rightPath, args.workdir);
2679
2758
  const result = await runPortableTextDiff(leftPath, rightPath, cwd, leftLabel, rightLabel);
2680
- return formatTextDiff("diffRevisions", args.format, "revision-to-revision", result, args.workdir);
2759
+ return formatTextDiff("diffRevisions", args.format, "revision-to-revision", result, args.workdir, {}, args.maxChars);
2681
2760
  }
2682
2761
  finally
2683
2762
  {
@@ -2686,53 +2765,124 @@ export const diffRevisions = tool({
2686
2765
  },
2687
2766
  });
2688
2767
 
2768
+ type WorkspacePendingDiff = {
2769
+ item?: PendingItem;
2770
+ workspacePath: string;
2771
+ displayPath: string;
2772
+ revisionPath: string;
2773
+ pendingKind: "added" | "changed" | "moved" | "deleted" | "private" | "other";
2774
+ result: TextDiffResult;
2775
+ comparisonKind: string;
2776
+ };
2777
+
2778
+ const diffPendingWorkspaceFile = async (
2779
+ args: { path: string; workdir?: string },
2780
+ pendingItem?: PendingItem,
2781
+ ): Promise<WorkspacePendingDiff> =>
2782
+ {
2783
+ const cwd = args.workdir ?? process.cwd();
2784
+ const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
2785
+ const pendingKind = pendingItem?.kind ?? "changed";
2786
+ const isAdded = pendingKind === "added";
2787
+ const isPrivate = pendingKind === "private";
2788
+ const isDeleted = pendingKind === "deleted";
2789
+ const workspaceStat = await fs.stat(workspacePath).catch(() => null);
2790
+ if (!workspaceStat?.isFile() && !isDeleted)
2791
+ {
2792
+ throw new Error(`Workspace file '${args.path}' does not exist or is not a regular file. Use plastic_status to confirm its pending status.`);
2793
+ }
2794
+
2795
+ const displayPath = isAbsolute(args.path) ? toCommandPath(workspacePath, cwd) : args.path;
2796
+ const revisionPath = displayPath.length > 0 ? displayPath : args.path;
2797
+ const workspaceBase = resolveDiffFileRevision(revisionPath);
2798
+ const leftLabel = isAdded
2799
+ ? `${revisionPath} (empty before add)`
2800
+ : isPrivate
2801
+ ? `${revisionPath} (empty before private/new file)`
2802
+ : workspaceBase.resolved.includes("#") ? workspaceBase.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`;
2803
+ const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
2804
+ const materializeSpec = pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : workspaceBase.resolved;
2805
+ if (isDeleted && !pendingItem?.revisionId)
2806
+ {
2807
+ throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
2808
+ }
2809
+
2810
+ const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
2811
+ const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
2812
+ const emptyWorkspacePath = join(tempDir, `workspace${safeTempExtension(workspacePath)}`);
2813
+ try
2814
+ {
2815
+ if (isAdded || isPrivate)
2816
+ {
2817
+ await fs.writeFile(basePath, "");
2818
+ }
2819
+ else
2820
+ {
2821
+ try
2822
+ {
2823
+ // Status revision IDs preserve the exact pre-change materialization for
2824
+ // changed, moved, and deleted records. Explicit revision requests use a
2825
+ // separate path below and never consult workspace status.
2826
+ await materializeRevision(materializeSpec, basePath, args.workdir);
2827
+ }
2828
+ catch (error)
2829
+ {
2830
+ throw withWorkspaceBaseUnavailableDiagnostic(args.path, error);
2831
+ }
2832
+ }
2833
+
2834
+ if (isDeleted)
2835
+ {
2836
+ await fs.writeFile(emptyWorkspacePath, "");
2837
+ }
2838
+ const result = await runPortableTextDiff(basePath, isDeleted ? emptyWorkspacePath : workspacePath, cwd, leftLabel, rightLabel);
2839
+ return {
2840
+ item: pendingItem,
2841
+ workspacePath,
2842
+ displayPath,
2843
+ revisionPath,
2844
+ pendingKind,
2845
+ result,
2846
+ comparisonKind: isPrivate ? "workspace-private-new" : (isAdded ? "workspace-added" : (isDeleted ? "workspace-deleted" : `workspace-${pendingKind}`)),
2847
+ };
2848
+ }
2849
+ finally
2850
+ {
2851
+ await fs.rm(tempDir, { recursive: true, force: true });
2852
+ }
2853
+ };
2854
+
2689
2855
  export const diffFile = tool({
2690
2856
  description: "Show a bounded text-only diff between a workspace file and its Plastic base (or a supported revision). Omit revision for the common workspace-base comparison.",
2691
2857
  args: {
2692
2858
  path: tool.schema.string().min(1).describe("Workspace file path to diff."),
2693
2859
  revision: tool.schema.string().optional().describe("Optional revision: changeset number/cs:<number>, br:/<branch>, lb:<label>, file-qualified '<path>#<selector>', or global revid:/rev:. Omit for the workspace base."),
2860
+ maxChars: tool.schema.number().int().min(DIFF_RESPONSE_MIN_CHARS).max(DIFF_RESPONSE_MAX_CHARS).optional().describe(`Maximum diff-body characters returned (default ${DIFF_RESPONSE_DEFAULT_MAX_CHARS}, maximum ${DIFF_RESPONSE_MAX_CHARS}).`),
2694
2861
  format: outputFormatArg,
2695
2862
  workdir: workdirArg,
2696
2863
  },
2697
2864
  async execute(args)
2698
2865
  {
2699
- const cwd = args.workdir ?? process.cwd();
2700
- const workspacePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
2701
- const workspaceStat = await fs.stat(workspacePath).catch(() => null);
2702
- const pendingItems = args.revision ? [] : await getMachineReadablePendingItems(args.workdir);
2703
- const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
2704
- const isAdded = !args.revision && pendingItem?.kind === "added";
2705
- const isDeleted = !args.revision && pendingItem?.kind === "deleted";
2706
- if (!workspaceStat?.isFile() && !isDeleted)
2707
- {
2708
- throw new Error(`Workspace file '${args.path}' does not exist or is not a regular file. Use plastic_status to confirm whether it is a pending deletion.`);
2709
- }
2710
-
2711
- const displayPath = isAbsolute(args.path) ? relative(cwd, args.path) : args.path;
2712
- const revisionPath = displayPath.length > 0 ? displayPath : args.path;
2713
- const revision = resolveDiffFileRevision(revisionPath, args.revision);
2714
- const leftLabel = isAdded ? `${revisionPath} (empty before add)` : (revision.resolved.includes("#") ? revision.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`);
2715
- const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
2716
- const materializeSpec = isDeleted && pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : revision.resolved;
2717
- if (isDeleted && !pendingItem?.revisionId)
2718
- {
2719
- throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
2720
- }
2721
- const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
2722
- const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
2723
- const emptyWorkspacePath = join(tempDir, `workspace${safeTempExtension(workspacePath)}`);
2724
-
2725
- try
2866
+ // Explicit revision behavior intentionally bypasses workspace status. It is
2867
+ // a request for the supplied historical selector, not a pending-item diff.
2868
+ if (args.revision)
2726
2869
  {
2727
- if (isAdded)
2870
+ const cwd = args.workdir ?? process.cwd();
2871
+ const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
2872
+ const workspaceStat = await fs.stat(workspacePath).catch(() => null);
2873
+ if (!workspaceStat?.isFile())
2728
2874
  {
2729
- await fs.writeFile(basePath, "");
2875
+ throw new Error(`Workspace file '${args.path}' does not exist or is not a regular file.`);
2730
2876
  }
2731
- else
2877
+ const displayPath = isAbsolute(args.path) ? toCommandPath(workspacePath, cwd) : args.path;
2878
+ const revision = resolveDiffFileRevision(displayPath || args.path, args.revision);
2879
+ const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
2880
+ const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
2881
+ try
2732
2882
  {
2733
2883
  try
2734
2884
  {
2735
- await materializeRevision(materializeSpec, basePath, args.workdir);
2885
+ await materializeRevision(revision.resolved, basePath, args.workdir);
2736
2886
  }
2737
2887
  catch (error)
2738
2888
  {
@@ -2747,21 +2897,280 @@ export const diffFile = tool({
2747
2897
  }
2748
2898
  throw error;
2749
2899
  }
2900
+ const result = await runPortableTextDiff(basePath, workspacePath, cwd, revision.resolved.replace("#", "@"), `${displayPath} (workspace)`);
2901
+ return formatTextDiff("diffFile", args.format, revision.kind, result, args.workdir, { pendingKind: null, isNew: false }, args.maxChars);
2750
2902
  }
2751
-
2752
- if (isDeleted)
2903
+ finally
2753
2904
  {
2754
- await fs.writeFile(emptyWorkspacePath, "");
2905
+ await fs.rm(tempDir, { recursive: true, force: true });
2755
2906
  }
2756
- const comparisonPath = isDeleted ? emptyWorkspacePath : workspacePath;
2757
- const comparisonKind = isAdded ? "workspace-added" : (isDeleted ? "workspace-deleted" : revision.kind);
2758
- const result = await runPortableTextDiff(basePath, comparisonPath, cwd, leftLabel, rightLabel);
2759
- return formatTextDiff("diffFile", args.format, comparisonKind, result, args.workdir);
2760
2907
  }
2761
- finally
2908
+
2909
+ const cwd = args.workdir ?? process.cwd();
2910
+ const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
2911
+ const pendingItems = await getMachineReadablePendingItems(args.workdir);
2912
+ const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
2913
+ const compared = await diffPendingWorkspaceFile(args, pendingItem);
2914
+ return formatTextDiff(
2915
+ "diffFile",
2916
+ args.format,
2917
+ compared.comparisonKind,
2918
+ compared.result,
2919
+ args.workdir,
2920
+ { pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId },
2921
+ args.maxChars,
2922
+ );
2923
+ },
2924
+ });
2925
+
2926
+ const WORKSPACE_DIFF_DEFAULT_MAX_FILES = 3;
2927
+ const WORKSPACE_DIFF_MAX_FILES = 20;
2928
+ const WORKSPACE_DIFF_MAX_PATHS = 20;
2929
+ const WORKSPACE_DIFF_PATH_MAX_CHARS = 1_024;
2930
+ const WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS = 256;
2931
+ const WORKSPACE_DIFF_ERROR_MAX_CHARS = 1_024;
2932
+ const WORKSPACE_DIFF_DEFAULT_MAX_CHARS = 3_000;
2933
+ const WORKSPACE_DIFF_PER_FILE_MAX_CHARS = 8_000;
2934
+ const WORKSPACE_DIFF_MIN_CHARS = 500;
2935
+ // This is a response bound, not just a diff-body bound. Reserve space for
2936
+ // framing and an omission summary so both text and JSON remain useful.
2937
+ const WORKSPACE_DIFF_TOTAL_MAX_CHARS = 20_000;
2938
+ const WORKSPACE_DIFF_CONTENT_MAX_CHARS = 16_000;
2939
+
2940
+ const boundWorkspaceValue = (value: string, maxChars: number): string =>
2941
+ {
2942
+ if (maxChars <= 0)
2943
+ {
2944
+ return "";
2945
+ }
2946
+ if (value.length <= maxChars)
2947
+ {
2948
+ return value;
2949
+ }
2950
+ const suffix = `… [truncated; original ${value.length} characters]`;
2951
+ return suffix.length >= maxChars ? value.slice(0, maxChars) : `${value.slice(0, maxChars - suffix.length)}${suffix}`;
2952
+ };
2953
+
2954
+ const boundWorkspaceDiffResult = (result: TextDiffResult, maxChars: number): TextDiffResult =>
2955
+ {
2956
+ if (result.output.length <= maxChars)
2957
+ {
2958
+ return result;
2959
+ }
2960
+ return { ...result, output: boundWorkspaceValue(result.output, maxChars), truncated: true };
2961
+ };
2962
+
2963
+ const workspacePathPreview = (paths: string[]): string[] => paths.map((path) => boundWorkspaceValue(path, WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS));
2964
+
2965
+ const appendBoundedWorkspaceText = (prefix: string, sections: string[], omitted: number): string =>
2966
+ {
2967
+ const omission = (count: number): string => `\n\n[${count} file outcome(s) omitted to keep this response within ${WORKSPACE_DIFF_TOTAL_MAX_CHARS} characters.]`;
2968
+ let text = prefix;
2969
+ let omittedCount = omitted;
2970
+ for (let index = 0; index < sections.length; index += 1)
2971
+ {
2972
+ const candidate = `${text}\n\n${sections[index]}`;
2973
+ if (candidate.length + omission(omittedCount + sections.length - index - 1).length > WORKSPACE_DIFF_TOTAL_MAX_CHARS)
2762
2974
  {
2763
- await fs.rm(tempDir, { recursive: true, force: true });
2975
+ omittedCount += sections.length - index;
2976
+ break;
2764
2977
  }
2978
+ text = candidate;
2979
+ }
2980
+ return omittedCount > 0 ? `${text}${omission(omittedCount)}` : text;
2981
+ };
2982
+
2983
+ const formatWorkspaceDiffResult = async (
2984
+ format: OutputFormat,
2985
+ textPrefix: string,
2986
+ textSections: string[],
2987
+ data: Record<string, unknown>,
2988
+ warnings: string[],
2989
+ outcomes: Array<Record<string, unknown>>,
2990
+ preOmittedOutcomes: number,
2991
+ workdir?: string,
2992
+ ): Promise<string> =>
2993
+ {
2994
+ if (format !== "json")
2995
+ {
2996
+ return appendBoundedWorkspaceText(textPrefix, textSections, preOmittedOutcomes);
2997
+ }
2998
+
2999
+ const cliVersion = boundWorkspaceValue(await getCmVersion(workdir), WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS);
3000
+ const wrap = (payload: Record<string, unknown>): string => `## workspaceDiff\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
3001
+ const retained: Array<Record<string, unknown>> = [];
3002
+ let omittedOutcomes = preOmittedOutcomes;
3003
+ const payloadFor = (): Record<string, unknown> => ({
3004
+ ok: true,
3005
+ action: "workspaceDiff",
3006
+ toolVersion: TOOL_VERSION,
3007
+ cliVersion,
3008
+ data: { ...data, outcomes: retained, omittedOutcomes },
3009
+ ...(warnings.length > 0 ? { warnings } : {}),
3010
+ });
3011
+
3012
+ for (let index = 0; index < outcomes.length; index += 1)
3013
+ {
3014
+ retained.push(outcomes[index]);
3015
+ const remainingCount = outcomes.length - index - 1;
3016
+ // Keep an omission field in the serialized payload before deciding to
3017
+ // retain another result; JSON escaping can otherwise exceed the bound.
3018
+ omittedOutcomes = preOmittedOutcomes + remainingCount;
3019
+ if (wrap(payloadFor()).length > WORKSPACE_DIFF_TOTAL_MAX_CHARS)
3020
+ {
3021
+ retained.pop();
3022
+ omittedOutcomes = preOmittedOutcomes + outcomes.length - index;
3023
+ break;
3024
+ }
3025
+ }
3026
+ return wrap(payloadFor());
3027
+ };
3028
+
3029
+ export const workspaceDiff = tool({
3030
+ description: "Review explicitly selected pending workspace file diffs, or opt into a small bounded whole-workspace review with allPending=true. Use plastic_status when only changed paths are needed.",
3031
+ args: {
3032
+ paths: tool.schema.array(tool.schema.string().min(1).max(WORKSPACE_DIFF_PATH_MAX_CHARS)).max(WORKSPACE_DIFF_MAX_PATHS).optional().describe(`Pending workspace paths to review (maximum ${WORKSPACE_DIFF_MAX_PATHS}, ${WORKSPACE_DIFF_PATH_MAX_CHARS} characters each). Selecting paths includes matching private files.`),
3033
+ allPending: tool.schema.boolean().optional().describe("Explicitly review pending non-private files when paths are omitted. Defaults to false; required for an unscoped workspace diff."),
3034
+ includePrivate: tool.schema.boolean().optional().describe("Include private pending files with allPending=true. Selected private paths do not require this flag."),
3035
+ maxFiles: tool.schema.number().int().min(1).max(WORKSPACE_DIFF_MAX_FILES).optional().describe(`Maximum files to compare for allPending review (default ${WORKSPACE_DIFF_DEFAULT_MAX_FILES}, maximum ${WORKSPACE_DIFF_MAX_FILES}); selected paths are all considered unless this is set.`),
3036
+ maxChars: tool.schema.number().int().min(WORKSPACE_DIFF_MIN_CHARS).max(WORKSPACE_DIFF_PER_FILE_MAX_CHARS).optional().describe(`Maximum diff-body characters returned per file (default ${WORKSPACE_DIFF_DEFAULT_MAX_CHARS}, maximum ${WORKSPACE_DIFF_PER_FILE_MAX_CHARS}).`),
3037
+ format: outputFormatArg,
3038
+ workdir: workdirArg,
3039
+ },
3040
+ async execute(args)
3041
+ {
3042
+ const cwd = args.workdir ?? process.cwd();
3043
+ // Runtime guards also cover direct execute() callers that bypass schema validation.
3044
+ if (args.paths !== undefined && !Array.isArray(args.paths))
3045
+ {
3046
+ throw new Error("paths must be an array of non-blank workspace paths.");
3047
+ }
3048
+ if (args.allPending !== undefined && typeof args.allPending !== "boolean")
3049
+ {
3050
+ throw new Error("allPending must be a boolean; whole-workspace review requires allPending=true explicitly.");
3051
+ }
3052
+ if (args.includePrivate !== undefined && typeof args.includePrivate !== "boolean")
3053
+ {
3054
+ throw new Error("includePrivate must be a boolean and only applies with allPending=true.");
3055
+ }
3056
+ const rawPaths = Array.isArray(args.paths) ? args.paths : [];
3057
+ if (rawPaths.some((path) => typeof path !== "string" || path.trim().length === 0))
3058
+ {
3059
+ throw new Error("paths must contain only non-blank workspace paths.");
3060
+ }
3061
+ const selectedPaths = rawPaths.length > 0
3062
+ ? rawPaths.slice(0, WORKSPACE_DIFF_MAX_PATHS).map((path) => boundWorkspaceValue(path, WORKSPACE_DIFF_PATH_MAX_CHARS))
3063
+ : undefined;
3064
+ const allPending = args.allPending === true;
3065
+ const includePrivate = args.includePrivate === true;
3066
+ const workspaceRoot = toNormalizedAbsolutePath(".", cwd);
3067
+ if (selectedPaths?.some((path) => toPathComparisonKeyFromAbsolutePath(toNormalizedAbsolutePath(path, cwd)) === toPathComparisonKeyFromAbsolutePath(workspaceRoot)))
3068
+ {
3069
+ throw new Error("A workspace-root path is a whole-workspace review; use allPending=true instead of selecting the workspace root.");
3070
+ }
3071
+ if (selectedPaths && allPending)
3072
+ {
3073
+ throw new Error("Choose either explicit paths or allPending=true, not both.");
3074
+ }
3075
+ if (!selectedPaths && !allPending)
3076
+ {
3077
+ throw new Error("plastic_workspaceDiff requires explicit paths or allPending=true. Use plastic_status when only pending paths are needed.");
3078
+ }
3079
+ if (selectedPaths && includePrivate)
3080
+ {
3081
+ throw new Error("includePrivate is only used with allPending=true; explicitly selected private paths are already included.");
3082
+ }
3083
+ const ignoredPathInputs = Math.max(0, rawPaths.length - (selectedPaths?.length ?? 0));
3084
+ const pendingItems = await getMachineReadablePendingItems(args.workdir); // exactly one status command per batch
3085
+ const selectedScopes = selectedPaths?.map((path) => toNormalizedAbsolutePath(path, cwd)) ?? [];
3086
+ let candidates = selectedPaths ? filterPendingItemsByScope(pendingItems, selectedScopes) : pendingItems.filter((item) => item.kind !== "private");
3087
+ if (!selectedPaths && includePrivate)
3088
+ {
3089
+ candidates = pendingItems;
3090
+ }
3091
+ const defaultMaxFiles = selectedPaths ? selectedPaths.length : WORKSPACE_DIFF_DEFAULT_MAX_FILES;
3092
+ const requestedMaxFiles = typeof args.maxFiles === "number" && Number.isFinite(args.maxFiles) ? Math.trunc(args.maxFiles) : defaultMaxFiles;
3093
+ const maxFiles = Math.min(WORKSPACE_DIFF_MAX_FILES, Math.max(1, requestedMaxFiles));
3094
+ const requestedMaxChars = typeof args.maxChars === "number" && Number.isFinite(args.maxChars) ? Math.trunc(args.maxChars) : WORKSPACE_DIFF_DEFAULT_MAX_CHARS;
3095
+ const maxChars = Math.min(WORKSPACE_DIFF_PER_FILE_MAX_CHARS, Math.max(WORKSPACE_DIFF_MIN_CHARS, requestedMaxChars));
3096
+ const skippedByLimit = Math.max(0, candidates.length - maxFiles);
3097
+ candidates = candidates.slice(0, maxFiles);
3098
+
3099
+ const outcomes: Array<Record<string, unknown>> = [];
3100
+ const textSections: string[] = [];
3101
+ let totalOutputChars = 0;
3102
+ let unprocessedCandidates = 0;
3103
+ for (let index = 0; index < candidates.length; index += 1)
3104
+ {
3105
+ if (totalOutputChars >= WORKSPACE_DIFF_CONTENT_MAX_CHARS)
3106
+ {
3107
+ unprocessedCandidates = candidates.length - index;
3108
+ break;
3109
+ }
3110
+ const item = candidates[index];
3111
+ const path = toCommandPath(item.normalizedPath, cwd);
3112
+ const displayPath = boundWorkspaceValue(path, WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS);
3113
+ if (item.isDirectory)
3114
+ {
3115
+ outcomes.push({ path: displayPath, kind: item.kind, status: "skipped-directory" });
3116
+ textSections.push(`### ${displayPath} (${item.kind})\nDirectory entries are not diffed.`);
3117
+ totalOutputChars += textSections[textSections.length - 1].length;
3118
+ continue;
3119
+ }
3120
+ try
3121
+ {
3122
+ const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item);
3123
+ const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
3124
+ const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
3125
+ const bounded = boundWorkspaceDiffResult(perFile, remaining);
3126
+ const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : "unchanged");
3127
+ const diffOutput = bounded.changed && !bounded.binary ? bounded.output : undefined;
3128
+ outcomes.push({ path: displayPath, kind: item.kind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, ...(diffOutput ? { diff: diffOutput } : {}) });
3129
+ const body = bounded.binary ? (bounded.changed ? "Binary content differs; a text diff is unavailable." : "No differences.") : (bounded.changed ? bounded.output : "No differences.");
3130
+ const section = `### ${displayPath} (${item.kind})\n${body}`;
3131
+ textSections.push(section);
3132
+ totalOutputChars += section.length;
3133
+ }
3134
+ catch (error)
3135
+ {
3136
+ const message = boundWorkspaceValue(error instanceof Error ? error.message : String(error), WORKSPACE_DIFF_ERROR_MAX_CHARS);
3137
+ outcomes.push({ path: displayPath, kind: item.kind, status: "unavailable", error: message });
3138
+ const section = `### ${displayPath} (${item.kind})\nUnavailable: ${message}`;
3139
+ textSections.push(section);
3140
+ totalOutputChars += section.length;
3141
+ }
3142
+ }
3143
+
3144
+ const unmatchedPaths = selectedPaths?.filter((path) => !filterPendingItemsByScope(pendingItems, [toNormalizedAbsolutePath(path, cwd)]).length) ?? [];
3145
+ const unmatchedPreviews = workspacePathPreview(unmatchedPaths);
3146
+ const warnings = [
3147
+ ...(skippedByLimit > 0 ? [`Skipped ${skippedByLimit} pending item(s) after the maxFiles bound of ${maxFiles}.`] : []),
3148
+ ...(unmatchedPreviews.length > 0 ? [`${unmatchedPreviews.length} selected path(s) had no pending status record: ${unmatchedPreviews.slice(0, 5).join(", ")}${unmatchedPreviews.length > 5 ? `; ${unmatchedPreviews.length - 5} more omitted` : ""}.`] : []),
3149
+ ...(ignoredPathInputs > 0 ? [`Ignored ${ignoredPathInputs} path input(s) after the ${WORKSPACE_DIFF_MAX_PATHS}-path bound.`] : []),
3150
+ ...(!selectedPaths && !includePrivate && pendingItems.some((item) => item.kind === "private") ? ["Private pending files were excluded; select their paths explicitly or pass includePrivate=true."] : []),
3151
+ ].map((warning) => boundWorkspaceValue(warning, WORKSPACE_DIFF_ERROR_MAX_CHARS));
3152
+ const textPrefix = [
3153
+ "## Workspace Diff",
3154
+ "",
3155
+ `- Status records inspected once: ${pendingItems.length}`,
3156
+ `- Files considered: ${candidates.length}`,
3157
+ `- Per-file output bound: ${maxChars} characters; complete response bound: ${WORKSPACE_DIFF_TOTAL_MAX_CHARS} characters.`,
3158
+ ...warnings.map((warning) => `- Warning: ${warning}`),
3159
+ ...(textSections.length > 0 ? [] : ["", "No eligible pending files."]),
3160
+ ].join("\n");
3161
+ return formatWorkspaceDiffResult(args.format ?? "text", textPrefix, textSections, {
3162
+ statusRecords: pendingItems.length,
3163
+ selectedPaths: selectedPaths ? workspacePathPreview(selectedPaths) : null,
3164
+ selectedPathCount: selectedPaths?.length ?? 0,
3165
+ allPending,
3166
+ includePrivate,
3167
+ maxFiles,
3168
+ perFileMaxChars: maxChars,
3169
+ totalMaxChars: WORKSPACE_DIFF_TOTAL_MAX_CHARS,
3170
+ skippedByLimit,
3171
+ unmatchedPaths: unmatchedPreviews,
3172
+ unprocessedCandidates,
3173
+ }, warnings, outcomes, unprocessedCandidates, args.workdir);
2765
3174
  },
2766
3175
  });
2767
3176