@goodtek/vibeops 3.3.1 → 3.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.4.0 - 2026-11-07
8
+
9
+ ### Added
10
+
11
+ - **`.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).
12
+
7
13
  ## 3.3.1 - 2026-10-18
8
14
 
9
15
  ### 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:
@@ -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":
@@ -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.0",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",