@shanepadgett/tau-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/docs/extending-tau-agent.md +119 -0
- package/docs/subagents.md +57 -0
- package/docs/tui.md +107 -0
- package/extensions/appshot/README.md +11 -0
- package/extensions/appshot/capture.swift +191 -0
- package/extensions/appshot/index.ts +245 -0
- package/extensions/appshot/native-helper.ts +102 -0
- package/extensions/attention/README.md +23 -0
- package/extensions/attention/index.ts +71 -0
- package/extensions/auto-name/README.md +26 -0
- package/extensions/auto-name/index.ts +163 -0
- package/extensions/branch/README.md +22 -0
- package/extensions/branch/index.ts +145 -0
- package/extensions/branch/panel.ts +148 -0
- package/extensions/clear-screen/README.md +29 -0
- package/extensions/clear-screen/index.ts +75 -0
- package/extensions/commit/README.md +15 -0
- package/extensions/commit/commit-plan.ts +328 -0
- package/extensions/commit/git-change-set.ts +268 -0
- package/extensions/commit/index.ts +138 -0
- package/extensions/commit/review-ui.ts +745 -0
- package/extensions/explore/README.md +7 -0
- package/extensions/explore/autoread.ts +140 -0
- package/extensions/explore/find.ts +147 -0
- package/extensions/explore/grep.ts +749 -0
- package/extensions/explore/index.ts +17 -0
- package/extensions/explore/limits.ts +22 -0
- package/extensions/explore/ls.ts +113 -0
- package/extensions/explore/path-display.ts +41 -0
- package/extensions/explore/path-tree.ts +150 -0
- package/extensions/explore/read.ts +160 -0
- package/extensions/explore/result.ts +29 -0
- package/extensions/explore/traverse.ts +226 -0
- package/extensions/footer/README.md +23 -0
- package/extensions/footer/index.ts +534 -0
- package/extensions/footer/settings.ts +13 -0
- package/extensions/ideas/README.md +10 -0
- package/extensions/ideas/browser.ts +66 -0
- package/extensions/ideas/index.ts +31 -0
- package/extensions/ideas/store.ts +32 -0
- package/extensions/image-gen/README.md +11 -0
- package/extensions/image-gen/client.ts +186 -0
- package/extensions/image-gen/index.ts +152 -0
- package/extensions/manage-sessions/README.md +12 -0
- package/extensions/manage-sessions/index.ts +58 -0
- package/extensions/manage-sessions/manager-ui.ts +323 -0
- package/extensions/manage-sessions/sessions.ts +126 -0
- package/extensions/patch/README.md +48 -0
- package/extensions/patch/executor.ts +355 -0
- package/extensions/patch/index.ts +170 -0
- package/extensions/patch/matcher.ts +236 -0
- package/extensions/patch/parser.ts +347 -0
- package/extensions/patch/render.ts +247 -0
- package/extensions/patch/summary.ts +35 -0
- package/extensions/publish/README.md +11 -0
- package/extensions/publish/index.ts +225 -0
- package/extensions/qna/README.md +45 -0
- package/extensions/qna/additional-context-body.ts +51 -0
- package/extensions/qna/body-render.ts +6 -0
- package/extensions/qna/choice-question-body.ts +322 -0
- package/extensions/qna/index.ts +245 -0
- package/extensions/qna/inline-editor-row.ts +56 -0
- package/extensions/qna/input-question-body.ts +89 -0
- package/extensions/qna/model.ts +317 -0
- package/extensions/qna/panel.ts +240 -0
- package/extensions/qna/ui.ts +16 -0
- package/extensions/reference/README.md +47 -0
- package/extensions/reference/index.ts +76 -0
- package/extensions/reference/panel.ts +874 -0
- package/extensions/reference/settings.ts +29 -0
- package/extensions/run-summary/README.md +5 -0
- package/extensions/run-summary/index.ts +98 -0
- package/extensions/silent-command-runner/README.md +25 -0
- package/extensions/silent-command-runner/index.ts +468 -0
- package/extensions/silent-command-runner/settings.ts +65 -0
- package/extensions/soul/README.md +19 -0
- package/extensions/soul/index.ts +20 -0
- package/extensions/soul/prompt.ts +254 -0
- package/extensions/soul/settings.ts +13 -0
- package/extensions/stash/README.md +48 -0
- package/extensions/stash/browser.ts +51 -0
- package/extensions/stash/index.ts +55 -0
- package/extensions/stash/store.ts +36 -0
- package/extensions/subagent/README.md +34 -0
- package/extensions/subagent/agents/scout.md +85 -0
- package/extensions/subagent/agents/web-research.md +95 -0
- package/extensions/subagent/agents.ts +196 -0
- package/extensions/subagent/index.ts +214 -0
- package/extensions/subagent/render.ts +89 -0
- package/extensions/subagent/run.ts +325 -0
- package/extensions/tau/README.md +17 -0
- package/extensions/tau/index.ts +122 -0
- package/extensions/tau-help/README.md +3 -0
- package/extensions/tau-help/help.md +125 -0
- package/extensions/tau-help/index.ts +68 -0
- package/extensions/turn-budget/README.md +14 -0
- package/extensions/turn-budget/index.ts +150 -0
- package/extensions/turn-budget/settings.ts +35 -0
- package/extensions/web/README.md +13 -0
- package/extensions/web/codesearch.ts +79 -0
- package/extensions/web/exa.ts +101 -0
- package/extensions/web/html.ts +67 -0
- package/extensions/web/index.ts +13 -0
- package/extensions/web/limits.ts +11 -0
- package/extensions/web/tool-output.ts +54 -0
- package/extensions/web/webfetch.ts +177 -0
- package/extensions/web/websearch.ts +100 -0
- package/package.json +57 -0
- package/prompts/.gitkeep +1 -0
- package/prompts/cavemanify.md +25 -0
- package/prompts/implement.md +25 -0
- package/prompts/interview.md +48 -0
- package/prompts/plan-feature.md +30 -0
- package/prompts/plan-implementation.md +50 -0
- package/schemas/tau.schema.json +205 -0
- package/shared/agent-blocked.ts +12 -0
- package/shared/description.ts +28 -0
- package/shared/events.ts +135 -0
- package/shared/git.ts +41 -0
- package/shared/injected-context.ts +69 -0
- package/shared/jsonl-store.ts +108 -0
- package/shared/model-fallback/index.ts +278 -0
- package/shared/model-fallback/settings.ts +18 -0
- package/shared/model-fallback/types.ts +8 -0
- package/shared/ranges.ts +21 -0
- package/shared/settings/define.ts +15 -0
- package/shared/settings/files.ts +20 -0
- package/shared/settings/json.ts +41 -0
- package/shared/settings/load.ts +59 -0
- package/shared/settings/merge.ts +21 -0
- package/shared/settings/paths.ts +41 -0
- package/shared/settings/schema.ts +25 -0
- package/shared/settings/specs.ts +23 -0
- package/shared/text.ts +11 -0
- package/shared/tool-row-state.ts +43 -0
- package/skills/writing-preferences/SKILL.md +136 -0
- package/themes/.gitkeep +1 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { type ThinkingLevel, type Tool, Type } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { generateToolValidated, generateValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
|
|
5
|
+
import { truncAt } from "../../shared/text.ts";
|
|
6
|
+
import type { CommitEvidence, DirtyFile } from "./git-change-set.ts";
|
|
7
|
+
|
|
8
|
+
const MAX_PLAN_EVIDENCE_CHARS = 48_000;
|
|
9
|
+
const CONVENTIONAL_COMMIT_TYPES = ["feat", "fix", "docs", "refactor", "test", "chore", "perf", "ci", "build", "revert"];
|
|
10
|
+
const COMMIT_MODELS: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel }> = [
|
|
11
|
+
{ provider: "openai-codex", model: "gpt-5.6-terra", reasoning: "low" },
|
|
12
|
+
];
|
|
13
|
+
const COMMIT_PLAN_TOOL = {
|
|
14
|
+
name: "create_commit_plan",
|
|
15
|
+
description: "Submit the commit plan for the dirty repository files.",
|
|
16
|
+
parameters: Type.Object({
|
|
17
|
+
commits: Type.Array(
|
|
18
|
+
Type.Object({
|
|
19
|
+
message: Type.String({ description: "Git commit message." }),
|
|
20
|
+
allFiles: Type.Boolean({ description: "True only for a single commit that includes every dirty file." }),
|
|
21
|
+
files: Type.Array(Type.Integer({ description: "Numeric dirty file ID. Empty when allFiles is true." })),
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
}),
|
|
25
|
+
} satisfies Tool;
|
|
26
|
+
|
|
27
|
+
export interface CommitGroup {
|
|
28
|
+
id: string;
|
|
29
|
+
message: string;
|
|
30
|
+
files: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CommitPlanState {
|
|
34
|
+
files: readonly DirtyFile[];
|
|
35
|
+
groups: CommitGroup[];
|
|
36
|
+
worktreeSignature: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CommitPlanToolInput {
|
|
40
|
+
commits: readonly CommitPlanToolCommit[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CommitPlanToolCommit {
|
|
44
|
+
message: string;
|
|
45
|
+
allFiles: boolean;
|
|
46
|
+
files: readonly number[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function generatePlan(
|
|
50
|
+
ctx: ExtensionCommandContext,
|
|
51
|
+
evidence: CommitEvidence,
|
|
52
|
+
previousPlan: readonly CommitGroup[] = [],
|
|
53
|
+
regenerationNote = "",
|
|
54
|
+
): Promise<CommitGroup[]> {
|
|
55
|
+
const prompt = buildPlanPrompt(evidence, previousPlan, regenerationNote);
|
|
56
|
+
return generateToolValidated(
|
|
57
|
+
ctx,
|
|
58
|
+
await resolveCandidates(ctx, COMMIT_MODELS),
|
|
59
|
+
prompt,
|
|
60
|
+
COMMIT_PLAN_TOOL,
|
|
61
|
+
(input) => commitGroupsFromToolInput(input, evidence.files),
|
|
62
|
+
(error, text) =>
|
|
63
|
+
[
|
|
64
|
+
`That commit plan failed validation: ${error.message}`,
|
|
65
|
+
`Call ${COMMIT_PLAN_TOOL.name} again with corrected arguments only.`,
|
|
66
|
+
"Use only numeric file IDs provided in the prompt.",
|
|
67
|
+
"Include every dirty file ID exactly once across the plan.",
|
|
68
|
+
"Set allFiles true only for a single all-files commit; then set files to an empty array.",
|
|
69
|
+
"Previous response:",
|
|
70
|
+
text,
|
|
71
|
+
].join("\n"),
|
|
72
|
+
{ statusKey: "commit", notifyOnFallback: true },
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function regenerateMessage(
|
|
77
|
+
ctx: ExtensionCommandContext,
|
|
78
|
+
evidence: CommitEvidence,
|
|
79
|
+
files: readonly string[],
|
|
80
|
+
previousPlan: readonly CommitGroup[] = [],
|
|
81
|
+
selectedGroupId: string | undefined = undefined,
|
|
82
|
+
regenerationNote = "",
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
const selected = evidence.files.filter((file) => files.includes(file.path));
|
|
85
|
+
const prompt = buildMessagePrompt(evidence, selected, previousPlan, selectedGroupId, regenerationNote);
|
|
86
|
+
return generateValidated(ctx, await resolveCandidates(ctx, COMMIT_MODELS), prompt, requireCommitMessage, undefined, {
|
|
87
|
+
statusKey: "commit",
|
|
88
|
+
notifyOnFallback: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function requireCommitMessage(rawMessage: string): string {
|
|
93
|
+
const message = stripCodeFence(rawMessage)
|
|
94
|
+
.replace(/^commit message:\s*/i, "")
|
|
95
|
+
.trim()
|
|
96
|
+
.replace(/\r\n/g, "\n");
|
|
97
|
+
if (!message) throw new Error("Commit message is empty.");
|
|
98
|
+
|
|
99
|
+
const [subject, ...bodyLines] = message.split("\n");
|
|
100
|
+
if (!subject) throw new Error("Commit message subject is empty.");
|
|
101
|
+
const match = subject.match(
|
|
102
|
+
/^(feat|fix|docs|refactor|test|chore|perf|ci|build|revert)(\([a-z0-9]+(?:-[a-z0-9]+)*\))?(!)?: .+$/,
|
|
103
|
+
);
|
|
104
|
+
if (!match) throw new Error("Commit subject must be a valid conventional commit message.");
|
|
105
|
+
|
|
106
|
+
const body = bodyLines.join("\n");
|
|
107
|
+
if (!match[3]) {
|
|
108
|
+
if (body) throw new Error("Non-breaking commit messages must not have a body.");
|
|
109
|
+
return message;
|
|
110
|
+
}
|
|
111
|
+
if (!body.startsWith("\nBREAKING CHANGE:")) {
|
|
112
|
+
throw new Error("Breaking commit messages need one body paragraph starting with BREAKING CHANGE:.");
|
|
113
|
+
}
|
|
114
|
+
if (body.slice(1).includes("\n\n"))
|
|
115
|
+
throw new Error("Breaking commit messages must have exactly one body paragraph.");
|
|
116
|
+
return message;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function commitGroupsFromToolInput(input: unknown, files: readonly DirtyFile[]): CommitGroup[] {
|
|
120
|
+
if (!isCommitPlanToolInput(input)) throw new Error("Commit plan tool input is malformed.");
|
|
121
|
+
const pathById = new Map(files.map((file) => [file.id, file.path]));
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
const groups = input.commits.flatMap((commit) =>
|
|
124
|
+
groupFromToolCommit(commit, input.commits.length, files, pathById, seen),
|
|
125
|
+
);
|
|
126
|
+
if (groups.length === 0) throw new Error("Commit plan produced no non-empty commits.");
|
|
127
|
+
assertAllFilesAssigned(files, seen);
|
|
128
|
+
return groups;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function groupFromToolCommit(
|
|
132
|
+
commit: CommitPlanToolCommit,
|
|
133
|
+
commitCount: number,
|
|
134
|
+
files: readonly DirtyFile[],
|
|
135
|
+
pathById: ReadonlyMap<number, string>,
|
|
136
|
+
seen: Set<string>,
|
|
137
|
+
): CommitGroup[] {
|
|
138
|
+
const paths = commit.allFiles
|
|
139
|
+
? allFilesForSingleCommit(commitCount, files, seen)
|
|
140
|
+
: listedFiles(commit.files, pathById, seen);
|
|
141
|
+
return paths.length > 0 ? [{ id: randomUUID(), message: requireCommitMessage(commit.message), files: paths }] : [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function allFilesForSingleCommit(commitCount: number, files: readonly DirtyFile[], seen: Set<string>): string[] {
|
|
145
|
+
if (commitCount !== 1) throw new Error("allFiles is only valid for a single-commit plan.");
|
|
146
|
+
const paths = files.map((file) => file.path);
|
|
147
|
+
for (const path of paths) seen.add(path);
|
|
148
|
+
return paths;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function listedFiles(ids: readonly number[], pathById: ReadonlyMap<number, string>, seen: Set<string>): string[] {
|
|
152
|
+
const paths: string[] = [];
|
|
153
|
+
for (const id of ids) {
|
|
154
|
+
const path = pathById.get(id);
|
|
155
|
+
if (!path) throw new Error(`Unknown dirty file ID in commit plan: ${id}`);
|
|
156
|
+
if (seen.has(path)) throw new Error(`Dirty file ID is used more than once in commit plan: ${id}`);
|
|
157
|
+
seen.add(path);
|
|
158
|
+
paths.push(path);
|
|
159
|
+
}
|
|
160
|
+
return paths;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function assertAllFilesAssigned(files: readonly DirtyFile[], seen: ReadonlySet<string>): void {
|
|
164
|
+
const missing = files.filter((file) => !seen.has(file.path));
|
|
165
|
+
if (missing.length === 0) return;
|
|
166
|
+
throw new Error(`Commit plan omitted dirty file IDs: ${missing.map((file) => file.id).join(", ")}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function buildPlanPrompt(
|
|
170
|
+
evidence: CommitEvidence,
|
|
171
|
+
previousPlan: readonly CommitGroup[],
|
|
172
|
+
regenerationNote: string,
|
|
173
|
+
): string {
|
|
174
|
+
return [
|
|
175
|
+
"Create the fewest useful commits for the dirty repository files.",
|
|
176
|
+
`You have exactly one job: call ${COMMIT_PLAN_TOOL.name} once with the final plan.`,
|
|
177
|
+
"Do not answer in text. Do not explain. Do not include markdown.",
|
|
178
|
+
`Any response except a single ${COMMIT_PLAN_TOOL.name} tool call is invalid.`,
|
|
179
|
+
"File references must be numeric IDs only, never paths.",
|
|
180
|
+
"Every dirty file ID from the File catalog must appear in exactly one commit.",
|
|
181
|
+
"If any dirty file ID is missing from the plan, validation fails.",
|
|
182
|
+
"Tool argument shape:",
|
|
183
|
+
"commits: [{ message: string, allFiles: boolean, files: number[] }]",
|
|
184
|
+
"For one commit containing all dirty files: { message, allFiles: true, files: [] }",
|
|
185
|
+
"Otherwise: { message, allFiles: false, files: [numeric IDs from File catalog] }",
|
|
186
|
+
"Commit ladder:",
|
|
187
|
+
"1. Can all dirty files be committed together with one honest conventional commit message a user would understand?",
|
|
188
|
+
" If yes, return one commit.",
|
|
189
|
+
"2. For one all-files commit, set allFiles true and files to an empty array instead of listing every ID.",
|
|
190
|
+
"3. If one message would be misleading, split off only files with a clearly different purpose.",
|
|
191
|
+
"4. Keep README/docs/tests/prompts/UI text with the code change they describe or verify.",
|
|
192
|
+
"5. Do not split by file type, directory, or conventional commit type alone.",
|
|
193
|
+
"6. Every dirty file must belong to one of the commits.",
|
|
194
|
+
"7. Use the minimum number of commits that preserves meaning.",
|
|
195
|
+
"Rules:",
|
|
196
|
+
"- Prefer fewer coherent commits over tidy-looking categories.",
|
|
197
|
+
"- Use each dirty file exactly once.",
|
|
198
|
+
"- Use only numeric file IDs listed below.",
|
|
199
|
+
"- Prefer conventional commit messages.",
|
|
200
|
+
`- Allowed conventional commit types: ${CONVENTIONAL_COMMIT_TYPES.join(", ")}.`,
|
|
201
|
+
"- Optional scope must be lowercase kebab-case, singular, and useful; omit it for broad or unrelated changes.",
|
|
202
|
+
"- Subject must be one concise imperative line, no trailing period, max 100 characters.",
|
|
203
|
+
"- Prefer exactly one line for non-breaking changes.",
|
|
204
|
+
"- For breaking changes, prefer ! plus one body paragraph starting with BREAKING CHANGE:.",
|
|
205
|
+
"Recent commit subjects are style guidance. User intent explains why. File evidence is authoritative.",
|
|
206
|
+
"When regenerating, use the previous plan and user note to understand what to change; do not copy mistakes.",
|
|
207
|
+
"",
|
|
208
|
+
"Recent commit subjects:",
|
|
209
|
+
evidence.recentSubjects || "(none)",
|
|
210
|
+
"",
|
|
211
|
+
"User intent since last commit:",
|
|
212
|
+
formatIntent(evidence.intent),
|
|
213
|
+
"",
|
|
214
|
+
...formatRegenerationContext(evidence.files, previousPlan, undefined, regenerationNote),
|
|
215
|
+
"File catalog:",
|
|
216
|
+
formatFileCatalog(evidence.files),
|
|
217
|
+
"",
|
|
218
|
+
"File evidence:",
|
|
219
|
+
truncAt(evidence.files.map(formatFileEvidence).join("\n\n"), MAX_PLAN_EVIDENCE_CHARS),
|
|
220
|
+
].join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildMessagePrompt(
|
|
224
|
+
evidence: CommitEvidence,
|
|
225
|
+
files: readonly DirtyFile[],
|
|
226
|
+
previousPlan: readonly CommitGroup[],
|
|
227
|
+
selectedGroupId: string | undefined,
|
|
228
|
+
regenerationNote: string,
|
|
229
|
+
): string {
|
|
230
|
+
return [
|
|
231
|
+
"Write one git commit message for the selected dirty files.",
|
|
232
|
+
"Prefer this conventional commit format:",
|
|
233
|
+
"<type>[optional scope][!]: <description>",
|
|
234
|
+
`Allowed conventional commit types: ${CONVENTIONAL_COMMIT_TYPES.join(", ")}.`,
|
|
235
|
+
"Prefer lowercase kebab-case scopes only when useful; omit for broad or unrelated changes.",
|
|
236
|
+
"For non-breaking changes, output one subject line and no body.",
|
|
237
|
+
"For breaking changes, use ! plus one body paragraph starting with BREAKING CHANGE:.",
|
|
238
|
+
"Do not wrap the message in markdown or code fences.",
|
|
239
|
+
"",
|
|
240
|
+
"Recent commit subjects:",
|
|
241
|
+
evidence.recentSubjects || "(none)",
|
|
242
|
+
"",
|
|
243
|
+
"User intent since last commit:",
|
|
244
|
+
formatIntent(evidence.intent),
|
|
245
|
+
"",
|
|
246
|
+
...formatRegenerationContext(evidence.files, previousPlan, selectedGroupId, regenerationNote),
|
|
247
|
+
"Selected files:",
|
|
248
|
+
truncAt(files.map(formatFileEvidence).join("\n\n"), MAX_PLAN_EVIDENCE_CHARS),
|
|
249
|
+
].join("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function formatFileCatalog(files: readonly DirtyFile[]): string {
|
|
253
|
+
const groups = new Map<string, string[]>();
|
|
254
|
+
for (const file of files) {
|
|
255
|
+
const slash = file.path.lastIndexOf("/");
|
|
256
|
+
const folder = slash >= 0 ? `${file.path.slice(0, slash)}/` : "./";
|
|
257
|
+
const name = slash >= 0 ? file.path.slice(slash + 1) : file.path;
|
|
258
|
+
const lines = groups.get(folder) ?? [];
|
|
259
|
+
lines.push(` [${file.id}] ${file.status} ${name}${file.renamedFrom ? ` ← ${file.renamedFrom}` : ""}`);
|
|
260
|
+
groups.set(folder, lines);
|
|
261
|
+
}
|
|
262
|
+
return [...groups.entries()].map(([folder, lines]) => [folder, ...lines].join("\n")).join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function formatFileEvidence(file: DirtyFile): string {
|
|
266
|
+
return [
|
|
267
|
+
`[${file.id}] ${file.status} ${file.path}`,
|
|
268
|
+
file.renamedFrom && `Renamed from: ${file.renamedFrom}`,
|
|
269
|
+
`Kind: ${file.kind}`,
|
|
270
|
+
file.evidence,
|
|
271
|
+
]
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.join("\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function formatIntent(intent: readonly string[]): string {
|
|
277
|
+
return intent.length > 0 ? intent.map((message, index) => `[${index + 1}]\n${message}`).join("\n\n") : "(none)";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function formatRegenerationContext(
|
|
281
|
+
files: readonly DirtyFile[],
|
|
282
|
+
previousPlan: readonly CommitGroup[],
|
|
283
|
+
selectedGroupId: string | undefined,
|
|
284
|
+
regenerationNote: string,
|
|
285
|
+
): string[] {
|
|
286
|
+
if (previousPlan.length === 0 && !regenerationNote.trim()) return [];
|
|
287
|
+
const idByPath = new Map(files.map((file) => [file.path, file.id]));
|
|
288
|
+
return [
|
|
289
|
+
"Previous commit plan:",
|
|
290
|
+
previousPlan.length > 0
|
|
291
|
+
? previousPlan
|
|
292
|
+
.map((group, index) => {
|
|
293
|
+
const selected = group.id === selectedGroupId ? " (selected)" : "";
|
|
294
|
+
const groupFiles = group.files.map((file) => `- [${idByPath.get(file) ?? "?"}] ${file}`).join("\n");
|
|
295
|
+
return `[${index + 1}]${selected}\nMessage: ${group.message}\nFiles:\n${groupFiles}`;
|
|
296
|
+
})
|
|
297
|
+
.join("\n\n")
|
|
298
|
+
: "(none)",
|
|
299
|
+
"",
|
|
300
|
+
"User regeneration note:",
|
|
301
|
+
regenerationNote.trim() || "(none)",
|
|
302
|
+
"",
|
|
303
|
+
];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function stripCodeFence(raw: string): string {
|
|
307
|
+
const text = raw.trim();
|
|
308
|
+
const fenced = text.match(/^```(?:gitcommit|json|text)?\s*\n([\s\S]*?)\n```$/i);
|
|
309
|
+
return fenced?.[1]?.trim() ?? text;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isCommitPlanToolInput(value: unknown): value is CommitPlanToolInput {
|
|
313
|
+
return isRecord(value) && Array.isArray(value.commits) && value.commits.every(isCommitPlanToolCommit);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isCommitPlanToolCommit(value: unknown): value is CommitPlanToolCommit {
|
|
317
|
+
return (
|
|
318
|
+
isRecord(value) &&
|
|
319
|
+
typeof value.message === "string" &&
|
|
320
|
+
typeof value.allFiles === "boolean" &&
|
|
321
|
+
Array.isArray(value.files) &&
|
|
322
|
+
value.files.every((file) => typeof file === "number" && Number.isInteger(file))
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
327
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
328
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
6
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { GitRunner } from "../../shared/git.ts";
|
|
8
|
+
import { truncAt } from "../../shared/text.ts";
|
|
9
|
+
|
|
10
|
+
const COMMIT_TIMEOUT_MS = 120_000;
|
|
11
|
+
const EVIDENCE_CONCURRENCY = 4;
|
|
12
|
+
const MAX_FILE_EVIDENCE_CHARS = 4_000;
|
|
13
|
+
const MAX_INTENT_CHARS = 8_000;
|
|
14
|
+
const MAX_UNTRACKED_PREVIEW_BYTES = 12_000;
|
|
15
|
+
|
|
16
|
+
export interface CommitEvidence {
|
|
17
|
+
recentSubjects: string;
|
|
18
|
+
intent: readonly string[];
|
|
19
|
+
files: readonly DirtyFile[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DirtyFile {
|
|
23
|
+
id: number;
|
|
24
|
+
path: string;
|
|
25
|
+
status: string;
|
|
26
|
+
kind: "modified" | "added" | "deleted" | "renamed" | "untracked";
|
|
27
|
+
untracked: boolean;
|
|
28
|
+
evidence: string;
|
|
29
|
+
renamedFrom?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ParsedDirtyFile {
|
|
33
|
+
path: string;
|
|
34
|
+
status: string;
|
|
35
|
+
untracked: boolean;
|
|
36
|
+
renamedFrom?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function loadChangeSet(
|
|
40
|
+
git: GitRunner,
|
|
41
|
+
root: string,
|
|
42
|
+
entries: readonly SessionEntry[],
|
|
43
|
+
markerType: string,
|
|
44
|
+
): Promise<CommitEvidence> {
|
|
45
|
+
const [raw, head, recentSubjects] = await Promise.all([
|
|
46
|
+
git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root }),
|
|
47
|
+
git.run(["rev-parse", "--verify", "HEAD"], { cwd: root, optional: true }),
|
|
48
|
+
git.run(["log", "-12", "--pretty=format:%s"], { cwd: root, optional: true }),
|
|
49
|
+
]);
|
|
50
|
+
const files = parsePorcelainV2Z(raw);
|
|
51
|
+
return {
|
|
52
|
+
recentSubjects,
|
|
53
|
+
intent: collectIntent(entries, markerType),
|
|
54
|
+
files: await collectFileEvidence(git, root, files, Boolean(head)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function computeWorktreeSignature(
|
|
59
|
+
git: GitRunner,
|
|
60
|
+
root: string,
|
|
61
|
+
files: readonly DirtyFile[],
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
const hash = createHash("sha256");
|
|
64
|
+
const [status, stagedDiff, unstagedDiff] = await Promise.all([
|
|
65
|
+
git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root }),
|
|
66
|
+
git.run(["diff", "--cached", "--no-color", "--no-ext-diff"], { cwd: root, optional: true }),
|
|
67
|
+
git.run(["diff", "--no-color", "--no-ext-diff"], { cwd: root, optional: true }),
|
|
68
|
+
]);
|
|
69
|
+
hash.update(status);
|
|
70
|
+
hash.update("\0staged\0");
|
|
71
|
+
hash.update(stagedDiff);
|
|
72
|
+
hash.update("\0unstaged\0");
|
|
73
|
+
hash.update(unstagedDiff);
|
|
74
|
+
|
|
75
|
+
for (const file of files.filter((item) => item.untracked)) await hashUntrackedFile(hash, root, file.path);
|
|
76
|
+
return hash.digest("hex");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function stageFilesOnly(git: GitRunner, root: string, files: readonly DirtyFile[]): Promise<void> {
|
|
80
|
+
await git.run(["reset", "--mixed", "--quiet"], { cwd: root });
|
|
81
|
+
const paths = files.flatMap((file) => (file.renamedFrom ? [file.renamedFrom, file.path] : [file.path]));
|
|
82
|
+
if (paths.length > 0) await git.run(["add", "--", ...paths], { cwd: root });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function commitStaged(git: GitRunner, root: string, message: string): Promise<string> {
|
|
86
|
+
const messageFile = join(tmpdir(), `pi-commit-${randomUUID()}.txt`);
|
|
87
|
+
try {
|
|
88
|
+
await writeFile(messageFile, `${message}\n`, "utf8");
|
|
89
|
+
await git.run(["commit", "-F", messageFile], { cwd: root, timeout: COMMIT_TIMEOUT_MS });
|
|
90
|
+
return await git.run(["rev-parse", "--short", "HEAD"], { cwd: root });
|
|
91
|
+
} finally {
|
|
92
|
+
await rm(messageFile, { force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function assertCommittableState(files: readonly DirtyFile[]): void {
|
|
97
|
+
const deletedStagedAdds = files.filter((file) => file.status === "AD").map((file) => file.path);
|
|
98
|
+
if (deletedStagedAdds.length === 0) return;
|
|
99
|
+
throw new Error(
|
|
100
|
+
[
|
|
101
|
+
"Staged additions were deleted from the working tree.",
|
|
102
|
+
"Unstage or restore them before /commit:",
|
|
103
|
+
...deletedStagedAdds.map((path) => `- ${path}`),
|
|
104
|
+
].join("\n"),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function collectFileEvidence(
|
|
109
|
+
git: GitRunner,
|
|
110
|
+
root: string,
|
|
111
|
+
files: readonly DirtyFile[],
|
|
112
|
+
hasHead: boolean,
|
|
113
|
+
): Promise<DirtyFile[]> {
|
|
114
|
+
const withEvidence: DirtyFile[] = [];
|
|
115
|
+
for (let index = 0; index < files.length; index += EVIDENCE_CONCURRENCY) {
|
|
116
|
+
withEvidence.push(
|
|
117
|
+
...(await Promise.all(
|
|
118
|
+
files
|
|
119
|
+
.slice(index, index + EVIDENCE_CONCURRENCY)
|
|
120
|
+
.map(async (file) => ({ ...file, evidence: await loadEvidenceForFile(git, root, file, hasHead) })),
|
|
121
|
+
)),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return withEvidence;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parsePorcelainV2Z(raw: string): DirtyFile[] {
|
|
128
|
+
if (!raw) return [];
|
|
129
|
+
const parts = raw.split("\0").filter(Boolean);
|
|
130
|
+
const parsed: ParsedDirtyFile[] = [];
|
|
131
|
+
|
|
132
|
+
for (let index = 0; index < parts.length; index++) {
|
|
133
|
+
const entry = parts[index];
|
|
134
|
+
if (!entry) continue;
|
|
135
|
+
if (entry[0] === "u") throw new Error("Unmerged conflict entries detected; resolve conflicts before committing.");
|
|
136
|
+
if (entry[0] === "?") {
|
|
137
|
+
parsed.push({ path: entry.slice(2), status: "??", untracked: true });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fields = entry.split(" ");
|
|
142
|
+
if (entry[0] === "1")
|
|
143
|
+
parsed.push({ path: fields.slice(8).join(" "), status: statusText(fields), untracked: false });
|
|
144
|
+
if (entry[0] === "2") {
|
|
145
|
+
const renamedFrom = parts[index + 1];
|
|
146
|
+
parsed.push({
|
|
147
|
+
path: fields.slice(9).join(" "),
|
|
148
|
+
status: statusText(fields),
|
|
149
|
+
untracked: false,
|
|
150
|
+
...(renamedFrom ? { renamedFrom } : {}),
|
|
151
|
+
});
|
|
152
|
+
index++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return parsed
|
|
157
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
158
|
+
.map((file, index) => ({ ...file, id: index + 1, kind: changeKind(file), evidence: "" }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function statusText(fields: readonly string[]): string {
|
|
162
|
+
return (fields[1] ?? " ").replace(/\./g, " ");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function loadEvidenceForFile(git: GitRunner, root: string, file: DirtyFile, hasHead: boolean): Promise<string> {
|
|
166
|
+
if (file.untracked) return untrackedEvidence(root, file.path);
|
|
167
|
+
if (file.kind === "deleted") return "deleted file; contents omitted";
|
|
168
|
+
if (file.renamedFrom && !/[MAD]/.test(file.status)) return "renamed file; contents unchanged";
|
|
169
|
+
|
|
170
|
+
const paths = file.renamedFrom ? [file.renamedFrom, file.path] : [file.path];
|
|
171
|
+
if (!hasHead) return diffFromIndexAndWorktree(git, root, paths);
|
|
172
|
+
const diff = await git.run(["diff", "--stat", "--patch", "--no-color", "--no-ext-diff", "HEAD", "--", ...paths], {
|
|
173
|
+
cwd: root,
|
|
174
|
+
optional: true,
|
|
175
|
+
});
|
|
176
|
+
return truncAt(diff || "metadata-only change", MAX_FILE_EVIDENCE_CHARS);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function diffFromIndexAndWorktree(git: GitRunner, root: string, paths: readonly string[]): Promise<string> {
|
|
180
|
+
const [staged, unstaged] = await Promise.all([
|
|
181
|
+
git.run(["diff", "--cached", "--stat", "--patch", "--no-color", "--no-ext-diff", "--", ...paths], {
|
|
182
|
+
cwd: root,
|
|
183
|
+
optional: true,
|
|
184
|
+
}),
|
|
185
|
+
git.run(["diff", "--stat", "--patch", "--no-color", "--no-ext-diff", "--", ...paths], {
|
|
186
|
+
cwd: root,
|
|
187
|
+
optional: true,
|
|
188
|
+
}),
|
|
189
|
+
]);
|
|
190
|
+
return truncAt(
|
|
191
|
+
[staged && `staged diff:\n${staged}`, unstaged && `unstaged diff:\n${unstaged}`].filter(Boolean).join("\n\n") ||
|
|
192
|
+
"metadata-only change",
|
|
193
|
+
MAX_FILE_EVIDENCE_CHARS,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function untrackedEvidence(root: string, path: string): Promise<string> {
|
|
198
|
+
try {
|
|
199
|
+
const fullPath = join(root, path);
|
|
200
|
+
const info = await stat(fullPath);
|
|
201
|
+
if (!info.isFile()) return `untracked ${info.isDirectory() ? "directory" : "non-file"}`;
|
|
202
|
+
if (info.size > MAX_UNTRACKED_PREVIEW_BYTES) return `untracked file, ${info.size} bytes`;
|
|
203
|
+
const bytes = await readFile(fullPath);
|
|
204
|
+
if (bytes.includes(0)) return `untracked binary file, ${info.size} bytes`;
|
|
205
|
+
return truncAt(`untracked file preview:\n${bytes.toString("utf8")}`, MAX_FILE_EVIDENCE_CHARS);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return `untracked file preview unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function hashUntrackedFile(hash: ReturnType<typeof createHash>, root: string, path: string): Promise<void> {
|
|
212
|
+
const fullPath = join(root, path);
|
|
213
|
+
try {
|
|
214
|
+
const info = await stat(fullPath);
|
|
215
|
+
hash.update(`\0${path}:${info.size}:${info.mtimeMs}`);
|
|
216
|
+
if (info.isFile() && info.size <= MAX_UNTRACKED_PREVIEW_BYTES) hash.update(await readFile(fullPath));
|
|
217
|
+
} catch {
|
|
218
|
+
hash.update(`\0${path}:missing`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function changeKind(file: ParsedDirtyFile): DirtyFile["kind"] {
|
|
223
|
+
if (file.untracked) return "untracked";
|
|
224
|
+
if (file.renamedFrom) return "renamed";
|
|
225
|
+
if (file.status.includes("D")) return "deleted";
|
|
226
|
+
if (file.status.includes("A")) return "added";
|
|
227
|
+
return "modified";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function collectIntent(entries: readonly SessionEntry[], markerType: string): string[] {
|
|
231
|
+
let intent: string[] = [];
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
if (entry.type === "custom" && entry.customType === markerType) {
|
|
234
|
+
intent = [];
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (entry.type !== "message" || entry.message.role !== "user") continue;
|
|
238
|
+
const text = extractUserText(entry.message.content).trim();
|
|
239
|
+
if (text && !/^\/commit(?:\s|$)/.test(text)) intent.push(text);
|
|
240
|
+
}
|
|
241
|
+
return boundIntent(intent);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function boundIntent(intent: readonly string[]): string[] {
|
|
245
|
+
const bounded: string[] = [];
|
|
246
|
+
let used = 0;
|
|
247
|
+
for (let index = intent.length - 1; index >= 0; index--) {
|
|
248
|
+
const message = intent[index];
|
|
249
|
+
if (!message) continue;
|
|
250
|
+
const remaining = MAX_INTENT_CHARS - used;
|
|
251
|
+
if (remaining <= 0) break;
|
|
252
|
+
const clipped = message.length > remaining ? truncAt(message, remaining) : message;
|
|
253
|
+
bounded.push(clipped);
|
|
254
|
+
used += clipped.length;
|
|
255
|
+
}
|
|
256
|
+
return bounded.reverse();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function extractUserText(content: string | Message["content"]): string {
|
|
260
|
+
if (typeof content === "string") return content;
|
|
261
|
+
return content
|
|
262
|
+
.flatMap((part) => {
|
|
263
|
+
if (part.type === "text") return [part.text];
|
|
264
|
+
if (part.type === "image") return ["[image omitted]"];
|
|
265
|
+
return [];
|
|
266
|
+
})
|
|
267
|
+
.join("\n");
|
|
268
|
+
}
|