@goodtek/vibeops 2.2.1 → 2.4.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.
@@ -1,13 +1,55 @@
1
1
  import { resolve } from "node:path";
2
2
  import { readText } from "../lib/filesystem.js";
3
+ import { requireGitConfig } from "../lib/git-config.js";
4
+ import { gitCommitsAhead, gitHeadCommit, gitPush, gitRemoteBranchExists, readGitInfo, } from "../lib/git.js";
5
+ import { askYesNo, isInteractiveSession } from "../lib/inquirer-helpers.js";
3
6
  import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
4
7
  import { projectPaths } from "../lib/paths.js";
5
- import { resolveTask, taskNotFoundMessage } from "../lib/resolve-task.js";
8
+ import { prNumberFromUrl } from "../lib/pr-create.js";
9
+ import { resolveShipTarget, taskNotFoundMessage } from "../lib/resolve-task.js";
6
10
  import { relPath } from "../lib/task-context.js";
7
- import { commitDirtyWorkingTree, docsCommitMessageFor, featCommitMessageFor, } from "../lib/task-git-commit.js";
11
+ import { resolveShipCommitMessage } from "../lib/task-commit-msg.js";
12
+ import { commitDirtyWorkingTree, docsCommitMessageFor, } from "../lib/task-git-commit.js";
13
+ import { runPostMergeNewCycle } from "../lib/task-new-cycle.js";
8
14
  import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, writeTaskResultSections, } from "../lib/task-ship-llm.js";
15
+ import { detectShipPrState, prRefLabel, resolveShipAction, } from "../lib/task-ship-state.js";
9
16
  import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
10
17
  import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
18
+ function describePushFailure(message) {
19
+ const lower = message.toLowerCase();
20
+ if (lower.includes("protected") ||
21
+ lower.includes("pre-receive") ||
22
+ lower.includes("gh006") ||
23
+ lower.includes("hook declined")) {
24
+ return "Push rejected by branch protection. Adjust the rule on the host (or push via the MR), then rerun.";
25
+ }
26
+ if (lower.includes("non-fast-forward") ||
27
+ lower.includes("fetch first") ||
28
+ lower.includes("rejected") ||
29
+ lower.includes("behind")) {
30
+ return "Push rejected — the remote branch has commits you don't. Pull/rebase the task branch, then rerun.";
31
+ }
32
+ return `git push failed: ${message}`;
33
+ }
34
+ function warnPlaceholderSections(body) {
35
+ const resultOk = hasNonEmptySection(body, "Result");
36
+ const testOk = hasNonEmptySection(body, "Test Result");
37
+ if (!resultOk || !testOk) {
38
+ log.warn("Result / Test Result still look like placeholders (docs-before-ship). Update the TASK file before merge.");
39
+ }
40
+ }
41
+ async function ensureOnTaskBranch(cwd, gitCtx, dryRun) {
42
+ const git = await readGitInfo(cwd);
43
+ if (git.branch === gitCtx.taskBranch)
44
+ return true;
45
+ const where = git.branch ?? "(detached)";
46
+ if (dryRun) {
47
+ log.warn(`Current branch is ${where}, not ${gitCtx.taskBranch}.`);
48
+ return true;
49
+ }
50
+ log.error(`On ${where}, not ${gitCtx.taskBranch}. Switch with \`git switch ${gitCtx.taskBranch}\` and rerun.`);
51
+ return false;
52
+ }
11
53
  async function commitShipMetadata(opts) {
12
54
  if (opts.dryRun) {
13
55
  log.info(dim(" would set Status → Shipped"));
@@ -21,23 +63,10 @@ async function commitShipMetadata(opts) {
21
63
  log.warn("No file changes for ship metadata commit — check TASK Status on the remote branch.");
22
64
  }
23
65
  }
24
- export async function taskShipCommand(taskRef, options = {}) {
25
- const cwd = resolve(options.cwd ?? process.cwd());
26
- const paths = projectPaths(cwd);
27
- const dryRun = options.dryRun === true;
28
- const resolved = await resolveTask(paths, cwd, taskRef);
29
- if (resolved === null) {
30
- log.error(taskNotFoundMessage(taskRef));
31
- process.exitCode = 1;
32
- return;
33
- }
34
- const { taskId, taskFile } = resolved;
35
- const meta = await readTaskFile(taskFile);
36
- const body = await readText(taskFile);
37
- const relFile = relPath(cwd, taskFile);
38
- log.info(bold(`vibeops task ship ${taskId}`));
39
- log.info(` ${dim("file")} ${relFile}`);
40
- log.blank();
66
+ /** State 1: first submit — push the task branch and open a new PR/MR. */
67
+ async function runFirstShip(ctx) {
68
+ const { cwd, taskId, taskFile, meta, relFile, dryRun, nonInteractive, options } = ctx;
69
+ let body = ctx.body;
41
70
  let resultOk = hasNonEmptySection(body, "Result");
42
71
  let testOk = hasNonEmptySection(body, "Test Result");
43
72
  if (!resultOk || !testOk) {
@@ -68,9 +97,9 @@ export async function taskShipCommand(taskRef, options = {}) {
68
97
  await writeTaskResultSections(taskFile, fb.result, fb.testResult);
69
98
  }
70
99
  }
71
- const body2 = dryRun ? body : await readText(taskFile);
72
- resultOk = hasNonEmptySection(body2, "Result");
73
- testOk = hasNonEmptySection(body2, "Test Result");
100
+ body = dryRun ? body : await readText(taskFile);
101
+ resultOk = hasNonEmptySection(body, "Result");
102
+ testOk = hasNonEmptySection(body, "Test Result");
74
103
  }
75
104
  log.info(bold("Sections"));
76
105
  log.info(` ${resultOk ? green("✓") : yellow("·")} Result`);
@@ -86,15 +115,10 @@ export async function taskShipCommand(taskRef, options = {}) {
86
115
  if (!resultOk || !testOk) {
87
116
  log.info(" · LLM fill Result / Test Result + patch docs/project/*");
88
117
  }
89
- log.info(" · commit implementation + ship metadata (Status → Shipped)");
90
- log.info(" · push task branch once, open MR/PR (unless --no-pr)");
118
+ log.info(" · commit implementation (TASK-scoped message) + ship metadata (Status → Shipped)");
119
+ log.info(" · push task branch once, open a new MR/PR (unless --no-pr)");
91
120
  const partsDry = parseTaskFilename(taskFile);
92
- await commitShipMetadata({
93
- cwd,
94
- taskFile,
95
- taskId: partsDry.id,
96
- dryRun: true,
97
- });
121
+ await commitShipMetadata({ cwd, taskFile, taskId: partsDry.id, dryRun: true });
98
122
  await finishTaskWithPullRequest({
99
123
  cwd,
100
124
  taskFile,
@@ -103,33 +127,223 @@ export async function taskShipCommand(taskRef, options = {}) {
103
127
  });
104
128
  return;
105
129
  }
106
- const parts = parseTaskFilename(taskFile);
107
- await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
108
130
  const gitCtx = await readGitContext(taskFile);
109
- if (gitCtx !== null) {
110
- await commitShipMetadata({
131
+ if (gitCtx !== null && !(await ensureOnTaskBranch(cwd, gitCtx, dryRun))) {
132
+ process.exitCode = 1;
133
+ return;
134
+ }
135
+ const parts = parseTaskFilename(taskFile);
136
+ if (options.noCommit === true) {
137
+ const git = await readGitInfo(cwd);
138
+ if (git.dirty === true) {
139
+ log.warn("Uncommitted changes left in place (--no-commit); committing ship metadata only.");
140
+ }
141
+ }
142
+ else {
143
+ const message = await resolveShipCommitMessage({
111
144
  cwd,
112
- taskFile,
113
145
  taskId: parts.id,
114
- dryRun: false,
146
+ title: meta.title,
147
+ provided: options.message,
148
+ nonInteractive,
115
149
  });
116
- const prResult = await finishTaskWithPullRequest({
150
+ await commitDirtyWorkingTree(cwd, message, false);
151
+ }
152
+ if (gitCtx === null) {
153
+ log.info(dim("No Git Context — push/MR skipped."));
154
+ return;
155
+ }
156
+ await commitShipMetadata({ cwd, taskFile, taskId: parts.id, dryRun: false });
157
+ const prResult = await finishTaskWithPullRequest({
158
+ cwd,
159
+ taskFile,
160
+ skipPr: options.noPr === true,
161
+ });
162
+ if (!prResult.ok) {
163
+ log.error("TASK left In Progress — fix push/MR, then rerun task ship.");
164
+ process.exitCode = 1;
165
+ return;
166
+ }
167
+ log.blank();
168
+ if (prResult.mergeRequestUrl && prResult.mergeRequestUrl !== "(dry-run)") {
169
+ const ref = prRefLabel(prNumberFromUrl(prResult.mergeRequestUrl));
170
+ log.ok(`Created PR ${ref} → ${prResult.mergeRequestUrl}`);
171
+ }
172
+ else if (options.noPr === true) {
173
+ log.ok(`Pushed ${gitCtx.taskBranch} (no PR created — --no-pr).`);
174
+ }
175
+ else {
176
+ log.ok(`Pushed ${gitCtx.taskBranch} (create the MR/PR manually).`);
177
+ }
178
+ log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
179
+ }
180
+ /** State 2: an open PR exists — commit + push to update it (no new PR). */
181
+ async function runUpdateOpenPr(ctx) {
182
+ const { cwd, taskId, taskFile, meta, body, prCtx, dryRun, nonInteractive, options } = ctx;
183
+ const gitCtx = await readGitContext(taskFile);
184
+ if (gitCtx === null) {
185
+ log.error("No Git Context — cannot update the open PR.");
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ let gitCfg;
190
+ try {
191
+ gitCfg = await requireGitConfig(cwd);
192
+ }
193
+ catch (e) {
194
+ log.error(e instanceof Error ? e.message : String(e));
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+ const remote = gitCfg.remote;
199
+ if (!(await ensureOnTaskBranch(cwd, gitCtx, dryRun))) {
200
+ process.exitCode = 1;
201
+ return;
202
+ }
203
+ warnPlaceholderSections(body);
204
+ if (dryRun) {
205
+ log.info(bold("dry-run — would:"));
206
+ if (options.noCommit === true) {
207
+ log.info(" · skip commit (--no-commit); push committed history only");
208
+ }
209
+ else {
210
+ log.info(" · commit working tree with a TASK-scoped message");
211
+ }
212
+ log.info(` · push ${gitCtx.taskBranch} → ${remote} (no new PR)`);
213
+ log.info(` · update existing PR ${prRefLabel(prCtx.number)} — CI re-runs`);
214
+ return;
215
+ }
216
+ if (meta.status === "in_progress") {
217
+ await updateInlineStatus(taskFile, "shipped");
218
+ log.ok("Status → Shipped");
219
+ await commitDirtyWorkingTree(cwd, docsCommitMessageFor(taskId, "mark shipped"), false);
220
+ }
221
+ if (options.noCommit === true) {
222
+ const git = await readGitInfo(cwd);
223
+ if (git.dirty === true) {
224
+ log.warn("Uncommitted changes left in place (--no-commit); pushing committed history only.");
225
+ }
226
+ }
227
+ else {
228
+ const message = await resolveShipCommitMessage({
117
229
  cwd,
118
- taskFile,
119
- skipPr: options.noPr === true,
230
+ taskId,
231
+ title: meta.title,
232
+ provided: options.message,
233
+ nonInteractive,
120
234
  });
121
- if (!prResult.ok) {
122
- log.error("TASK left In Progress — fix push/MR, then rerun task ship.");
235
+ await commitDirtyWorkingTree(cwd, message, false);
236
+ }
237
+ const remoteExists = await gitRemoteBranchExists(cwd, remote, gitCtx.taskBranch);
238
+ const ahead = remoteExists
239
+ ? await gitCommitsAhead(cwd, `${remote}/${gitCtx.taskBranch}`, "HEAD")
240
+ : 1;
241
+ const git = await readGitInfo(cwd);
242
+ const stillDirty = git.dirty === true;
243
+ if (ahead === 0 && !stillDirty) {
244
+ log.blank();
245
+ log.info(`Nothing to ship (working tree clean, PR ${prRefLabel(prCtx.number)} up to date).`);
246
+ return;
247
+ }
248
+ try {
249
+ await gitPush(cwd, remote, gitCtx.taskBranch, false);
250
+ log.ok(`Pushed ${gitCtx.taskBranch} → ${remote}`);
251
+ }
252
+ catch (e) {
253
+ const msg = e instanceof Error ? e.message : String(e);
254
+ log.error(describePushFailure(msg));
255
+ process.exitCode = 1;
256
+ return;
257
+ }
258
+ const sha = (await gitHeadCommit(cwd, true)) ?? "HEAD";
259
+ log.blank();
260
+ log.ok(`Updated existing PR ${prRefLabel(prCtx.number)} (pushed ${sha}) — CI re-running`);
261
+ if (prCtx.url)
262
+ log.info(` ${dim("MR/PR")} ${prCtx.url}`);
263
+ }
264
+ /** State 3: the previous PR is merged — start a new PR cycle (guarded). */
265
+ async function runNewCycleFromShip(ctx) {
266
+ const { taskId, prCtx, dryRun, nonInteractive, options } = ctx;
267
+ if (!dryRun && options.newCycle !== true && options.allowOpenMr !== true) {
268
+ if (nonInteractive) {
269
+ log.error(`PR ${prRefLabel(prCtx.number)} is merged. Starting a NEW PR cycle requires --new-cycle in non-interactive mode.`);
123
270
  process.exitCode = 1;
124
271
  return;
125
272
  }
126
- if (prResult.mergeRequestUrl) {
127
- log.info(` ${dim("MR/PR")} ${prResult.mergeRequestUrl}`);
273
+ const ok = await askYesNo({
274
+ message: "This starts a NEW PR cycle after merge. Continue?",
275
+ nonInteractive: false,
276
+ defaultValue: false,
277
+ });
278
+ if (!ok) {
279
+ log.info(`Aborted. Use ${cyan("vibeops task add")} for unrelated work, or rerun with ${cyan("--new-cycle")}.`);
280
+ return;
128
281
  }
129
282
  }
130
- else {
131
- log.info(dim("No Git Context — push/MR skipped."));
283
+ await runPostMergeNewCycle(taskId, {
284
+ dryRun,
285
+ cwd: options.cwd,
286
+ noPr: options.noPr,
287
+ noIntegrate: options.noIntegrate,
288
+ recreateBranch: options.recreateBranch,
289
+ skipLlm: options.skipLlm,
290
+ allowOpenMr: options.allowOpenMr,
291
+ });
292
+ }
293
+ /** State/PR mismatch: Status is Shipped but no open/merged PR was found. */
294
+ function runMismatchHint(ctx) {
295
+ log.warn(`${ctx.taskId} is Shipped but no open or merged PR was found for its branch.`);
296
+ log.info("The PR was likely merged and the branch synced/deleted.");
297
+ log.info(`Next: start new work with ${cyan("vibeops task add")}, or run ${cyan("vibeops status")} to re-check.`);
298
+ }
299
+ export async function taskShipCommand(taskRef, options = {}) {
300
+ const cwd = resolve(options.cwd ?? process.cwd());
301
+ const paths = projectPaths(cwd);
302
+ const dryRun = options.dryRun === true;
303
+ const nonInteractive = options.nonInteractive === true || !isInteractiveSession();
304
+ const resolved = await resolveShipTarget(paths, cwd, taskRef);
305
+ if (resolved === null) {
306
+ log.error(taskNotFoundMessage(taskRef));
307
+ process.exitCode = 1;
308
+ return;
309
+ }
310
+ const { taskId, taskFile } = resolved;
311
+ const meta = await readTaskFile(taskFile);
312
+ const body = await readText(taskFile);
313
+ const relFile = relPath(cwd, taskFile);
314
+ const prCtx = await detectShipPrState(cwd, taskFile);
315
+ const action = resolveShipAction(prCtx.state, meta.status);
316
+ log.info(bold(`vibeops task ship ${taskId}`));
317
+ log.info(` ${dim("file")} ${relFile}`);
318
+ if (prCtx.state !== "none") {
319
+ log.info(` ${dim("PR")} ${prRefLabel(prCtx.number)} (${prCtx.state})`);
132
320
  }
133
321
  log.blank();
134
- log.info(`Next: ${cyan("vibeops task merge")} (or merge in the host UI), then ${cyan("vibeops task sync")}`);
322
+ const ctx = {
323
+ cwd,
324
+ taskId,
325
+ taskFile,
326
+ meta,
327
+ body,
328
+ relFile,
329
+ prCtx,
330
+ dryRun,
331
+ nonInteractive,
332
+ options,
333
+ };
334
+ switch (action) {
335
+ case "update-open":
336
+ await runUpdateOpenPr(ctx);
337
+ return;
338
+ case "new-cycle":
339
+ await runNewCycleFromShip(ctx);
340
+ return;
341
+ case "mismatch":
342
+ runMismatchHint(ctx);
343
+ return;
344
+ case "first":
345
+ default:
346
+ await runFirstShip(ctx);
347
+ return;
348
+ }
135
349
  }
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, relative } from "node:path";
3
3
  export async function pathExists(path) {
4
4
  try {
@@ -45,6 +45,9 @@ export async function writeText(path, contents) {
45
45
  await ensureDir(dirname(path));
46
46
  await writeFile(path, contents, "utf-8");
47
47
  }
48
+ export async function deleteFile(path) {
49
+ await rm(path, { force: true });
50
+ }
48
51
  export async function walk(root) {
49
52
  const out = [];
50
53
  async function visit(dir) {
@@ -1,5 +1,9 @@
1
1
  import { checkbox, confirm, input, select } from "@inquirer/prompts";
2
2
  export const OTHER_LABEL = "Other";
3
+ /** True when both stdin and stdout are TTYs (safe to show interactive prompts). */
4
+ export function isInteractiveSession() {
5
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
6
+ }
3
7
  function isOther(value) {
4
8
  return value === OTHER_LABEL;
5
9
  }
@@ -157,8 +157,13 @@ export async function findMergeRequestByBranches(opts) {
157
157
  return null;
158
158
  }
159
159
  }
160
- function prRefFromUrl(url) {
160
+ /** Extract the numeric PR/MR id from a host URL (e.g. `#42`), or `null`. */
161
+ export function prNumberFromUrl(url) {
162
+ if (typeof url !== "string")
163
+ return null;
161
164
  const trimmed = url.trim();
165
+ if (trimmed.length === 0)
166
+ return null;
162
167
  const pullMatch = /\/pull\/(\d+)/i.exec(trimmed);
163
168
  if (pullMatch)
164
169
  return pullMatch[1];
@@ -167,7 +172,10 @@ function prRefFromUrl(url) {
167
172
  return mrMatch[1];
168
173
  if (/^\d+$/.test(trimmed))
169
174
  return trimmed;
170
- return trimmed;
175
+ return null;
176
+ }
177
+ function prRefFromUrl(url) {
178
+ return prNumberFromUrl(url) ?? url.trim();
171
179
  }
172
180
  export async function getMergeRequestState(cwd, host, url) {
173
181
  const ref = prRefFromUrl(url);
@@ -197,6 +205,25 @@ export async function getMergeRequestState(cwd, host, url) {
197
205
  return "unknown";
198
206
  }
199
207
  }
208
+ export async function closeMergeRequest(opts) {
209
+ const ref = prRefFromUrl(opts.url);
210
+ const label = mergeRequestLabel(opts.host);
211
+ if (opts.dryRun === true) {
212
+ log.info(`would close ${label} ${ref}`);
213
+ return;
214
+ }
215
+ if (opts.host === "gitlab") {
216
+ await execFileAsync("glab", ["mr", "close", ref], {
217
+ cwd: opts.cwd,
218
+ maxBuffer: 4 * 1024 * 1024,
219
+ });
220
+ return;
221
+ }
222
+ await execFileAsync("gh", ["pr", "close", ref], {
223
+ cwd: opts.cwd,
224
+ maxBuffer: 4 * 1024 * 1024,
225
+ });
226
+ }
200
227
  export async function mergeMergeRequest(opts) {
201
228
  const ref = prRefFromUrl(opts.url);
202
229
  const method = opts.method ?? "squash";
@@ -1,4 +1,4 @@
1
- import { findTaskFile, loadActionableTasks, pickActiveTask, pickInProgressTask, } from "./task.js";
1
+ import { findTaskFile, loadActionableTasks, pickActiveTask, pickInProgressTask, pickLatestShippedTask, } from "./task.js";
2
2
  export function normalizeTaskRef(ref) {
3
3
  const t = ref.trim();
4
4
  if (/^task-/i.test(t) && !/^TASK-/i.test(t)) {
@@ -26,6 +26,36 @@ export async function resolveTask(paths, cwd, taskRef) {
26
26
  }
27
27
  return null;
28
28
  }
29
+ /**
30
+ * Resolve the TASK that `ship` should act on. Unlike {@link resolveTask}, this
31
+ * also matches an already-Shipped TASK so state-aware ship (update open PR /
32
+ * new PR cycle) can re-run on it.
33
+ * Order: explicit ref → In Progress → active `task/*` branch → latest Shipped.
34
+ */
35
+ export async function resolveShipTarget(paths, cwd, taskRef) {
36
+ const trimmed = taskRef?.trim();
37
+ if (trimmed !== undefined && trimmed.length > 0) {
38
+ const taskId = normalizeTaskRef(trimmed);
39
+ const taskFile = await findTaskFile(paths.docsTasks, taskId);
40
+ if (taskFile === null)
41
+ return null;
42
+ return { taskId, taskFile };
43
+ }
44
+ const tasks = await loadActionableTasks(paths.docsTasks);
45
+ const inProgress = pickInProgressTask(tasks);
46
+ if (inProgress !== null) {
47
+ return { taskId: inProgress.id, taskFile: inProgress.filePath };
48
+ }
49
+ const onBranch = await pickActiveTask(paths.docsTasks, cwd);
50
+ if (onBranch !== null) {
51
+ return { taskId: onBranch.id, taskFile: onBranch.filePath };
52
+ }
53
+ const shipped = pickLatestShippedTask(tasks);
54
+ if (shipped !== null) {
55
+ return { taskId: shipped.id, taskFile: shipped.filePath };
56
+ }
57
+ return null;
58
+ }
29
59
  export function taskNotFoundMessage(taskRef) {
30
60
  if (taskRef?.trim()) {
31
61
  return `TASK not found: ${normalizeTaskRef(taskRef)}. Check docs/tasks/.`;
@@ -0,0 +1,80 @@
1
+ import { readConfig } from "./config.js";
2
+ import { gitDiffNameOnly, listWorkingTreeRelPaths } from "./git.js";
3
+ import { anyLlmProviderAvailable, llmCompleteText } from "./llm-complete.js";
4
+ import { getLlmPreferenceFromConfig } from "./llm-preference.js";
5
+ import { askInput, isInteractiveSession } from "./inquirer-helpers.js";
6
+ import { dim, log } from "./logger.js";
7
+ import { featCommitMessageFor, taskScopedCommitMessage } from "./task-git-commit.js";
8
+ async function changedPathsForMessage(cwd) {
9
+ const working = await listWorkingTreeRelPaths(cwd);
10
+ if (working.length > 0)
11
+ return working;
12
+ return gitDiffNameOnly(cwd);
13
+ }
14
+ /** Ask the LLM for a one-line conventional-commit subject from the changed files. */
15
+ export async function generateCommitSubjectWithLlm(cwd, taskId, title) {
16
+ const files = await changedPathsForMessage(cwd);
17
+ const fileList = files.slice(0, 40).join("\n") || "(no file changes detected)";
18
+ const messages = [
19
+ {
20
+ role: "system",
21
+ content: "Write a single Conventional Commit subject line (under 72 chars) for the change. " +
22
+ "Reply with ONLY the subject line — no scope, no body, no quotes, no code fences. " +
23
+ "Pick the type from feat, fix, docs, refactor, test, chore.",
24
+ },
25
+ {
26
+ role: "user",
27
+ content: [
28
+ `TASK: ${taskId} — ${title}`,
29
+ "",
30
+ "Changed files:",
31
+ fileList,
32
+ ].join("\n"),
33
+ },
34
+ ];
35
+ try {
36
+ const config = await readConfig(cwd);
37
+ const preference = getLlmPreferenceFromConfig(config);
38
+ const { text } = await llmCompleteText(messages, { cwd, preference });
39
+ const line = text
40
+ .split("\n")
41
+ .map((l) => l.trim())
42
+ .find((l) => l.length > 0);
43
+ if (line === undefined)
44
+ return null;
45
+ return line.replace(/^["'`]+|["'`]+$/g, "").trim() || null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Resolve the commit subject for a ship, always TASK-id-scoped:
53
+ * 1. explicit `--message`, 2. LLM from diff (when a provider is connected),
54
+ * 3. interactive prompt, 4. TASK-title fallback.
55
+ */
56
+ export async function resolveShipCommitMessage(opts) {
57
+ const provided = opts.provided?.trim();
58
+ if (provided !== undefined && provided.length > 0) {
59
+ return taskScopedCommitMessage(opts.taskId, provided);
60
+ }
61
+ if (await anyLlmProviderAvailable(opts.cwd)) {
62
+ const subject = await generateCommitSubjectWithLlm(opts.cwd, opts.taskId, opts.title);
63
+ if (subject !== null && subject.length > 0) {
64
+ const msg = taskScopedCommitMessage(opts.taskId, subject);
65
+ log.info(dim(` commit message (LLM): ${msg}`));
66
+ return msg;
67
+ }
68
+ }
69
+ if (!opts.nonInteractive && isInteractiveSession()) {
70
+ const typed = await askInput({
71
+ message: `Commit message for ${opts.taskId}`,
72
+ nonInteractive: false,
73
+ default: opts.title,
74
+ });
75
+ if (typed.trim().length > 0) {
76
+ return taskScopedCommitMessage(opts.taskId, typed);
77
+ }
78
+ }
79
+ return featCommitMessageFor(opts.taskId, opts.title);
80
+ }