@goodtek/vibeops 3.3.1 → 3.4.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.4.1 - 2026-11-08
8
+
9
+ ### Fixed
10
+
11
+ - **Merge gate: a re-run supersedes the earlier run of the same check.** `statusCheckRollup` keeps every run of a check on the head commit, and any failure among them refused the merge — so a check that failed once (e.g. a transient host error) and then passed on a re-run still blocked `task merge`. The gate now keeps the latest item per check identity (CheckRun: workflow + name; StatusContext: context), latest = started last (`startedAt`, then `completedAt`; still running = newest). A later failure still beats an earlier pass, a newer pending run still waits, and when the order is unknown (an item without `startedAt`, or an exact tie) every run of that check is kept, so the gate stays fail-closed (D-016, TASK-059).
12
+
13
+ ## 3.4.0 - 2026-11-07
14
+
15
+ ### Added
16
+
17
+ - **`.vibeops.json` `git.commitTrailers`**: git trailers (e.g. `Co-Authored-By: …`) added with `git commit --trailer` to every commit `task ship` / `--new-cycle` makes, including the `mark shipped` and new-cycle metadata commits. Entries are validated strictly (one-line `Token: value`); an invalid entry exits 1 naming it. `--dry-run` lists them (TASK-057).
18
+
7
19
  ## 3.3.1 - 2026-10-18
8
20
 
9
21
  ### Changed
package/README.md CHANGED
@@ -163,6 +163,26 @@ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
163
163
  - **`status`**: Now / Next card — focus stage, checklist, PR, and the next command to run.
164
164
  - No force-push to shared branches.
165
165
 
166
+ ### Commit trailers (`.vibeops.json` → `git.commitTrailers`)
167
+
168
+ Commits VibeOps makes itself (`task ship` implementation and `mark shipped` commits, `--new-cycle` metadata) can carry git trailers. git's `trailer.*` config only applies with `--trailer` / `interpret-trailers`, and `commit.template` is ignored for `-m`, so set them here:
169
+
170
+ ```json
171
+ {
172
+ "git": {
173
+ "remote": "origin",
174
+ "host": "github",
175
+ "integrationBranch": "develop",
176
+ "productionBranch": "main",
177
+ "commitTrailers": ["Co-Authored-By: Name <name@example.com>"]
178
+ }
179
+ }
180
+ ```
181
+
182
+ - Each entry is passed as `git commit --trailer <entry>`; `--dry-run` lists them.
183
+ - Validated strictly: an array of one-line `Token: value` strings (token: letters, digits, `-`). Anything else (not an array, newline, missing `: `, empty value) makes the command exit 1 naming the entry.
184
+ - Unset → no trailers. Re-running `init` keeps the list. The initial `init` commit does not use it.
185
+
166
186
  ## Worktrees
167
187
 
168
188
  VibeOps supports several `git worktree` checkouts of one repo working in parallel. It never needs to check out the integration branch:
@@ -234,7 +254,8 @@ Before calling `gh pr merge` / `glab mr merge`:
234
254
  - **passed:** `SUCCESS`, `NEUTRAL`, `SKIPPED`
235
255
  - **failed:** `FAILURE`, `CANCELLED`, `TIMED_OUT`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`, `ERROR`
236
256
  - **pending:** CheckRun `REQUESTED`, `QUEUED`, `IN_PROGRESS`, `WAITING`, `PENDING`; StatusContext `PENDING`, `EXPECTED`
237
- - **anything missing or undocumented fails closed** (treated as failed). Several checks with the same name: any failure fails.
257
+ - **anything missing or undocumented fails closed** (treated as failed).
258
+ - **Re-runs: the latest run of each check counts.** The rollup keeps every run of a check on the head commit (a re-run adds one). The gate keeps the latest per check identity — CheckRun: workflow + name (`test` in two workflows are two checks); StatusContext: context — where latest = started last (`startedAt`, ties by `completedAt`, a still-running item is newest). So a failure followed by a passing re-run passes, a pass followed by a failing re-run fails, and a newer queued / running re-run waits. If the order is unknown — an item without `startedAt` or an exact tie — all runs of that check are kept and any failure among them fails.
238
259
  - **GitHub Actions — the PR's own CI, not leftovers:** the rollup lists every check on the head commit, including checks from other events (e.g. a finished `push` workflow on `develop` when a release PR `develop → main` is opened). Right after a PR is opened or pushed, GitHub has not created its `pull_request` run — or that run's jobs — yet, so such a rollup looked green and 3.1.x merged before CI started. The gate therefore also reads the GitHub Actions API for the head SHA:
239
260
  - **Expected workflows:** every `.github/workflows/*.yml|yaml` at the head commit whose `on:` triggers `pull_request` for this PR, using GitHub's filter rules — `branches` / `branches-ignore` against the base branch, `paths` / `paths-ignore` against the PR's changed files (first 300, like GitHub), and `types` (a custom list must contain both `opened` and `synchronize`). Disabled workflows are skipped; `pull_request_target`, `workflow_call` and other events are not awaited. Each expected workflow must have a `pull_request` run for the head SHA — until it appears the gate **waits** (`workflow … (pull_request) has not started for <sha> yet`), then refuses at `waitTimeoutSeconds`.
240
261
  - **All runs for the head SHA** (any event, latest run per workflow + event) must be `completed` with `success`, `neutral` or `skipped`. A queued / in-progress / waiting run waits; any other conclusion refuses immediately — including `startup_failure` (no jobs, so nothing in the rollup) and `action_required` (fork PR awaiting approval).
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { readText } from "../lib/filesystem.js";
3
- import { requireGitConfig } from "../lib/git-config.js";
3
+ import { readCommitTrailers, requireGitConfig } from "../lib/git-config.js";
4
4
  import { BASELINE_REPAIR_SECTION, preflightBaseline } from "../lib/project-baseline.js";
5
5
  import { gitCommitsAhead, gitHeadCommit, gitPush, gitRemoteBranchExists, readGitInfo, } from "../lib/git.js";
6
6
  import { askYesNo, isInteractiveSession } from "../lib/inquirer-helpers.js";
@@ -56,18 +56,20 @@ async function commitShipMetadata(opts) {
56
56
  if (opts.dryRun) {
57
57
  log.info(dim(" would set Status → Shipped"));
58
58
  log.info(dim(" would commit ship metadata"));
59
+ if (opts.trailers.length > 0)
60
+ log.info(dim(` with trailers: ${opts.trailers.join(" | ")}`));
59
61
  return;
60
62
  }
61
63
  await updateInlineStatus(opts.taskFile, "shipped");
62
64
  log.ok("Status → Shipped");
63
- const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "mark shipped"), false);
65
+ const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "mark shipped"), false, opts.trailers);
64
66
  if (!committed) {
65
67
  log.warn("No file changes for ship metadata commit — check TASK Status on the remote branch.");
66
68
  }
67
69
  }
68
70
  /** State 1: first submit — push the task branch and open a new PR/MR. */
69
71
  async function runFirstShip(ctx) {
70
- const { cwd, taskId, taskFile, meta, relFile, dryRun, nonInteractive, options } = ctx;
72
+ const { cwd, taskId, taskFile, meta, relFile, dryRun, nonInteractive, options, trailers } = ctx;
71
73
  let body = ctx.body;
72
74
  let resultOk = hasNonEmptySection(body, "Result");
73
75
  let testOk = hasNonEmptySection(body, "Test Result");
@@ -127,7 +129,7 @@ async function runFirstShip(ctx) {
127
129
  log.info(" · commit implementation (TASK-scoped message) + ship metadata (Status → Shipped)");
128
130
  log.info(" · push task branch once, open a new MR/PR (unless --no-pr)");
129
131
  const partsDry = parseTaskFilename(taskFile);
130
- await commitShipMetadata({ cwd, taskFile, taskId: partsDry.id, dryRun: true });
132
+ await commitShipMetadata({ cwd, taskFile, taskId: partsDry.id, dryRun: true, trailers });
131
133
  await finishTaskWithPullRequest({
132
134
  cwd,
133
135
  taskFile,
@@ -155,9 +157,9 @@ async function runFirstShip(ctx) {
155
157
  provided: options.message,
156
158
  nonInteractive,
157
159
  });
158
- await commitDirtyWorkingTree(cwd, message, false);
160
+ await commitDirtyWorkingTree(cwd, message, false, trailers);
159
161
  }
160
- await commitShipMetadata({ cwd, taskFile, taskId: parts.id, dryRun: false });
162
+ await commitShipMetadata({ cwd, taskFile, taskId: parts.id, dryRun: false, trailers });
161
163
  const prResult = await finishTaskWithPullRequest({
162
164
  cwd,
163
165
  taskFile,
@@ -183,7 +185,7 @@ async function runFirstShip(ctx) {
183
185
  }
184
186
  /** State 2: an open PR exists — commit + push to update it (no new PR). */
185
187
  async function runUpdateOpenPr(ctx) {
186
- const { cwd, taskId, taskFile, meta, body, prCtx, dryRun, nonInteractive, options } = ctx;
188
+ const { cwd, taskId, taskFile, meta, body, prCtx, dryRun, nonInteractive, options, trailers } = ctx;
187
189
  const gitCtx = await readGitContext(taskFile);
188
190
  if (gitCtx === null) {
189
191
  log.error(`No Git Context — cannot update the open PR: ${await gitContextProblem(taskFile)}.`);
@@ -220,7 +222,7 @@ async function runUpdateOpenPr(ctx) {
220
222
  if (meta.status === "in_progress") {
221
223
  await updateInlineStatus(taskFile, "shipped");
222
224
  log.ok("Status → Shipped");
223
- await commitDirtyWorkingTree(cwd, docsCommitMessageFor(taskId, "mark shipped"), false);
225
+ await commitDirtyWorkingTree(cwd, docsCommitMessageFor(taskId, "mark shipped"), false, trailers);
224
226
  }
225
227
  if (options.noCommit === true) {
226
228
  const git = await readGitInfo(cwd);
@@ -236,7 +238,7 @@ async function runUpdateOpenPr(ctx) {
236
238
  provided: options.message,
237
239
  nonInteractive,
238
240
  });
239
- await commitDirtyWorkingTree(cwd, message, false);
241
+ await commitDirtyWorkingTree(cwd, message, false, trailers);
240
242
  }
241
243
  const remoteExists = await gitRemoteBranchExists(cwd, remote, gitCtx.taskBranch);
242
244
  const ahead = remoteExists
@@ -338,6 +340,15 @@ export async function taskShipCommand(taskRef, options = {}) {
338
340
  process.exitCode = 1;
339
341
  return;
340
342
  }
343
+ let trailers;
344
+ try {
345
+ trailers = await readCommitTrailers(cwd);
346
+ }
347
+ catch (e) {
348
+ log.error(e instanceof Error ? e.message : String(e));
349
+ process.exitCode = 1;
350
+ return;
351
+ }
341
352
  const ctx = {
342
353
  cwd,
343
354
  taskId,
@@ -349,6 +360,7 @@ export async function taskShipCommand(taskRef, options = {}) {
349
360
  dryRun,
350
361
  nonInteractive,
351
362
  options,
363
+ trailers,
352
364
  };
353
365
  switch (action) {
354
366
  case "update-open":
@@ -5,6 +5,11 @@
5
5
  * GitHub returns two item shapes:
6
6
  * - `CheckRun` (GitHub Actions, check apps): `status` + `conclusion`, no `state`.
7
7
  * - `StatusContext` (commit status API): `state`, no `status`/`conclusion`.
8
+ *
9
+ * The rollup keeps every run of a check: a re-run (new attempt or new workflow run on the
10
+ * same head SHA) adds an item next to the old one. {@link latestPerCheck} keeps the latest
11
+ * item per check identity, so a passed re-run supersedes an earlier failure and a later
12
+ * failure supersedes an earlier pass.
8
13
  */
9
14
  /** GitHub GraphQL `CheckStatusState` values other than COMPLETED. */
10
15
  const CHECK_RUN_IN_FLIGHT = new Set(["REQUESTED", "QUEUED", "IN_PROGRESS", "WAITING", "PENDING"]);
@@ -32,7 +37,23 @@ function isCheckRun(item) {
32
37
  // Unknown typename: infer from fields.
33
38
  return (item.status ?? null) !== null || (item.conclusion ?? null) !== null;
34
39
  }
40
+ /** RFC 3339 → epoch ms; missing, unparseable or Go's zero time (`0001-01-01T00:00:00Z`) → `null`. */
41
+ function timestamp(raw) {
42
+ if (raw === null || raw === undefined || raw.trim() === "")
43
+ return null;
44
+ const ms = Date.parse(raw);
45
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
46
+ }
35
47
  export function classifyRollupItem(item) {
48
+ const base = classifyVerdict(item);
49
+ return {
50
+ ...base,
51
+ workflow: base.kind === "CheckRun" ? (item.workflowName ?? "").trim() || null : null,
52
+ startedAt: timestamp(item.startedAt),
53
+ completedAt: timestamp(item.completedAt),
54
+ };
55
+ }
56
+ function classifyVerdict(item) {
36
57
  if (isCheckRun(item)) {
37
58
  const name = (item.name ?? "").trim() || "(unnamed check run)";
38
59
  const status = upper(item.status);
@@ -72,8 +93,62 @@ export function classifyRollupItem(item) {
72
93
  // Missing or undocumented state — fail closed.
73
94
  return { name, kind: "StatusContext", verdict: "failed", detail: state || "MISSING_STATE" };
74
95
  }
96
+ /** Classified, latest item per check identity ({@link latestPerCheck}). */
75
97
  export function classifyRollup(items) {
76
- return (items ?? []).map(classifyRollupItem);
98
+ return latestPerCheck((items ?? []).map(classifyRollupItem));
99
+ }
100
+ /**
101
+ * Check identity: CheckRun = workflow + name (two workflows may both have a `test` job);
102
+ * StatusContext = context. GitHub's `workflowName` carries no event, so runs of one workflow
103
+ * from different events share an identity here; the Actions part of the gate
104
+ * (`evaluateActionsRuns`) still judges the latest run per workflow + event.
105
+ */
106
+ function checkIdentity(c) {
107
+ return c.kind === "CheckRun"
108
+ ? `CheckRun\u0000${c.workflow ?? ""}\u0000${c.name}`
109
+ : `StatusContext\u0000${c.name}`;
110
+ }
111
+ /**
112
+ * Keep the latest item per check identity, like GitHub's merge box, which shows the latest
113
+ * result of each check. "Latest" = the item that started last (`startedAt`; a re-run starts
114
+ * after the run it repeats), ties broken by `completedAt` (an in-flight item with no
115
+ * `completedAt` is newest). Its verdict stands: a newer pending item is pending, a newer
116
+ * failure fails even after an earlier pass.
117
+ *
118
+ * Fail closed when the order is unknown: if any item of an identity lacks `startedAt`
119
+ * (e.g. queued, or an older gh), or the latest items tie exactly, every item of that
120
+ * identity is kept — so any failure among them still fails and any pending still waits.
121
+ */
122
+ export function latestPerCheck(checks) {
123
+ const groups = new Map();
124
+ for (const c of checks) {
125
+ const key = checkIdentity(c);
126
+ const group = groups.get(key);
127
+ if (group === undefined)
128
+ groups.set(key, [c]);
129
+ else
130
+ group.push(c);
131
+ }
132
+ const out = [];
133
+ for (const group of groups.values()) {
134
+ if (group.length === 1 || group.some((c) => (c.startedAt ?? null) === null)) {
135
+ out.push(...group);
136
+ continue;
137
+ }
138
+ const sorted = [...group].sort(compareRecency);
139
+ const newest = sorted[sorted.length - 1];
140
+ out.push(...sorted.filter((c) => compareRecency(c, newest) === 0));
141
+ }
142
+ return out;
143
+ }
144
+ /** Ascending recency: `startedAt`, then `completedAt` (missing = still running = newest). */
145
+ function compareRecency(a, b) {
146
+ const started = (a.startedAt ?? 0) - (b.startedAt ?? 0);
147
+ if (started !== 0)
148
+ return started;
149
+ const ac = a.completedAt ?? Number.POSITIVE_INFINITY;
150
+ const bc = b.completedAt ?? Number.POSITIVE_INFINITY;
151
+ return ac === bc ? 0 : ac < bc ? -1 : 1;
77
152
  }
78
153
  /** Exact name, or a `*` glob (e.g. `lint*`, `* · linux`). */
79
154
  export function checkNameMatches(name, pattern) {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Git trailers for commits VibeOps creates itself (`.vibeops.json` `git.commitTrailers`).
3
+ * Each entry is passed as `git commit --trailer <entry>` (git-commit(1), git-interpret-trailers(1)).
4
+ */
5
+ export class CommitTrailersConfigError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "CommitTrailersConfigError";
9
+ }
10
+ }
11
+ /** `Token: value` — token of letters, digits and `-` (git trailer key), non-empty single-line value. */
12
+ const TRAILER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*: \S.*$/;
13
+ /**
14
+ * Validate the raw `git.commitTrailers` value. Absent → `undefined`; anything that is not
15
+ * an array of single-line `Token: value` strings throws (fail closed, entry named).
16
+ */
17
+ export function parseCommitTrailers(raw) {
18
+ if (raw === undefined)
19
+ return undefined;
20
+ if (!Array.isArray(raw)) {
21
+ throw new CommitTrailersConfigError(".vibeops.json git.commitTrailers must be an array of \"Token: value\" strings.");
22
+ }
23
+ return raw.map((entry, i) => {
24
+ if (typeof entry !== "string" || /[\r\n]/.test(entry) || !TRAILER_RE.test(entry)) {
25
+ throw new CommitTrailersConfigError(`.vibeops.json git.commitTrailers[${i}] is invalid: ${JSON.stringify(entry)} — expected one line "Token: value" (e.g. "Co-Authored-By: Name <name@example.com>").`);
26
+ }
27
+ return entry;
28
+ });
29
+ }
30
+ /** `git commit` arguments for the trailers (`--trailer <t>` each). */
31
+ export function commitTrailerArgs(trailers) {
32
+ return trailers.flatMap((t) => ["--trailer", t]);
33
+ }
@@ -4,6 +4,7 @@ import { VIBEOPS_CONFIG_FILE } from "./paths.js";
4
4
  import { VERSION } from "../version.js";
5
5
  import { VIBEOPS_CONFIG_SCHEMA_VERSION, } from "../types/config.js";
6
6
  import { isVibeopsClientId } from "./init-clients.js";
7
+ import { CommitTrailersConfigError, parseCommitTrailers } from "./commit-trailers.js";
7
8
  function parseClientsBlock(raw) {
8
9
  if (!Array.isArray(raw))
9
10
  return undefined;
@@ -40,7 +41,14 @@ function parseGitBlock(raw) {
40
41
  : undefined;
41
42
  if (!host || !integrationBranch || !productionBranch)
42
43
  return undefined;
43
- return { remote, host, integrationBranch, productionBranch };
44
+ const commitTrailers = parseCommitTrailers(o.commitTrailers);
45
+ return {
46
+ remote,
47
+ host,
48
+ integrationBranch,
49
+ productionBranch,
50
+ ...(commitTrailers ? { commitTrailers } : {}),
51
+ };
44
52
  }
45
53
  function parseLlmBlock(raw) {
46
54
  if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) {
@@ -81,7 +89,10 @@ export async function readConfig(root) {
81
89
  }
82
90
  return null;
83
91
  }
84
- catch {
92
+ catch (e) {
93
+ // An invalid commitTrailers entry is a visible error, not "no config".
94
+ if (e instanceof CommitTrailersConfigError)
95
+ throw e;
85
96
  return null;
86
97
  }
87
98
  }
@@ -92,7 +103,12 @@ export function buildConfig(name, clients, git, existing, nowIso) {
92
103
  schemaVersion: VIBEOPS_CONFIG_SCHEMA_VERSION,
93
104
  createdAt: existing?.createdAt ?? nowIso ?? new Date().toISOString(),
94
105
  clients,
95
- git,
106
+ git: {
107
+ ...git,
108
+ ...(git.commitTrailers === undefined && existing?.git?.commitTrailers
109
+ ? { commitTrailers: existing.git.commitTrailers }
110
+ : {}),
111
+ },
96
112
  llm: existing?.llm ?? { provider: "auto" },
97
113
  ...(existing?.merge !== undefined ? { merge: existing.merge } : {}),
98
114
  ...(existing?.baseline !== undefined ? { baseline: existing.baseline } : {}),
@@ -19,3 +19,7 @@ export async function requireGitConfig(cwd) {
19
19
  }
20
20
  return config.git;
21
21
  }
22
+ /** `git.commitTrailers` from `.vibeops.json` (empty when unset or no config); throws when invalid. */
23
+ export async function readCommitTrailers(cwd) {
24
+ return (await readConfig(cwd))?.git?.commitTrailers ?? [];
25
+ }
@@ -1,3 +1,4 @@
1
+ import { commitTrailerArgs } from "./commit-trailers.js";
1
2
  import { dim, log } from "./logger.js";
2
3
  import { gitAddPaths, gitPush, listUnstagedRelPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "./git.js";
3
4
  export function featCommitMessageFor(taskId, title) {
@@ -40,9 +41,10 @@ export function taskScopedCommitMessage(taskId, rawMessage, defaultType = "feat"
40
41
  * Commit all committable dirty paths (staged and unstaged, including deletions
41
42
  * and renames); returns whether a commit was created. Only paths whose
42
43
  * working-tree state is not yet in the index are passed to `git add`; staged
43
- * changes are already part of the commit.
44
+ * changes are already part of the commit. Each `trailers` entry
45
+ * (`.vibeops.json` `git.commitTrailers`) is added with `git commit --trailer`.
44
46
  */
45
- export async function commitDirtyWorkingTree(cwd, message, dryRun) {
47
+ export async function commitDirtyWorkingTree(cwd, message, dryRun, trailers) {
46
48
  const git = await readGitInfo(cwd);
47
49
  if (!git.isRepo || git.dirty !== true)
48
50
  return false;
@@ -54,11 +56,13 @@ export async function commitDirtyWorkingTree(cwd, message, dryRun) {
54
56
  }
55
57
  if (dryRun) {
56
58
  log.info(dim(` would commit ${committable.length} path(s): ${message}`));
59
+ if (trailers.length > 0)
60
+ log.info(dim(` with trailers: ${trailers.join(" | ")}`));
57
61
  return true;
58
62
  }
59
63
  const committableSet = new Set(committable);
60
64
  await gitAddPaths(cwd, (await listUnstagedRelPaths(cwd)).filter((p) => committableSet.has(p)));
61
- await runGit(cwd, ["commit", "-q", "-m", message]);
65
+ await runGit(cwd, ["commit", "-q", "-m", message, ...commitTrailerArgs(trailers)]);
62
66
  log.ok(`Committed: ${message}`);
63
67
  return true;
64
68
  }
@@ -7,6 +7,7 @@ import { projectPaths } from "./paths.js";
7
7
  import { prNumberFromUrl } from "./pr-create.js";
8
8
  import { relPath } from "./task-context.js";
9
9
  import { resolveShipCommitMessage } from "./task-commit-msg.js";
10
+ import { readCommitTrailers } from "./git-config.js";
10
11
  import { commitDirtyWorkingTree, docsCommitMessageFor } from "./task-git-commit.js";
11
12
  import { finishTaskWithPullRequest } from "./task-push-pr.js";
12
13
  import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "./task-ship-llm.js";
@@ -33,7 +34,7 @@ async function commitReshipMetadata(opts) {
33
34
  log.info(dim(" would commit new-cycle metadata (Status stays Shipped)"));
34
35
  return;
35
36
  }
36
- const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "new PR cycle metadata"), false);
37
+ const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "new PR cycle metadata"), false, opts.trailers);
37
38
  if (!committed) {
38
39
  log.info(dim("No new-cycle metadata commit (Git Context already on disk)."));
39
40
  }
@@ -66,6 +67,18 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
66
67
  process.exitCode = 1;
67
68
  return;
68
69
  }
70
+ let trailers;
71
+ try {
72
+ trailers = await readCommitTrailers(cwd);
73
+ }
74
+ catch (e) {
75
+ log.error(e instanceof Error ? e.message : String(e));
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+ if (dryRun && trailers.length > 0) {
80
+ log.info(dim(` commits would carry trailers: ${trailers.join(" | ")}`));
81
+ }
69
82
  const integrationBranch = gitCfg?.integrationBranch ?? "develop";
70
83
  const remote = gitCfg?.remote ?? "origin";
71
84
  const updateOpenMr = options.allowOpenMr === true;
@@ -173,7 +186,7 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
173
186
  provided: options.message,
174
187
  nonInteractive: options.nonInteractive === true,
175
188
  });
176
- await commitDirtyWorkingTree(cwd, message, false);
189
+ await commitDirtyWorkingTree(cwd, message, false, trailers);
177
190
  }
178
191
  else {
179
192
  // Work may already be committed on the task branch (not merged into the base yet).
@@ -213,6 +226,7 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
213
226
  taskFile,
214
227
  taskId: parts.id,
215
228
  dryRun: false,
229
+ trailers,
216
230
  });
217
231
  }
218
232
  const prResult = await finishTaskWithPullRequest({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "3.3.1",
3
+ "version": "3.4.1",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",