@astrosheep/keiyaku 0.1.76 → 0.1.78

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 (53) hide show
  1. package/README.md +88 -96
  2. package/build/.tsbuildinfo +1 -1
  3. package/build/config/apply-argument-descriptions.js +1 -1
  4. package/build/config/base-rules.js +14 -7
  5. package/build/config/dotenv.js +17 -11
  6. package/build/config/keiyaku-home.js +9 -0
  7. package/build/config/settings.js +41 -24
  8. package/build/config/term-presets/resolver.js +0 -3
  9. package/build/errno.js +3 -0
  10. package/build/flow-error.js +2 -0
  11. package/build/generated/version.js +1 -1
  12. package/build/git/diff/constants.js +1 -0
  13. package/build/git/diff/filter.js +3 -18
  14. package/build/git/diff/parsers.js +149 -61
  15. package/build/git/diff/preview.js +16 -2
  16. package/build/git/diff/read.js +32 -20
  17. package/build/git/snapshot.js +5 -24
  18. package/build/git/worktree.js +5 -4
  19. package/build/mcp/responses.js +3 -2
  20. package/build/mcp/server.js +61 -69
  21. package/build/protocol/draft-artifacts.js +2 -1
  22. package/build/protocol/file-guards.js +2 -1
  23. package/build/protocol/markdown/lex.js +52 -14
  24. package/build/protocol/markdown/normalization.js +3 -2
  25. package/build/protocol/markdown/parser.js +2 -2
  26. package/build/protocol/response-history.js +44 -5
  27. package/build/protocol/status-previews.js +20 -8
  28. package/build/protocol/summon-draft.js +3 -2
  29. package/build/protocol/summon-input.js +1 -0
  30. package/build/protocol/trace.js +1 -1
  31. package/build/tools/amend/index.js +11 -21
  32. package/build/tools/ask/index.js +11 -18
  33. package/build/tools/ask/persist.js +60 -37
  34. package/build/tools/ask/run.js +17 -7
  35. package/build/tools/create-handler.js +31 -0
  36. package/build/tools/drive/index.js +11 -24
  37. package/build/tools/drive/run.js +9 -5
  38. package/build/tools/petition/claim-gates.js +38 -9
  39. package/build/tools/petition/claim.js +20 -2
  40. package/build/tools/petition/forfeit.js +4 -1
  41. package/build/tools/petition/index.js +43 -58
  42. package/build/tools/petition/run.js +12 -0
  43. package/build/tools/round/head-guard.js +10 -0
  44. package/build/tools/round/incremental-diff.js +6 -2
  45. package/build/tools/round/report.js +24 -2
  46. package/build/tools/round/run.js +6 -0
  47. package/build/tools/round/worktree.js +6 -2
  48. package/build/tools/status/index.js +11 -24
  49. package/build/tools/status/read.js +6 -4
  50. package/build/tools/summon/index.js +17 -27
  51. package/build/tools/summon/run.js +21 -18
  52. package/package.json +6 -6
  53. package/build/git/diff/stat.js +0 -9
@@ -1,8 +1,10 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { z } from "zod";
4
+ import { isErrnoException } from "../errno.js";
4
5
  import { FlowError } from "../flow-error.js";
5
6
  import { AGENT_ROLES, CODEX_MODEL_REASONING_EFFORT_CONFIG_KEY, ROUND_TIERS, SETTINGS_FILE, } from "../keiyaku.js";
7
+ import { getKeiyakuHome } from "./keiyaku-home.js";
6
8
  const reasoningEffortSchema = z.enum(["low", "medium", "high"]);
7
9
  const tomlValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.array(tomlValueSchema), z.record(z.string(), tomlValueSchema)]));
8
10
  function optionalTrimmedString() {
@@ -73,8 +75,6 @@ const opencodeAgentProfileSchema = z
73
75
  command: commandSchema,
74
76
  })
75
77
  .strict();
76
- export const roundTierSchema = z.enum(ROUND_TIERS);
77
- export const agentRoleSchema = z.enum(AGENT_ROLES);
78
78
  export const agentProfileSchema = z.discriminatedUnion("provider", [
79
79
  codexAgentProfileSchema,
80
80
  codexSdkAgentProfileSchema,
@@ -132,41 +132,61 @@ export class SettingsParseError extends Error {
132
132
  }
133
133
  }
134
134
  function parseSettings(rawSettings) {
135
- let parsed;
136
- try {
137
- parsed = JSON.parse(rawSettings);
138
- }
139
- catch (error) {
140
- throw new SettingsParseError("INVALID_JSON", "Invalid settings JSON.", { cause: error });
141
- }
142
- const result = settingsSchema.safeParse(parsed);
135
+ const result = settingsSchema.safeParse(rawSettings);
143
136
  if (!result.success) {
144
137
  const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`);
145
138
  throw new SettingsParseError("INVALID_SCHEMA", `Invalid settings schema. ${issues.join("; ")}`, { issues });
146
139
  }
147
140
  return result.data;
148
141
  }
149
- export async function loadKeiyakuSettings(cwd) {
150
- let rawSettings;
142
+ function isPlainObject(value) {
143
+ return typeof value === "object" && value !== null && !Array.isArray(value);
144
+ }
145
+ function deepMerge(base, override) {
146
+ if (!isPlainObject(base) || !isPlainObject(override)) {
147
+ return override;
148
+ }
149
+ const merged = { ...base };
150
+ for (const [key, value] of Object.entries(override)) {
151
+ merged[key] = key in merged ? deepMerge(merged[key], value) : value;
152
+ }
153
+ return merged;
154
+ }
155
+ async function readOptionalSettings(filePath) {
151
156
  try {
152
- rawSettings = await fs.readFile(path.join(cwd, SETTINGS_FILE), "utf-8");
157
+ const raw = await fs.readFile(filePath, "utf-8");
158
+ return { found: true, value: JSON.parse(raw) };
153
159
  }
154
160
  catch (error) {
155
- if (error?.code === "ENOENT") {
156
- return null;
161
+ if (isErrnoException(error) && error.code === "ENOENT") {
162
+ return { found: false, value: undefined };
163
+ }
164
+ if (error instanceof SyntaxError) {
165
+ throw new FlowError("INVALID_SETTINGS", `Invalid settings in ${filePath} (must be valid JSON).`, {
166
+ cause: error,
167
+ });
157
168
  }
158
169
  throw error;
159
170
  }
171
+ }
172
+ export async function loadKeiyakuSettings(cwd) {
173
+ const localSettingsPath = path.join(cwd, SETTINGS_FILE);
174
+ const globalSettingsPath = path.join(getKeiyakuHome(), path.basename(SETTINGS_FILE));
175
+ const globalSettings = await readOptionalSettings(globalSettingsPath);
176
+ const localSettings = await readOptionalSettings(localSettingsPath);
177
+ if (!globalSettings.found && !localSettings.found) {
178
+ return null;
179
+ }
180
+ const resolvedSettings = globalSettings.found && localSettings.found
181
+ ? deepMerge(globalSettings.value, localSettings.value)
182
+ : globalSettings.found
183
+ ? globalSettings.value
184
+ : localSettings.value;
160
185
  try {
161
- return parseSettings(rawSettings);
186
+ return parseSettings(resolvedSettings);
162
187
  }
163
188
  catch (error) {
164
189
  if (error instanceof SettingsParseError) {
165
- if (error.code === "INVALID_JSON") {
166
- throw new FlowError("INVALID_SETTINGS", `Invalid settings in ${SETTINGS_FILE} (must be valid JSON).`, {
167
- cause: error,
168
- });
169
- }
170
190
  throw new FlowError("INVALID_SETTINGS", `Invalid settings in ${SETTINGS_FILE} (invalid schema).`, {
171
191
  cause: error,
172
192
  hints: error.issues,
@@ -175,9 +195,6 @@ export async function loadKeiyakuSettings(cwd) {
175
195
  throw error;
176
196
  }
177
197
  }
178
- export function getDefaultAgentProfile(role) {
179
- return DEFAULT_AGENT_PROFILES[role];
180
- }
181
198
  export function resolveAgentProfile(settings, name) {
182
199
  const configuredProfile = settings?.agent?.[name];
183
200
  if (configuredProfile) {
@@ -10,9 +10,6 @@ export function resolveTermPreset() {
10
10
  }
11
11
  throw new Error(`Unsupported ${ENV_KEYS.TERM_PRESET} '${raw}'. Expected '${fallbackPreset}'.`);
12
12
  }
13
- export function listTermPresets() {
14
- return [DEFAULT_PRESET];
15
- }
16
13
  function extractPresetAvailableNames(preset) {
17
14
  return (preset.availableNames?.map((value) => value.trim()).filter((value) => value.length > 0) ?? []);
18
15
  }
package/build/errno.js ADDED
@@ -0,0 +1,3 @@
1
+ export function isErrnoException(error) {
2
+ return error instanceof Error && "code" in error;
3
+ }
@@ -88,6 +88,8 @@ function hintForFlowCode(code, context) {
88
88
  return "Invalid close intent. Valid values are `CLAIM` and `FORFEIT`.";
89
89
  case "INVALID_SETTINGS":
90
90
  return `Fix invalid configuration in \`${SETTINGS_FILE}\` and retry.`;
91
+ case "CONCURRENT_MODIFICATION":
92
+ return `Concurrent repository changes were detected during \`${context.driveToolName}\`. Retry on the latest branch state.`;
91
93
  case "INTERNAL_STATE":
92
94
  return "An internal state error occurred. Please verify your repository status.";
93
95
  case "REVIEW_REJECTED":
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.mjs
2
- export const VERSION = "0.1.76";
2
+ export const VERSION = "0.1.78";
@@ -4,3 +4,4 @@ export const MAX_DIFF_LINES_PER_FILE = 80;
4
4
  export const INCREMENTAL_DIFF_RANGE = "HEAD~1...HEAD";
5
5
  export const TARGETED_DIFF_WARNING = "Warning: no valid diff coordinates found in KEIYAKU_TRACE.md; showing stat-only diff.";
6
6
  export const NOT_SHOWN_IN_DETAIL_PREFIX = "--- not shown in detail: ";
7
+ export const DIFF_TRUNCATED_MARKER = "...[truncated]";
@@ -1,5 +1,5 @@
1
1
  import { NOT_SHOWN_IN_DETAIL_PREFIX } from "./constants.js";
2
- import { isDeletedDiffSection, parseDiffPathFromHeader, parseUnifiedHunks, splitDiffByFile, truncateTextWithFooter, } from "./parsers.js";
2
+ import { isDeletedDiffSection, parseDiffPathFromHeader, parseUnifiedHunks, splitDiffByFile, truncateDiffSectionByLineCount, } from "./parsers.js";
3
3
  function rangesOverlap(aStart, aEnd, bStart, bEnd) {
4
4
  return aStart <= bEnd && bStart <= aEnd;
5
5
  }
@@ -12,21 +12,6 @@ function buildCoordinateIndex(coordinates) {
12
12
  }
13
13
  return index;
14
14
  }
15
- export function capTargetedDiffText(diffText, maxChars) {
16
- if (diffText.length <= maxChars)
17
- return diffText;
18
- return truncateTextWithFooter(diffText, maxChars, diffText.length - maxChars);
19
- }
20
- function capDiffSectionByLineCount(section, maxLinesPerFile) {
21
- if (maxLinesPerFile < 1)
22
- return "";
23
- const lines = section.split("\n");
24
- if (lines.length <= maxLinesPerFile)
25
- return section;
26
- const truncated = lines.slice(0, maxLinesPerFile);
27
- truncated.push(`... [truncated ${lines.length - maxLinesPerFile} lines for this file]`);
28
- return truncated.join("\n");
29
- }
30
15
  export function filterDiffByCoordinates(rawPatch, coordinates, maxLinesPerFile = Number.POSITIVE_INFINITY) {
31
16
  const sections = splitDiffByFile(rawPatch);
32
17
  if (sections.length === 0)
@@ -66,11 +51,11 @@ export function filterDiffByCoordinates(rawPatch, coordinates, maxLinesPerFile =
66
51
  ...prelude,
67
52
  ...matchingHunks.flatMap((hunk) => [hunk.header, ...hunk.lines]),
68
53
  ].join("\n");
69
- selectedSectionsByPath.set(coordinate.path, capDiffSectionByLineCount(rendered, maxLinesPerFile));
54
+ selectedSectionsByPath.set(coordinate.path, truncateDiffSectionByLineCount(rendered, maxLinesPerFile));
70
55
  }
71
56
  const selectedSections = [
72
57
  ...selectedSectionsByPath.values(),
73
- ...deletedSections.map((section) => capDiffSectionByLineCount(section, maxLinesPerFile)),
58
+ ...deletedSections.map((section) => truncateDiffSectionByLineCount(section, maxLinesPerFile)),
74
59
  ];
75
60
  if (selectedSections.length === 0)
76
61
  return "";
@@ -1,58 +1,17 @@
1
- const SIMPLE_NAME_STATUS_VALUES = ["A", "M", "D", "T", "U", "X", "B"];
2
- const SIMPLE_NAME_STATUS_SET = new Set(SIMPLE_NAME_STATUS_VALUES);
3
- function isSimpleNameStatus(status) {
4
- return SIMPLE_NAME_STATUS_SET.has(status);
5
- }
6
- export function parseNumStat(content) {
7
- const rows = [];
8
- for (const line of content.split(/\r?\n/)) {
9
- if (!line.trim())
10
- continue;
11
- const [addRaw, delRaw, ...pathParts] = line.split("\t");
12
- const filePath = pathParts.join("\t").trim();
13
- if (!filePath)
14
- continue;
15
- const binary = addRaw === "-" || delRaw === "-";
16
- rows.push({
17
- path: filePath,
18
- additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
19
- deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
20
- binary,
21
- });
22
- }
23
- return rows;
24
- }
25
- export function parseNameStatus(content) {
26
- const map = new Map();
27
- for (const line of content.split(/\r?\n/)) {
28
- if (!line.trim())
29
- continue;
30
- const parts = line.split("\t");
31
- const statusRaw = (parts[0] ?? "").trim();
32
- if (!statusRaw)
33
- continue;
34
- if ((statusRaw.startsWith("R") || statusRaw.startsWith("C")) && parts.length >= 3) {
35
- const scoreRaw = statusRaw.slice(1);
36
- const score = Number.parseInt(scoreRaw, 10);
37
- const oldPath = (parts[1] ?? "").trim();
38
- const path = (parts[2] ?? "").trim();
39
- if (!path)
40
- continue;
41
- const status = statusRaw[0] === "R" ? "R" : "C";
42
- map.set(path, { status, score: Number.isFinite(score) ? score : 0, oldPath, path });
43
- continue;
44
- }
45
- const path = (parts[1] ?? "").trim();
46
- if (!path)
47
- continue;
48
- // Git can emit single-letter statuses, possibly in combinations; we keep just the first.
49
- const status = statusRaw[0] ?? "";
50
- if (!isSimpleNameStatus(status))
51
- continue;
52
- map.set(path, { status, path });
53
- }
54
- return map;
55
- }
1
+ import { Buffer } from "node:buffer";
2
+ import { DIFF_TRUNCATED_MARKER } from "./constants.js";
3
+ const DIFF_GIT_HEADER_PREFIX = "diff --git ";
4
+ const GIT_QUOTED_PATH_ESCAPES = {
5
+ '"': 34,
6
+ "\\": 92,
7
+ a: 7,
8
+ b: 8,
9
+ f: 12,
10
+ n: 10,
11
+ r: 13,
12
+ t: 9,
13
+ v: 11,
14
+ };
56
15
  export function splitDiffByFile(content) {
57
16
  const sections = [];
58
17
  let current = [];
@@ -135,10 +94,83 @@ export function isMissingBaseRevisionError(err) {
135
94
  text.includes("unknown revision or path not in the working tree") ||
136
95
  text.includes("ambiguous argument"));
137
96
  }
97
+ function unescapeQuotedDiffPath(value) {
98
+ const bytes = [];
99
+ for (let index = 0; index < value.length; index += 1) {
100
+ const char = value[index];
101
+ if (char !== "\\") {
102
+ bytes.push(...Buffer.from(char ?? "", "utf8"));
103
+ continue;
104
+ }
105
+ const escaped = value[index + 1];
106
+ if (escaped === undefined) {
107
+ bytes.push(92);
108
+ continue;
109
+ }
110
+ if (escaped >= "0" && escaped <= "7") {
111
+ let octal = escaped;
112
+ let cursor = index + 2;
113
+ while (cursor < value.length && octal.length < 3) {
114
+ const digit = value[cursor];
115
+ if (digit === undefined || digit < "0" || digit > "7")
116
+ break;
117
+ octal += digit;
118
+ cursor += 1;
119
+ }
120
+ bytes.push(Number.parseInt(octal, 8));
121
+ index = cursor - 1;
122
+ continue;
123
+ }
124
+ const decoded = GIT_QUOTED_PATH_ESCAPES[escaped];
125
+ if (decoded !== undefined) {
126
+ bytes.push(decoded);
127
+ index += 1;
128
+ continue;
129
+ }
130
+ bytes.push(...Buffer.from(escaped, "utf8"));
131
+ index += 1;
132
+ }
133
+ return Buffer.from(bytes).toString("utf8");
134
+ }
135
+ function readDiffHeaderToken(line, startIndex) {
136
+ let index = startIndex;
137
+ while (index < line.length && line[index] === " ") {
138
+ index += 1;
139
+ }
140
+ if (index >= line.length)
141
+ return null;
142
+ if (line[index] !== '"') {
143
+ let endIndex = index;
144
+ while (endIndex < line.length && line[endIndex] !== " ") {
145
+ endIndex += 1;
146
+ }
147
+ return {
148
+ value: line.slice(index, endIndex),
149
+ nextIndex: endIndex,
150
+ };
151
+ }
152
+ let endIndex = index + 1;
153
+ let escaped = false;
154
+ while (endIndex < line.length) {
155
+ const char = line[endIndex];
156
+ if (!escaped && char === '"') {
157
+ return {
158
+ value: unescapeQuotedDiffPath(line.slice(index + 1, endIndex)),
159
+ nextIndex: endIndex + 1,
160
+ };
161
+ }
162
+ escaped = !escaped && char === "\\";
163
+ endIndex += 1;
164
+ }
165
+ return null;
166
+ }
138
167
  export function parseDiffPathFromHeader(diffGitLine) {
139
- // Format: diff --git a/<path> b/<path>
140
- const parts = diffGitLine.split(" ");
141
- const bPart = parts[3] ?? "";
168
+ if (!diffGitLine.startsWith(DIFF_GIT_HEADER_PREFIX))
169
+ return null;
170
+ const aPart = readDiffHeaderToken(diffGitLine, DIFF_GIT_HEADER_PREFIX.length);
171
+ if (!aPart)
172
+ return null;
173
+ const bPart = readDiffHeaderToken(diffGitLine, aPart.nextIndex)?.value ?? "";
142
174
  if (!bPart.startsWith("b/"))
143
175
  return null;
144
176
  return bPart.slice(2);
@@ -158,20 +190,76 @@ export function truncateTextWithFooter(text, maxChars, omittedChars) {
158
190
  const cut = text.slice(0, maxChars - footer.length);
159
191
  return `${cut}${footer}`;
160
192
  }
161
- export function truncateBlockToLineBudget(block, maxChars) {
193
+ function isChangedDiffLine(line) {
194
+ return (line.startsWith("+") && !line.startsWith("+++")) || (line.startsWith("-") && !line.startsWith("---"));
195
+ }
196
+ function trimUnsafeDiffBoundary(kept, remaining) {
197
+ if (kept.length === 0 || remaining.length === 0)
198
+ return kept;
199
+ const safe = [...kept];
200
+ while (safe.length > 0 && isChangedDiffLine(safe[safe.length - 1] ?? "") && isChangedDiffLine(remaining[0] ?? "")) {
201
+ safe.pop();
202
+ }
203
+ if ((safe[safe.length - 1] ?? "").startsWith("@@ ")) {
204
+ safe.pop();
205
+ }
206
+ return safe;
207
+ }
208
+ function renderTruncatedDiff(lines) {
209
+ if (lines.length === 0)
210
+ return DIFF_TRUNCATED_MARKER;
211
+ return `${lines.join("\n")}\n${DIFF_TRUNCATED_MARKER}`;
212
+ }
213
+ export function truncateDiffSectionByLineCount(section, maxLines) {
214
+ if (maxLines < 1)
215
+ return "";
216
+ const lines = section.split("\n");
217
+ if (lines.length <= maxLines)
218
+ return section;
219
+ const { prelude, hunks } = parseUnifiedHunks(lines);
220
+ if (hunks.length === 0 || prelude.length >= maxLines) {
221
+ return renderTruncatedDiff(trimUnsafeDiffBoundary(lines.slice(0, maxLines), lines.slice(maxLines)));
222
+ }
223
+ const kept = [...prelude];
224
+ let lineCount = prelude.length;
225
+ for (let index = 0; index < hunks.length; index += 1) {
226
+ const hunk = hunks[index];
227
+ const hunkLines = [hunk.header, ...hunk.lines];
228
+ if (lineCount + hunkLines.length <= maxLines) {
229
+ kept.push(...hunkLines);
230
+ lineCount += hunkLines.length;
231
+ continue;
232
+ }
233
+ kept.push(...hunkLines);
234
+ lineCount += hunkLines.length;
235
+ break;
236
+ }
237
+ if (lineCount >= lines.length)
238
+ return section;
239
+ return renderTruncatedDiff(kept);
240
+ }
241
+ export function truncateDiffBlockToCharBudget(block, maxChars) {
162
242
  if (maxChars <= 0)
163
243
  return "";
164
244
  if (block.length <= maxChars)
165
245
  return block;
246
+ if (DIFF_TRUNCATED_MARKER.length >= maxChars) {
247
+ return DIFF_TRUNCATED_MARKER.slice(0, maxChars);
248
+ }
166
249
  const lines = block.split("\n");
167
250
  const kept = [];
168
251
  let used = 0;
169
252
  for (const line of lines) {
170
253
  const delta = (kept.length > 0 ? 1 : 0) + line.length;
171
- if (used + delta > maxChars)
254
+ const reservedMarker = 1 + DIFF_TRUNCATED_MARKER.length;
255
+ if (used + delta + reservedMarker > maxChars)
172
256
  break;
173
257
  kept.push(line);
174
258
  used += delta;
175
259
  }
176
- return kept.join("\n");
260
+ const safe = trimUnsafeDiffBoundary(kept, lines.slice(kept.length));
261
+ if (safe.length === 0) {
262
+ return DIFF_TRUNCATED_MARKER.slice(0, maxChars);
263
+ }
264
+ return renderTruncatedDiff(safe);
177
265
  }
@@ -1,6 +1,5 @@
1
1
  import { createGit, wrapGitError } from "../core.js";
2
2
  import { DIFF_EXCLUDES } from "./constants.js";
3
- import { parseNumStat } from "./parsers.js";
4
3
  export async function getDiffStats(cwd, baseBranch) {
5
4
  const git = createGit(cwd);
6
5
  let rawNumStat;
@@ -10,7 +9,22 @@ export async function getDiffStats(cwd, baseBranch) {
10
9
  catch (err) {
11
10
  throw wrapGitError(`diff --numstat ${baseBranch}...HEAD`, err, cwd);
12
11
  }
13
- const rows = parseNumStat(rawNumStat);
12
+ const rows = rawNumStat
13
+ .split(/\r?\n/)
14
+ .flatMap((line) => {
15
+ if (!line.trim())
16
+ return [];
17
+ const [addRaw, delRaw, ...pathParts] = line.split("\t");
18
+ const filePath = pathParts.join("\t").trim();
19
+ if (!filePath)
20
+ return [];
21
+ const binary = addRaw === "-" || delRaw === "-";
22
+ return [{
23
+ path: filePath,
24
+ additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
25
+ deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
26
+ }];
27
+ });
14
28
  return {
15
29
  filesChanged: rows.length,
16
30
  insertions: rows.reduce((sum, row) => sum + row.additions, 0),
@@ -1,13 +1,18 @@
1
1
  import { createGit, wrapGitError } from "../core.js";
2
2
  import { DIFF_EXCLUDES } from "./constants.js";
3
- import { isMissingBaseRevisionError, splitDiffByFile, totalJoinedLength, truncateBlockToLineBudget, } from "./parsers.js";
4
- import { renderStatDiffPreviewText } from "./stat.js";
5
- function buildPatchCommand(range, options) {
3
+ import { isMissingBaseRevisionError, splitDiffByFile, totalJoinedLength, truncateDiffBlockToCharBudget, truncateDiffSectionByLineCount, truncateTextWithFooter, } from "./parsers.js";
4
+ function normalizeDiffTarget(target) {
5
+ return typeof target === "string" ? [target] : [...target];
6
+ }
7
+ function formatDiffTarget(target) {
8
+ return typeof target === "string" ? target : target.join(" ");
9
+ }
10
+ function buildPatchCommand(target, options) {
6
11
  const args = ["diff", "--no-color", "--no-ext-diff", "--unified=3"];
7
12
  if (options.algorithm === "histogram") {
8
13
  args.push("--diff-algorithm=histogram");
9
14
  }
10
- args.push(range, "--", ".", ...DIFF_EXCLUDES);
15
+ args.push(...normalizeDiffTarget(target), "--", ".", ...DIFF_EXCLUDES);
11
16
  return args;
12
17
  }
13
18
  function renderPatchDiff(output, options) {
@@ -26,11 +31,9 @@ function renderPatchDiff(output, options) {
26
31
  let omittedFiles = 0;
27
32
  for (let i = 0; i < sections.length; i += 1) {
28
33
  const section = sections[i];
29
- const content = maxLinesPerFile === undefined ? [...section] : section.slice(0, maxLinesPerFile);
30
- if (maxLinesPerFile !== undefined && section.length > maxLinesPerFile) {
31
- content.push(`... [truncated ${section.length - maxLinesPerFile} lines for this file]`);
32
- }
33
- const fileBlock = content.join("\n");
34
+ const fileBlock = maxLinesPerFile === undefined
35
+ ? section.join("\n")
36
+ : truncateDiffSectionByLineCount(section.join("\n"), maxLinesPerFile);
34
37
  if (maxChars === undefined) {
35
38
  renderedSections.push(fileBlock);
36
39
  continue;
@@ -44,7 +47,7 @@ function renderPatchDiff(output, options) {
44
47
  if (renderedSections.length === 0) {
45
48
  const notice = `... [omitted ${omittedFiles} file(s) to stay under ${maxChars} chars] ...`;
46
49
  const partialBudget = Math.max(0, maxChars - totalJoinedLength(["", notice]));
47
- const partial = truncateBlockToLineBudget(fileBlock, partialBudget);
50
+ const partial = truncateDiffBlockToCharBudget(fileBlock, partialBudget);
48
51
  if (partial.trim()) {
49
52
  renderedSections.push(partial);
50
53
  }
@@ -62,20 +65,29 @@ function renderPatchDiff(output, options) {
62
65
  }
63
66
  return renderedSections.join("\n\n").trim() || "No diff.";
64
67
  }
65
- export async function readDiff(cwd, range, format, options = {}) {
68
+ export async function readDiff(cwd, target, format, options = {}) {
66
69
  const git = createGit(cwd);
70
+ const formattedTarget = formatDiffTarget(target);
67
71
  try {
68
72
  if (format === "stat") {
69
73
  const statVariant = options.statVariant ?? "summary";
70
74
  const args = statVariant === "numstat"
71
- ? ["diff", "--numstat", range, "--", ".", ...DIFF_EXCLUDES]
72
- : ["diff", "--stat=120,80", "--compact-summary", range, "--", ".", ...DIFF_EXCLUDES];
75
+ ? ["diff", "--numstat", ...normalizeDiffTarget(target), "--", ".", ...DIFF_EXCLUDES]
76
+ : ["diff", "--stat=120,80", "--compact-summary", ...normalizeDiffTarget(target), "--", ".", ...DIFF_EXCLUDES];
73
77
  const output = await git.raw(args);
74
- return statVariant === "summary"
75
- ? renderStatDiffPreviewText(output, options.maxChars ?? Number.POSITIVE_INFINITY)
76
- : output.trim() || "No diff.";
78
+ if (statVariant !== "summary") {
79
+ return output.trim() || "No diff.";
80
+ }
81
+ const trimmed = output.trim();
82
+ if (!trimmed)
83
+ return "No diff.";
84
+ const maxChars = options.maxChars ?? Number.POSITIVE_INFINITY;
85
+ if (trimmed.length <= maxChars) {
86
+ return trimmed;
87
+ }
88
+ return truncateTextWithFooter(trimmed, maxChars, trimmed.length - maxChars);
77
89
  }
78
- const output = await git.raw(buildPatchCommand(range, options));
90
+ const output = await git.raw(buildPatchCommand(target, options));
79
91
  return renderPatchDiff(output, options);
80
92
  }
81
93
  catch (error) {
@@ -84,9 +96,9 @@ export async function readDiff(cwd, range, format, options = {}) {
84
96
  }
85
97
  const commandLabel = format === "stat"
86
98
  ? options.statVariant === "numstat"
87
- ? `diff --numstat ${range}`
88
- : `diff --stat=120,80 --compact-summary ${range}`
89
- : `diff --no-color --no-ext-diff --unified=3 ${range}`;
99
+ ? `diff --numstat ${formattedTarget}`
100
+ : `diff --stat=120,80 --compact-summary ${formattedTarget}`
101
+ : `diff --no-color --no-ext-diff --unified=3 ${formattedTarget}`;
90
102
  throw wrapGitError(commandLabel, error, cwd);
91
103
  }
92
104
  }
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
- import { MISSING_HEAD_PATTERNS, NOT_GIT_REPOSITORY_PATTERNS, createGit, errorContainsAnyPattern, wrapGitError } from "./core.js";
4
+ import { NOT_GIT_REPOSITORY_PATTERNS, createGit, errorContainsAnyPattern, wrapGitError } from "./core.js";
5
5
  async function withTempIndex(cwd, run) {
6
6
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "keiyaku-git-index-"));
7
7
  const tempIndexPath = path.join(tempDir, "index");
@@ -18,26 +18,8 @@ export async function createWorkspaceSnapshot(cwd) {
18
18
  try {
19
19
  return await withTempIndex(cwd, async (git) => {
20
20
  await git.raw(["add", "-A"]);
21
- const tree = (await git.raw(["write-tree"])).trim();
22
- if (!tree)
23
- return undefined;
24
- let head;
25
- try {
26
- head = (await git.revparse(["--verify", "HEAD"])).trim() || undefined;
27
- }
28
- catch (error) {
29
- if (!errorContainsAnyPattern(error, MISSING_HEAD_PATTERNS)) {
30
- throw wrapGitError("rev-parse --verify HEAD", error, cwd);
31
- }
32
- head = undefined;
33
- }
34
- const args = ["commit-tree", tree];
35
- if (head) {
36
- args.push("-p", head);
37
- }
38
- args.push("-m", "keiyaku ask snapshot");
39
- const sha = await git.raw(args);
40
- return sha.trim() || undefined;
21
+ const tree = await git.raw(["write-tree"]);
22
+ return tree.trim() || undefined;
41
23
  });
42
24
  }
43
25
  catch (error) {
@@ -50,11 +32,10 @@ export async function createWorkspaceSnapshot(cwd) {
50
32
  export async function getChangedFilesBetweenSnapshots(cwd, beforeSha, afterSha) {
51
33
  const git = createGit(cwd);
52
34
  try {
53
- const output = await git.raw(["diff", "--name-status", `${beforeSha}..${afterSha}`]);
35
+ const output = await git.raw(["diff", "--name-status", beforeSha, afterSha]);
54
36
  return output.trim();
55
37
  }
56
38
  catch (err) {
57
- throw wrapGitError(`diff --name-status ${beforeSha}..${afterSha}`, err, cwd);
39
+ throw wrapGitError(`diff --name-status ${beforeSha} ${afterSha}`, err, cwd);
58
40
  }
59
41
  }
60
- export { withTempIndex };
@@ -6,6 +6,7 @@ export const DIRTY_FILE_CATEGORY = {
6
6
  renamed: "renamed",
7
7
  conflicted: "conflicted",
8
8
  copied: "copied",
9
+ untracked: "untracked",
9
10
  };
10
11
  function normalizeGitPath(filePath) {
11
12
  return filePath.trim().replace(/^\.\/+/, "");
@@ -22,6 +23,9 @@ function toDirtyStatusPathSets(status) {
22
23
  };
23
24
  }
24
25
  function deriveDirtyFileCategory(file, statusPathSets) {
26
+ if (statusPathSets.untracked.has(file.path)) {
27
+ return DIRTY_FILE_CATEGORY.untracked;
28
+ }
25
29
  if (statusPathSets.conflicted.has(file.path)) {
26
30
  return DIRTY_FILE_CATEGORY.conflicted;
27
31
  }
@@ -56,10 +60,7 @@ export async function getDirtyFiles(cwd) {
56
60
  throw wrapGitError("status --porcelain", err, cwd);
57
61
  }
58
62
  const statusPathSets = toDirtyStatusPathSets(status);
59
- return status.files
60
- .filter((file) => !(file.index === "?" && file.working_dir === "?"))
61
- .filter((file) => !statusPathSets.untracked.has(file.path))
62
- .map((file) => ({
63
+ return status.files.map((file) => ({
63
64
  path: file.path,
64
65
  index: file.index,
65
66
  working_dir: file.working_dir,
@@ -142,7 +142,7 @@ function formatReview(review) {
142
142
  `Review ${review.review}: ${review.result}`,
143
143
  ...formatMaybe("See", review.historyPath, FORMAT_LIST_MAX_ITEM_CHARS),
144
144
  ];
145
- if (review.result === "rejected" && review.findings?.trim()) {
145
+ if (review.findings?.trim()) {
146
146
  lines.push("", truncateForDisplay(review.findings, DISPLAY_TEXT_MAX_CHARS));
147
147
  }
148
148
  return lines;
@@ -380,6 +380,7 @@ export function buildPetitionClaimResponse(result, input) {
380
380
  ? `Scores: placement=${input.placement} exactness=${input.exactness} containment=${input.containment} idiomatic=${input.idiomatic} [total: ${input.placement + input.exactness + input.containment + input.idiomatic}/${CLOSE_TOTAL_SCORE_MAX}]`
381
381
  : "Claim Gates: bypassed by Architect";
382
382
  const diffSection = result.diff ? buildSection(DIFF_OVERVIEW_SECTION_TITLE, result.diff) : null;
383
+ const reviewSection = result.review ? buildSection(REVIEW_SECTION_TITLE, formatReview(result.review)) : null;
383
384
  const infoLines = [
384
385
  ...formatMaybe("Result", result.result, INFO_COMMIT_MAX_CHARS),
385
386
  ...formatMaybe("Title", input.title, INFO_BRANCH_MAX_CHARS),
@@ -392,7 +393,7 @@ export function buildPetitionClaimResponse(result, input) {
392
393
  scoreLine,
393
394
  ...formatMaybe("Oath", input.oath, INFO_PLEA_MAX_CHARS),
394
395
  ];
395
- const text = assembleResponse(`${RESPONSE_MARKERS.claim} Keiyaku Fulfilled (Claim)`, `Merged '${result.deletedBranch}' into '${result.mergedInto}'${result.commit ? ` [${result.commit}]` : ''}. Deleted feature branch.`, [diffSection].filter((section) => section !== null), infoLines);
396
+ const text = assembleResponse(`${RESPONSE_MARKERS.claim} Keiyaku Fulfilled (Claim)`, `Merged '${result.deletedBranch}' into '${result.mergedInto}'${result.commit ? ` [${result.commit}]` : ''}. Deleted feature branch.`, [diffSection, reviewSection].filter((section) => section !== null), infoLines);
396
397
  return {
397
398
  content: [{ type: "text", text }],
398
399
  structuredContent: buildSuccessStructuredContent(resultData),