@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 +6 -0
- package/README.md +20 -0
- package/dist/commands/task-ship.js +21 -9
- package/dist/lib/commit-trailers.js +33 -0
- package/dist/lib/config.js +19 -3
- package/dist/lib/git-config.js +4 -0
- package/dist/lib/task-git-commit.js +7 -3
- package/dist/lib/task-new-cycle.js +16 -2
- package/package.json +1 -1
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
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -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
|
-
|
|
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 } : {}),
|
package/dist/lib/git-config.js
CHANGED
|
@@ -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({
|