supipowers 1.2.6 → 1.5.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 (137) hide show
  1. package/README.md +118 -56
  2. package/bin/install.ts +48 -128
  3. package/package.json +11 -3
  4. package/skills/code-review/SKILL.md +137 -40
  5. package/skills/context-mode/SKILL.md +67 -56
  6. package/skills/creating-supi-agents/SKILL.md +204 -0
  7. package/skills/debugging/SKILL.md +86 -40
  8. package/skills/fix-pr/SKILL.md +96 -65
  9. package/skills/planning/SKILL.md +103 -46
  10. package/skills/qa-strategy/SKILL.md +68 -46
  11. package/skills/receiving-code-review/SKILL.md +60 -53
  12. package/skills/release/SKILL.md +111 -39
  13. package/skills/tdd/SKILL.md +118 -67
  14. package/skills/verification/SKILL.md +71 -37
  15. package/src/bootstrap.ts +27 -5
  16. package/src/commands/agents.ts +249 -0
  17. package/src/commands/ai-review.ts +1113 -0
  18. package/src/commands/config.ts +224 -95
  19. package/src/commands/doctor.ts +19 -13
  20. package/src/commands/fix-pr.ts +8 -11
  21. package/src/commands/generate.ts +200 -0
  22. package/src/commands/model-picker.ts +5 -15
  23. package/src/commands/model.ts +4 -5
  24. package/src/commands/optimize-context.ts +202 -0
  25. package/src/commands/plan.ts +148 -92
  26. package/src/commands/qa.ts +14 -23
  27. package/src/commands/release.ts +504 -275
  28. package/src/commands/review.ts +643 -86
  29. package/src/commands/status.ts +44 -17
  30. package/src/commands/supi.ts +69 -41
  31. package/src/commands/update.ts +57 -2
  32. package/src/config/defaults.ts +6 -39
  33. package/src/config/loader.ts +388 -40
  34. package/src/config/model-resolver.ts +26 -22
  35. package/src/config/schema.ts +113 -48
  36. package/src/context/analyzer.ts +61 -2
  37. package/src/context/optimizer.ts +199 -0
  38. package/src/context-mode/compressor.ts +14 -11
  39. package/src/context-mode/detector.ts +16 -54
  40. package/src/context-mode/event-extractor.ts +45 -16
  41. package/src/context-mode/event-store.ts +225 -16
  42. package/src/context-mode/hooks.ts +195 -22
  43. package/src/context-mode/knowledge/chunker.ts +235 -0
  44. package/src/context-mode/knowledge/store.ts +187 -0
  45. package/src/context-mode/routing.ts +12 -23
  46. package/src/context-mode/sandbox/executor.ts +183 -0
  47. package/src/context-mode/sandbox/runners.ts +40 -0
  48. package/src/context-mode/snapshot-builder.ts +243 -7
  49. package/src/context-mode/tools.ts +440 -0
  50. package/src/context-mode/web/fetcher.ts +117 -0
  51. package/src/context-mode/web/html-to-md.ts +293 -0
  52. package/src/debug/logger.ts +107 -0
  53. package/src/deps/registry.ts +0 -20
  54. package/src/docs/drift.ts +454 -0
  55. package/src/fix-pr/fetch-comments.ts +66 -0
  56. package/src/git/commit-msg.ts +2 -1
  57. package/src/git/commit.ts +123 -141
  58. package/src/git/conventions.ts +2 -2
  59. package/src/git/status.ts +4 -1
  60. package/src/lsp/bridge.ts +138 -12
  61. package/src/planning/approval-flow.ts +125 -19
  62. package/src/planning/plan-writer-prompt.ts +4 -11
  63. package/src/planning/planning-ask-tool.ts +81 -0
  64. package/src/planning/prompt-builder.ts +9 -169
  65. package/src/planning/system-prompt.ts +290 -0
  66. package/src/platform/omp.ts +50 -4
  67. package/src/platform/progress.ts +182 -0
  68. package/src/platform/test-utils.ts +4 -1
  69. package/src/platform/tui-colors.ts +30 -0
  70. package/src/platform/types.ts +1 -0
  71. package/src/qa/detect-app-type.ts +102 -0
  72. package/src/qa/discover-routes.ts +353 -0
  73. package/src/quality/ai-session.ts +96 -0
  74. package/src/quality/ai-setup.ts +86 -0
  75. package/src/quality/gates/ai-review.ts +129 -0
  76. package/src/quality/gates/build.ts +8 -0
  77. package/src/quality/gates/command.ts +150 -0
  78. package/src/quality/gates/format.ts +28 -0
  79. package/src/quality/gates/lint.ts +22 -0
  80. package/src/quality/gates/lsp-diagnostics.ts +84 -0
  81. package/src/quality/gates/test-suite.ts +8 -0
  82. package/src/quality/gates/typecheck.ts +22 -0
  83. package/src/quality/registry.ts +25 -0
  84. package/src/quality/review-gates.ts +33 -0
  85. package/src/quality/runner.ts +268 -0
  86. package/src/quality/schemas.ts +48 -0
  87. package/src/quality/setup.ts +227 -0
  88. package/src/release/changelog.ts +7 -3
  89. package/src/release/channels/custom.ts +43 -0
  90. package/src/release/channels/gitea.ts +35 -0
  91. package/src/release/channels/github.ts +35 -0
  92. package/src/release/channels/gitlab.ts +35 -0
  93. package/src/release/channels/registry.ts +52 -0
  94. package/src/release/channels/types.ts +27 -0
  95. package/src/release/detector.ts +10 -63
  96. package/src/release/executor.ts +61 -51
  97. package/src/release/prompt.ts +38 -38
  98. package/src/release/version.ts +129 -10
  99. package/src/review/agent-loader.ts +331 -0
  100. package/src/review/consolidator.ts +180 -0
  101. package/src/review/default-agents/correctness.md +72 -0
  102. package/src/review/default-agents/maintainability.md +64 -0
  103. package/src/review/default-agents/security.md +67 -0
  104. package/src/review/fixer.ts +219 -0
  105. package/src/review/multi-agent-runner.ts +135 -0
  106. package/src/review/output.ts +147 -0
  107. package/src/review/prompts/agent-review-wrapper.md +36 -0
  108. package/src/review/prompts/fix-findings.md +32 -0
  109. package/src/review/prompts/fix-output-schema.md +18 -0
  110. package/src/review/prompts/invalid-output-retry.md +22 -0
  111. package/src/review/prompts/output-instructions.md +14 -0
  112. package/src/review/prompts/review-output-schema.md +38 -0
  113. package/src/review/prompts/single-review.md +53 -0
  114. package/src/review/prompts/validation-review.md +30 -0
  115. package/src/review/runner.ts +128 -0
  116. package/src/review/scope.ts +353 -0
  117. package/src/review/template.ts +15 -0
  118. package/src/review/types.ts +296 -0
  119. package/src/review/validator.ts +160 -0
  120. package/src/storage/plans.ts +5 -3
  121. package/src/storage/reports.ts +50 -7
  122. package/src/storage/review-sessions.ts +117 -0
  123. package/src/text.ts +19 -0
  124. package/src/types.ts +336 -26
  125. package/src/utils/paths.ts +39 -0
  126. package/src/visual/companion.ts +5 -3
  127. package/src/visual/start-server.ts +101 -0
  128. package/src/visual/stop-server.ts +39 -0
  129. package/bin/ctx-mode-wrapper.mjs +0 -66
  130. package/src/config/profiles.ts +0 -64
  131. package/src/context-mode/installer.ts +0 -38
  132. package/src/quality/ai-review-gate.ts +0 -43
  133. package/src/quality/gate-runner.ts +0 -67
  134. package/src/quality/lsp-gate.ts +0 -24
  135. package/src/quality/test-gate.ts +0 -39
  136. package/src/visual/scripts/start-server.sh +0 -98
  137. package/src/visual/scripts/stop-server.sh +0 -21
@@ -0,0 +1,35 @@
1
+ // src/release/channels/gitlab.ts — Built-in GitLab release channel
2
+ import type { ChannelHandler, ChannelPublishContext, ChannelStatus, ExecFn } from "./types.js";
3
+
4
+ export const gitlab: ChannelHandler = {
5
+ id: "gitlab",
6
+ label: "GitLab Releases",
7
+
8
+ async detect(exec: ExecFn, cwd: string): Promise<ChannelStatus> {
9
+ try {
10
+ const result = await exec("glab", ["auth", "status"], { cwd });
11
+ if (result.code === 0) {
12
+ return { channel: "gitlab", available: true, detail: "Authenticated with GitLab CLI" };
13
+ }
14
+ return { channel: "gitlab", available: false, detail: "GitLab CLI not authenticated (run: glab auth login)" };
15
+ } catch {
16
+ return { channel: "gitlab", available: false, detail: "GitLab CLI not installed (install: https://gitlab.com/gitlab-org/cli)" };
17
+ }
18
+ },
19
+
20
+ async publish(exec: ExecFn, ctx: ChannelPublishContext): Promise<{ success: boolean; error?: string }> {
21
+ try {
22
+ const result = await exec(
23
+ "glab",
24
+ ["release", "create", ctx.tag, "--notes", ctx.changelog],
25
+ { cwd: ctx.cwd },
26
+ );
27
+ if (result.code !== 0) {
28
+ return { success: false, error: result.stderr || result.stdout || `glab release create exited with code ${result.code}` };
29
+ }
30
+ return { success: true };
31
+ } catch (err) {
32
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
33
+ }
34
+ },
35
+ };
@@ -0,0 +1,52 @@
1
+ // src/release/channels/registry.ts — Channel handler resolution and discovery
2
+ import type { CustomChannelConfig } from "../../types.js";
3
+ import type { ChannelHandler, ChannelStatus, ExecFn } from "./types.js";
4
+ import { github } from "./github.js";
5
+ import { gitlab } from "./gitlab.js";
6
+ import { gitea } from "./gitea.js";
7
+ import { createCustomHandler } from "./custom.js";
8
+
9
+ const BUILTIN_CHANNELS: ChannelHandler[] = [github, gitlab, gitea];
10
+
11
+ const BUILTIN_MAP = new Map(BUILTIN_CHANNELS.map((h) => [h.id, h]));
12
+
13
+ /**
14
+ * Resolve a channel ID to a handler. Custom channel configs override built-in
15
+ * handlers when the IDs match. Returns null for unknown IDs with no custom config.
16
+ */
17
+ export function resolveChannelHandler(
18
+ id: string,
19
+ customChannels: Record<string, CustomChannelConfig>,
20
+ ): ChannelHandler | null {
21
+ if (id in customChannels) {
22
+ return createCustomHandler(id, customChannels[id]);
23
+ }
24
+ return BUILTIN_MAP.get(id) ?? null;
25
+ }
26
+
27
+ /**
28
+ * Detect availability of all built-in channels plus any custom channels.
29
+ * Returns a status entry for each, ordered built-ins first, then custom.
30
+ */
31
+ export async function getAllAvailableChannels(
32
+ exec: ExecFn,
33
+ cwd: string,
34
+ customChannels: Record<string, CustomChannelConfig>,
35
+ ): Promise<ChannelStatus[]> {
36
+ const handlers: ChannelHandler[] = [];
37
+
38
+ for (const builtin of BUILTIN_CHANNELS) {
39
+ const handler = resolveChannelHandler(builtin.id, customChannels);
40
+ if (handler) {
41
+ handlers.push(handler);
42
+ }
43
+ }
44
+
45
+ for (const [id, config] of Object.entries(customChannels)) {
46
+ if (!BUILTIN_MAP.has(id)) {
47
+ handlers.push(createCustomHandler(id, config));
48
+ }
49
+ }
50
+
51
+ return Promise.all(handlers.map((handler) => handler.detect(exec, cwd)));
52
+ }
@@ -0,0 +1,27 @@
1
+ // src/release/channels/types.ts — Shared types for release channel handlers
2
+
3
+ export type ExecFn = (
4
+ cmd: string,
5
+ args: string[],
6
+ opts?: { cwd?: string; env?: Record<string, string> },
7
+ ) => Promise<{ stdout: string; stderr: string; code: number }>;
8
+
9
+ export interface ChannelStatus {
10
+ channel: string;
11
+ available: boolean;
12
+ detail: string;
13
+ }
14
+
15
+ export interface ChannelPublishContext {
16
+ version: string;
17
+ tag: string;
18
+ changelog: string;
19
+ cwd: string;
20
+ }
21
+
22
+ export interface ChannelHandler {
23
+ id: string;
24
+ label: string;
25
+ detect(exec: ExecFn, cwd: string): Promise<ChannelStatus>;
26
+ publish(exec: ExecFn, ctx: ChannelPublishContext): Promise<{ success: boolean; error?: string }>;
27
+ }
@@ -1,69 +1,16 @@
1
1
  // src/release/detector.ts — Detect which release channels are available
2
-
3
- import type { ReleaseChannel } from "../types.js";
4
-
5
- type ExecFn = (
6
- cmd: string,
7
- args: string[],
8
- opts?: { cwd?: string },
9
- ) => Promise<{ stdout: string; stderr: string; code: number }>;
10
-
11
- export interface ChannelStatus {
12
- channel: ReleaseChannel;
13
- available: boolean;
14
- detail: string;
15
- }
16
-
17
- async function checkGitHub(exec: ExecFn, cwd: string): Promise<ChannelStatus> {
18
- try {
19
- const result = await exec("gh", ["auth", "status"], { cwd });
20
- if (result.code === 0) {
21
- return { channel: "github", available: true, detail: "Authenticated with GitHub CLI" };
22
- }
23
- return {
24
- channel: "github",
25
- available: false,
26
- detail: "GitHub CLI not authenticated (run: gh auth login)",
27
- };
28
- } catch {
29
- return {
30
- channel: "github",
31
- available: false,
32
- detail: "GitHub CLI not authenticated (run: gh auth login)",
33
- };
34
- }
35
- }
36
-
37
- async function checkNpm(exec: ExecFn, cwd: string): Promise<ChannelStatus> {
38
- try {
39
- const result = await exec("npm", ["whoami"], { cwd });
40
- if (result.code === 0) {
41
- return {
42
- channel: "npm",
43
- available: true,
44
- detail: `Logged in as ${result.stdout.trim()}`,
45
- };
46
- }
47
- return {
48
- channel: "npm",
49
- available: false,
50
- detail: "npm not authenticated (run: npm login)",
51
- };
52
- } catch {
53
- return {
54
- channel: "npm",
55
- available: false,
56
- detail: "npm not authenticated (run: npm login)",
57
- };
58
- }
59
- }
2
+ import type { CustomChannelConfig } from "../types.js";
3
+ import type { ChannelStatus, ExecFn } from "./channels/types.js";
4
+ import { getAllAvailableChannels } from "./channels/registry.js";
60
5
 
61
6
  /**
62
7
  * Detect which release channels are available in the current environment.
63
- * Both checks run concurrently and independently a failure in one does not
64
- * affect the other.
8
+ * Checks all built-in channels plus any user-defined custom channels.
65
9
  */
66
- export async function detectChannels(exec: ExecFn, cwd: string): Promise<ChannelStatus[]> {
67
- const [github, npm] = await Promise.all([checkGitHub(exec, cwd), checkNpm(exec, cwd)]);
68
- return [github, npm];
10
+ export async function detectChannels(
11
+ exec: ExecFn,
12
+ cwd: string,
13
+ customChannels: Record<string, CustomChannelConfig> = {},
14
+ ): Promise<ChannelStatus[]> {
15
+ return getAllAvailableChannels(exec, cwd, customChannels);
69
16
  }
@@ -1,14 +1,11 @@
1
1
  // src/release/executor.ts — Release execution: build, version bump, git ops, channel publishing
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import type { ReleaseChannel, ReleaseResult } from "../types.js";
4
+ import type { CustomChannelConfig, ReleaseChannel, ReleaseResult } from "../types.js";
5
5
  import { commitStaged } from "../git/commit.js";
6
-
7
- type ExecFn = (
8
- cmd: string,
9
- args: string[],
10
- opts?: { cwd?: string },
11
- ) => Promise<{ stdout: string; stderr: string; code: number }>;
6
+ import { formatTag } from "./version.js";
7
+ import { resolveChannelHandler } from "./channels/registry.js";
8
+ import type { ExecFn } from "./channels/types.js";
12
9
 
13
10
  /** Callback to report step progress during release execution. */
14
11
  export type ReleaseProgressFn = (step: string, status: "active" | "done" | "error", detail?: string) => void;
@@ -20,9 +17,12 @@ export interface ExecuteReleaseOptions {
20
17
  changelog: string;
21
18
  channels: ReleaseChannel[];
22
19
  dryRun: boolean;
20
+ tagFormat: string;
21
+ /** User-defined custom channel configurations */
22
+ customChannels?: Record<string, CustomChannelConfig>;
23
23
  /** Skip the package.json version write (version was already set locally). */
24
24
  skipBump?: boolean;
25
- /** Skip creating the git tag (it already exists locally). */
25
+ /** Reuse/update the local git tag after the rebase step instead of creating a new one. */
26
26
  skipTag?: boolean;
27
27
  /** Optional callback for step-by-step progress reporting. */
28
28
  onProgress?: ReleaseProgressFn;
@@ -39,18 +39,21 @@ export interface ExecuteReleaseOptions {
39
39
  * happen (all flags true) so callers can preview without side-effects.
40
40
  */
41
41
  export async function executeRelease(opts: ExecuteReleaseOptions): Promise<ReleaseResult> {
42
- const { exec, cwd, version, changelog, channels, dryRun, skipBump, skipTag, onProgress } = opts;
42
+ const { exec, cwd, version, changelog, channels, dryRun, tagFormat, skipBump, skipTag, onProgress, customChannels = {} } = opts;
43
43
  const progress = onProgress ?? (() => {});
44
+ const tagName = formatTag(version, tagFormat);
45
+ const tagMessage = `Release ${tagName}\n\n${changelog}`;
44
46
 
45
47
  if (dryRun) {
46
48
  console.log(`[dry-run] Would build (if scripts.build exists)`);
47
49
  console.log(`[dry-run] Would bump version to ${version}`);
48
50
  console.log(`[dry-run] Would git add -A`);
49
- console.log(`[dry-run] Would git commit -m "chore(release): v${version}"`);
51
+ console.log(`[dry-run] Would git commit -m "chore(release): ${tagName}"`);
52
+ console.log(`[dry-run] Would git pull --rebase origin`);
50
53
  if (skipTag) {
51
- console.log(`[dry-run] Would skip git tag (already exists locally)`);
54
+ console.log(`[dry-run] Would refresh existing git tag ${tagName} to the rebased HEAD`);
52
55
  } else {
53
- console.log(`[dry-run] Would git tag -a v${version}`);
56
+ console.log(`[dry-run] Would git tag -a ${tagName}`);
54
57
  }
55
58
  console.log(`[dry-run] Would git push origin HEAD --follow-tags`);
56
59
  for (const ch of channels) {
@@ -107,7 +110,7 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
107
110
  }
108
111
  progress("git-add", "done");
109
112
 
110
- const commitMessage = `chore(release): v${version}`;
113
+ const commitMessage = `chore(release): ${formatTag(version, tagFormat)}`;
111
114
  progress("git-commit", "active", commitMessage);
112
115
  const commitResult = await commitStaged(exec, cwd, commitMessage);
113
116
  if (!commitResult.success) {
@@ -117,26 +120,36 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
117
120
  progress("git-commit", "done");
118
121
  }
119
122
 
120
- if (skipTag) {
121
- progress("git-tag", "done", "Already exists");
122
- } else {
123
- progress("git-tag", "active", `v${version}`);
124
- const tagMessage = `Release v${version}\n\n${changelog}`;
125
- const gitTag = await exec("git", ["tag", "-a", `v${version}`, "-m", tagMessage], { cwd });
126
- if (gitTag.code !== 0) {
127
- const detail = gitTag.stderr || gitTag.stdout || `exit code ${gitTag.code}`;
128
- progress("git-tag", "error", detail);
129
- return { version, tagCreated: false, pushed: false, channels: [], error: `git tag: ${detail}` };
130
- }
131
- progress("git-tag", "done");
123
+ progress("git-pull", "active", "Pulling latest from origin");
124
+ const gitPull = await exec("git", ["pull", "--rebase", "origin"], { cwd });
125
+ if (gitPull.code !== 0) {
126
+ const detail = gitPull.stderr || gitPull.stdout || `exit code ${gitPull.code}`;
127
+ progress("git-pull", "error", detail);
128
+ return { version, tagCreated: Boolean(skipTag), pushed: false, channels: [], error: `git pull: ${detail}` };
132
129
  }
130
+ progress("git-pull", "done");
131
+
132
+ progress("git-tag", "active", skipTag ? `Refreshing ${tagName}` : tagName);
133
+ const gitTag = await exec(
134
+ "git",
135
+ skipTag
136
+ ? ["tag", "-a", "-f", tagName, "-m", tagMessage]
137
+ : ["tag", "-a", tagName, "-m", tagMessage],
138
+ { cwd },
139
+ );
140
+ if (gitTag.code !== 0) {
141
+ const detail = gitTag.stderr || gitTag.stdout || `exit code ${gitTag.code}`;
142
+ progress("git-tag", "error", detail);
143
+ return { version, tagCreated: Boolean(skipTag), pushed: false, channels: [], error: `git tag: ${detail}` };
144
+ }
145
+ progress("git-tag", "done", skipTag ? "Refreshed existing tag" : undefined);
133
146
 
134
147
  progress("git-push", "active", "Pushing to origin");
135
148
  const gitPush = await exec("git", ["push", "origin", "HEAD", "--follow-tags"], { cwd });
136
149
  if (gitPush.code !== 0) {
137
150
  const detail = gitPush.stderr || gitPush.stdout || `exit code ${gitPush.code}`;
138
151
  progress("git-push", "error", detail);
139
- // Tag was created locally but push failed
152
+ // Tag was created or refreshed locally but push failed
140
153
  return { version, tagCreated: true, pushed: false, channels: [], error: `git push: ${detail}` };
141
154
  }
142
155
  progress("git-push", "done");
@@ -146,31 +159,28 @@ export async function executeRelease(opts: ExecuteReleaseOptions): Promise<Relea
146
159
 
147
160
  for (const channel of channels) {
148
161
  progress(`publish-${channel}`, "active", `Publishing to ${channel}`);
149
- try {
150
- let result: { code: number; stdout: string; stderr: string };
151
- if (channel === "github") {
152
- result = await exec(
153
- "gh",
154
- ["release", "create", `v${version}`, "--title", `v${version}`, "--notes", changelog],
155
- { cwd },
156
- );
157
- } else {
158
- // "npm"
159
- result = await exec("npm", ["publish"], { cwd });
160
- }
161
-
162
- if (result.code !== 0) {
163
- const err = result.stderr || result.stdout || `${channel} publish exited with code ${result.code}`;
164
- progress(`publish-${channel}`, "error", err);
165
- channelResults.push({ channel, success: false, error: err });
166
- } else {
167
- progress(`publish-${channel}`, "done");
168
- channelResults.push({ channel, success: true });
169
- }
170
- } catch (err) {
171
- const msg = err instanceof Error ? err.message : String(err);
172
- progress(`publish-${channel}`, "error", msg);
173
- channelResults.push({ channel, success: false, error: msg });
162
+
163
+ const handler = resolveChannelHandler(channel, customChannels);
164
+ if (!handler) {
165
+ const err = `Unknown channel '${channel}' — no built-in handler and no custom config found`;
166
+ progress(`publish-${channel}`, "error", err);
167
+ channelResults.push({ channel, success: false, error: err });
168
+ continue;
169
+ }
170
+
171
+ const result = await handler.publish(exec, {
172
+ version,
173
+ tag: formatTag(version, tagFormat),
174
+ changelog,
175
+ cwd,
176
+ });
177
+
178
+ if (result.success) {
179
+ progress(`publish-${channel}`, "done");
180
+ channelResults.push({ channel, success: true });
181
+ } else {
182
+ progress(`publish-${channel}`, "error", result.error);
183
+ channelResults.push({ channel, success: false, error: result.error });
174
184
  }
175
185
  }
176
186
 
@@ -1,22 +1,19 @@
1
- import type { ReleaseChannel } from "../types.js";
1
+ import { formatTag } from "./version.js";
2
2
 
3
3
  export interface BuildPolishPromptOpts {
4
4
  changelog: string;
5
5
  version: string;
6
- currentVersion: string;
7
- channels: ReleaseChannel[];
8
- commands: string[];
6
+ tagFormat?: string;
9
7
  }
10
8
 
11
9
  /**
12
- * Build a steer prompt that instructs the LLM to polish the pre-built changelog,
13
- * present it to the user for confirmation, then run the release commands on approval.
10
+ * Build a prompt for a headless agent session that polishes raw changelog
11
+ * text into clear, user-facing release notes.
12
+ *
13
+ * The agent returns only the polished markdown — no commands, no confirmation.
14
14
  */
15
15
  export function buildPolishPrompt(opts: BuildPolishPromptOpts): string {
16
- const { changelog, version, currentVersion, channels, commands } = opts;
17
-
18
- const channelList = channels.map((c) => `- ${c}`).join("\n");
19
- const commandList = commands.map((c) => `- \`${c}\``).join("\n");
16
+ const { changelog, version } = opts;
20
17
 
21
18
  const changelogSection =
22
19
  changelog.trim().length > 0
@@ -24,48 +21,51 @@ export function buildPolishPrompt(opts: BuildPolishPromptOpts): string {
24
21
  : "_No notable changes in this release._";
25
22
 
26
23
  return [
27
- "# Release Polish & Confirmation",
28
- "",
29
- `You are preparing a release of **${version}** (from ${currentVersion}).`,
24
+ "# Polish Release Notes",
30
25
  "",
31
- "## Target Channels",
26
+ `You are polishing changelog notes for **${formatTag(version, opts.tagFormat ?? "v${version}")}**.`,
32
27
  "",
33
- channelList,
28
+ "## Raw Changelog",
34
29
  "",
35
- "## Pre-built Changelog",
30
+ changelogSection,
36
31
  "",
37
- "The following changelog was generated from commits. It is raw and may contain",
38
- "terse commit-message language.",
32
+ "## Instructions",
39
33
  "",
40
- changelogSection,
34
+ "Rewrite the raw changelog above into clear, user-facing release notes.",
41
35
  "",
42
- "## Your Task",
36
+ "### Content rules",
43
37
  "",
44
- "1. **Polish** the changelog above into clear, user-facing language:",
45
- " - Rewrite terse commit descriptions into plain English a user would understand.",
46
- " - Group closely related changes together under the same bullet.",
47
- " - Remove noise (chores, reformats, typo fixes) unless they fix a user-visible bug.",
48
- " - Do **not** change version numbers.",
49
- " - Do **not** skip or reorder the section headings (Breaking Changes, Features, Fixes, Improvements, Maintenance, Other).",
50
- " - Do **not** invent changes that are not in the raw changelog.",
38
+ "| Rule | Details |",
39
+ "|------|---------|",
40
+ "| Plain English | Rewrite terse commit descriptions so a user understands the impact |",
41
+ "| Group related | Merge closely related changes into a single bullet |",
42
+ "| Remove noise | Drop chores, reformats, typo fixes — unless they fix a user-visible bug |",
43
+ "| No invented changes | Every bullet must trace back to the raw changelog |",
44
+ "| Preserve headings | Keep section order: Breaking Changes, Features, Fixes, Improvements, Maintenance, Other. Omit empty sections |",
45
+ "| Preserve versions | Do **not** change version numbers |",
51
46
  "",
52
- "2. **Present** the polished changelog to the user in a readable format.",
47
+ "### Superseded changes",
53
48
  "",
54
- "3. **Ask** the user for confirmation:",
55
- ' > "Does this look good? Type **yes** to proceed with the release, or **no** to abort."',
49
+ "Drop entries whose effect is entirely negated or replaced by a later entry in the same release.",
56
50
  "",
57
- "## On Approval",
51
+ "Detect these patterns:",
52
+ "- **Replaced**: feature A is added, then later replaced by feature B → keep only B.",
53
+ "- **Reverted**: a change is introduced and then reverted → drop both.",
54
+ "- **Evolved**: an item is added, then renamed/rewritten → keep only the final form.",
58
55
  "",
59
- "If the user confirms, run the following commands **in order** without modification:",
56
+ "The changelog describes the **net state** of the release — what a user upgrading from the previous version actually gets not the development history that produced it.",
60
57
  "",
61
- commandList,
58
+ "```",
59
+ "before (wrong):",
60
+ "- Added caching layer for API responses",
61
+ "- Replaced caching layer with Redis-backed store",
62
62
  "",
63
- "Do **not** skip any command. Do **not** modify the commands.",
64
- "Report the result of each command as it completes.",
63
+ "after (correct):",
64
+ "- Added Redis-backed caching for API responses",
65
+ "```",
65
66
  "",
66
- "## On Rejection",
67
+ "## Output",
67
68
  "",
68
- "If the user rejects, abort the release immediately.",
69
- "Inform the user: _Release aborted. No changes were made._",
69
+ "Return **only** the polished markdown changelog. No preamble, no explanation, no code fences wrapping the whole output.",
70
70
  ].join("\n");
71
71
  }
@@ -8,6 +8,14 @@ type ExecFn = (
8
8
  opts?: { cwd?: string },
9
9
  ) => Promise<{ stdout: string; stderr: string; code: number }>;
10
10
 
11
+ /**
12
+ * Replace `${version}` in a tag format template with the actual version string.
13
+ * If the template contains no placeholder, it is returned as-is.
14
+ */
15
+ export function formatTag(version: string, tagFormat: string): string {
16
+ return tagFormat.replace("${version}", version);
17
+ }
18
+
11
19
  /**
12
20
  * Suggest a semver bump type based on categorized commits.
13
21
  * Breaking changes win over features; features win over everything else.
@@ -59,22 +67,133 @@ export function getCurrentVersion(cwd: string): string {
59
67
  }
60
68
  }
61
69
 
70
+ interface ParsedReleaseTag {
71
+ tag: string;
72
+ version: string;
73
+ }
74
+
75
+ const SEMVER_CAPTURE = "([0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?)";
76
+
77
+ function escapeRegExp(value: string): string {
78
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
+ }
80
+
81
+ function extractVersionFromTag(tag: string, tagFormat: string): string | null {
82
+ if (!tagFormat.includes("${version}")) {
83
+ return null;
84
+ }
85
+
86
+ const pattern = `^${escapeRegExp(tagFormat).replace(escapeRegExp("${version}"), SEMVER_CAPTURE)}$`;
87
+ const match = tag.match(new RegExp(pattern));
88
+ return match?.[1] ?? null;
89
+ }
90
+
91
+ function parseReleaseTag(tag: string, tagFormat: string): ParsedReleaseTag | null {
92
+ const version = extractVersionFromTag(tag, tagFormat) ?? extractVersionFromTag(tag, LEGACY_RELEASE_TAG_FORMAT);
93
+ return version ? { tag, version } : null;
94
+ }
95
+
96
+ function compareSemver(left: string, right: string): number {
97
+ const [leftCore] = left.split("-");
98
+ const [rightCore] = right.split("-");
99
+ const leftParts = leftCore.split(".").map(Number);
100
+ const rightParts = rightCore.split(".").map(Number);
101
+
102
+ for (let index = 0; index < 3; index += 1) {
103
+ const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
104
+ if (delta !== 0) {
105
+ return delta;
106
+ }
107
+ }
108
+
109
+ if (left === right) {
110
+ return 0;
111
+ }
112
+
113
+ return left.includes("-") ? -1 : 1;
114
+ }
115
+
116
+ async function isTagAtHead(exec: ExecFn, cwd: string, tag: string): Promise<boolean> {
117
+ const [tagCommit, headCommit] = await Promise.all([
118
+ exec("git", ["rev-list", "-n", "1", tag], { cwd }),
119
+ exec("git", ["rev-parse", "HEAD"], { cwd }),
120
+ ]);
121
+
122
+ return tagCommit.code === 0
123
+ && headCommit.code === 0
124
+ && tagCommit.stdout.trim() !== ""
125
+ && tagCommit.stdout.trim() === headCommit.stdout.trim();
126
+ }
127
+
128
+ export async function findResumableLocalRelease(
129
+ exec: ExecFn,
130
+ cwd: string,
131
+ currentVersion: string,
132
+ tagFormat: string,
133
+ ): Promise<{ version: string; tag: string } | null> {
134
+ try {
135
+ const localTags = await exec("git", ["tag", "--merged", "HEAD"], { cwd });
136
+ if (localTags.code !== 0) {
137
+ return null;
138
+ }
139
+
140
+ const candidates = localTags.stdout
141
+ .split(/\r?\n/)
142
+ .map((tag) => tag.trim())
143
+ .filter(Boolean)
144
+ .map((tag) => parseReleaseTag(tag, tagFormat))
145
+ .filter((candidate): candidate is ParsedReleaseTag => Boolean(candidate))
146
+ .filter((candidate) => compareSemver(candidate.version, currentVersion) > 0)
147
+ .sort((left, right) => compareSemver(right.version, left.version));
148
+
149
+ for (const candidate of candidates) {
150
+ const remoteTag = await exec("git", ["ls-remote", "--tags", "origin", candidate.tag], { cwd });
151
+ if (remoteTag.code !== 0 || remoteTag.stdout.trim() !== "") {
152
+ continue;
153
+ }
154
+
155
+ if (await isTagAtHead(exec, cwd, candidate.tag)) {
156
+ return { version: candidate.version, tag: candidate.tag };
157
+ }
158
+ }
159
+
160
+ return null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+
167
+ const LEGACY_RELEASE_TAG_FORMAT = "v${version}";
168
+
169
+ function getReleaseTagCandidates(version: string, tagFormat: string): string[] {
170
+ return [...new Set([formatTag(version, tagFormat), formatTag(version, LEGACY_RELEASE_TAG_FORMAT)])];
171
+ }
172
+
173
+ async function hasMatchingReleaseTag(
174
+ exec: ExecFn,
175
+ cwd: string,
176
+ args: string[],
177
+ version: string,
178
+ tagFormat: string,
179
+ ): Promise<boolean> {
180
+ const result = await exec("git", [...args, ...getReleaseTagCandidates(version, tagFormat)], { cwd });
181
+ return result.code === 0 && result.stdout.trim().length > 0;
182
+ }
62
183
 
63
184
  /**
64
185
  * Check whether a tag for the given version already exists locally.
65
- * Returns `true` when `v{version}` is a known git tag meaning this
66
- * version has already been released and a bump is needed.
186
+ * Returns `true` when either the current-format tag or the legacy `v{version}`
187
+ * tag is already known locally, which means this version was already released.
67
188
  */
68
189
  export async function isVersionReleased(
69
190
  exec: ExecFn,
70
191
  cwd: string,
71
192
  version: string,
193
+ tagFormat: string,
72
194
  ): Promise<boolean> {
73
195
  try {
74
- const tag = version.startsWith("v") ? version : `v${version}`;
75
- const result = await exec("git", ["tag", "-l", tag], { cwd });
76
- // git tag -l prints matching tags, one per line. Non-empty = exists.
77
- return result.code === 0 && result.stdout.trim().length > 0;
196
+ return await hasMatchingReleaseTag(exec, cwd, ["tag", "-l"], version, tagFormat);
78
197
  } catch {
79
198
  return false;
80
199
  }
@@ -82,17 +201,17 @@ export async function isVersionReleased(
82
201
 
83
202
  /**
84
203
  * Check whether a tag for the given version exists on the remote (origin).
85
- * Returns true when `v{version}` is found via `git ls-remote --tags origin`.
204
+ * Returns true when either the current-format tag or the legacy `v{version}`
205
+ * tag is found via `git ls-remote --tags origin`.
86
206
  */
87
207
  export async function isTagOnRemote(
88
208
  exec: ExecFn,
89
209
  cwd: string,
90
210
  version: string,
211
+ tagFormat: string,
91
212
  ): Promise<boolean> {
92
213
  try {
93
- const tag = version.startsWith("v") ? version : `v${version}`;
94
- const result = await exec("git", ["ls-remote", "--tags", "origin", tag], { cwd });
95
- return result.code === 0 && result.stdout.trim().length > 0;
214
+ return await hasMatchingReleaseTag(exec, cwd, ["ls-remote", "--tags", "origin"], version, tagFormat);
96
215
  } catch {
97
216
  return false;
98
217
  }