@goodtek/vibeops 3.1.9 → 3.3.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 +19 -0
- package/README.md +24 -2
- package/dist/cli.js +4 -0
- package/dist/commands/status.js +20 -0
- package/dist/commands/task-add.js +11 -2
- package/dist/commands/task-merge.js +15 -3
- package/dist/commands/task-release.js +23 -1
- package/dist/commands/task-ship.js +10 -2
- package/dist/lib/config.js +3 -0
- package/dist/lib/merge-config.js +10 -0
- package/dist/lib/pr-create.js +98 -23
- package/dist/lib/project-baseline.js +196 -0
- package/dist/lib/release-tag.js +64 -0
- package/dist/lib/task-new-cycle.js +1 -1
- package/dist/lib/task-reship.js +21 -5
- package/dist/lib/task.js +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 3.3.0 - 2026-10-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`task add --repair-baseline`**: open the TASK that repairs a broken baseline even in `enforce` mode (failures shown as warnings for that command only); the TASK file records it (`## Baseline` → Repair) and its `task ship` requires every baseline check to pass, whatever the mode (D-015, TASK-052).
|
|
12
|
+
|
|
13
|
+
## 3.2.0 - 2026-10-18
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`task ship --new-cycle` recorded the TASK's own commit as Base Commit** (the task branch HEAD after the follow-up commit). It now records where the cycle starts on the base: the merge base of the task branch and the freshly fetched `<remote>/<base>` (TASK-050).
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`task merge` updates a stale PR branch** (GitHub, `merge.updateBranch`, default on): update-branch merges the base into the PR branch on the host (no force push), the gate waits for the new head's CI and re-checks with the same rules, repeating while bot PRs keep moving the base, within `waitTimeoutSeconds`; conflicts stop. `task ship`'s push-rejected hint now says merge (`git pull --no-rebase`), never rebase + force-push (D-014, TASK-048).
|
|
22
|
+
- **`vibeops status` shows the repository baseline**: the baseline checks, mode, the persona crew and its pinned catalog commit, and other remote branches (`--json` → `baseline`) (TASK-047).
|
|
23
|
+
- **`task release --tag`**: after the release merge, tag the merge commit `v<package.json version>` and push the tag (refuses when the commit is not on the production branch, the version is not semver, or the tag already exists) — merge into production and tagging for publish become one step (D-013, TASK-046).
|
|
24
|
+
- **Baseline checks before `task add` / `task ship`** (`.vibeops.json` → `baseline`): `branch-policy` (integration ≠ production), `default-branch` (remote default = integration), and with `baseline.crew` set `personas` / `agents-contract`. `mode`: `warn` (default), `enforce` (stop, exit 1), `off` (D-012, TASK-045).
|
|
25
|
+
|
|
7
26
|
## 3.1.9 - 2026-10-17
|
|
8
27
|
|
|
9
28
|
### Fixed
|
package/README.md
CHANGED
|
@@ -147,7 +147,7 @@ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
|
|
|
147
147
|
- **`task ship`**: `-m/--message`, `--new-cycle`, `--no-commit`, `--dry-run`, `--no-pr`, `--non-interactive`, `--allow-open-mr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`
|
|
148
148
|
- **`task merge`**: `--dry-run`, `--merge`, `--rebase`
|
|
149
149
|
- **`task sync`**: `--dry-run`, `--no-remote-delete`, `--force`
|
|
150
|
-
- **`task release`**: `--dry-run`, `--no-merge`, `--merge`, `--rebase`
|
|
150
|
+
- **`task release`**: `--dry-run`, `--no-merge`, `--merge`, `--rebase`, `--tag` (after the merge, tag the release merge commit `v<package.json version>` and push it; never moves an existing tag)
|
|
151
151
|
- **`pull`**: `--dry-run`
|
|
152
152
|
- **`status`**: `--json`
|
|
153
153
|
|
|
@@ -201,6 +201,27 @@ A branch counts only when its name has the **generated form** `task/<NNN>` or `t
|
|
|
201
201
|
|
|
202
202
|
If a configured remote cannot be fetched, `task add` stops without creating a TASK file. Across machines, `task ship` re-checks the remote (`git ls-remote`) right before pushing and refuses if another task branch with the same number already exists there.
|
|
203
203
|
|
|
204
|
+
## Baseline checks
|
|
205
|
+
|
|
206
|
+
`task add` and `task ship` first check that the repository follows the VibeOps flow (`.vibeops.json` → `baseline`):
|
|
207
|
+
|
|
208
|
+
| Check | OK when |
|
|
209
|
+
|-------|---------|
|
|
210
|
+
| `branch-policy` | `git.integrationBranch` ≠ `git.productionBranch` (e.g. `develop` → `main`) |
|
|
211
|
+
| `default-branch` | the remote's default branch (`git ls-remote --symref <remote> HEAD`) is the integration branch |
|
|
212
|
+
| `personas` | with `baseline.crew` set: `.personas/<crew>/SOURCE` exists |
|
|
213
|
+
| `agents-contract` | with `baseline.crew` set: AGENTS.md has the `<!-- getpersona:<crew> -->` section |
|
|
214
|
+
|
|
215
|
+
```json
|
|
216
|
+
{ "baseline": { "mode": "warn", "crew": "vibe-crew" } }
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- `mode`: `warn` (default — print each failing check with a fix and continue), `enforce` (stop with exit code 1, nothing created or pushed), `off`.
|
|
220
|
+
- `crew`: persona crew to require; omitted → the crew checks are skipped.
|
|
221
|
+
- `vibeops status` shows the same checks (`Baseline …`), the crew's pinned catalog commit, and remote branches other than integration / production / `task/*` / `sync/*` (`status --json` → `baseline`).
|
|
222
|
+
- The block is validated strictly (unknown keys / values are errors and stop the command).
|
|
223
|
+
- **Repairing in `enforce` mode:** `vibeops task add --repair-baseline` opens the TASK anyway (failures shown as warnings for that command only) and writes a `## Baseline` section ("Repair …") into the TASK file; `task ship` of that TASK goes through only when every baseline check passes, whatever `baseline.mode` is.
|
|
224
|
+
|
|
204
225
|
## Merge gate
|
|
205
226
|
|
|
206
227
|
> **3.0.0 breaking:** a PR/MR with **no checks at all** is refused by default. Repos without CI: set `"merge": {"allowNoChecks": true}` in `.vibeops.json`. Requires **gh ≥ 2.13.0**.
|
|
@@ -227,7 +248,7 @@ Before calling `gh pr merge` / `glab mr merge`:
|
|
|
227
248
|
- **After the merge:** `gh pr merge` / `glab mr merge` return the result synchronously (GitHub `merged: true`, GitLab `state: "merged"`), so VibeOps reads the PR/MR once to confirm. Not merged at that point (merge queue / auto-merge) is reported, not waited for.
|
|
228
249
|
- **The merge is pinned to the checked commit:** right before merging the gate re-reads the PR head; if it moved, the new head is evaluated from scratch. The merge itself uses `gh pr merge --match-head-commit <sha>` (requires **gh ≥ 2.13.0**) / `glab mr merge --auto-merge=false --sha <sha>` (verified with glab 1.106), so a push between the last check and the merge makes the host refuse. A GitLab `405` re-runs the gate and retries once with the same flags.
|
|
229
250
|
- **Unreadable Git Context stops `task ship` / `task add`:** a TASK file whose `## Git Context` cannot be read (e.g. an edit left `## ## Git Context`) makes `task ship` exit 1 before committing or pushing, naming the line; `task add` refuses to "resume" such a TASK (it would write a second section) and names the file and line.
|
|
230
|
-
- **`task merge` refuses a stale base:** the head commit the gate approved must contain the current `<remote>/<base>` (`git merge-base --is-ancestor`, after fetching the base branch and the head commit). A head that does not — the base moved since the branch was pushed, or the branch was rebuilt on an old base (e.g. `git reset --soft <stale origin/develop>` + commit) — can silently revert work merged into the base since, and a squash merge lands that revert.
|
|
251
|
+
- **`task merge` refuses a stale base:** the head commit the gate approved must contain the current `<remote>/<base>` (`git merge-base --is-ancestor`, after fetching the base branch and the head commit). A head that does not — the base moved since the branch was pushed, or the branch was rebuilt on an old base (e.g. `git reset --soft <stale origin/develop>` + commit) — can silently revert work merged into the base since, and a squash merge lands that revert. On GitHub (`merge.updateBranch`, default `true`) `task merge` brings the branch up to date itself: [update-branch](https://docs.github.com/rest/pulls/pulls#update-a-pull-request-branch) merges the base into the PR branch on the host (a merge commit — no rebase, no force push), then the gate waits for the new head and checks it with the same rules, again if the base moved meanwhile, all within `waitTimeoutSeconds`; a merge conflict stops with the local-merge instruction. Otherwise (GitLab, or `updateBranch: false`) bring the branch up to date yourself (`git fetch` + `git merge <remote>/<base>`, test, `task ship`), wait for CI, then rerun `task merge`. A fetch failure refuses (fail closed). `task release` does not apply this check (with merge-commit releases the production branch is normally not an ancestor of the integration branch).
|
|
231
252
|
|
|
232
253
|
### Configuration (`.vibeops.json` → `merge`)
|
|
233
254
|
|
|
@@ -252,6 +273,7 @@ Before calling `gh pr merge` / `glab mr merge`:
|
|
|
252
273
|
| `pollIntervalSeconds` | `10` | integer ≥ 1, ≤ timeout | Poll interval while waiting (same default as `gh pr checks --watch`). |
|
|
253
274
|
| `emptyRollupGraceSeconds` | `0` | integer ≥ 0, ≤ timeout | Only when no GitHub Actions workflow is expected: how long an empty check list is re-polled before "no checks" applies. Default `0`: name CI outside GitHub Actions in `requiredChecks` so the gate waits for it by name, instead of guessing how long it takes to report. |
|
|
254
275
|
| `allowNoChecks` | `false` | boolean | Merge a PR that has no checks at all (repo without CI). Prints one warning per such merge. |
|
|
276
|
+
| `updateBranch` | `true` | boolean | `task merge` on GitHub: update a stale PR branch with update-branch (merge, no force push) and re-check, instead of refusing. |
|
|
255
277
|
|
|
256
278
|
- Check names are exact (`CheckRun.name` / `StatusContext.context`) or a `*` glob (`"lint*"`, `"* · linux"`). Required checks are GitHub-only; GitLab gates on the head pipeline.
|
|
257
279
|
- The block is validated strictly: it must be an object, unknown keys (typos) and wrong types are errors, and `task merge` / `task release` exit 1 naming the key.
|
package/dist/cli.js
CHANGED
|
@@ -67,9 +67,11 @@ task
|
|
|
67
67
|
.option("--dry-run", "Plan only")
|
|
68
68
|
.option("--non-interactive", "CI: minimal scaffold (optional --idea)")
|
|
69
69
|
.option("--idea <text>", "Short description (CI)")
|
|
70
|
+
.option("--repair-baseline", "Open a TASK that repairs the baseline even in enforce mode (its ship requires every check to pass)")
|
|
70
71
|
.option("--cwd <path>", "Target directory")
|
|
71
72
|
.action(async (opts) => {
|
|
72
73
|
await taskAddCommand({
|
|
74
|
+
repairBaseline: Boolean(opts.repairBaseline),
|
|
73
75
|
dryRun: Boolean(opts.dryRun),
|
|
74
76
|
nonInteractive: Boolean(opts.nonInteractive),
|
|
75
77
|
idea: opts.idea,
|
|
@@ -161,9 +163,11 @@ task
|
|
|
161
163
|
.option("--squash", "Squash merge (default is merge commit for release)")
|
|
162
164
|
.option("--merge", "Merge commit (default for release)")
|
|
163
165
|
.option("--rebase", "Rebase merge")
|
|
166
|
+
.option("--tag", "After the merge, tag the release merge commit v<package.json version> and push it")
|
|
164
167
|
.option("--cwd <path>", "Target directory")
|
|
165
168
|
.action(async (opts) => {
|
|
166
169
|
await taskReleaseCommand({
|
|
170
|
+
tag: Boolean(opts.tag),
|
|
167
171
|
dryRun: Boolean(opts.dryRun),
|
|
168
172
|
noMerge: opts.merge === false,
|
|
169
173
|
merge: Boolean(opts.merge),
|
package/dist/commands/status.js
CHANGED
|
@@ -6,6 +6,7 @@ import { readGitInfo } from "../lib/git.js";
|
|
|
6
6
|
import { buildLlmStatusReport } from "../lib/llm-status.js";
|
|
7
7
|
import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
8
8
|
import { projectPaths } from "../lib/paths.js";
|
|
9
|
+
import { summarizeBaseline } from "../lib/project-baseline.js";
|
|
9
10
|
import { VERSION } from "../version.js";
|
|
10
11
|
import { computeNextHint, hintToLines, hintToText, listInProgressTasks, pickFocusTask, relPath, taskBranchExistsFor, } from "../lib/task-context.js";
|
|
11
12
|
import { hasOpenTaskMergeRequest, isMergeRequestMerged, resolveTaskMergeRequestUrl, taskNeedsSync, } from "../lib/task-effective-status.js";
|
|
@@ -77,6 +78,7 @@ async function buildReport(cwd) {
|
|
|
77
78
|
hasLocalChanges: git.dirty === true,
|
|
78
79
|
});
|
|
79
80
|
const llm = isVibeopsProject ? await buildLlmStatusReport(cwd) : null;
|
|
81
|
+
const baseline = isVibeopsProject && config?.git && git.isRepo ? await summarizeBaseline(cwd, config.git) : null;
|
|
80
82
|
return {
|
|
81
83
|
vibeopsVersion: VERSION,
|
|
82
84
|
isVibeopsProject,
|
|
@@ -100,6 +102,7 @@ async function buildReport(cwd) {
|
|
|
100
102
|
nextHint: hintToText(hint, cwd, focus),
|
|
101
103
|
nextLines: [...hintToLines(hint, cwd, focus)],
|
|
102
104
|
llm,
|
|
105
|
+
baseline,
|
|
103
106
|
};
|
|
104
107
|
}
|
|
105
108
|
function filledLabel(ok) {
|
|
@@ -183,6 +186,23 @@ function printDetails(report) {
|
|
|
183
186
|
if (hostBits.length > 0) {
|
|
184
187
|
log.info(` ${dim("Git")} ${hostBits.join(" · ")}`);
|
|
185
188
|
}
|
|
189
|
+
if (report.baseline) {
|
|
190
|
+
const b = report.baseline;
|
|
191
|
+
if (b.configError !== null) {
|
|
192
|
+
log.info(` ${dim("Baseline")} ${yellow(b.configError)}`);
|
|
193
|
+
}
|
|
194
|
+
const failing = b.checks.filter((c) => !c.ok);
|
|
195
|
+
const crew = b.crew !== null ? ` · ${b.crew}${b.crewCommit ? ` ${b.crewCommit.slice(0, 7)}` : ""}` : "";
|
|
196
|
+
log.info(` ${dim("Baseline")} ${failing.length === 0
|
|
197
|
+
? `ok (${b.checks.map((c) => c.id).join(", ")})`
|
|
198
|
+
: yellow(failing.map((c) => `${c.id}: ${c.detail}`).join("; "))} · mode ${b.mode}${crew}`);
|
|
199
|
+
if (b.otherBranches === null) {
|
|
200
|
+
log.info(` ${dim("Branches")} ${dim("(remote branches unreadable)")}`);
|
|
201
|
+
}
|
|
202
|
+
else if (b.otherBranches.length > 0) {
|
|
203
|
+
log.info(` ${dim("Branches")} other remote branches: ${b.otherBranches.join(", ")}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
186
206
|
if (report.llm) {
|
|
187
207
|
const active = report.llm.active !== null
|
|
188
208
|
? `${report.llm.active} (ok)`
|
|
@@ -3,6 +3,7 @@ import { pathExists, writeText } from "../lib/filesystem.js";
|
|
|
3
3
|
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
4
4
|
import { ensureIntegrationSynced, printIntegrationSyncDiagnosis, } from "../lib/git-integration-sync.js";
|
|
5
5
|
import { askInput } from "../lib/inquirer-helpers.js";
|
|
6
|
+
import { BASELINE_REPAIR_SECTION, BASELINE_REPAIR_TEXT, preflightBaseline, } from "../lib/project-baseline.js";
|
|
6
7
|
import { bold, cyan, dim, log, yellow } from "../lib/logger.js";
|
|
7
8
|
import { projectPaths } from "../lib/paths.js";
|
|
8
9
|
import { slugify } from "../lib/slug.js";
|
|
@@ -12,7 +13,7 @@ import { findBlockingTask, relPath } from "../lib/task-context.js";
|
|
|
12
13
|
import { allocateTaskId, TaskIdAllocationError } from "../lib/task-id-allocation.js";
|
|
13
14
|
import { readTaskLockConfig, TaskLockConfigError, TaskLockHeldError, withTaskLock, } from "../lib/task-lock.js";
|
|
14
15
|
import { isIncompleteTaskStart, startTaskBranch } from "../lib/task-start.js";
|
|
15
|
-
import { branchNameForTaskFile, damagedGitContext, loadActionableTasks, readGitContext, } from "../lib/task.js";
|
|
16
|
+
import { branchNameForTaskFile, damagedGitContext, withSectionAfterStatus, loadActionableTasks, readGitContext, } from "../lib/task.js";
|
|
16
17
|
async function loadGitConfigOrNull(root, dryRun) {
|
|
17
18
|
try {
|
|
18
19
|
return await requireGitConfig(root);
|
|
@@ -80,6 +81,11 @@ export async function taskAddCommand(opts = {}) {
|
|
|
80
81
|
const gitCfg = await loadGitConfigOrNull(root, dryRun);
|
|
81
82
|
if (!dryRun && gitCfg === null)
|
|
82
83
|
return;
|
|
84
|
+
const repair = opts.repairBaseline === true;
|
|
85
|
+
if (gitCfg !== null && !(await preflightBaseline(root, gitCfg, "task add", { repair }))) {
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
83
89
|
let lockCfg;
|
|
84
90
|
try {
|
|
85
91
|
lockCfg = await readTaskLockConfig(root);
|
|
@@ -243,9 +249,12 @@ export async function taskAddCommand(opts = {}) {
|
|
|
243
249
|
}
|
|
244
250
|
}
|
|
245
251
|
const taskId = formatTaskId(taskNumber);
|
|
246
|
-
const
|
|
252
|
+
const drafted = taskId === provisionalId
|
|
247
253
|
? draftMarkdown
|
|
248
254
|
: draftMarkdown.replace(new RegExp(`\\b${provisionalId}\\b`, "g"), taskId);
|
|
255
|
+
const markdown = repair
|
|
256
|
+
? withSectionAfterStatus(drafted, BASELINE_REPAIR_SECTION, BASELINE_REPAIR_TEXT)
|
|
257
|
+
: drafted;
|
|
249
258
|
const { filePath } = uniqueTaskPath(paths.docsTasks, taskId, slugify(slug || title), tasks.map((t) => t.filePath));
|
|
250
259
|
const relFile = relPath(root, filePath);
|
|
251
260
|
const integration = gitCfg?.integrationBranch ?? "integration";
|
|
@@ -108,7 +108,11 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
108
108
|
method,
|
|
109
109
|
requiredChecks,
|
|
110
110
|
gate,
|
|
111
|
-
requireBaseInHead: {
|
|
111
|
+
requireBaseInHead: {
|
|
112
|
+
remote: gitCfg.remote,
|
|
113
|
+
baseBranch: ctx.baseBranch,
|
|
114
|
+
updateBranch: mergeCfg.updateBranch,
|
|
115
|
+
},
|
|
112
116
|
dryRun: true,
|
|
113
117
|
});
|
|
114
118
|
log.blank();
|
|
@@ -142,7 +146,11 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
142
146
|
waitForCi: true,
|
|
143
147
|
requiredChecks,
|
|
144
148
|
gate,
|
|
145
|
-
requireBaseInHead: {
|
|
149
|
+
requireBaseInHead: {
|
|
150
|
+
remote: gitCfg.remote,
|
|
151
|
+
baseBranch: ctx.baseBranch,
|
|
152
|
+
updateBranch: mergeCfg.updateBranch,
|
|
153
|
+
},
|
|
146
154
|
});
|
|
147
155
|
const verified = await assertMergeRequestMerged({
|
|
148
156
|
cwd,
|
|
@@ -181,7 +189,11 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
181
189
|
waitForCi: true,
|
|
182
190
|
requiredChecks,
|
|
183
191
|
gate,
|
|
184
|
-
requireBaseInHead: {
|
|
192
|
+
requireBaseInHead: {
|
|
193
|
+
remote: gitCfg.remote,
|
|
194
|
+
baseBranch: ctx.baseBranch,
|
|
195
|
+
updateBranch: mergeCfg.updateBranch,
|
|
196
|
+
},
|
|
185
197
|
});
|
|
186
198
|
const verified = await assertMergeRequestMerged({
|
|
187
199
|
cwd,
|
|
@@ -5,7 +5,8 @@ import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-
|
|
|
5
5
|
import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
|
|
6
6
|
import { gitRemoteUrl } from "../lib/git.js";
|
|
7
7
|
import { bold, dim, log } from "../lib/logger.js";
|
|
8
|
-
import { createMergeRequest, findMergeRequestByBranches, getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
8
|
+
import { createMergeRequest, findMergeRequestByBranches, getMergeRequestDetails, getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
9
|
+
import { tagReleaseCommit } from "../lib/release-tag.js";
|
|
9
10
|
import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
|
|
10
11
|
function resolveReleaseMergeMethod(opts) {
|
|
11
12
|
if (opts.rebase === true)
|
|
@@ -17,6 +18,11 @@ function resolveReleaseMergeMethod(opts) {
|
|
|
17
18
|
export async function taskReleaseCommand(options = {}) {
|
|
18
19
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
19
20
|
const dryRun = options.dryRun === true;
|
|
21
|
+
if (options.tag === true && options.noMerge === true) {
|
|
22
|
+
log.error("--tag needs the merge: it tags the release merge commit (drop --no-merge).");
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
20
26
|
let gitCfg;
|
|
21
27
|
try {
|
|
22
28
|
gitCfg = await requireGitConfig(cwd);
|
|
@@ -72,6 +78,9 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
72
78
|
if (mergeCfg.allowNoChecks) {
|
|
73
79
|
log.info(" · allowNoChecks: true (a release PR with no checks would merge)");
|
|
74
80
|
}
|
|
81
|
+
if (options.tag === true) {
|
|
82
|
+
log.info(` · tag the release merge commit v<package.json version> and push the tag to ${remote}`);
|
|
83
|
+
}
|
|
75
84
|
}
|
|
76
85
|
return;
|
|
77
86
|
}
|
|
@@ -157,6 +166,19 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
157
166
|
return;
|
|
158
167
|
}
|
|
159
168
|
log.ok(`Release ${label} merged into ${productionBranch}.`);
|
|
169
|
+
if (options.tag === true) {
|
|
170
|
+
const merged = await getMergeRequestDetails(cwd, host, releaseUrl);
|
|
171
|
+
const commit = merged?.mergeCommitSha ?? null;
|
|
172
|
+
const tagged = commit === null
|
|
173
|
+
? { ok: false, message: `the host did not report the merge commit of ${releaseUrl}` }
|
|
174
|
+
: await tagReleaseCommit({ cwd, remote, productionBranch, commit });
|
|
175
|
+
if (!tagged.ok) {
|
|
176
|
+
log.error(`Release merged, but not tagged: ${tagged.message}.`);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
log.ok(`Tagged ${tagged.tag} → ${tagged.commit.slice(0, 7)} and pushed to ${remote}.`);
|
|
181
|
+
}
|
|
160
182
|
}
|
|
161
183
|
catch (e) {
|
|
162
184
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { readText } from "../lib/filesystem.js";
|
|
3
3
|
import { requireGitConfig } from "../lib/git-config.js";
|
|
4
|
+
import { BASELINE_REPAIR_SECTION, preflightBaseline } from "../lib/project-baseline.js";
|
|
4
5
|
import { gitCommitsAhead, gitHeadCommit, gitPush, gitRemoteBranchExists, readGitInfo, } from "../lib/git.js";
|
|
5
6
|
import { askYesNo, isInteractiveSession } from "../lib/inquirer-helpers.js";
|
|
6
7
|
import { bold, cyan, dim, green, log, yellow } from "../lib/logger.js";
|
|
@@ -14,7 +15,7 @@ import { runPostMergeNewCycle } from "../lib/task-new-cycle.js";
|
|
|
14
15
|
import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, writeTaskResultSections, } from "../lib/task-ship-llm.js";
|
|
15
16
|
import { detectShipPrState, prRefLabel, resolveShipAction, } from "../lib/task-ship-state.js";
|
|
16
17
|
import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
|
|
17
|
-
import { hasNonEmptySection, parseTaskFilename, gitContextProblem, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
18
|
+
import { hasNonEmptySection, parseTaskFilename, gitContextProblem, readGitContext, readSection, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
18
19
|
import { assertTaskIdFreeOnRemote } from "../lib/task-id-allocation.js";
|
|
19
20
|
function describePushFailure(message) {
|
|
20
21
|
const lower = message.toLowerCase();
|
|
@@ -28,7 +29,7 @@ function describePushFailure(message) {
|
|
|
28
29
|
lower.includes("fetch first") ||
|
|
29
30
|
lower.includes("rejected") ||
|
|
30
31
|
lower.includes("behind")) {
|
|
31
|
-
return "Push rejected — the remote branch has commits you don't.
|
|
32
|
+
return "Push rejected — the remote branch has commits you don't. Merge them in (git pull --no-rebase), then rerun; never rebase + force-push a shared task branch.";
|
|
32
33
|
}
|
|
33
34
|
return `git push failed: ${message}`;
|
|
34
35
|
}
|
|
@@ -330,6 +331,13 @@ export async function taskShipCommand(taskRef, options = {}) {
|
|
|
330
331
|
log.info(` ${dim("PR")} ${prRefLabel(prCtx.number)} (${prCtx.state})`);
|
|
331
332
|
}
|
|
332
333
|
log.blank();
|
|
334
|
+
const baselineGit = await requireGitConfig(cwd).catch(() => null);
|
|
335
|
+
const repairTask = readSection(body, BASELINE_REPAIR_SECTION).startsWith("Repair");
|
|
336
|
+
if (baselineGit !== null &&
|
|
337
|
+
!(await preflightBaseline(cwd, baselineGit, "task ship", { requireOk: repairTask }))) {
|
|
338
|
+
process.exitCode = 1;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
333
341
|
const ctx = {
|
|
334
342
|
cwd,
|
|
335
343
|
taskId,
|
package/dist/lib/config.js
CHANGED
|
@@ -65,6 +65,7 @@ export async function readConfig(root) {
|
|
|
65
65
|
const llm = parseLlmBlock(parsed.llm);
|
|
66
66
|
const git = parseGitBlock(parsed.git);
|
|
67
67
|
const merge = parsed.merge;
|
|
68
|
+
const baseline = parsed.baseline;
|
|
68
69
|
const clients = parseClientsBlock(parsed.clients) ?? ["cursor"];
|
|
69
70
|
return {
|
|
70
71
|
name: parsed.name,
|
|
@@ -75,6 +76,7 @@ export async function readConfig(root) {
|
|
|
75
76
|
...(git ? { git } : {}),
|
|
76
77
|
...(llm ? { llm } : {}),
|
|
77
78
|
...(merge !== undefined ? { merge } : {}),
|
|
79
|
+
...(baseline !== undefined ? { baseline } : {}),
|
|
78
80
|
};
|
|
79
81
|
}
|
|
80
82
|
return null;
|
|
@@ -93,6 +95,7 @@ export function buildConfig(name, clients, git, existing, nowIso) {
|
|
|
93
95
|
git,
|
|
94
96
|
llm: existing?.llm ?? { provider: "auto" },
|
|
95
97
|
...(existing?.merge !== undefined ? { merge: existing.merge } : {}),
|
|
98
|
+
...(existing?.baseline !== undefined ? { baseline: existing.baseline } : {}),
|
|
96
99
|
};
|
|
97
100
|
}
|
|
98
101
|
export function configToJson(config) {
|
package/dist/lib/merge-config.js
CHANGED
|
@@ -21,6 +21,8 @@ export const MERGE_GATE_DEFAULTS = {
|
|
|
21
21
|
// to report.
|
|
22
22
|
emptyRollupGraceSeconds: 0,
|
|
23
23
|
allowNoChecks: false,
|
|
24
|
+
// GitHub's own "Update branch" (merge, no force push) — see `updateGitHubPullBranch`.
|
|
25
|
+
updateBranch: true,
|
|
24
26
|
};
|
|
25
27
|
/** Inclusive bounds for the numeric settings (seconds); cross-checks below keep them ≤ the wait. */
|
|
26
28
|
export const MERGE_GATE_LIMITS = {
|
|
@@ -35,6 +37,7 @@ const KNOWN_KEYS = new Set([
|
|
|
35
37
|
"pollIntervalSeconds",
|
|
36
38
|
"emptyRollupGraceSeconds",
|
|
37
39
|
"allowNoChecks",
|
|
40
|
+
"updateBranch",
|
|
38
41
|
]);
|
|
39
42
|
/** Invalid `merge` block in `.vibeops.json`. Merge commands refuse to run. */
|
|
40
43
|
export class MergeConfigError extends Error {
|
|
@@ -96,7 +99,14 @@ export function parseMergeConfig(raw) {
|
|
|
96
99
|
? MERGE_GATE_DEFAULTS.emptyRollupGraceSeconds
|
|
97
100
|
: seconds("emptyRollupGraceSeconds", o.emptyRollupGraceSeconds),
|
|
98
101
|
allowNoChecks: MERGE_GATE_DEFAULTS.allowNoChecks,
|
|
102
|
+
updateBranch: MERGE_GATE_DEFAULTS.updateBranch,
|
|
99
103
|
};
|
|
104
|
+
if (o.updateBranch !== undefined) {
|
|
105
|
+
if (typeof o.updateBranch !== "boolean") {
|
|
106
|
+
throw new MergeConfigError("`updateBranch` must be true or false.");
|
|
107
|
+
}
|
|
108
|
+
cfg.updateBranch = o.updateBranch;
|
|
109
|
+
}
|
|
100
110
|
if (o.allowNoChecks !== undefined) {
|
|
101
111
|
if (typeof o.allowNoChecks !== "boolean") {
|
|
102
112
|
throw new MergeConfigError("`allowNoChecks` must be true or false.");
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -570,6 +570,39 @@ function isMergeHttp405(error) {
|
|
|
570
570
|
const msg = error instanceof Error ? error.message : String(error);
|
|
571
571
|
return msg.includes("405") || msg.toLowerCase().includes("method not allowed");
|
|
572
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* GitHub "Update a pull request branch": merges the base branch into the PR branch on the
|
|
575
|
+
* host (a merge commit — no rebase, no force push) and answers `202 Accepted`; a
|
|
576
|
+
* non-matching `expected_head_sha` answers 422
|
|
577
|
+
* (docs.github.com/rest/pulls/pulls#update-a-pull-request-branch).
|
|
578
|
+
*/
|
|
579
|
+
export async function updateGitHubPullBranch(cwd, url, expectedHeadSha) {
|
|
580
|
+
const pr = parseGitHubPullUrl(url);
|
|
581
|
+
if (pr === null)
|
|
582
|
+
return "error";
|
|
583
|
+
try {
|
|
584
|
+
await execFileAsync("gh", [
|
|
585
|
+
"api",
|
|
586
|
+
"--hostname",
|
|
587
|
+
pr.hostname,
|
|
588
|
+
"--method",
|
|
589
|
+
"PUT",
|
|
590
|
+
`repos/${pr.owner}/${pr.repo}/pulls/${pr.number}/update-branch`,
|
|
591
|
+
"--raw-field",
|
|
592
|
+
`expected_head_sha=${expectedHeadSha}`,
|
|
593
|
+
], { cwd, maxBuffer: 1024 * 1024 });
|
|
594
|
+
return "accepted";
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
const text = `${e.stderr ?? ""} ${e instanceof Error ? e.message : ""}`.toLowerCase();
|
|
598
|
+
if (text.includes("conflict"))
|
|
599
|
+
return "conflict";
|
|
600
|
+
if (text.includes("expected head sha") || text.includes("head branch was modified")) {
|
|
601
|
+
return "head-moved";
|
|
602
|
+
}
|
|
603
|
+
return "error";
|
|
604
|
+
}
|
|
605
|
+
}
|
|
573
606
|
export async function closeMergeRequest(opts) {
|
|
574
607
|
const ref = prRefFromUrl(opts.url);
|
|
575
608
|
const label = mergeRequestLabel(opts.host);
|
|
@@ -599,8 +632,10 @@ export async function mergeMergeRequest(opts) {
|
|
|
599
632
|
: "";
|
|
600
633
|
log.info(`would check ${label} status checks (all green${required}) before merging`);
|
|
601
634
|
if (opts.requireBaseInHead !== undefined) {
|
|
602
|
-
const { remote, baseBranch } = opts.requireBaseInHead;
|
|
603
|
-
log.info(
|
|
635
|
+
const { remote, baseBranch, updateBranch } = opts.requireBaseInHead;
|
|
636
|
+
log.info(updateBranch === true && opts.host === "github"
|
|
637
|
+
? `would update a stale branch from ${remote}/${baseBranch} on the host (update-branch, merge) and re-check`
|
|
638
|
+
: `would refuse unless the head commit contains ${remote}/${baseBranch} (stale base)`);
|
|
604
639
|
}
|
|
605
640
|
if (opts.host === "gitlab") {
|
|
606
641
|
log.info(`would ${label} merge ${ref} (glab ${gitLabMergeArgs(ref, method, "<head sha>").join(" ")})`);
|
|
@@ -610,31 +645,56 @@ export async function mergeMergeRequest(opts) {
|
|
|
610
645
|
}
|
|
611
646
|
return;
|
|
612
647
|
}
|
|
648
|
+
const timing = { ...mergeGateTiming(MERGE_GATE_DEFAULTS), ...opts.gate };
|
|
649
|
+
// One budget for the whole merge, including branch updates (`waitTimeoutSeconds`).
|
|
650
|
+
const deadline = Date.now() + timing.timeoutMs;
|
|
613
651
|
const runGate = async () => {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if (gate.alreadyMerged)
|
|
622
|
-
return null;
|
|
623
|
-
if (gate.details.headSha === null || gate.details.headSha.trim().length === 0) {
|
|
624
|
-
// Strict read already guarantees this; never merge unpinned.
|
|
625
|
-
throw new MergeGateError(label, ["host did not report the head commit SHA"]);
|
|
626
|
-
}
|
|
627
|
-
if (opts.requireBaseInHead !== undefined) {
|
|
628
|
-
const fresh = await checkBaseInHead({
|
|
629
|
-
cwd: opts.cwd,
|
|
630
|
-
remote: opts.requireBaseInHead.remote,
|
|
631
|
-
baseBranch: gate.details.baseBranch ?? opts.requireBaseInHead.baseBranch,
|
|
632
|
-
headSha: gate.details.headSha,
|
|
652
|
+
let updateErrors = 0;
|
|
653
|
+
for (;;) {
|
|
654
|
+
const gate = await waitForMergeGate(opts.cwd, opts.host, opts.url, {
|
|
655
|
+
...opts.gate,
|
|
656
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
657
|
+
waitForPending: opts.waitForCi === true,
|
|
658
|
+
requiredChecks: opts.requiredChecks ?? [],
|
|
633
659
|
});
|
|
634
|
-
if (!
|
|
660
|
+
if (!gate.ok)
|
|
661
|
+
throw new MergeGateError(label, gate.problems);
|
|
662
|
+
if (gate.alreadyMerged)
|
|
663
|
+
return null;
|
|
664
|
+
const headSha = gate.details.headSha;
|
|
665
|
+
if (headSha === null || headSha.trim().length === 0) {
|
|
666
|
+
// Strict read already guarantees this; never merge unpinned.
|
|
667
|
+
throw new MergeGateError(label, ["host did not report the head commit SHA"]);
|
|
668
|
+
}
|
|
669
|
+
const base = opts.requireBaseInHead;
|
|
670
|
+
if (base === undefined)
|
|
671
|
+
return gate.details;
|
|
672
|
+
const baseBranch = gate.details.baseBranch ?? base.baseBranch;
|
|
673
|
+
const fresh = await checkBaseInHead({ cwd: opts.cwd, remote: base.remote, baseBranch, headSha });
|
|
674
|
+
if (fresh.ok)
|
|
675
|
+
return gate.details;
|
|
676
|
+
const canUpdate = base.updateBranch === true && opts.host === "github" && fresh.problem.startsWith("stale base:");
|
|
677
|
+
if (!canUpdate)
|
|
635
678
|
throw new MergeGateError(label, [fresh.problem]);
|
|
679
|
+
if (Date.now() >= deadline) {
|
|
680
|
+
throw new MergeGateError(label, [fresh.problem, "gave up updating the branch (waitTimeoutSeconds)"]);
|
|
681
|
+
}
|
|
682
|
+
const outcome = await updateGitHubPullBranch(opts.cwd, opts.url, headSha);
|
|
683
|
+
if (outcome === "conflict") {
|
|
684
|
+
throw new MergeGateError(label, [
|
|
685
|
+
fresh.problem,
|
|
686
|
+
`update-branch reports a merge conflict — merge ${base.remote}/${baseBranch} into the task branch locally, resolve, task ship`,
|
|
687
|
+
]);
|
|
688
|
+
}
|
|
689
|
+
if (outcome === "error") {
|
|
690
|
+
// Read / API error: exponential back-off with full jitter, within the same budget.
|
|
691
|
+
await sleep(Math.min(errorBackoffMs(timing.intervalMs, updateErrors++), deadline - Date.now()));
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
updateErrors = 0;
|
|
695
|
+
log.info(dim(`${baseBranch} moved — updated the ${label} branch on the host (merge, no force push); re-checking the new head…`));
|
|
696
|
+
await waitForHeadChange(opts.cwd, opts.host, opts.url, headSha, deadline, timing.intervalMs, label);
|
|
636
697
|
}
|
|
637
|
-
return gate.details;
|
|
638
698
|
};
|
|
639
699
|
const details = await runGate();
|
|
640
700
|
if (details === null)
|
|
@@ -662,6 +722,21 @@ export async function mergeMergeRequest(opts) {
|
|
|
662
722
|
maxBuffer: 4 * 1024 * 1024,
|
|
663
723
|
});
|
|
664
724
|
}
|
|
725
|
+
/** After update-branch (202, applied asynchronously): wait until the PR reports a new head. */
|
|
726
|
+
async function waitForHeadChange(cwd, host, url, oldHead, deadline, intervalMs, label) {
|
|
727
|
+
for (;;) {
|
|
728
|
+
const details = await getMergeRequestDetails(cwd, host, url);
|
|
729
|
+
if (details !== null && (details.state === "merged" || (details.headSha !== null && details.headSha !== oldHead))) {
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (Date.now() >= deadline) {
|
|
733
|
+
throw new MergeGateError(label, [
|
|
734
|
+
`update-branch was accepted but the ${label} head is still ${oldHead.slice(0, 7)} (gave up, waitTimeoutSeconds)`,
|
|
735
|
+
]);
|
|
736
|
+
}
|
|
737
|
+
await sleep(Math.min(intervalMs, deadline - Date.now()));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
665
740
|
/** `gh pr merge` argv. `--match-head-commit` needs gh ≥ 2.13.0. */
|
|
666
741
|
export function gitHubMergeArgs(ref, method, headSha) {
|
|
667
742
|
return ["pr", "merge", ref, `--${method}`, "--match-head-commit", headSha];
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readTextOrNull } from "./filesystem.js";
|
|
3
|
+
import { runGit } from "./git.js";
|
|
4
|
+
import { dim, log } from "./logger.js";
|
|
5
|
+
import { VIBEOPS_CONFIG_FILE } from "./paths.js";
|
|
6
|
+
export const BASELINE_DEFAULTS = { mode: "warn", crew: null };
|
|
7
|
+
export class BaselineConfigError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(`Invalid \`baseline\` block in .vibeops.json: ${message}`);
|
|
10
|
+
this.name = "BaselineConfigError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const MODES = ["off", "warn", "enforce"];
|
|
14
|
+
const CREW_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
|
+
/** Strict: object with only `mode` / `crew`. `undefined` → defaults. */
|
|
16
|
+
export function parseBaselineConfig(raw) {
|
|
17
|
+
if (raw === undefined)
|
|
18
|
+
return BASELINE_DEFAULTS;
|
|
19
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
20
|
+
throw new BaselineConfigError("must be an object.");
|
|
21
|
+
}
|
|
22
|
+
const o = raw;
|
|
23
|
+
const unknown = Object.keys(o).filter((k) => k !== "mode" && k !== "crew");
|
|
24
|
+
if (unknown.length > 0) {
|
|
25
|
+
throw new BaselineConfigError(`unknown key \`${unknown[0]}\` (known: mode, crew).`);
|
|
26
|
+
}
|
|
27
|
+
let mode = BASELINE_DEFAULTS.mode;
|
|
28
|
+
if (o.mode !== undefined) {
|
|
29
|
+
if (typeof o.mode !== "string" || !MODES.includes(o.mode)) {
|
|
30
|
+
throw new BaselineConfigError("`mode` must be one of off, warn, enforce.");
|
|
31
|
+
}
|
|
32
|
+
mode = o.mode;
|
|
33
|
+
}
|
|
34
|
+
let crew = null;
|
|
35
|
+
if (o.crew !== undefined) {
|
|
36
|
+
if (typeof o.crew !== "string" || !CREW_RE.test(o.crew)) {
|
|
37
|
+
throw new BaselineConfigError("`crew` must be a crew name like `vibe-crew`.");
|
|
38
|
+
}
|
|
39
|
+
crew = o.crew;
|
|
40
|
+
}
|
|
41
|
+
return { mode, crew };
|
|
42
|
+
}
|
|
43
|
+
export async function readBaselineConfig(root) {
|
|
44
|
+
const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
|
|
45
|
+
if (text === null)
|
|
46
|
+
return BASELINE_DEFAULTS;
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(text);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new BaselineConfigError("file is not valid JSON.");
|
|
53
|
+
}
|
|
54
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
55
|
+
return BASELINE_DEFAULTS;
|
|
56
|
+
}
|
|
57
|
+
return parseBaselineConfig(parsed.baseline);
|
|
58
|
+
}
|
|
59
|
+
/** The remote's default branch from `git ls-remote --symref <remote> HEAD`, or null. */
|
|
60
|
+
async function remoteDefaultBranch(cwd, remote) {
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await runGit(cwd, ["ls-remote", "--symref", remote, "HEAD"]);
|
|
63
|
+
const m = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(stdout);
|
|
64
|
+
return m ? m[1] : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export async function runBaselineChecks(cwd, git, cfg) {
|
|
71
|
+
const checks = [];
|
|
72
|
+
const gitflow = git.integrationBranch !== git.productionBranch;
|
|
73
|
+
checks.push({
|
|
74
|
+
id: "branch-policy",
|
|
75
|
+
ok: gitflow,
|
|
76
|
+
detail: gitflow
|
|
77
|
+
? `${git.integrationBranch} (integration) → ${git.productionBranch} (production)`
|
|
78
|
+
: `integration and production are both ${git.integrationBranch}`,
|
|
79
|
+
fix: "set `git.integrationBranch` (e.g. develop) ≠ `git.productionBranch` (main) in .vibeops.json",
|
|
80
|
+
});
|
|
81
|
+
const def = await remoteDefaultBranch(cwd, git.remote);
|
|
82
|
+
checks.push({
|
|
83
|
+
id: "default-branch",
|
|
84
|
+
ok: def === git.integrationBranch,
|
|
85
|
+
detail: def === null
|
|
86
|
+
? `could not read the default branch of ${git.remote}`
|
|
87
|
+
: `${git.remote} default branch is ${def}`,
|
|
88
|
+
fix: git.host === "gitlab"
|
|
89
|
+
? `glab api --method PUT projects/:id --raw-field default_branch=${git.integrationBranch}`
|
|
90
|
+
: `gh repo edit --default-branch ${git.integrationBranch}`,
|
|
91
|
+
});
|
|
92
|
+
if (cfg.crew !== null) {
|
|
93
|
+
const source = await readTextOrNull(join(cwd, ".personas", cfg.crew, "SOURCE"));
|
|
94
|
+
checks.push({
|
|
95
|
+
id: "personas",
|
|
96
|
+
ok: source !== null,
|
|
97
|
+
detail: source !== null ? `.personas/${cfg.crew}/SOURCE present` : `.personas/${cfg.crew}/SOURCE missing`,
|
|
98
|
+
fix: `install the ${cfg.crew} crew (it writes .personas/${cfg.crew}/ and the AGENTS.md section)`,
|
|
99
|
+
});
|
|
100
|
+
const agents = await readTextOrNull(join(cwd, "AGENTS.md"));
|
|
101
|
+
const marker = `<!-- getpersona:${cfg.crew} -->`;
|
|
102
|
+
const has = agents !== null && agents.includes(marker);
|
|
103
|
+
checks.push({
|
|
104
|
+
id: "agents-contract",
|
|
105
|
+
ok: has,
|
|
106
|
+
detail: has ? `AGENTS.md has the ${cfg.crew} section` : `AGENTS.md lacks ${marker}`,
|
|
107
|
+
fix: `install the ${cfg.crew} crew (AGENTS.md section between ${marker} markers)`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return checks;
|
|
111
|
+
}
|
|
112
|
+
/** Section `task add --repair-baseline` writes into the TASK file (read back by `task ship`). */
|
|
113
|
+
export const BASELINE_REPAIR_SECTION = "Baseline";
|
|
114
|
+
export const BASELINE_REPAIR_TEXT = "Repair — opened with `vibeops task add --repair-baseline`. `task ship` requires every baseline check to pass (whatever `baseline.mode` is).";
|
|
115
|
+
/**
|
|
116
|
+
* Run the configured checks and print failures. Returns false only in `enforce` mode with a
|
|
117
|
+
* failing check (the caller stops); `warn` prints and continues; `off` skips. See
|
|
118
|
+
* {@link PreflightOptions} for the baseline-repair flow.
|
|
119
|
+
*/
|
|
120
|
+
export async function preflightBaseline(cwd, git, command, opts = {}) {
|
|
121
|
+
let cfg;
|
|
122
|
+
try {
|
|
123
|
+
cfg = await readBaselineConfig(cwd);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
127
|
+
if (opts.repair !== true) {
|
|
128
|
+
log.error(msg);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
log.warn(`${msg} — continuing with defaults (baseline repair).`);
|
|
132
|
+
cfg = BASELINE_DEFAULTS;
|
|
133
|
+
}
|
|
134
|
+
if (cfg.mode === "off" && opts.repair !== true && opts.requireOk !== true)
|
|
135
|
+
return true;
|
|
136
|
+
const failed = (await runBaselineChecks(cwd, git, cfg)).filter((c) => !c.ok);
|
|
137
|
+
if (failed.length === 0) {
|
|
138
|
+
if (opts.repair === true)
|
|
139
|
+
log.info(dim("baseline: every check passes already (--repair-baseline not needed)."));
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
const stop = opts.requireOk === true || (cfg.mode === "enforce" && opts.repair !== true);
|
|
143
|
+
for (const c of failed) {
|
|
144
|
+
const line = `baseline ${c.id}: ${c.detail}`;
|
|
145
|
+
if (stop)
|
|
146
|
+
log.error(line);
|
|
147
|
+
else
|
|
148
|
+
log.warn(line);
|
|
149
|
+
if (c.fix)
|
|
150
|
+
log.info(dim(` fix: ${c.fix}`));
|
|
151
|
+
}
|
|
152
|
+
if (opts.requireOk === true) {
|
|
153
|
+
log.info(dim(`${command} stopped: a baseline-repair TASK ships only when every check passes.`));
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (stop) {
|
|
157
|
+
log.info(dim(`${command} stopped (.vibeops.json baseline.mode: enforce). To repair: vibeops task add --repair-baseline`));
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
if (opts.repair === true)
|
|
161
|
+
log.info(dim("baseline repair: failures reported as warnings for this TASK."));
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
/** Read-only summary for `vibeops status` (never throws). */
|
|
165
|
+
export async function summarizeBaseline(cwd, git) {
|
|
166
|
+
let cfg = BASELINE_DEFAULTS;
|
|
167
|
+
let configError = null;
|
|
168
|
+
try {
|
|
169
|
+
cfg = await readBaselineConfig(cwd);
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
configError = e instanceof Error ? e.message : String(e);
|
|
173
|
+
}
|
|
174
|
+
const checks = await runBaselineChecks(cwd, git, cfg);
|
|
175
|
+
let crewCommit = null;
|
|
176
|
+
if (cfg.crew !== null) {
|
|
177
|
+
const source = await readTextOrNull(join(cwd, ".personas", cfg.crew, "SOURCE"));
|
|
178
|
+
crewCommit = source === null ? null : (/^commit:\s*(\S+)/m.exec(source)?.[1] ?? null);
|
|
179
|
+
}
|
|
180
|
+
let otherBranches = null;
|
|
181
|
+
try {
|
|
182
|
+
const { stdout } = await runGit(cwd, ["ls-remote", "--heads", git.remote]);
|
|
183
|
+
otherBranches = stdout
|
|
184
|
+
.split("\n")
|
|
185
|
+
.map((l) => l.split("\t")[1]?.replace(/^refs\/heads\//, "") ?? "")
|
|
186
|
+
.filter((b) => b.length > 0 &&
|
|
187
|
+
b !== git.integrationBranch &&
|
|
188
|
+
b !== git.productionBranch &&
|
|
189
|
+
!b.startsWith("task/") &&
|
|
190
|
+
!b.startsWith("sync/"));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
otherBranches = null;
|
|
194
|
+
}
|
|
195
|
+
return { mode: cfg.mode, crew: cfg.crew, crewCommit, checks, otherBranches, configError };
|
|
196
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { fetchBaseBranch } from "./merge-base-freshness.js";
|
|
2
|
+
import { gitIsAncestor, runGit } from "./git.js";
|
|
3
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
4
|
+
/**
|
|
5
|
+
* Tag the release merge commit `v<package.json version>` and push the tag, so "merged into
|
|
6
|
+
* production" and "tagged for publishing" are one step. Refuses when the commit is not on the
|
|
7
|
+
* freshly fetched `<remote>/<production>`, when package.json has no plain semver `version`,
|
|
8
|
+
* or when the tag already exists locally or on the remote (tags are never moved).
|
|
9
|
+
*/
|
|
10
|
+
export async function tagReleaseCommit(opts) {
|
|
11
|
+
const { cwd, remote, productionBranch, commit } = opts;
|
|
12
|
+
let prodRef;
|
|
13
|
+
try {
|
|
14
|
+
prodRef = await fetchBaseBranch(cwd, remote, productionBranch);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
return { ok: false, message: `could not fetch ${remote}/${productionBranch}: ${firstLine(e)}` };
|
|
18
|
+
}
|
|
19
|
+
if (!(await gitIsAncestor(cwd, commit, prodRef))) {
|
|
20
|
+
return { ok: false, message: `${short(commit)} is not on ${remote}/${productionBranch}` };
|
|
21
|
+
}
|
|
22
|
+
let version;
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await runGit(cwd, ["show", `${commit}:package.json`]);
|
|
25
|
+
version = JSON.parse(stdout).version;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return { ok: false, message: `no readable package.json at ${short(commit)} to take the version from` };
|
|
29
|
+
}
|
|
30
|
+
if (typeof version !== "string" || !SEMVER_RE.test(version)) {
|
|
31
|
+
return { ok: false, message: `package.json version at ${short(commit)} is not a semver string` };
|
|
32
|
+
}
|
|
33
|
+
const tag = `v${version}`;
|
|
34
|
+
try {
|
|
35
|
+
await runGit(cwd, ["rev-parse", "-q", "--verify", `refs/tags/${tag}`]);
|
|
36
|
+
return { ok: false, message: `tag ${tag} already exists locally — tags are not moved` };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// absent locally: good
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const { stdout } = await runGit(cwd, ["ls-remote", "--tags", remote, `refs/tags/${tag}`]);
|
|
43
|
+
if (stdout.trim().length > 0) {
|
|
44
|
+
return { ok: false, message: `tag ${tag} already exists on ${remote} — tags are not moved` };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return { ok: false, message: `could not list tags on ${remote}: ${firstLine(e)}` };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
await runGit(cwd, ["tag", tag, commit]);
|
|
52
|
+
await runGit(cwd, ["push", remote, `refs/tags/${tag}`]);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
return { ok: false, message: `could not create / push ${tag}: ${firstLine(e)}` };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, tag, commit };
|
|
58
|
+
}
|
|
59
|
+
function short(sha) {
|
|
60
|
+
return sha.slice(0, 7);
|
|
61
|
+
}
|
|
62
|
+
function firstLine(e) {
|
|
63
|
+
return (e instanceof Error ? e.message : String(e)).split("\n")[0];
|
|
64
|
+
}
|
|
@@ -206,7 +206,7 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
|
|
|
206
206
|
log.info(dim("Updating open MR/PR (--allow-open-mr)."));
|
|
207
207
|
}
|
|
208
208
|
else {
|
|
209
|
-
gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd);
|
|
209
|
+
gitCtx = await refreshGitContextBaseCommit(taskFile, gitCtx, cwd, remote);
|
|
210
210
|
gitCtx = await prepareGitContextForReship(cwd, taskFile, gitCtx);
|
|
211
211
|
await commitReshipMetadata({
|
|
212
212
|
cwd,
|
package/dist/lib/task-reship.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { GitConfigError, requireGitConfig } from "./git-config.js";
|
|
3
|
-
import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitDeleteBranch, gitFetchRemote,
|
|
3
|
+
import { gitBranchExists, gitCheckout, gitCheckoutNewBranch, gitDeleteBranch, gitFetchRemote, gitMergeRemoteBranch, gitMergeInProgress, gitFetch, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, listUnmergedRelPaths, readGitInfo, runGit, } from "./git.js";
|
|
4
4
|
import { fastForwardLocalIntegration, leaveTaskBranch, logLocalIntegrationUpdate, resolveIntegrationBaseRef, } from "./git-worktree.js";
|
|
5
5
|
import { dim, log } from "./logger.js";
|
|
6
|
+
import { fetchBaseBranch } from "./merge-base-freshness.js";
|
|
6
7
|
import { findMergeRequestByBranches } from "./pr-create.js";
|
|
7
8
|
import { normalizeTaskRef } from "./resolve-task.js";
|
|
8
9
|
import { getTaskMergeRequestLifecycle, resolveGitHostForCwd, } from "./task-effective-status.js";
|
|
@@ -234,11 +235,26 @@ export async function assertMergeRequestNotOpen(cwd, taskFile, allowOpenMr) {
|
|
|
234
235
|
log.error("Current MR/PR is still open. Merge or close it first, or rerun with --allow-open-mr.");
|
|
235
236
|
return false;
|
|
236
237
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Base Commit of a new PR cycle: where this cycle starts on the base branch — the merge base
|
|
240
|
+
* of the task branch and the freshly fetched `<remote>/<base>` (the base tip after the
|
|
241
|
+
* integration merge; the fork point with `--no-integrate`). Not the task branch HEAD, which
|
|
242
|
+
* after the follow-up commit is the TASK's own work. Fetch / read failure keeps the previous
|
|
243
|
+
* value (with a warning) rather than recording a wrong one.
|
|
244
|
+
*/
|
|
245
|
+
export async function refreshGitContextBaseCommit(taskFile, gitCtx, cwd, remote) {
|
|
246
|
+
try {
|
|
247
|
+
const baseRef = await fetchBaseBranch(cwd, remote, gitCtx.baseBranch);
|
|
248
|
+
const { stdout } = await runGit(cwd, ["merge-base", "HEAD", baseRef]);
|
|
249
|
+
const base = stdout.trim();
|
|
250
|
+
if (base.length === 0)
|
|
251
|
+
return gitCtx;
|
|
252
|
+
return { ...gitCtx, baseCommit: base };
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
log.warn(`Could not read ${remote}/${gitCtx.baseBranch} — Base Commit left at ${gitCtx.baseCommit.slice(0, 7)}.`);
|
|
240
256
|
return gitCtx;
|
|
241
|
-
|
|
257
|
+
}
|
|
242
258
|
}
|
|
243
259
|
export async function prepareGitContextForReship(cwd, taskFile, gitCtx) {
|
|
244
260
|
const previous = [...(gitCtx.previousMergeRequestUrls ?? [])];
|
package/dist/lib/task.js
CHANGED
|
@@ -335,6 +335,12 @@ function insertSectionAfter(body, afterTitle, newTitle, newContent) {
|
|
|
335
335
|
const after = lines.slice(block.end);
|
|
336
336
|
return [...before, ...newSection, ...after].join("\n");
|
|
337
337
|
}
|
|
338
|
+
/** Add (or replace) a `## <title>` section right after `## Status`. */
|
|
339
|
+
export function withSectionAfterStatus(body, title, content) {
|
|
340
|
+
if (locateSection(body, title) !== null)
|
|
341
|
+
return replaceSectionContent(body, title, content);
|
|
342
|
+
return insertSectionAfter(body, "Status", title, content);
|
|
343
|
+
}
|
|
338
344
|
export async function updateInlineStatus(filePath, next) {
|
|
339
345
|
const raw = await readText(filePath);
|
|
340
346
|
const display = statusDisplay(next);
|