@astrosheep/keiyaku 0.1.47 → 0.1.49

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 (58) hide show
  1. package/build/.tsbuildinfo +1 -1
  2. package/build/agents/round-runner.js +1 -1
  3. package/build/agents/selector.js +1 -1
  4. package/build/common/constants.js +6 -1
  5. package/build/common/errors.js +1 -1
  6. package/build/common/response-style.js +4 -1
  7. package/build/config/term-presets/constants.js +24 -0
  8. package/build/config/term-presets/default-preset.js +119 -0
  9. package/build/config/term-presets/index.js +5 -0
  10. package/build/config/term-presets/mischief-preset.js +105 -0
  11. package/build/config/term-presets/pocket-preset.js +105 -0
  12. package/build/config/term-presets/resolver.js +52 -0
  13. package/build/config/term-presets/types.js +1 -0
  14. package/build/handlers/ask.js +10 -120
  15. package/build/handlers/close.js +2 -9
  16. package/build/handlers/drive.js +1 -15
  17. package/build/handlers/shared.js +1 -2
  18. package/build/handlers/start.js +0 -17
  19. package/build/handlers/status.js +2 -6
  20. package/build/index.js +2 -2
  21. package/build/types/git-diff.js +1 -0
  22. package/build/utils/ask-history.js +75 -0
  23. package/build/utils/draft.js +22 -0
  24. package/build/utils/git-diff/constants.js +9 -0
  25. package/build/utils/git-diff/filter.js +70 -0
  26. package/build/utils/git-diff/incremental.js +111 -0
  27. package/build/utils/git-diff/index.js +3 -0
  28. package/build/utils/git-diff/parsers.js +160 -0
  29. package/build/utils/git-diff/preview.js +157 -0
  30. package/build/utils/git-diff/stat.js +27 -0
  31. package/build/utils/git-diff/types.js +1 -0
  32. package/build/utils/git-ops.js +9 -0
  33. package/build/utils/git.js +1 -1
  34. package/build/utils/keiyaku-document/index.js +13 -0
  35. package/build/utils/keiyaku-document/lex.js +178 -0
  36. package/build/utils/keiyaku-document/parser.js +242 -0
  37. package/build/utils/keiyaku-document/render.js +68 -0
  38. package/build/utils/keiyaku-document/sections.js +105 -0
  39. package/build/utils/keiyaku-document/types.js +6 -0
  40. package/build/utils/keiyaku-document.test.js +12 -1
  41. package/build/utils/trace.js +6 -7
  42. package/build/workflow/ask-execution.js +81 -0
  43. package/build/workflow/drive.js +10 -5
  44. package/build/workflow/iterate-plan.js +1 -1
  45. package/build/workflow/keiyaku-draft.js +1 -1
  46. package/build/workflow/{contract.js → keiyaku.js} +2 -2
  47. package/build/workflow/markdown-normalization.js +3 -2
  48. package/build/workflow/present.js +68 -13
  49. package/build/workflow/response-builders.js +75 -44
  50. package/build/workflow/response-meta.js +15 -0
  51. package/build/workflow/response-renderer.js +2 -13
  52. package/build/workflow/round-summary.js +1 -1
  53. package/build/workflow/start.js +8 -3
  54. package/build/workflow/status.js +129 -62
  55. package/package.json +1 -1
  56. package/build/config/term-presets.js +0 -398
  57. package/build/utils/git-diff.js +0 -519
  58. package/build/utils/keiyaku-document.js +0 -539
@@ -1,8 +1,6 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
2
  import { startKeiyaku } from "../workflow/start.js";
3
3
  import { buildKeiyakuSuccessResponse, } from "../workflow/response-builders.js";
4
- import { resolveTermPreset } from "../config/term-presets.js";
5
- import { renderPreset } from "../utils/text-utils.js";
6
4
  import { handleToolError } from "./shared.js";
7
5
  function normalizeOptionalArg(value) {
8
6
  if (value === undefined)
@@ -12,14 +10,6 @@ function normalizeOptionalArg(value) {
12
10
  export function createStartHandler() {
13
11
  return async ({ from_file, title, goal, directive, context, rules, criteria, name, cwd }, extra) => {
14
12
  const workingDir = cwd || process.cwd();
15
- const preset = resolveTermPreset();
16
- const nextHints = renderPreset(preset.nextHints, {
17
- start: preset.tools.start.name,
18
- drive: preset.tools.drive.name,
19
- ask: preset.tools.ask.name,
20
- close: preset.tools.close.name,
21
- identity: preset.identity,
22
- });
23
13
  const normalizedFromFile = normalizeOptionalArg(from_file);
24
14
  const parsedCriteria = normalizeOptionalArg(criteria);
25
15
  const parsedRules = normalizeOptionalArg(rules);
@@ -55,8 +45,6 @@ export function createStartHandler() {
55
45
  });
56
46
  return buildKeiyakuSuccessResponse(result, {
57
47
  name,
58
- cwd: workingDir,
59
- nextHints,
60
48
  });
61
49
  }
62
50
  catch (err) {
@@ -64,11 +52,6 @@ export function createStartHandler() {
64
52
  error: err,
65
53
  cwd: workingDir,
66
54
  logLabel: "tool start",
67
- inputEcho: [
68
- ...(normalizedFromFile ? [`From file: ${normalizedFromFile}`] : []),
69
- ...(name ? [`${preset.identity}: ${name}`] : []),
70
- `Path: ${workingDir}`,
71
- ],
72
55
  });
73
56
  }
74
57
  };
@@ -1,5 +1,5 @@
1
1
  import { appendDebugLog } from "../utils/debug-log.js";
2
- import { resolveTermPreset } from "../config/term-presets.js";
2
+ import { resolveTermPreset } from "../config/term-presets/index.js";
3
3
  import { readKeiyakuStatus } from "../workflow/status.js";
4
4
  import { buildStatusResponse } from "../workflow/response-builders.js";
5
5
  import { handleToolError } from "./shared.js";
@@ -20,7 +20,7 @@ export function createStatusHandler() {
20
20
  return buildStatusResponse(outcome, {
21
21
  start: preset.tools.start.name,
22
22
  drive: preset.tools.drive.name,
23
- present: preset.tools.close.name,
23
+ close: preset.tools.close.name,
24
24
  });
25
25
  }
26
26
  catch (err) {
@@ -28,10 +28,6 @@ export function createStatusHandler() {
28
28
  error: err,
29
29
  cwd: workingDir,
30
30
  logLabel: "tool status",
31
- inputEcho: [
32
- `${preset.tools.status.title}: read-only`,
33
- `Path: ${workingDir}`,
34
- ],
35
31
  });
36
32
  }
37
33
  };
package/build/index.js CHANGED
@@ -4,9 +4,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
5
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
6
6
  import { readFileSync } from "node:fs";
7
- import { resolveOath } from "./workflow/contract.js";
7
+ import { resolveOath } from "./workflow/keiyaku.js";
8
8
  import { askToolSchema, startToolSchema, driveToolSchema, closeToolSchema, helpToolSchema, statusToolSchema, } from "./types/tooling.js";
9
- import { listTermPresets, resolveTermPreset, getAvailableNamesForPreset } from "./config/term-presets.js";
9
+ import { listTermPresets, resolveTermPreset, getAvailableNamesForPreset } from "./config/term-presets/index.js";
10
10
  import { renderPreset } from "./utils/text-utils.js";
11
11
  import { applyArgumentDescriptions } from "./utils/schema-utils.js";
12
12
  import { assertMcpServerStartupAllowed } from "./common/startup-guard.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ const ASK_HISTORY_DIR = path.join(".keiyaku", "history", "ask");
4
+ const ASK_HISTORY_SLUG_MAX_LENGTH = 24;
5
+ const ASK_HISTORY_FALLBACK_SLUG = "response";
6
+ function toTimestampParts(now) {
7
+ const year = String(now.getFullYear());
8
+ const month = String(now.getMonth() + 1).padStart(2, "0");
9
+ const day = String(now.getDate()).padStart(2, "0");
10
+ const hour = String(now.getHours()).padStart(2, "0");
11
+ const minute = String(now.getMinutes()).padStart(2, "0");
12
+ const second = String(now.getSeconds()).padStart(2, "0");
13
+ return {
14
+ fileStamp: `${year}${month}${day}-${hour}${minute}${second}`,
15
+ iso: now.toISOString(),
16
+ };
17
+ }
18
+ function toSlug(text) {
19
+ const normalized = text.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-");
20
+ const truncated = normalized
21
+ .slice(0, ASK_HISTORY_SLUG_MAX_LENGTH)
22
+ .replace(/^-+/, "")
23
+ .replace(/-+$/, "");
24
+ return truncated || ASK_HISTORY_FALLBACK_SLUG;
25
+ }
26
+ function toYamlSingleQuoted(value) {
27
+ return `'${value.replace(/'/g, "''")}'`;
28
+ }
29
+ function buildAskHistoryMarkdown(input) {
30
+ const sessionId = input.sessionId?.trim() || "(none)";
31
+ const branch = input.branch?.trim() || "(unknown)";
32
+ const commit = input.commit?.trim();
33
+ const frontMatterLines = [
34
+ "---",
35
+ `branch: ${toYamlSingleQuoted(branch)}`,
36
+ ...(commit ? [`commit: ${toYamlSingleQuoted(commit)}`] : []),
37
+ `session_id: ${toYamlSingleQuoted(sessionId)}`,
38
+ `time: ${toYamlSingleQuoted(input.timestampIso)}`,
39
+ "---",
40
+ "",
41
+ ];
42
+ return [
43
+ ...frontMatterLines,
44
+ "# Ask History",
45
+ "",
46
+ "## Request",
47
+ input.request,
48
+ "",
49
+ "## Context",
50
+ input.context,
51
+ "",
52
+ "## Response",
53
+ input.response,
54
+ "",
55
+ ].join("\n");
56
+ }
57
+ export async function persistAskHistory(input) {
58
+ const now = input.now ?? new Date();
59
+ const { fileStamp, iso } = toTimestampParts(now);
60
+ const slug = toSlug(input.summary);
61
+ const historyDir = path.join(input.cwd, ASK_HISTORY_DIR);
62
+ const savedToPath = path.join(historyDir, `${fileStamp}_${slug}.md`);
63
+ const markdown = buildAskHistoryMarkdown({
64
+ request: input.request,
65
+ context: input.context,
66
+ response: input.summary,
67
+ sessionId: input.sessionId,
68
+ branch: input.branch,
69
+ commit: input.commit,
70
+ timestampIso: iso,
71
+ });
72
+ await fs.mkdir(historyDir, { recursive: true });
73
+ await fs.writeFile(savedToPath, markdown, "utf-8");
74
+ return path.relative(input.cwd, savedToPath);
75
+ }
@@ -0,0 +1,22 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
3
+ import { KEIYAKU_DRAFT_FILE } from "../common/constants.js";
4
+ export async function readDraftSnapshot(cwd) {
5
+ const draftFilePath = path.join(cwd, KEIYAKU_DRAFT_FILE);
6
+ try {
7
+ const stat = await fs.stat(draftFilePath);
8
+ if (!stat.isFile())
9
+ return null;
10
+ const content = await fs.readFile(draftFilePath);
11
+ return {
12
+ path: KEIYAKU_DRAFT_FILE,
13
+ content,
14
+ };
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export async function restoreDraftSnapshot(cwd, snapshot) {
21
+ await fs.writeFile(path.join(cwd, snapshot.path), snapshot.content);
22
+ }
@@ -0,0 +1,9 @@
1
+ export const DIFF_EXCLUDES = [":(exclude)KEIYAKU.md", ":(exclude)KEIYAKU_TRACE.md"];
2
+ // Diff preview limits (no env/config knobs on purpose).
3
+ export const DIFF_PREVIEW_MAX_FILES = 12;
4
+ export const DIFF_PREVIEW_MAX_HUNKS_PER_FILE = 4;
5
+ export const DIFF_PREVIEW_MAX_LINES_PER_HUNK = 80;
6
+ export const DIFF_PREVIEW_MAX_PRELUDE_LINES = 50;
7
+ export const INCREMENTAL_DIFF_RANGE = "HEAD~1...HEAD";
8
+ export const TARGETED_DIFF_WARNING = "Warning: no valid diff coordinates found in KEIYAKU_TRACE.md; showing stat-only diff.";
9
+ export const NOT_SHOWN_IN_DETAIL_PREFIX = "--- not shown in detail: ";
@@ -0,0 +1,70 @@
1
+ import { NOT_SHOWN_IN_DETAIL_PREFIX } from "./constants.js";
2
+ import { isDeletedDiffSection, parseDiffPathFromHeader, parseUnifiedHunks, splitDiffByFile } from "./parsers.js";
3
+ function rangesOverlap(aStart, aEnd, bStart, bEnd) {
4
+ return aStart <= bEnd && bStart <= aEnd;
5
+ }
6
+ function buildCoordinateIndex(coordinates) {
7
+ const index = new Map();
8
+ for (const coordinate of coordinates) {
9
+ const existing = index.get(coordinate.path) ?? [];
10
+ existing.push(coordinate);
11
+ index.set(coordinate.path, existing);
12
+ }
13
+ return index;
14
+ }
15
+ export function capTargetedDiffText(diffText, maxChars) {
16
+ if (diffText.length <= maxChars)
17
+ return diffText;
18
+ const cut = diffText.slice(0, maxChars);
19
+ return `${cut}\n...[truncated ${diffText.length - maxChars} chars]...`;
20
+ }
21
+ export function filterDiffByCoordinates(rawPatch, coordinates) {
22
+ const sections = splitDiffByFile(rawPatch);
23
+ if (sections.length === 0)
24
+ return "";
25
+ const byPath = buildCoordinateIndex(coordinates);
26
+ const selectedSectionsByPath = new Map();
27
+ const deletedSections = [];
28
+ const nonDeletedPathsInPatch = [];
29
+ const sectionByPath = new Map();
30
+ for (const section of sections) {
31
+ const path = parseDiffPathFromHeader(section[0] ?? "");
32
+ if (!path)
33
+ continue;
34
+ if (isDeletedDiffSection(section)) {
35
+ deletedSections.push(section.join("\n"));
36
+ continue;
37
+ }
38
+ if (!sectionByPath.has(path)) {
39
+ nonDeletedPathsInPatch.push(path);
40
+ sectionByPath.set(path, section);
41
+ }
42
+ }
43
+ for (const coordinate of coordinates) {
44
+ const section = sectionByPath.get(coordinate.path);
45
+ if (!section || selectedSectionsByPath.has(coordinate.path))
46
+ continue;
47
+ const fileCoordinates = byPath.get(coordinate.path);
48
+ if (!fileCoordinates || fileCoordinates.length === 0)
49
+ continue;
50
+ const { prelude, hunks } = parseUnifiedHunks(section);
51
+ if (hunks.length === 0)
52
+ continue;
53
+ const matchingHunks = hunks.filter((hunk) => fileCoordinates.some((coord) => rangesOverlap(hunk.newStart, hunk.newEnd, coord.start, coord.end)));
54
+ if (matchingHunks.length === 0)
55
+ continue;
56
+ const rendered = [
57
+ ...prelude,
58
+ ...matchingHunks.flatMap((hunk) => [hunk.header, ...hunk.lines]),
59
+ ].join("\n");
60
+ selectedSectionsByPath.set(coordinate.path, rendered);
61
+ }
62
+ const selectedSections = [...selectedSectionsByPath.values(), ...deletedSections];
63
+ if (selectedSections.length === 0)
64
+ return "";
65
+ const filteredOutPaths = nonDeletedPathsInPatch.filter((path) => !selectedSectionsByPath.has(path));
66
+ if (filteredOutPaths.length > 0) {
67
+ selectedSections.push(`${NOT_SHOWN_IN_DETAIL_PREFIX}${filteredOutPaths.join(", ")}`);
68
+ }
69
+ return selectedSections.join("\n");
70
+ }
@@ -0,0 +1,111 @@
1
+ import { DEFAULT_INCREMENTAL_DIFF_MODE, } from "../../common/constants.js";
2
+ import { createGit, wrapGitError } from "../git-ops.js";
3
+ import { DIFF_EXCLUDES, INCREMENTAL_DIFF_RANGE, TARGETED_DIFF_WARNING } from "./constants.js";
4
+ import { capTargetedDiffText, filterDiffByCoordinates } from "./filter.js";
5
+ import { isMissingBaseRevisionError, parseDiffPathFromHeader, splitDiffByFile, totalJoinedLength, truncateBlockToLineBudget } from "./parsers.js";
6
+ import { renderStatDiffPreviewText } from "./stat.js";
7
+ async function readIncrementalPatch(git, cwd) {
8
+ try {
9
+ return await git.raw([
10
+ "diff",
11
+ "--no-color",
12
+ "--no-ext-diff",
13
+ "--unified=3",
14
+ INCREMENTAL_DIFF_RANGE,
15
+ "--",
16
+ ".",
17
+ ...DIFF_EXCLUDES,
18
+ ]);
19
+ }
20
+ catch (err) {
21
+ if (isMissingBaseRevisionError(err)) {
22
+ return null;
23
+ }
24
+ throw wrapGitError(`diff --no-color --no-ext-diff --unified=3 ${INCREMENTAL_DIFF_RANGE}`, err, cwd);
25
+ }
26
+ }
27
+ function renderIncrementalUnifiedDiff(rawPatch, maxChars) {
28
+ const sections = splitDiffByFile(rawPatch);
29
+ if (sections.length === 0)
30
+ return "No changes in last round.";
31
+ const maxLinesPerFile = 90;
32
+ const filePreviews = [];
33
+ let omittedFiles = 0;
34
+ for (let i = 0; i < sections.length; i += 1) {
35
+ const section = sections[i];
36
+ const fileName = parseDiffPathFromHeader(section[0] ?? "") ?? "unknown";
37
+ const header = `--- ${fileName} ---`;
38
+ const content = section.slice(0, maxLinesPerFile);
39
+ const isTruncated = section.length > maxLinesPerFile;
40
+ if (isTruncated) {
41
+ content.push(`... [truncated ${section.length - maxLinesPerFile} lines for this file]`);
42
+ }
43
+ const fileBlock = `${header}\n${content.join("\n")}\n`;
44
+ const projected = totalJoinedLength([...filePreviews, fileBlock]);
45
+ if (projected <= maxChars) {
46
+ filePreviews.push(fileBlock);
47
+ continue;
48
+ }
49
+ omittedFiles = sections.length - i;
50
+ if (filePreviews.length === 0) {
51
+ const notice = `... [omitted ${omittedFiles} file(s) to stay under ${maxChars} chars] ...`;
52
+ const partialBudget = Math.max(0, maxChars - notice.length - 1);
53
+ const partial = truncateBlockToLineBudget(fileBlock, partialBudget);
54
+ if (partial.trim().length > 0) {
55
+ filePreviews.push(partial);
56
+ }
57
+ }
58
+ break;
59
+ }
60
+ if (omittedFiles > 0) {
61
+ const notice = `... [omitted ${omittedFiles} file(s) to stay under ${maxChars} chars] ...`;
62
+ while (filePreviews.length > 0 && totalJoinedLength([...filePreviews, notice]) > maxChars) {
63
+ filePreviews.pop();
64
+ }
65
+ if (totalJoinedLength([...filePreviews, notice]) <= maxChars) {
66
+ filePreviews.push(notice);
67
+ }
68
+ }
69
+ return filePreviews.join("\n");
70
+ }
71
+ async function getIncrementalUnifiedDiff(git, cwd, maxChars) {
72
+ const rawPatch = await readIncrementalPatch(git, cwd);
73
+ if (rawPatch === null)
74
+ return "No incremental diff available.";
75
+ return renderIncrementalUnifiedDiff(rawPatch, maxChars);
76
+ }
77
+ async function getIncrementalStatDiff(git, cwd, maxChars) {
78
+ let output = "";
79
+ try {
80
+ output = await git.raw(["diff", "--numstat", INCREMENTAL_DIFF_RANGE, "--", ".", ...DIFF_EXCLUDES]);
81
+ }
82
+ catch (err) {
83
+ if (isMissingBaseRevisionError(err))
84
+ return "No incremental diff available.";
85
+ throw wrapGitError(`diff --numstat ${INCREMENTAL_DIFF_RANGE}`, err, cwd);
86
+ }
87
+ return renderStatDiffPreviewText(output, maxChars);
88
+ }
89
+ export async function getIncrementalDiff(cwd, mode = DEFAULT_INCREMENTAL_DIFF_MODE, options) {
90
+ const git = createGit(cwd);
91
+ const { coordinates, maxChars } = options;
92
+ const renderByMode = {
93
+ unified: () => getIncrementalUnifiedDiff(git, cwd, maxChars.targeted),
94
+ stat: () => getIncrementalStatDiff(git, cwd, maxChars.stat),
95
+ targeted: async () => {
96
+ const stat = await getIncrementalStatDiff(git, cwd, maxChars.stat);
97
+ if (!coordinates || coordinates.length === 0) {
98
+ return `${stat}\n\n${TARGETED_DIFF_WARNING}`;
99
+ }
100
+ const rawPatch = await readIncrementalPatch(git, cwd);
101
+ if (rawPatch === null)
102
+ return stat;
103
+ const targetedPatch = filterDiffByCoordinates(rawPatch, coordinates).trim();
104
+ if (!targetedPatch) {
105
+ return `${stat}\n\n${TARGETED_DIFF_WARNING}`;
106
+ }
107
+ return `${stat}\n\n${capTargetedDiffText(targetedPatch, maxChars.targeted)}`;
108
+ },
109
+ };
110
+ return renderByMode[mode]();
111
+ }
@@ -0,0 +1,3 @@
1
+ export { filterDiffByCoordinates } from "./filter.js";
2
+ export { getIncrementalDiff } from "./incremental.js";
3
+ export { getDiff, getDiffPreviewText, getDiffStats } from "./preview.js";
@@ -0,0 +1,160 @@
1
+ export function parseNumStat(content) {
2
+ const rows = [];
3
+ for (const line of content.split(/\r?\n/)) {
4
+ if (!line.trim())
5
+ continue;
6
+ const [addRaw, delRaw, ...pathParts] = line.split("\t");
7
+ const filePath = pathParts.join("\t").trim();
8
+ if (!filePath)
9
+ continue;
10
+ const binary = addRaw === "-" || delRaw === "-";
11
+ rows.push({
12
+ path: filePath,
13
+ additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
14
+ deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
15
+ binary,
16
+ });
17
+ }
18
+ return rows;
19
+ }
20
+ export function parseNameStatus(content) {
21
+ const map = new Map();
22
+ for (const line of content.split(/\r?\n/)) {
23
+ if (!line.trim())
24
+ continue;
25
+ const parts = line.split("\t");
26
+ const statusRaw = (parts[0] ?? "").trim();
27
+ if (!statusRaw)
28
+ continue;
29
+ if ((statusRaw.startsWith("R") || statusRaw.startsWith("C")) && parts.length >= 3) {
30
+ const scoreRaw = statusRaw.slice(1);
31
+ const score = Number.parseInt(scoreRaw, 10);
32
+ const oldPath = (parts[1] ?? "").trim();
33
+ const path = (parts[2] ?? "").trim();
34
+ if (!path)
35
+ continue;
36
+ const status = statusRaw[0] === "R" ? "R" : "C";
37
+ map.set(path, { status, score: Number.isFinite(score) ? score : 0, oldPath, path });
38
+ continue;
39
+ }
40
+ const path = (parts[1] ?? "").trim();
41
+ if (!path)
42
+ continue;
43
+ // Git can emit single-letter statuses, possibly in combinations; we keep just the first.
44
+ const status = statusRaw[0];
45
+ map.set(path, { status, path });
46
+ }
47
+ return map;
48
+ }
49
+ export function splitDiffByFile(content) {
50
+ const sections = [];
51
+ let current = [];
52
+ for (const line of content.split(/\r?\n/)) {
53
+ if (line.startsWith("diff --git ")) {
54
+ if (current.length > 0)
55
+ sections.push(current);
56
+ current = [line];
57
+ continue;
58
+ }
59
+ if (current.length > 0)
60
+ current.push(line);
61
+ }
62
+ if (current.length > 0)
63
+ sections.push(current);
64
+ return sections;
65
+ }
66
+ export function parseUnifiedHunks(section) {
67
+ const firstHunkIndex = section.findIndex((line) => line.startsWith("@@ "));
68
+ if (firstHunkIndex === -1) {
69
+ return { prelude: [...section], hunks: [] };
70
+ }
71
+ const prelude = section.slice(0, firstHunkIndex);
72
+ const hunks = [];
73
+ let current = [];
74
+ for (const line of section.slice(firstHunkIndex)) {
75
+ if (line.startsWith("@@ ")) {
76
+ if (current.length > 0) {
77
+ const parsed = parseHunkRange(current[0] ?? "");
78
+ if (parsed) {
79
+ hunks.push({
80
+ header: current[0] ?? "",
81
+ lines: current.slice(1),
82
+ newStart: parsed.start,
83
+ newEnd: parsed.end,
84
+ });
85
+ }
86
+ }
87
+ current = [line];
88
+ continue;
89
+ }
90
+ if (current.length > 0)
91
+ current.push(line);
92
+ }
93
+ if (current.length > 0) {
94
+ const parsed = parseHunkRange(current[0] ?? "");
95
+ if (parsed) {
96
+ hunks.push({
97
+ header: current[0] ?? "",
98
+ lines: current.slice(1),
99
+ newStart: parsed.start,
100
+ newEnd: parsed.end,
101
+ });
102
+ }
103
+ }
104
+ return { prelude, hunks };
105
+ }
106
+ function parseHunkRange(hunkHeader) {
107
+ const match = hunkHeader.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
108
+ if (!match)
109
+ return null;
110
+ const start = Number.parseInt(match[1] ?? "", 10);
111
+ const countRaw = match[2];
112
+ const count = countRaw ? Number.parseInt(countRaw, 10) : 1;
113
+ if (!Number.isFinite(start) || !Number.isFinite(count))
114
+ return null;
115
+ if (count <= 0)
116
+ return { start, end: start - 1 };
117
+ return { start, end: start + count - 1 };
118
+ }
119
+ export function isDeletedDiffSection(section) {
120
+ return section.some((line) => line.startsWith("deleted file mode ") || line.startsWith("+++ /dev/null"));
121
+ }
122
+ export function isMissingBaseRevisionError(err) {
123
+ const source = (err ?? {});
124
+ const text = [source.message, source.stderr, source.stdErr, source.stdout, source.stdOut]
125
+ .filter((value) => typeof value === "string" && value.length > 0)
126
+ .join("\n");
127
+ return (text.includes("bad revision") ||
128
+ text.includes("unknown revision or path not in the working tree") ||
129
+ text.includes("ambiguous argument"));
130
+ }
131
+ export function parseDiffPathFromHeader(diffGitLine) {
132
+ // Format: diff --git a/<path> b/<path>
133
+ const parts = diffGitLine.split(" ");
134
+ const bPart = parts[3] ?? "";
135
+ if (!bPart.startsWith("b/"))
136
+ return null;
137
+ return bPart.slice(2);
138
+ }
139
+ export function totalJoinedLength(blocks) {
140
+ if (blocks.length === 0)
141
+ return 0;
142
+ return blocks.reduce((sum, block) => sum + block.length, 0) + (blocks.length - 1);
143
+ }
144
+ export function truncateBlockToLineBudget(block, maxChars) {
145
+ if (maxChars <= 0)
146
+ return "";
147
+ if (block.length <= maxChars)
148
+ return block;
149
+ const lines = block.split("\n");
150
+ const kept = [];
151
+ let used = 0;
152
+ for (const line of lines) {
153
+ const delta = (kept.length > 0 ? 1 : 0) + line.length;
154
+ if (used + delta > maxChars)
155
+ break;
156
+ kept.push(line);
157
+ used += delta;
158
+ }
159
+ return kept.join("\n");
160
+ }