@oh-my-pi/pi-coding-agent 8.0.16 → 8.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.
Files changed (166) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/package.json +14 -11
  3. package/scripts/generate-wasm-b64.ts +24 -0
  4. package/src/capability/context-file.ts +1 -1
  5. package/src/capability/extension-module.ts +1 -1
  6. package/src/capability/extension.ts +1 -1
  7. package/src/capability/hook.ts +1 -1
  8. package/src/capability/instruction.ts +1 -1
  9. package/src/capability/mcp.ts +1 -1
  10. package/src/capability/prompt.ts +1 -1
  11. package/src/capability/rule.ts +1 -1
  12. package/src/capability/settings.ts +1 -1
  13. package/src/capability/skill.ts +1 -1
  14. package/src/capability/slash-command.ts +1 -1
  15. package/src/capability/ssh.ts +1 -1
  16. package/src/capability/system-prompt.ts +1 -1
  17. package/src/capability/tool.ts +1 -1
  18. package/src/cli/args.ts +1 -1
  19. package/src/cli/plugin-cli.ts +1 -5
  20. package/src/commit/agentic/agent.ts +309 -0
  21. package/src/commit/agentic/fallback.ts +96 -0
  22. package/src/commit/agentic/index.ts +359 -0
  23. package/src/commit/agentic/prompts/analyze-file.md +22 -0
  24. package/src/commit/agentic/prompts/session-user.md +26 -0
  25. package/src/commit/agentic/prompts/split-confirm.md +1 -0
  26. package/src/commit/agentic/prompts/system.md +40 -0
  27. package/src/commit/agentic/state.ts +74 -0
  28. package/src/commit/agentic/tools/analyze-file.ts +131 -0
  29. package/src/commit/agentic/tools/git-file-diff.ts +194 -0
  30. package/src/commit/agentic/tools/git-hunk.ts +50 -0
  31. package/src/commit/agentic/tools/git-overview.ts +84 -0
  32. package/src/commit/agentic/tools/index.ts +56 -0
  33. package/src/commit/agentic/tools/propose-changelog.ts +128 -0
  34. package/src/commit/agentic/tools/propose-commit.ts +154 -0
  35. package/src/commit/agentic/tools/recent-commits.ts +81 -0
  36. package/src/commit/agentic/tools/split-commit.ts +284 -0
  37. package/src/commit/agentic/topo-sort.ts +44 -0
  38. package/src/commit/agentic/trivial.ts +51 -0
  39. package/src/commit/agentic/validation.ts +200 -0
  40. package/src/commit/analysis/conventional.ts +169 -0
  41. package/src/commit/analysis/index.ts +4 -0
  42. package/src/commit/analysis/scope.ts +242 -0
  43. package/src/commit/analysis/summary.ts +114 -0
  44. package/src/commit/analysis/validation.ts +66 -0
  45. package/src/commit/changelog/detect.ts +36 -0
  46. package/src/commit/changelog/generate.ts +112 -0
  47. package/src/commit/changelog/index.ts +233 -0
  48. package/src/commit/changelog/parse.ts +44 -0
  49. package/src/commit/cli.ts +93 -0
  50. package/src/commit/git/diff.ts +148 -0
  51. package/src/commit/git/errors.ts +11 -0
  52. package/src/commit/git/index.ts +217 -0
  53. package/src/commit/git/operations.ts +53 -0
  54. package/src/commit/index.ts +5 -0
  55. package/src/commit/map-reduce/.map-phase.ts.kate-swp +0 -0
  56. package/src/commit/map-reduce/index.ts +63 -0
  57. package/src/commit/map-reduce/map-phase.ts +193 -0
  58. package/src/commit/map-reduce/reduce-phase.ts +147 -0
  59. package/src/commit/map-reduce/utils.ts +9 -0
  60. package/src/commit/message.ts +11 -0
  61. package/src/commit/model-selection.ts +84 -0
  62. package/src/commit/pipeline.ts +242 -0
  63. package/src/commit/prompts/analysis-system.md +155 -0
  64. package/src/commit/prompts/analysis-user.md +41 -0
  65. package/src/commit/prompts/changelog-system.md +56 -0
  66. package/src/commit/prompts/changelog-user.md +19 -0
  67. package/src/commit/prompts/file-observer-system.md +26 -0
  68. package/src/commit/prompts/file-observer-user.md +9 -0
  69. package/src/commit/prompts/reduce-system.md +60 -0
  70. package/src/commit/prompts/reduce-user.md +17 -0
  71. package/src/commit/prompts/summary-retry.md +4 -0
  72. package/src/commit/prompts/summary-system.md +52 -0
  73. package/src/commit/prompts/summary-user.md +13 -0
  74. package/src/commit/prompts/types-description.md +2 -0
  75. package/src/commit/types.ts +109 -0
  76. package/src/commit/utils/exclusions.ts +42 -0
  77. package/src/config/file-lock.ts +111 -0
  78. package/src/config/model-registry.ts +16 -7
  79. package/src/config/settings-manager.ts +115 -40
  80. package/src/config.ts +5 -5
  81. package/src/discovery/agents-md.ts +1 -1
  82. package/src/discovery/builtin.ts +1 -1
  83. package/src/discovery/claude.ts +1 -1
  84. package/src/discovery/cline.ts +1 -1
  85. package/src/discovery/codex.ts +1 -1
  86. package/src/discovery/cursor.ts +1 -1
  87. package/src/discovery/gemini.ts +1 -1
  88. package/src/discovery/github.ts +1 -1
  89. package/src/discovery/index.ts +11 -11
  90. package/src/discovery/mcp-json.ts +1 -1
  91. package/src/discovery/ssh.ts +1 -1
  92. package/src/discovery/vscode.ts +1 -1
  93. package/src/discovery/windsurf.ts +1 -1
  94. package/src/extensibility/custom-commands/loader.ts +1 -1
  95. package/src/extensibility/custom-commands/types.ts +1 -1
  96. package/src/extensibility/custom-tools/loader.ts +1 -1
  97. package/src/extensibility/custom-tools/types.ts +1 -1
  98. package/src/extensibility/extensions/loader.ts +1 -1
  99. package/src/extensibility/extensions/types.ts +1 -1
  100. package/src/extensibility/hooks/loader.ts +1 -1
  101. package/src/extensibility/hooks/types.ts +3 -3
  102. package/src/index.ts +10 -10
  103. package/src/ipy/executor.ts +97 -1
  104. package/src/lsp/index.ts +1 -1
  105. package/src/lsp/render.ts +90 -46
  106. package/src/main.ts +16 -3
  107. package/src/mcp/loader.ts +3 -3
  108. package/src/migrations.ts +3 -3
  109. package/src/modes/components/assistant-message.ts +29 -1
  110. package/src/modes/components/tool-execution.ts +5 -3
  111. package/src/modes/components/tree-selector.ts +1 -1
  112. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  113. package/src/modes/controllers/selector-controller.ts +1 -1
  114. package/src/modes/interactive-mode.ts +5 -3
  115. package/src/modes/rpc/rpc-client.ts +1 -1
  116. package/src/modes/rpc/rpc-mode.ts +1 -4
  117. package/src/modes/rpc/rpc-types.ts +1 -1
  118. package/src/modes/theme/mermaid-cache.ts +89 -0
  119. package/src/modes/theme/theme.ts +2 -0
  120. package/src/modes/types.ts +2 -2
  121. package/src/patch/index.ts +3 -9
  122. package/src/patch/shared.ts +33 -5
  123. package/src/prompts/tools/task.md +2 -0
  124. package/src/sdk.ts +60 -22
  125. package/src/session/agent-session.ts +3 -3
  126. package/src/session/agent-storage.ts +32 -28
  127. package/src/session/artifacts.ts +24 -1
  128. package/src/session/auth-storage.ts +25 -10
  129. package/src/session/storage-migration.ts +12 -53
  130. package/src/system-prompt.ts +2 -2
  131. package/src/task/.executor.ts.kate-swp +0 -0
  132. package/src/task/executor.ts +1 -1
  133. package/src/task/index.ts +10 -1
  134. package/src/task/output-manager.ts +94 -0
  135. package/src/task/render.ts +7 -12
  136. package/src/task/worker.ts +1 -1
  137. package/src/tools/ask.ts +35 -13
  138. package/src/tools/bash.ts +80 -87
  139. package/src/tools/calculator.ts +42 -40
  140. package/src/tools/complete.ts +1 -1
  141. package/src/tools/fetch.ts +67 -104
  142. package/src/tools/find.ts +83 -86
  143. package/src/tools/grep.ts +80 -96
  144. package/src/tools/index.ts +10 -7
  145. package/src/tools/ls.ts +39 -65
  146. package/src/tools/notebook.ts +48 -64
  147. package/src/tools/output-utils.ts +1 -1
  148. package/src/tools/python.ts +71 -183
  149. package/src/tools/read.ts +74 -15
  150. package/src/tools/render-utils.ts +1 -15
  151. package/src/tools/ssh.ts +43 -24
  152. package/src/tools/todo-write.ts +27 -15
  153. package/src/tools/write.ts +93 -64
  154. package/src/tui/code-cell.ts +115 -0
  155. package/src/tui/file-list.ts +48 -0
  156. package/src/tui/index.ts +11 -0
  157. package/src/tui/output-block.ts +73 -0
  158. package/src/tui/status-line.ts +40 -0
  159. package/src/tui/tree-list.ts +56 -0
  160. package/src/tui/types.ts +17 -0
  161. package/src/tui/utils.ts +49 -0
  162. package/src/vendor/photon/photon_rs_bg.wasm.b64.js +1 -0
  163. package/src/web/search/auth.ts +1 -1
  164. package/src/web/search/index.ts +1 -1
  165. package/src/web/search/render.ts +119 -163
  166. package/tsconfig.json +0 -42
@@ -0,0 +1,114 @@
1
+ import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
2
+ import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
3
+ import summarySystemPrompt from "@oh-my-pi/pi-coding-agent/commit/prompts/summary-system.md" with { type: "text" };
4
+ import summaryUserPrompt from "@oh-my-pi/pi-coding-agent/commit/prompts/summary-user.md" with { type: "text" };
5
+ import type { CommitSummary } from "@oh-my-pi/pi-coding-agent/commit/types";
6
+ import { renderPromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates";
7
+ import { Type } from "@sinclair/typebox";
8
+
9
+ const SummaryTool = {
10
+ name: "create_commit_summary",
11
+ description: "Generate the summary line for a conventional commit message.",
12
+ parameters: Type.Object({
13
+ summary: Type.String(),
14
+ }),
15
+ };
16
+
17
+ export interface SummaryInput {
18
+ model: Model<Api>;
19
+ apiKey: string;
20
+ commitType: string;
21
+ scope: string | null;
22
+ details: string[];
23
+ stat: string;
24
+ maxChars: number;
25
+ userContext?: string;
26
+ }
27
+
28
+ /**
29
+ * Generate a commit summary line for the conventional commit header.
30
+ */
31
+ export async function generateSummary({
32
+ model,
33
+ apiKey,
34
+ commitType,
35
+ scope,
36
+ details,
37
+ stat,
38
+ maxChars,
39
+ userContext,
40
+ }: SummaryInput): Promise<CommitSummary> {
41
+ const systemPrompt = renderSummaryPrompt({ commitType, scope, maxChars });
42
+ const userPrompt = renderPromptTemplate(summaryUserPrompt, {
43
+ user_context: userContext,
44
+ details: details.join("\n"),
45
+ stat,
46
+ });
47
+
48
+ const response = await completeSimple(
49
+ model,
50
+ {
51
+ systemPrompt,
52
+ messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
53
+ tools: [SummaryTool],
54
+ },
55
+ { apiKey, maxTokens: 200 },
56
+ );
57
+
58
+ return parseSummaryFromResponse(response, commitType, scope);
59
+ }
60
+
61
+ function renderSummaryPrompt({
62
+ commitType,
63
+ scope,
64
+ maxChars,
65
+ }: {
66
+ commitType: string;
67
+ scope: string | null;
68
+ maxChars: number;
69
+ }): string {
70
+ const scopePrefix = scope ? `(${scope})` : "";
71
+ return renderPromptTemplate(summarySystemPrompt, {
72
+ commit_type: commitType,
73
+ scope_prefix: scopePrefix,
74
+ chars: String(maxChars),
75
+ });
76
+ }
77
+
78
+ function parseSummaryFromResponse(message: AssistantMessage, commitType: string, scope: string | null): CommitSummary {
79
+ const toolCall = extractToolCall(message, "create_commit_summary");
80
+ if (toolCall) {
81
+ const parsed = validateToolCall([SummaryTool], toolCall) as { summary: string };
82
+ return { summary: stripTypePrefix(parsed.summary, commitType, scope) };
83
+ }
84
+ const text = extractTextContent(message);
85
+ return { summary: stripTypePrefix(text, commitType, scope) };
86
+ }
87
+
88
+ function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
89
+ return message.content.find((content) => content.type === "toolCall" && content.name === name) as
90
+ | ToolCall
91
+ | undefined;
92
+ }
93
+
94
+ function extractTextContent(message: AssistantMessage): string {
95
+ return message.content
96
+ .filter((content) => content.type === "text")
97
+ .map((content) => content.text)
98
+ .join("")
99
+ .trim();
100
+ }
101
+
102
+ export function stripTypePrefix(summary: string, commitType: string, scope: string | null): string {
103
+ const trimmed = summary.trim();
104
+ const scopePart = scope ? `(${scope})` : "";
105
+ const withScope = `${commitType}${scopePart}: `;
106
+ if (trimmed.startsWith(withScope)) {
107
+ return trimmed.slice(withScope.length).trim();
108
+ }
109
+ const withoutScope = `${commitType}: `;
110
+ if (trimmed.startsWith(withoutScope)) {
111
+ return trimmed.slice(withoutScope.length).trim();
112
+ }
113
+ return trimmed;
114
+ }
@@ -0,0 +1,66 @@
1
+ import type { ConventionalAnalysis } from "@oh-my-pi/pi-coding-agent/commit/types";
2
+
3
+ export interface ValidationResult {
4
+ valid: boolean;
5
+ errors: string[];
6
+ }
7
+
8
+ export function validateSummary(summary: string, maxChars: number): ValidationResult {
9
+ const errors: string[] = [];
10
+ if (!summary.trim()) {
11
+ errors.push("Summary is empty");
12
+ }
13
+ if (summary.length > maxChars) {
14
+ errors.push(`Summary exceeds ${maxChars} characters`);
15
+ }
16
+ if (summary.trimEnd().endsWith(".")) {
17
+ errors.push("Summary must not end with a period");
18
+ }
19
+ if (summary.includes("\n")) {
20
+ errors.push("Summary must be a single line");
21
+ }
22
+ return { valid: errors.length === 0, errors };
23
+ }
24
+
25
+ export function validateScope(scope: string | null): ValidationResult {
26
+ if (!scope) return { valid: true, errors: [] };
27
+ const errors: string[] = [];
28
+ const segments = scope.split("/");
29
+ if (segments.length > 2) {
30
+ errors.push("Scope may contain at most two segments");
31
+ }
32
+ for (const segment of segments) {
33
+ if (!segment) {
34
+ errors.push("Scope segments cannot be empty");
35
+ continue;
36
+ }
37
+ if (segment !== segment.toLowerCase()) {
38
+ errors.push("Scope must be lowercase");
39
+ }
40
+ if (!/^[a-z0-9][a-z0-9-_]*$/.test(segment)) {
41
+ errors.push(`Scope segment has invalid characters: ${segment}`);
42
+ }
43
+ }
44
+ return { valid: errors.length === 0, errors };
45
+ }
46
+
47
+ export function validateAnalysis(analysis: ConventionalAnalysis): ValidationResult {
48
+ const errors: string[] = [];
49
+ const scopeResult = validateScope(analysis.scope);
50
+ if (!scopeResult.valid) {
51
+ errors.push(...scopeResult.errors);
52
+ }
53
+ for (const detail of analysis.details) {
54
+ if (!detail.text.trim()) {
55
+ errors.push("Detail text is empty");
56
+ continue;
57
+ }
58
+ if (!detail.text.trim().endsWith(".")) {
59
+ errors.push(`Detail must end with a period: ${detail.text}`);
60
+ }
61
+ if (detail.text.length > 120) {
62
+ errors.push(`Detail exceeds 120 characters: ${detail.text}`);
63
+ }
64
+ }
65
+ return { valid: errors.length === 0, errors };
66
+ }
@@ -0,0 +1,36 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import type { ChangelogBoundary } from "@oh-my-pi/pi-coding-agent/commit/types";
3
+
4
+ const CHANGELOG_NAME = "CHANGELOG.md";
5
+
6
+ export async function detectChangelogBoundaries(cwd: string, stagedFiles: string[]): Promise<ChangelogBoundary[]> {
7
+ const boundaries = new Map<string, string[]>();
8
+ for (const file of stagedFiles) {
9
+ if (file.toLowerCase().endsWith("changelog.md")) continue;
10
+ const changelogPath = await findNearestChangelog(cwd, file);
11
+ if (!changelogPath) continue;
12
+ const list = boundaries.get(changelogPath) ?? [];
13
+ list.push(file);
14
+ boundaries.set(changelogPath, list);
15
+ }
16
+
17
+ return Array.from(boundaries.entries()).map(([changelogPath, files]) => ({
18
+ changelogPath,
19
+ files,
20
+ }));
21
+ }
22
+
23
+ async function findNearestChangelog(cwd: string, filePath: string): Promise<string | null> {
24
+ let current = resolve(cwd, dirname(filePath));
25
+ const root = resolve(cwd);
26
+ while (true) {
27
+ const candidate = resolve(current, CHANGELOG_NAME);
28
+ if (await Bun.file(candidate).exists()) {
29
+ return candidate;
30
+ }
31
+ if (current === root) return null;
32
+ const parent = dirname(current);
33
+ if (parent === current) return null;
34
+ current = parent;
35
+ }
36
+ }
@@ -0,0 +1,112 @@
1
+ import type { Api, AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
2
+ import { completeSimple, validateToolCall } from "@oh-my-pi/pi-ai";
3
+ import changelogSystemPrompt from "@oh-my-pi/pi-coding-agent/commit/prompts/changelog-system.md" with { type: "text" };
4
+ import changelogUserPrompt from "@oh-my-pi/pi-coding-agent/commit/prompts/changelog-user.md" with { type: "text" };
5
+ import type { ChangelogGenerationResult } from "@oh-my-pi/pi-coding-agent/commit/types";
6
+ import { renderPromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates";
7
+ import { Type } from "@sinclair/typebox";
8
+
9
+ const ChangelogTool = {
10
+ name: "create_changelog_entries",
11
+ description: "Generate changelog entries grouped by Keep a Changelog categories.",
12
+ parameters: Type.Object({
13
+ entries: Type.Record(Type.String(), Type.Array(Type.String())),
14
+ }),
15
+ };
16
+
17
+ export interface ChangelogPromptInput {
18
+ model: Model<Api>;
19
+ apiKey: string;
20
+ changelogPath: string;
21
+ isPackageChangelog: boolean;
22
+ existingEntries?: string;
23
+ stat: string;
24
+ diff: string;
25
+ }
26
+
27
+ export async function generateChangelogEntries({
28
+ model,
29
+ apiKey,
30
+ changelogPath,
31
+ isPackageChangelog,
32
+ existingEntries,
33
+ stat,
34
+ diff,
35
+ }: ChangelogPromptInput): Promise<ChangelogGenerationResult> {
36
+ const prompt = renderPromptTemplate(changelogUserPrompt, {
37
+ changelog_path: changelogPath,
38
+ is_package_changelog: isPackageChangelog,
39
+ existing_entries: existingEntries,
40
+ stat,
41
+ diff,
42
+ });
43
+ const response = await completeSimple(
44
+ model,
45
+ {
46
+ systemPrompt: renderPromptTemplate(changelogSystemPrompt),
47
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
48
+ tools: [ChangelogTool],
49
+ },
50
+ { apiKey, maxTokens: 1200 },
51
+ );
52
+
53
+ const parsed = parseChangelogResponse(response);
54
+ return { entries: dedupeEntries(parsed.entries) };
55
+ }
56
+
57
+ function parseChangelogResponse(message: AssistantMessage): ChangelogGenerationResult {
58
+ const toolCall = extractToolCall(message, "create_changelog_entries");
59
+ if (toolCall) {
60
+ const parsed = validateToolCall([ChangelogTool], toolCall) as ChangelogGenerationResult;
61
+ return { entries: parsed.entries ?? {} };
62
+ }
63
+
64
+ const text = extractTextContent(message);
65
+ const parsed = parseJsonPayload(text) as ChangelogGenerationResult;
66
+ return { entries: parsed.entries ?? {} };
67
+ }
68
+
69
+ function extractToolCall(message: AssistantMessage, name: string): ToolCall | undefined {
70
+ return message.content.find((content) => content.type === "toolCall" && content.name === name) as
71
+ | ToolCall
72
+ | undefined;
73
+ }
74
+
75
+ function extractTextContent(message: AssistantMessage): string {
76
+ return message.content
77
+ .filter((content) => content.type === "text")
78
+ .map((content) => content.text)
79
+ .join("")
80
+ .trim();
81
+ }
82
+
83
+ function parseJsonPayload(text: string): unknown {
84
+ const trimmed = text.trim();
85
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
86
+ return JSON.parse(trimmed) as unknown;
87
+ }
88
+ const match = trimmed.match(/\{[\s\S]*\}/);
89
+ if (!match) {
90
+ throw new Error("No JSON payload found in changelog response");
91
+ }
92
+ return JSON.parse(match[0]) as unknown;
93
+ }
94
+
95
+ function dedupeEntries(entries: Record<string, string[]>): Record<string, string[]> {
96
+ const result: Record<string, string[]> = {};
97
+ for (const [category, values] of Object.entries(entries)) {
98
+ const seen = new Set<string>();
99
+ const cleaned: string[] = [];
100
+ for (const value of values) {
101
+ const trimmed = value.trim().replace(/\.$/, "");
102
+ const key = trimmed.toLowerCase();
103
+ if (!trimmed || seen.has(key)) continue;
104
+ seen.add(key);
105
+ cleaned.push(trimmed);
106
+ }
107
+ if (cleaned.length > 0) {
108
+ result[category] = cleaned;
109
+ }
110
+ }
111
+ return result;
112
+ }
@@ -0,0 +1,233 @@
1
+ import { relative, resolve } from "node:path";
2
+ import type { Api, Model } from "@oh-my-pi/pi-ai";
3
+ import { detectChangelogBoundaries } from "@oh-my-pi/pi-coding-agent/commit/changelog/detect";
4
+ import { generateChangelogEntries } from "@oh-my-pi/pi-coding-agent/commit/changelog/generate";
5
+ import { parseUnreleasedSection } from "@oh-my-pi/pi-coding-agent/commit/changelog/parse";
6
+ import type { ControlledGit } from "@oh-my-pi/pi-coding-agent/commit/git";
7
+ import { logger } from "@oh-my-pi/pi-utils";
8
+
9
+ const CHANGELOG_SECTIONS = ["Breaking Changes", "Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"];
10
+
11
+ const DEFAULT_MAX_DIFF_CHARS = 120_000;
12
+
13
+ export interface ChangelogFlowInput {
14
+ git: ControlledGit;
15
+ cwd: string;
16
+ model: Model<Api>;
17
+ apiKey: string;
18
+ stagedFiles: string[];
19
+ dryRun: boolean;
20
+ maxDiffChars?: number;
21
+ onProgress?: (message: string) => void;
22
+ }
23
+
24
+ export interface ChangelogProposalInput {
25
+ git: ControlledGit;
26
+ cwd: string;
27
+ proposals: Array<{
28
+ path: string;
29
+ entries: Record<string, string[]>;
30
+ deletions?: Record<string, string[]>;
31
+ }>;
32
+ dryRun: boolean;
33
+ onProgress?: (message: string) => void;
34
+ }
35
+
36
+ /**
37
+ * Update CHANGELOG.md entries for staged changes.
38
+ */
39
+ export async function runChangelogFlow({
40
+ git,
41
+ cwd,
42
+ model,
43
+ apiKey,
44
+ stagedFiles,
45
+ dryRun,
46
+ maxDiffChars,
47
+ onProgress,
48
+ }: ChangelogFlowInput): Promise<string[]> {
49
+ if (stagedFiles.length === 0) return [];
50
+ onProgress?.("Detecting changelog boundaries...");
51
+ const boundaries = await detectChangelogBoundaries(cwd, stagedFiles);
52
+ if (boundaries.length === 0) return [];
53
+
54
+ const updated: string[] = [];
55
+ for (const boundary of boundaries) {
56
+ onProgress?.(`Generating entries for ${boundary.changelogPath}...`);
57
+ const diff = await git.getDiffForFiles(boundary.files, true);
58
+ if (!diff.trim()) continue;
59
+ const stat = await git.getStatForFiles(boundary.files, true);
60
+ const diffForPrompt = truncateDiff(diff, maxDiffChars ?? DEFAULT_MAX_DIFF_CHARS);
61
+ const changelogContent = await Bun.file(boundary.changelogPath).text();
62
+ let unreleased: { startLine: number; endLine: number; entries: Record<string, string[]> };
63
+ try {
64
+ unreleased = parseUnreleasedSection(changelogContent);
65
+ } catch (error) {
66
+ logger.warn("commit changelog parse skipped", { path: boundary.changelogPath, error: String(error) });
67
+ continue;
68
+ }
69
+ const existingEntries = formatExistingEntries(unreleased.entries);
70
+ const isPackageChangelog = resolve(boundary.changelogPath) !== resolve(cwd, "CHANGELOG.md");
71
+ const generated = await generateChangelogEntries({
72
+ model,
73
+ apiKey,
74
+ changelogPath: boundary.changelogPath,
75
+ isPackageChangelog,
76
+ existingEntries: existingEntries || undefined,
77
+ stat,
78
+ diff: diffForPrompt,
79
+ });
80
+ if (Object.keys(generated.entries).length === 0) continue;
81
+
82
+ const updatedContent = applyChangelogEntries(changelogContent, unreleased, generated.entries);
83
+ if (!dryRun) {
84
+ await Bun.write(boundary.changelogPath, updatedContent);
85
+ await git.stageFiles([relative(cwd, boundary.changelogPath)]);
86
+ }
87
+ updated.push(boundary.changelogPath);
88
+ }
89
+
90
+ return updated;
91
+ }
92
+
93
+ /**
94
+ * Apply changelog entries provided by the commit agent.
95
+ */
96
+ export async function applyChangelogProposals({
97
+ git,
98
+ cwd,
99
+ proposals,
100
+ dryRun,
101
+ onProgress,
102
+ }: ChangelogProposalInput): Promise<string[]> {
103
+ const updated: string[] = [];
104
+ for (const proposal of proposals) {
105
+ if (
106
+ Object.keys(proposal.entries).length === 0 &&
107
+ (!proposal.deletions || Object.keys(proposal.deletions).length === 0)
108
+ )
109
+ continue;
110
+ onProgress?.(`Applying entries for ${proposal.path}...`);
111
+ const exists = await Bun.file(proposal.path).exists();
112
+ if (!exists) {
113
+ logger.warn("commit changelog path missing", { path: proposal.path });
114
+ continue;
115
+ }
116
+ const changelogContent = await Bun.file(proposal.path).text();
117
+ let unreleased: { startLine: number; endLine: number; entries: Record<string, string[]> };
118
+ try {
119
+ unreleased = parseUnreleasedSection(changelogContent);
120
+ } catch (error) {
121
+ logger.warn("commit changelog parse skipped", { path: proposal.path, error: String(error) });
122
+ continue;
123
+ }
124
+ const normalized = normalizeEntries(proposal.entries);
125
+ const normalizedDeletions = proposal.deletions ? normalizeEntries(proposal.deletions) : undefined;
126
+ if (Object.keys(normalized).length === 0 && !normalizedDeletions) continue;
127
+ const updatedContent = applyChangelogEntries(changelogContent, unreleased, normalized, normalizedDeletions);
128
+ if (!dryRun) {
129
+ await Bun.write(proposal.path, updatedContent);
130
+ await git.stageFiles([relative(cwd, proposal.path)]);
131
+ }
132
+ updated.push(proposal.path);
133
+ }
134
+
135
+ return updated;
136
+ }
137
+
138
+ function truncateDiff(diff: string, maxChars: number): string {
139
+ if (diff.length <= maxChars) return diff;
140
+ return `${diff.slice(0, maxChars)}\n... (truncated)`;
141
+ }
142
+
143
+ function formatExistingEntries(entries: Record<string, string[]>): string {
144
+ const lines: string[] = [];
145
+ for (const section of CHANGELOG_SECTIONS) {
146
+ const values = entries[section] ?? [];
147
+ if (values.length === 0) continue;
148
+ lines.push(`${section}:`);
149
+ for (const value of values) {
150
+ lines.push(`- ${value}`);
151
+ }
152
+ }
153
+ return lines.join("\n");
154
+ }
155
+
156
+ function applyChangelogEntries(
157
+ content: string,
158
+ unreleased: { startLine: number; endLine: number; entries: Record<string, string[]> },
159
+ entries: Record<string, string[]>,
160
+ deletions?: Record<string, string[]>,
161
+ ): string {
162
+ const lines = content.split("\n");
163
+ const before = lines.slice(0, unreleased.startLine + 1);
164
+ const after = lines.slice(unreleased.endLine);
165
+
166
+ let base = unreleased.entries;
167
+ if (deletions) {
168
+ base = applyDeletions(base, deletions);
169
+ }
170
+ const merged = mergeEntries(base, entries);
171
+ const sectionLines = renderUnreleasedSections(merged);
172
+ return [...before, ...sectionLines, ...after].join("\n");
173
+ }
174
+
175
+ function applyDeletions(
176
+ existing: Record<string, string[]>,
177
+ deletions: Record<string, string[]>,
178
+ ): Record<string, string[]> {
179
+ const result: Record<string, string[]> = {};
180
+ for (const [section, items] of Object.entries(existing)) {
181
+ const toDelete = new Set((deletions[section] ?? []).map((d) => d.toLowerCase()));
182
+ const filtered = items.filter((item) => !toDelete.has(item.toLowerCase()));
183
+ if (filtered.length > 0) {
184
+ result[section] = filtered;
185
+ }
186
+ }
187
+ return result;
188
+ }
189
+
190
+ function mergeEntries(
191
+ existing: Record<string, string[]>,
192
+ incoming: Record<string, string[]>,
193
+ ): Record<string, string[]> {
194
+ const merged: Record<string, string[]> = { ...existing };
195
+ for (const [section, items] of Object.entries(incoming)) {
196
+ const current = merged[section] ?? [];
197
+ const lower = new Set(current.map((item) => item.toLowerCase()));
198
+ for (const item of items) {
199
+ if (!lower.has(item.toLowerCase())) {
200
+ current.push(item);
201
+ }
202
+ }
203
+ merged[section] = current;
204
+ }
205
+ return merged;
206
+ }
207
+
208
+ function renderUnreleasedSections(entries: Record<string, string[]>): string[] {
209
+ const lines: string[] = [""];
210
+ for (const section of CHANGELOG_SECTIONS) {
211
+ const items = entries[section] ?? [];
212
+ if (items.length === 0) continue;
213
+ lines.push(`### ${section}`);
214
+ for (const item of items) {
215
+ lines.push(`- ${item}`);
216
+ }
217
+ lines.push("");
218
+ }
219
+ if (lines[lines.length - 1] === "") {
220
+ lines.pop();
221
+ }
222
+ return lines;
223
+ }
224
+
225
+ function normalizeEntries(entries: Record<string, string[]>): Record<string, string[]> {
226
+ const result: Record<string, string[]> = {};
227
+ for (const [section, items] of Object.entries(entries)) {
228
+ const trimmed = items.map((item) => item.trim().replace(/\.$/, "")).filter((item) => item.length > 0);
229
+ if (trimmed.length === 0) continue;
230
+ result[section] = Array.from(new Set(trimmed.map((item) => item.trim())));
231
+ }
232
+ return result;
233
+ }
@@ -0,0 +1,44 @@
1
+ import type { UnreleasedSection } from "@oh-my-pi/pi-coding-agent/commit/types";
2
+
3
+ const UNRELEASED_PATTERN = /^##\s+\[?Unreleased\]?/i;
4
+ const SECTION_PATTERN = /^###\s+(.*)$/;
5
+
6
+ export function parseUnreleasedSection(content: string): UnreleasedSection {
7
+ const lines = content.split("\n");
8
+ const startIndex = lines.findIndex((line) => UNRELEASED_PATTERN.test(line.trim()));
9
+ if (startIndex === -1) {
10
+ throw new Error("No [Unreleased] section found in changelog");
11
+ }
12
+
13
+ let endIndex = lines.length;
14
+ for (let i = startIndex + 1; i < lines.length; i += 1) {
15
+ if (lines[i]?.startsWith("## ")) {
16
+ endIndex = i;
17
+ break;
18
+ }
19
+ }
20
+
21
+ const sectionLines = lines.slice(startIndex + 1, endIndex);
22
+ const entries: Record<string, string[]> = {};
23
+ let currentSection: string | null = null;
24
+ for (const line of sectionLines) {
25
+ const sectionMatch = line.match(SECTION_PATTERN);
26
+ if (sectionMatch) {
27
+ currentSection = sectionMatch[1]?.trim() || null;
28
+ if (currentSection) {
29
+ entries[currentSection] = entries[currentSection] ?? [];
30
+ }
31
+ continue;
32
+ }
33
+
34
+ if (!currentSection) continue;
35
+ const trimmed = line.trim();
36
+ if (!trimmed.startsWith("-")) continue;
37
+ const entry = trimmed.replace(/^[-*]\s*/, "");
38
+ if (entry) {
39
+ entries[currentSection]?.push(entry);
40
+ }
41
+ }
42
+
43
+ return { startLine: startIndex, endLine: endIndex, entries };
44
+ }
@@ -0,0 +1,93 @@
1
+ import type { CommitCommandArgs } from "@oh-my-pi/pi-coding-agent/commit/types";
2
+ import chalk from "chalk";
3
+
4
+ const FLAG_ALIASES = new Map<string, string>([
5
+ ["-c", "--context"],
6
+ ["-m", "--model"],
7
+ ]);
8
+
9
+ export function parseCommitArgs(args: string[]): CommitCommandArgs | undefined {
10
+ if (args.length === 0 || args[0] !== "commit") {
11
+ return undefined;
12
+ }
13
+
14
+ const result: CommitCommandArgs = {
15
+ push: false,
16
+ dryRun: false,
17
+ noChangelog: false,
18
+ };
19
+
20
+ for (let i = 1; i < args.length; i += 1) {
21
+ const raw = args[i] ?? "";
22
+ const flag = FLAG_ALIASES.get(raw) ?? raw;
23
+ switch (flag) {
24
+ case "--push":
25
+ result.push = true;
26
+ break;
27
+ case "--dry-run":
28
+ result.dryRun = true;
29
+ break;
30
+ case "--no-changelog":
31
+ result.noChangelog = true;
32
+ break;
33
+ case "--legacy":
34
+ result.legacy = true;
35
+ break;
36
+ case "--context": {
37
+ const value = args[i + 1];
38
+ if (!value || value.startsWith("-")) {
39
+ writeStderr(chalk.red("Error: --context requires a value"));
40
+ process.exit(1);
41
+ }
42
+ result.context = value;
43
+ i += 1;
44
+ break;
45
+ }
46
+ case "--model": {
47
+ const value = args[i + 1];
48
+ if (!value || value.startsWith("-")) {
49
+ writeStderr(chalk.red("Error: --model requires a value"));
50
+ process.exit(1);
51
+ }
52
+ result.model = value;
53
+ i += 1;
54
+ break;
55
+ }
56
+ case "--help":
57
+ case "-h":
58
+ break;
59
+ default:
60
+ if (flag.startsWith("-")) {
61
+ writeStderr(chalk.red(`Error: Unknown flag ${flag}`));
62
+ process.exit(1);
63
+ }
64
+ }
65
+ }
66
+
67
+ return result;
68
+ }
69
+
70
+ export function printCommitHelp(): void {
71
+ const lines = [
72
+ "Usage:",
73
+ " omp commit [options]",
74
+ "",
75
+ "Options:",
76
+ " --push Push after committing",
77
+ " --dry-run Preview without committing",
78
+ " --no-changelog Skip changelog updates",
79
+ " --legacy Use legacy deterministic pipeline",
80
+ " --context, -c Additional context for the model",
81
+ " --model, -m Override model selection",
82
+ " --help, -h Show this help message",
83
+ ];
84
+ writeStdout(lines.join("\n"));
85
+ }
86
+
87
+ function writeStdout(message: string): void {
88
+ process.stdout.write(`${message}\n`);
89
+ }
90
+
91
+ function writeStderr(message: string): void {
92
+ process.stderr.write(`${message}\n`);
93
+ }