@goodtek/vibeops 3.1.1 → 3.1.4

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,41 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.1.4 - 2026-10-16
8
+
9
+ ### Changed
10
+
11
+ - No CLI changes. First release published from GitHub Actions (`publish.yml`, npm Trusted Publishing with provenance) instead of a maintainer machine.
12
+
13
+ ## 3.1.3 - 2026-10-16
14
+
15
+ ### Fixed
16
+
17
+ - **Negated flags were ignored.** commander stores `--no-foo` as `foo: false` and never sets `noFoo`, but the CLI read `noFoo`, so these flags did nothing: `task release --no-merge` (created the release PR and merged it anyway — observed merging before the PR's checks finished), `task del --no-remote-delete` / `--no-close-mr` (deleted the remote branch / closed the open PR anyway) and `task sync --no-remote-delete` (deleted the remote branch anyway). They now take effect, in dry-run plans too (TASK-025).
18
+ - **`task add --dry-run` crashed** with `ENOENT` after printing its plan: the branch step read the TASK file the dry run never wrote. The plan now also names the real branch (`task/<NNN>-<slug>`; it omitted the TASK number) (TASK-026).
19
+
20
+ ## 3.1.2 - 2026-10-16
21
+
22
+ ### Fixed
23
+
24
+ - **`task merge` / `task release` merged before CI started (GitHub).** The gate judged only `statusCheckRollup`, which also holds checks from other events on the head commit (e.g. a finished `push` workflow). Right after a PR was opened or pushed, its own `pull_request` run — or that run's jobs — did not exist yet, so earlier green checks counted as "all green" (observed: a release PR merged 5 s after opening, before its CI job was created; a required check name satisfied by the same-named job of a `push` workflow). The gate now also reads the GitHub Actions API for the head SHA: every workflow whose `on:` triggers `pull_request` for this PR (base-branch, path and type filters evaluated as GitHub does; disabled workflows skipped) must have a `pull_request` run, and every Actions run for the SHA must be completed with `success` / `neutral` / `skipped`. Missing or running → wait up to `waitTimeoutSeconds`, then refuse with the workflow named; `startup_failure`, `action_required` and other conclusions → refuse immediately; unreadable Actions API → wait, then refuse (TASK-024).
25
+ - **Head SHA race:** right before merging, the gate re-reads the PR head; if it moved while checks were read, the new head is evaluated from scratch (the merge stays pinned with `--match-head-commit` / `--sha`) (TASK-024).
26
+ - The `task merge` header no longer prints `checks all green` before anything was checked; it states the rule (`waits until every check on the head commit is green`).
27
+
28
+ ### Added
29
+
30
+ - **CI** (`.github/workflows/ci.yml`, pull requests + `main`): lint (ESLint, new `pnpm lint`), typecheck, build, tests and smoke on Node 20 / 22 / 24, GitHub-hosted runners only, read-only token, actions pinned by commit SHA (TASK-023).
31
+ - **Secret scanning in CI:** gitleaks 8.30.1 CLI (pinned release, sha256 checked against the pinned value and the release checksums file); PR commits `base..head`, full history on `main`; any finding fails the job (TASK-023).
32
+ - **npm Trusted Publishing** (`.github/workflows/publish.yml`): tag `vX.Y.Z` on `main` → verify → `npm publish` via OIDC with automatic provenance; no npm token in CI. Owner setup in `docs/project/09-deployment.md`; the manual `pnpm publish:npm` path stays until it is configured (TASK-023).
33
+
34
+ ### Changed
35
+
36
+ - `merge.pollIntervalSeconds` default **5 → 10** (the default of `gh pr checks --watch`). Host read errors back off exponentially with jitter (cap 60 s) within `waitTimeoutSeconds`.
37
+ - "No checks at all" now also requires that no GitHub Actions run exists or is expected for the head commit; `emptyRollupGraceSeconds` only matters for CI outside GitHub Actions.
38
+ - New runtime dependency: `yaml` (ISC) to read workflow triggers.
39
+ - `package.json` `repository` / `homepage` / `bugs` point at `github.com/goodtek-xyz/vibeops` (required to match exactly for Trusted Publishing provenance).
40
+ - Docs, scripts and examples use placeholders instead of maintainer-internal hosts and paths; `.infisical.json` is no longer tracked (gitignored).
41
+
7
42
  ## 3.1.1 - 2026-10-15
8
43
 
9
44
  ### Fixed
package/README.md CHANGED
@@ -213,11 +213,16 @@ Before calling `gh pr merge` / `glab mr merge`:
213
213
  - **failed:** `FAILURE`, `CANCELLED`, `TIMED_OUT`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`, `ERROR`
214
214
  - **pending:** CheckRun `REQUESTED`, `QUEUED`, `IN_PROGRESS`, `WAITING`, `PENDING`; StatusContext `PENDING`, `EXPECTED`
215
215
  - **anything missing or undocumented fails closed** (treated as failed). Several checks with the same name: any failure fails.
216
+ - **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:
217
+ - **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`.
218
+ - **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).
219
+ - The Actions API is required: if it cannot be read, the gate keeps waiting (back-off) and refuses at the timeout — it never merges blind.
216
220
  - **GitLab:** the MR head pipeline must be `success` or `skipped`. `failed`, `canceled`, `manual` and unknown statuses refuse; `created` / `pending` / `running` / `scheduled` / … wait.
217
221
  - **Host output is verified:** if `gh pr view` lacks or has empty `state`, `mergeable`, `headRefOid`, or `statusCheckRollup` is not a list (GitLab: `state`, `sha`, `merge_status`, `detailed_merge_status`, `head_pipeline`), the merge is refused.
218
222
  - **Failed check → refuse immediately** (exit code 1, checks named). **Pending → wait** up to `waitTimeoutSeconds`, then refuse. There is no override flag: fix CI (re-run `task ship`) and rerun `task merge`.
219
- - **No checks at all → refuse** (after `emptyRollupGraceSeconds`, which covers checks not yet registered right after a push) unless `allowNoChecks: true`.
220
- - **The merge is pinned to the checked commit:** `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). A push after the check makes the host refuse. A GitLab `405` re-runs the gate and retries once with the same flags.
223
+ - **No checks at all → refuse** unless `allowNoChecks: true`. "No checks" means: empty rollup, no GitHub Actions run and no expected workflow for the head commit (GitLab: no pipeline). For CI outside GitHub Actions (commit statuses / check apps), the empty list is re-polled for `emptyRollupGraceSeconds` first; list such checks in `requiredChecks` so the gate waits for them by name.
224
+ - **Waiting** polls the host (`gh pr view` + the Actions REST API) every `pollIntervalSeconds` (default 10 s, the default of `gh pr checks --watch`) until the state changes, bounded by `waitTimeoutSeconds` (default 900 s). Host read errors back off exponentially with jitter (capped at 60 s, GitHub's guidance for rate-limited clients).
225
+ - **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.
221
226
 
222
227
  ### Configuration (`.vibeops.json` → `merge`)
223
228
 
@@ -227,7 +232,7 @@ Before calling `gh pr merge` / `glab mr merge`:
227
232
  "requiredChecks": ["build", "lint*"],
228
233
  "releaseRequiredChecks": ["release smoke"],
229
234
  "waitTimeoutSeconds": 900,
230
- "pollIntervalSeconds": 5,
235
+ "pollIntervalSeconds": 10,
231
236
  "emptyRollupGraceSeconds": 30,
232
237
  "allowNoChecks": false
233
238
  }
@@ -239,13 +244,13 @@ Before calling `gh pr merge` / `glab mr merge`:
239
244
  | `requiredChecks` | `[]` | array of non-empty strings | Checks that must be present and green for `task merge`. A missing one refuses: `required check "X" never ran`. |
240
245
  | `releaseRequiredChecks` | `[]` | array of non-empty strings | Same for `task release` (integration → production). Shown by `task release --dry-run`. |
241
246
  | `waitTimeoutSeconds` | `900` | integer 1–7200 | Max wait for pending checks. |
242
- | `pollIntervalSeconds` | `5` | integer 1–300, ≤ timeout | Poll interval while waiting. |
243
- | `emptyRollupGraceSeconds` | `30` | integer 0–600 | How long an empty check list is re-polled before "no checks" applies. |
247
+ | `pollIntervalSeconds` | `10` | integer 1–300, ≤ timeout | Poll interval while waiting (same default as `gh pr checks --watch`). |
248
+ | `emptyRollupGraceSeconds` | `30` | integer 0–600 | Only when no GitHub Actions workflow is expected: how long an empty check list is re-polled before "no checks" applies (time for external CI to report). |
244
249
  | `allowNoChecks` | `false` | boolean | Merge a PR that has no checks at all (repo without CI). Prints one warning per such merge. |
245
250
 
246
251
  - Check names are exact (`CheckRun.name` / `StatusContext.context`) or a `*` glob (`"lint*"`, `"* · linux"`). Required checks are GitHub-only; GitLab gates on the head pipeline.
247
252
  - 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.
248
- - **Recommended:** list your CI jobs in `requiredChecks`. Without it, the gate only sees the checks that happened to start — a workflow that never triggers is invisible.
253
+ - **Recommended:** list your CI jobs in `requiredChecks`. Required checks must be present **and** completed green; missing or pending means wait. Without them the gate still waits for every GitHub Actions workflow that triggers on the PR (see above), but checks from other CI systems are only seen once they report.
249
254
 
250
255
  ## License
251
256
 
package/dist/cli.js CHANGED
@@ -88,8 +88,8 @@ task
88
88
  await taskDelCommand(taskRef, {
89
89
  dryRun: Boolean(opts.dryRun),
90
90
  force: Boolean(opts.force),
91
- noRemoteDelete: Boolean(opts.noRemoteDelete),
92
- noCloseMr: Boolean(opts.noCloseMr),
91
+ noRemoteDelete: opts.remoteDelete === false,
92
+ noCloseMr: opts.closeMr === false,
93
93
  cwd: opts.cwd,
94
94
  });
95
95
  });
@@ -148,7 +148,7 @@ task
148
148
  .action(async (taskRef, opts) => {
149
149
  await taskSyncCommand(taskRef, {
150
150
  dryRun: Boolean(opts.dryRun),
151
- noRemoteDelete: Boolean(opts.noRemoteDelete),
151
+ noRemoteDelete: opts.remoteDelete === false,
152
152
  force: Boolean(opts.force),
153
153
  cwd: opts.cwd,
154
154
  });
@@ -165,7 +165,7 @@ task
165
165
  .action(async (opts) => {
166
166
  await taskReleaseCommand({
167
167
  dryRun: Boolean(opts.dryRun),
168
- noMerge: Boolean(opts.noMerge),
168
+ noMerge: opts.merge === false,
169
169
  merge: Boolean(opts.merge),
170
170
  squash: Boolean(opts.squash),
171
171
  rebase: Boolean(opts.rebase),
@@ -12,7 +12,7 @@ import { findBlockingTask, relPath } from "../lib/task-context.js";
12
12
  import { allocateTaskId, TaskIdAllocationError } from "../lib/task-id-allocation.js";
13
13
  import { readTaskLockConfig, TaskLockConfigError, TaskLockHeldError, withTaskLock, } from "../lib/task-lock.js";
14
14
  import { isIncompleteTaskStart, startTaskBranch } from "../lib/task-start.js";
15
- import { loadActionableTasks, readGitContext } from "../lib/task.js";
15
+ import { branchNameForTaskFile, loadActionableTasks, readGitContext } from "../lib/task.js";
16
16
  async function loadGitConfigOrNull(root, dryRun) {
17
17
  try {
18
18
  return await requireGitConfig(root);
@@ -242,7 +242,7 @@ export async function taskAddCommand(opts = {}) {
242
242
  const integration = gitCfg?.integrationBranch ?? "integration";
243
243
  if (dryRun) {
244
244
  log.info(`[dry-run] Would create ${bold(taskId)} → ${cyan(relFile)}`);
245
- log.info(dim(` branch task/${slugify(slug || title).replace(/^(\d+)-/, "$1-")} from ${gitCfg?.remote ?? "origin"}/${integration}`));
245
+ log.info(dim(` branch ${branchNameForTaskFile(filePath)} from ${gitCfg?.remote ?? "origin"}/${integration}`));
246
246
  if (gitCfg) {
247
247
  await startTaskBranch({
248
248
  cwd: root,
@@ -95,7 +95,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
95
95
  log.info(` ${dim("MR/PR")} ${mergeRequestUrl}`);
96
96
  log.info(` ${dim("target")} ${ctx.baseBranch} (${gitCfg.integrationBranch})`);
97
97
  log.info(` ${dim("method")} ${method}`);
98
- log.info(` ${dim("checks")} all green${requiredChecks.length > 0 ? ` + required: ${requiredChecks.join(", ")}` : ""}${mergeCfg.allowNoChecks ? dim(" (allowNoChecks: true)") : ""}`);
98
+ log.info(` ${dim("gate")} waits until every check on the head commit is green${requiredChecks.length > 0 ? ` + required: ${requiredChecks.join(", ")}` : ""}${mergeCfg.allowNoChecks ? dim(" (allowNoChecks: true)") : ""}`);
99
99
  log.blank();
100
100
  if (dryRun) {
101
101
  await mergeMergeRequest({
@@ -0,0 +1,355 @@
1
+ /**
2
+ * GitHub Actions part of the merge gate: which workflow runs a PR head commit
3
+ * is still owed, and whether the runs that exist for it are finished and green.
4
+ *
5
+ * Why: `statusCheckRollup` lists every check on the head commit, including
6
+ * checks from other events (e.g. a `push` workflow on the same commit). Right
7
+ * after a PR is opened or pushed, GitHub has not created the `pull_request`
8
+ * workflow run — or its jobs — yet, so a rollup made only of earlier, finished
9
+ * checks looks green although the PR's own CI has not started. The gate
10
+ * therefore also asks the Actions API:
11
+ *
12
+ * 1. Expected runs: workflows in `.github/workflows` at the head commit whose
13
+ * `on:` triggers `pull_request` for this PR (base branch, changed paths,
14
+ * activity types — GitHub's documented filter rules) and that are active.
15
+ * Each must have a `pull_request` run for the head SHA.
16
+ * 2. Every workflow run for the head SHA (any event) must be completed with
17
+ * `success`, `neutral` or `skipped` (latest run per workflow + event).
18
+ *
19
+ * Docs: https://docs.github.com/actions/writing-workflows/workflow-syntax-for-github-actions#onpull_requestpull_request_targetbranchesbranches-ignore
20
+ * https://docs.github.com/rest/actions/workflow-runs#list-workflow-runs-for-a-repository
21
+ */
22
+ import { execFile } from "node:child_process";
23
+ import { promisify } from "node:util";
24
+ import { parse as parseYaml } from "yaml";
25
+ const execFileAsync = promisify(execFile);
26
+ // ---------------------------------------------------------------------------
27
+ // Filter patterns (GitHub "Filter pattern cheat sheet")
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * GitHub filter pattern → anchored RegExp. `*` = any chars except `/`,
31
+ * `**` = any chars, `?` / `+` = zero-or-one / one-or-more of the preceding
32
+ * char, `[…]` = character class, `\` escapes the next char.
33
+ */
34
+ export function filterPatternToRegExp(pattern) {
35
+ let out = "";
36
+ for (let i = 0; i < pattern.length; i++) {
37
+ const ch = pattern[i];
38
+ if (ch === "\\" && i + 1 < pattern.length) {
39
+ out += escapeRegExp(pattern[++i]);
40
+ }
41
+ else if (ch === "*") {
42
+ if (pattern[i + 1] === "*") {
43
+ out += ".*";
44
+ i++;
45
+ }
46
+ else {
47
+ out += "[^/]*";
48
+ }
49
+ }
50
+ else if (ch === "?" || ch === "+") {
51
+ out += ch;
52
+ }
53
+ else if (ch === "[") {
54
+ const end = pattern.indexOf("]", i + 1);
55
+ if (end === -1) {
56
+ out += "\\[";
57
+ }
58
+ else {
59
+ out += pattern.slice(i, end + 1);
60
+ i = end;
61
+ }
62
+ }
63
+ else {
64
+ out += escapeRegExp(ch);
65
+ }
66
+ }
67
+ return new RegExp(`^${out}$`);
68
+ }
69
+ function escapeRegExp(ch) {
70
+ return ch.replace(/[.^$(){}|[\]\\*+?/]/g, "\\$&");
71
+ }
72
+ /**
73
+ * Ordered include list with `!` negations (`branches`, `paths`): the last
74
+ * matching pattern decides; no match → not included.
75
+ */
76
+ export function includedByFilter(patterns, value) {
77
+ let included = false;
78
+ for (const raw of patterns) {
79
+ const negated = raw.startsWith("!");
80
+ const pattern = negated ? raw.slice(1) : raw;
81
+ if (filterPatternToRegExp(pattern).test(value))
82
+ included = !negated;
83
+ }
84
+ return included;
85
+ }
86
+ /** Ignore list (`branches-ignore`, `paths-ignore`): any match ignores. */
87
+ export function ignoredByFilter(patterns, value) {
88
+ return patterns.some((p) => filterPatternToRegExp(p).test(value));
89
+ }
90
+ // ---------------------------------------------------------------------------
91
+ // Workflow triggers
92
+ // ---------------------------------------------------------------------------
93
+ /** GitHub evaluates path filters against at most this many changed files. */
94
+ export const PATH_FILTER_FILE_LIMIT = 300;
95
+ function stringList(raw) {
96
+ if (raw === undefined || raw === null)
97
+ return null;
98
+ if (typeof raw === "string")
99
+ return [raw];
100
+ if (Array.isArray(raw))
101
+ return raw.filter((x) => typeof x === "string");
102
+ return [];
103
+ }
104
+ /**
105
+ * Does this workflow start a `pull_request` run for the PR's head commit?
106
+ *
107
+ * - `on: pull_request` in string, list or map form (no other key counts;
108
+ * `pull_request_target` runs on the base commit and is not awaited).
109
+ * - `types`: the head commit is reported by `opened` (new PR) or
110
+ * `synchronize` (push); a custom list must contain both, otherwise the
111
+ * workflow is not awaited (configure `merge.requiredChecks` for it).
112
+ * - `branches` / `branches-ignore` against the base branch;
113
+ * `paths` / `paths-ignore` against the PR's changed files (first 300).
114
+ * - YAML GitHub cannot parse still produces a failed run → expected.
115
+ */
116
+ export async function workflowExpectedForPullRequest(yamlText, ctx) {
117
+ let doc;
118
+ try {
119
+ doc = parseYaml(yamlText);
120
+ }
121
+ catch {
122
+ return { expected: true };
123
+ }
124
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
125
+ return { expected: true };
126
+ }
127
+ const on = doc["on"] ?? doc["true"];
128
+ if (typeof on === "string") {
129
+ return on === "pull_request"
130
+ ? { expected: true }
131
+ : { expected: false, reason: "not triggered by pull_request" };
132
+ }
133
+ if (Array.isArray(on)) {
134
+ return on.includes("pull_request")
135
+ ? { expected: true }
136
+ : { expected: false, reason: "not triggered by pull_request" };
137
+ }
138
+ if (on === null || typeof on !== "object" || !("pull_request" in on)) {
139
+ return { expected: false, reason: "not triggered by pull_request" };
140
+ }
141
+ const cfg = on["pull_request"];
142
+ if (cfg === null || cfg === undefined || typeof cfg !== "object" || Array.isArray(cfg)) {
143
+ return { expected: true };
144
+ }
145
+ const c = cfg;
146
+ const types = stringList(c["types"]);
147
+ if (types !== null && !(types.includes("opened") && types.includes("synchronize"))) {
148
+ return { expected: false, reason: `pull_request types [${types.join(", ")}]` };
149
+ }
150
+ if (ctx.baseBranch !== null) {
151
+ const branches = stringList(c["branches"]);
152
+ const branchesIgnore = stringList(c["branches-ignore"]);
153
+ if (branches !== null && !includedByFilter(branches, ctx.baseBranch)) {
154
+ return { expected: false, reason: `branches filter excludes ${ctx.baseBranch}` };
155
+ }
156
+ if (branchesIgnore !== null && ignoredByFilter(branchesIgnore, ctx.baseBranch)) {
157
+ return { expected: false, reason: `branches-ignore matches ${ctx.baseBranch}` };
158
+ }
159
+ }
160
+ const paths = stringList(c["paths"]);
161
+ const pathsIgnore = stringList(c["paths-ignore"]);
162
+ if (paths !== null || pathsIgnore !== null) {
163
+ const files = (await ctx.changedFiles()).slice(0, PATH_FILTER_FILE_LIMIT);
164
+ if (paths !== null && !files.some((f) => includedByFilter(paths, f))) {
165
+ return { expected: false, reason: "paths filter matches no changed file" };
166
+ }
167
+ if (pathsIgnore !== null && files.every((f) => ignoredByFilter(pathsIgnore, f))) {
168
+ return { expected: false, reason: "paths-ignore matches every changed file" };
169
+ }
170
+ }
171
+ return { expected: true };
172
+ }
173
+ const RUN_PASS = new Set(["success", "neutral", "skipped"]);
174
+ function runPath(run) {
175
+ return run.path.split("@")[0];
176
+ }
177
+ function describeRun(run, detail) {
178
+ return `workflow "${run.name}" (${run.event}) ${detail}`;
179
+ }
180
+ /**
181
+ * Latest run per (workflow, event) must be completed and passing; every
182
+ * expected workflow must have a `pull_request` run. Unknown statuses /
183
+ * conclusions fail closed.
184
+ */
185
+ export function evaluateActionsRuns(runs, expectedPaths) {
186
+ const latest = new Map();
187
+ for (const run of runs) {
188
+ const key = `${runPath(run)}\u0000${run.event}`;
189
+ const prev = latest.get(key);
190
+ if (prev === undefined || run.id > prev.id)
191
+ latest.set(key, run);
192
+ }
193
+ const failed = [];
194
+ const pending = [];
195
+ for (const run of latest.values()) {
196
+ const status = run.status.toLowerCase();
197
+ if (status !== "completed") {
198
+ pending.push(describeRun(run, `is ${status || "missing status"}`));
199
+ continue;
200
+ }
201
+ const conclusion = (run.conclusion ?? "").toLowerCase();
202
+ if (RUN_PASS.has(conclusion))
203
+ continue;
204
+ failed.push(describeRun(run, conclusion === "action_required"
205
+ ? "needs approval (action_required)"
206
+ : `concluded ${conclusion || "without a conclusion"}`));
207
+ }
208
+ const missing = expectedPaths.filter((p) => ![...latest.values()].some((r) => r.event === "pull_request" && runPath(r) === p));
209
+ return { failed, pending, missing };
210
+ }
211
+ /** `https://<host>/<owner>/<repo>/pull/<n>` → parts, else `null`. */
212
+ export function parseGitHubPullUrl(url) {
213
+ const m = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)/i.exec(url.trim());
214
+ if (m === null)
215
+ return null;
216
+ return { hostname: m[1], owner: m[2], repo: m[3], number: m[4] };
217
+ }
218
+ /** `gh api` failed with HTTP 404 (resource absent). */
219
+ export class GitHubNotFoundError extends Error {
220
+ }
221
+ async function ghApi(cwd, pr, path, accept) {
222
+ const args = ["api"];
223
+ if (pr.hostname.toLowerCase() !== "github.com")
224
+ args.push("--hostname", pr.hostname);
225
+ if (accept !== undefined)
226
+ args.push("-H", `Accept: ${accept}`);
227
+ args.push(path);
228
+ try {
229
+ const { stdout } = await execFileAsync("gh", args, { cwd, maxBuffer: 16 * 1024 * 1024 });
230
+ return stdout;
231
+ }
232
+ catch (error) {
233
+ const e = error;
234
+ const text = `${e.stderr ?? ""} ${e.message ?? ""}`;
235
+ if (/HTTP 404|Not Found/i.test(text))
236
+ throw new GitHubNotFoundError(path);
237
+ throw new Error(`gh api ${path}: ${(e.stderr ?? e.message ?? "").trim().split("\n")[0]}`);
238
+ }
239
+ }
240
+ function repoPath(pr) {
241
+ return `repos/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}`;
242
+ }
243
+ const RUNS_PAGE_SIZE = 100;
244
+ const RUNS_MAX_PAGES = 10;
245
+ const FILES_PAGE_SIZE = 100;
246
+ /** All workflow runs for `sha` (Actions disabled / absent → empty). */
247
+ export async function listWorkflowRunsForSha(cwd, pr, sha) {
248
+ const runs = [];
249
+ for (let page = 1; page <= RUNS_MAX_PAGES; page++) {
250
+ let text;
251
+ try {
252
+ text = await ghApi(cwd, pr, `${repoPath(pr)}/actions/runs?head_sha=${sha}&per_page=${RUNS_PAGE_SIZE}&page=${page}`);
253
+ }
254
+ catch (error) {
255
+ if (error instanceof GitHubNotFoundError)
256
+ return runs;
257
+ throw error;
258
+ }
259
+ const parsed = JSON.parse(text);
260
+ if (!Array.isArray(parsed.workflow_runs)) {
261
+ throw new Error("Actions API returned no workflow_runs list");
262
+ }
263
+ for (const r of parsed.workflow_runs) {
264
+ if (typeof r["id"] !== "number" || typeof r["path"] !== "string") {
265
+ throw new Error("Actions API returned a workflow run without id/path");
266
+ }
267
+ runs.push({
268
+ id: r["id"],
269
+ name: typeof r["name"] === "string" ? r["name"] : String(r["path"]),
270
+ path: r["path"],
271
+ event: typeof r["event"] === "string" ? r["event"] : "",
272
+ status: typeof r["status"] === "string" ? r["status"] : "",
273
+ conclusion: typeof r["conclusion"] === "string" ? r["conclusion"] : null,
274
+ });
275
+ }
276
+ if (parsed.workflow_runs.length < RUNS_PAGE_SIZE)
277
+ return runs;
278
+ }
279
+ throw new Error(`more than ${RUNS_PAGE_SIZE * RUNS_MAX_PAGES} workflow runs for ${sha}`);
280
+ }
281
+ /** Paths of workflows GitHub will not run (`disabled_*`). */
282
+ async function listDisabledWorkflowPaths(cwd, pr) {
283
+ let text;
284
+ try {
285
+ text = await ghApi(cwd, pr, `${repoPath(pr)}/actions/workflows?per_page=100`);
286
+ }
287
+ catch (error) {
288
+ if (error instanceof GitHubNotFoundError)
289
+ return new Set();
290
+ throw error;
291
+ }
292
+ const parsed = JSON.parse(text);
293
+ const out = new Set();
294
+ for (const w of parsed.workflows ?? []) {
295
+ if (typeof w.path === "string" && typeof w.state === "string" && w.state !== "active") {
296
+ out.add(w.path);
297
+ }
298
+ }
299
+ return out;
300
+ }
301
+ async function listChangedFiles(cwd, pr) {
302
+ const files = [];
303
+ // GitHub path filters look at the first 300 changed files only.
304
+ for (let page = 1; files.length < PATH_FILTER_FILE_LIMIT; page++) {
305
+ const text = await ghApi(cwd, pr, `${repoPath(pr)}/pulls/${pr.number}/files?per_page=${FILES_PAGE_SIZE}&page=${page}`);
306
+ const rows = JSON.parse(text);
307
+ if (!Array.isArray(rows))
308
+ throw new Error("pulls files API returned no list");
309
+ for (const row of rows)
310
+ if (typeof row.filename === "string")
311
+ files.push(row.filename);
312
+ if (rows.length < FILES_PAGE_SIZE)
313
+ break;
314
+ }
315
+ return files;
316
+ }
317
+ /**
318
+ * Workflows that will start a `pull_request` run for the head commit:
319
+ * `.github/workflows/*.y{a,}ml` at `sha`, triggered for this PR, not disabled.
320
+ */
321
+ export async function listExpectedPullRequestWorkflows(cwd, pr, sha, baseBranch) {
322
+ let listing;
323
+ try {
324
+ listing = await ghApi(cwd, pr, `${repoPath(pr)}/contents/.github/workflows?ref=${sha}`);
325
+ }
326
+ catch (error) {
327
+ if (error instanceof GitHubNotFoundError)
328
+ return [];
329
+ throw error;
330
+ }
331
+ const entries = JSON.parse(listing);
332
+ if (!Array.isArray(entries))
333
+ return [];
334
+ const candidates = entries
335
+ .filter((e) => e.type === "file" && typeof e.path === "string")
336
+ .map((e) => e.path)
337
+ .filter((p) => /\.ya?ml$/i.test(p));
338
+ if (candidates.length === 0)
339
+ return [];
340
+ const disabled = await listDisabledWorkflowPaths(cwd, pr);
341
+ let changed = null;
342
+ const ctx = {
343
+ baseBranch,
344
+ changedFiles: () => (changed ??= listChangedFiles(cwd, pr)),
345
+ };
346
+ const expected = [];
347
+ for (const path of candidates) {
348
+ if (disabled.has(path))
349
+ continue;
350
+ const text = await ghApi(cwd, pr, `${repoPath(pr)}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${sha}`, "application/vnd.github.raw+json");
351
+ if ((await workflowExpectedForPullRequest(text, ctx)).expected)
352
+ expected.push({ path });
353
+ }
354
+ return expected;
355
+ }
@@ -1,4 +1,3 @@
1
- /* eslint-disable no-console */
2
1
  const useColor = () => {
3
2
  if (process.env["NO_COLOR"])
4
3
  return false;
@@ -5,7 +5,8 @@ export const MERGE_GATE_DEFAULTS = {
5
5
  requiredChecks: [],
6
6
  releaseRequiredChecks: [],
7
7
  waitTimeoutSeconds: 900,
8
- pollIntervalSeconds: 5,
8
+ // Same default as `gh pr checks --watch --interval` (cli.github.com/manual/gh_pr_checks).
9
+ pollIntervalSeconds: 10,
9
10
  emptyRollupGraceSeconds: 30,
10
11
  allowNoChecks: false,
11
12
  };
@@ -3,6 +3,7 @@ import { promisify } from "node:util";
3
3
  import { mergeRequestLabel } from "./git-host.js";
4
4
  import { classifyRollup, evaluateCheckGate, formatCheckGateProblems, } from "./check-rollup.js";
5
5
  import { isMergeRequestReadyToMerge, isPipelineActive, pipelineGateState, pipelineStatusFromHost, } from "./merge-request-readiness.js";
6
+ import { evaluateActionsRuns, listExpectedPullRequestWorkflows, listWorkflowRunsForSha, parseGitHubPullUrl, } from "./github-actions-gate.js";
6
7
  import { dim, log } from "./logger.js";
7
8
  import { MERGE_GATE_DEFAULTS, mergeGateTiming } from "./merge-config.js";
8
9
  const execFileAsync = promisify(execFile);
@@ -268,6 +269,7 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
268
269
  hasConflicts: parsed.has_conflicts ?? null,
269
270
  checks: null,
270
271
  headSha: parsed.sha ?? null,
272
+ baseBranch: parsed.target_branch ?? null,
271
273
  };
272
274
  }
273
275
  const { stdout } = await execFileAsync("gh", [
@@ -275,7 +277,7 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
275
277
  "view",
276
278
  ref,
277
279
  "--json",
278
- "state,mergedAt,mergeCommit,mergeable,headRefOid,statusCheckRollup",
280
+ "state,mergedAt,mergeCommit,mergeable,headRefOid,baseRefName,statusCheckRollup",
279
281
  ], { cwd, maxBuffer: 4 * 1024 * 1024 });
280
282
  const parsed = JSON.parse(stdout.trim());
281
283
  if (options.strict) {
@@ -312,6 +314,7 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
312
314
  hasConflicts: mergeable === "CONFLICTING" ? true : mergeable === "MERGEABLE" ? false : null,
313
315
  checks,
314
316
  headSha: parsed.headRefOid ?? null,
317
+ baseBranch: parsed.baseRefName ?? null,
315
318
  };
316
319
  }
317
320
  /** Poll host until MR/PR state is merged (or timeout). */
@@ -365,11 +368,56 @@ function evaluateDetailsGate(details, requiredChecks) {
365
368
  };
366
369
  }
367
370
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
371
+ /**
372
+ * Upper bound for the back-off after consecutive host read errors. GitHub asks
373
+ * clients that hit a secondary rate limit to wait at least one minute before
374
+ * retrying (docs.github.com/rest/using-the-rest-api/rate-limits-for-the-rest-api).
375
+ */
376
+ const ERROR_BACKOFF_CAP_MS = 60_000;
377
+ /** Exponential back-off with full jitter in [base, min(cap, base·2^n)]. */
378
+ export function errorBackoffMs(baseMs, consecutiveErrors) {
379
+ const ceiling = Math.min(ERROR_BACKOFF_CAP_MS, baseMs * 2 ** Math.max(0, consecutiveErrors));
380
+ return baseMs + Math.random() * Math.max(0, ceiling - baseMs);
381
+ }
382
+ /**
383
+ * GitHub Actions view of the head commit (see `github-actions-gate.ts`).
384
+ * Expected workflows depend only on the head SHA, so they are cached per SHA.
385
+ */
386
+ async function evaluateGitHubActions(cwd, url, details, expectedCache) {
387
+ const pr = parseGitHubPullUrl(url);
388
+ if (pr === null) {
389
+ throw new MergeGateError("PR", [
390
+ `cannot derive the repository from "${url}" to read its GitHub Actions runs`,
391
+ ]);
392
+ }
393
+ const sha = details.headSha;
394
+ let expected = expectedCache.get(sha);
395
+ if (expected === undefined) {
396
+ expected = await listExpectedPullRequestWorkflows(cwd, pr, sha, details.baseBranch);
397
+ expectedCache.set(sha, expected);
398
+ }
399
+ const runs = await listWorkflowRunsForSha(cwd, pr, sha);
400
+ return {
401
+ result: evaluateActionsRuns(runs, expected.map((w) => w.path)),
402
+ runCount: runs.length,
403
+ expectedCount: expected.length,
404
+ };
405
+ }
406
+ function shortSha(sha) {
407
+ return (sha ?? "?").slice(0, 7);
408
+ }
368
409
  /**
369
410
  * Merge gate: poll the MR/PR until every check is green and the host reports it
370
411
  * mergeable. Refuses immediately on any failed check, on host output it cannot
371
412
  * trust, and on "no checks at all" (unless allowed); refuses after the timeout
372
- * when checks stay pending or a required check never appears.
413
+ * when checks stay pending or a required / expected check never appears.
414
+ *
415
+ * GitHub: besides `statusCheckRollup`, the head commit's GitHub Actions runs
416
+ * must all be finished and green, and every workflow that triggers on this
417
+ * PR's `pull_request` event must have a run for the head SHA — so checks left
418
+ * on the commit by other events never stand in for the PR's own CI. Before
419
+ * returning green, the head SHA is read again; if it moved, the new head is
420
+ * evaluated from scratch (the merge is also pinned with `--match-head-commit`).
373
421
  */
374
422
  export async function waitForMergeGate(cwd, host, url, options = {}) {
375
423
  const defaults = mergeGateTiming(MERGE_GATE_DEFAULTS);
@@ -382,10 +430,13 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
382
430
  const label = mergeRequestLabel(host);
383
431
  const start = Date.now();
384
432
  const deadline = start + timeoutMs;
385
- let loggedWait = false;
433
+ const expectedCache = new Map();
434
+ let lastLogged = "";
386
435
  let warnedGitLabRequired = false;
436
+ let consecutiveErrors = 0;
387
437
  for (;;) {
388
438
  let problems = [];
439
+ let readError = false;
389
440
  let details = null;
390
441
  try {
391
442
  details = await readMergeRequestDetails(cwd, host, url, { strict: true });
@@ -396,6 +447,7 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
396
447
  }
397
448
  const msg = error instanceof Error ? error.message.split("\n")[0] : String(error);
398
449
  problems = [`could not read ${label} details from the host: ${msg}`];
450
+ readError = true;
399
451
  }
400
452
  if (details === null) {
401
453
  // problems set in catch; keep polling (transient CLI / network error).
@@ -419,12 +471,39 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
419
471
  if (details.hasConflicts === true) {
420
472
  return { ok: false, problems: [`${label} has merge conflicts`] };
421
473
  }
474
+ let actions = null;
475
+ if (!gitlab) {
476
+ try {
477
+ actions = await evaluateGitHubActions(cwd, url, details, expectedCache);
478
+ }
479
+ catch (error) {
480
+ if (error instanceof MergeGateError)
481
+ return { ok: false, problems: error.problems };
482
+ const msg = error instanceof Error ? error.message.split("\n")[0] : String(error);
483
+ problems = [`could not read GitHub Actions runs for ${shortSha(details.headSha)}: ${msg}`];
484
+ readError = true;
485
+ }
486
+ if (actions !== null && actions.result.failed.length > 0) {
487
+ return { ok: false, problems: [...actions.result.failed] };
488
+ }
489
+ }
490
+ const actionsPending = actions === null
491
+ ? []
492
+ : [
493
+ ...actions.result.pending,
494
+ ...actions.result.missing.map((p) => `workflow ${p} (pull_request) has not started for ${shortSha(details.headSha)} yet`),
495
+ ];
496
+ const actionsSilent = actions === null || (actions.runCount === 0 && actions.expectedCount === 0);
422
497
  // "No checks at all" is judged here only when no required check could
423
- // report it instead (GitHub with requiredChecks → "never ran" path).
424
- const noChecksRule = ev.noChecks && (gitlab || required.length === 0);
498
+ // report it instead (GitHub with requiredChecks → "never ran" path) and
499
+ // no GitHub Actions run exists or is expected for the head commit.
500
+ const noChecksRule = !readError && ev.noChecks && actionsSilent && (gitlab || required.length === 0);
425
501
  const inGrace = waitForPending && noChecksRule && Date.now() - start < graceMs;
426
- if (ev.state === "pending") {
427
- problems = ev.problems;
502
+ if (readError) {
503
+ // problems set above; keep polling with back-off.
504
+ }
505
+ else if (ev.state === "pending" || actionsPending.length > 0) {
506
+ problems = [...ev.problems, ...actionsPending];
428
507
  }
429
508
  else if (inGrace) {
430
509
  problems = ["no status checks reported yet"];
@@ -441,17 +520,42 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
441
520
  else {
442
521
  const readiness = mergeRequestReadinessFromDetails(details);
443
522
  if (readiness !== null && isMergeRequestReadyToMerge(readiness)) {
444
- if (noChecksRule) {
445
- // Once, after the gate has decided to merge — not on every poll.
446
- log.warn(`WARNING: merging ${label} with NO checks (merge.allowNoChecks is true). Nothing verified this change.`);
523
+ // Race guard: the head may have moved while the checks were read.
524
+ let again = null;
525
+ try {
526
+ again = await readMergeRequestDetails(cwd, host, url, { strict: true });
527
+ }
528
+ catch (error) {
529
+ if (error instanceof MergeRequestShapeError) {
530
+ return { ok: false, problems: [error.message] };
531
+ }
532
+ readError = true;
533
+ problems = [`could not re-read the ${label} head before merging`];
534
+ }
535
+ if (again !== null && again.state === "merged") {
536
+ return { ok: true, alreadyMerged: true, details: again };
537
+ }
538
+ if (again !== null && again.headSha === details.headSha && again.state === "open") {
539
+ if (noChecksRule) {
540
+ // Once, after the gate has decided to merge — not on every poll.
541
+ log.warn(`WARNING: merging ${label} with NO checks (merge.allowNoChecks is true). Nothing verified this change.`);
542
+ }
543
+ return { ok: true, alreadyMerged: false, details };
544
+ }
545
+ if (again !== null) {
546
+ problems = [
547
+ `${label} head moved from ${shortSha(details.headSha)} to ${shortSha(again.headSha)} while checks were read; re-checking the new head`,
548
+ ];
447
549
  }
448
- return { ok: true, alreadyMerged: false, details };
449
550
  }
450
- problems = [
451
- `host does not report the ${label} as mergeable yet (${details.detailedMergeStatus || details.mergeStatus || "unknown"})`,
452
- ];
551
+ else {
552
+ problems = [
553
+ `host does not report the ${label} as mergeable yet (${details.detailedMergeStatus || details.mergeStatus || "unknown"})`,
554
+ ];
555
+ }
453
556
  }
454
557
  }
558
+ consecutiveErrors = readError ? consecutiveErrors + 1 : 0;
455
559
  const now = Date.now();
456
560
  if (!waitForPending || now >= deadline) {
457
561
  const suffix = waitForPending
@@ -462,11 +566,13 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
462
566
  problems: problems.map((p, i) => (i === problems.length - 1 ? `${p}${suffix}` : p)),
463
567
  };
464
568
  }
465
- if (!loggedWait) {
466
- log.info(dim(`Waiting for ${label} checks: ${problems.join("; ")}…`));
467
- loggedWait = true;
569
+ const summary = problems.join("; ");
570
+ if (summary !== lastLogged) {
571
+ log.info(dim(`Waiting for ${label} checks: ${summary}…`));
572
+ lastLogged = summary;
468
573
  }
469
- await sleep(Math.min(intervalMs, deadline - now));
574
+ const delay = readError ? errorBackoffMs(intervalMs, consecutiveErrors) : intervalMs;
575
+ await sleep(Math.min(delay, deadline - now));
470
576
  }
471
577
  }
472
578
  function isMergeHttp405(error) {
@@ -63,7 +63,7 @@ export async function llmScaffoldTask(params) {
63
63
  });
64
64
  const parsed = JSON.parse(extractJsonObject(raw));
65
65
  const title = typeof parsed.title === "string"
66
- ? parsed.title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim()
66
+ ? parsed.title.replace(/^TASK-\d+\s*[:-]\s*/i, "").trim()
67
67
  : "";
68
68
  const slug = typeof parsed.slug === "string" ? slugify(parsed.slug, "task") : slugify(title, "task");
69
69
  const markdown = typeof parsed.markdown === "string" ? parsed.markdown.trim() : "";
@@ -1,7 +1,7 @@
1
1
  import { dim, log } from "./logger.js";
2
2
  import { gitAddPaths, gitPush, listUnstagedRelPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "./git.js";
3
3
  export function featCommitMessageFor(taskId, title) {
4
- const slug = title.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim() || taskId;
4
+ const slug = title.replace(/^TASK-\d+\s*[:-]\s*/i, "").trim() || taskId;
5
5
  return `feat(${taskId.toLowerCase()}): ${slug}`;
6
6
  }
7
7
  export function docsCommitMessageFor(taskId, suffix) {
@@ -9,7 +9,7 @@ Body: markdown with Summary, Changes, Test plan sections. Facts only from the in
9
9
  /** Normalize PR titles to Conventional Commits with the TASK id as scope. */
10
10
  export function normalizeTaskPrTitle(taskId, rawTitle) {
11
11
  const trimmed = rawTitle.trim().replace(/\s+/g, " ");
12
- const withoutTaskPrefix = trimmed.replace(/^TASK-\d+\s*[:\-]\s*/i, "").trim();
12
+ const withoutTaskPrefix = trimmed.replace(/^TASK-\d+\s*[:-]\s*/i, "").trim();
13
13
  return taskScopedCommitMessage(taskId, withoutTaskPrefix || trimmed).slice(0, 72);
14
14
  }
15
15
  export async function generateTaskPrWithLlm(input, cwd, preference) {
@@ -21,7 +21,6 @@ export async function startTaskBranch(opts) {
21
21
  const taskFile = opts.taskFile;
22
22
  const integrationBranch = opts.integrationBranch;
23
23
  const remote = opts.remote ?? "origin";
24
- const meta = await readTaskFile(taskFile);
25
24
  const git = await readGitInfo(cwd);
26
25
  if (!git.isRepo) {
27
26
  if (opts.dryRun) {
@@ -39,8 +38,9 @@ export async function startTaskBranch(opts) {
39
38
  }
40
39
  log.warn("Only governance paths are dirty — proceeding.");
41
40
  }
41
+ // The branch name comes from the file name: a dry run plans for a TASK file
42
+ // that `task add --dry-run` never wrote, so nothing above reads the file.
42
43
  const taskBranch = branchNameForTaskFile(taskFile);
43
- const existingCtx = await readGitContext(taskFile);
44
44
  const branchExists = await gitBranchExists(cwd, taskBranch);
45
45
  if (opts.dryRun) {
46
46
  const base = (await resolveIntegrationBaseRef(cwd, remote, integrationBranch)) ??
@@ -52,6 +52,8 @@ export async function startTaskBranch(opts) {
52
52
  : `dry-run — would git fetch ${remote} ${integrationBranch}, then git switch -c ${taskBranch} --no-track ${base}`));
53
53
  return true;
54
54
  }
55
+ const meta = await readTaskFile(taskFile);
56
+ const existingCtx = await readGitContext(taskFile);
55
57
  // Worktree-safe (D-006): never check out the integration branch — another
56
58
  // worktree may own it. New task branches start from <remote>/<integration>.
57
59
  let baseRef = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "3.1.1",
3
+ "version": "3.1.4",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",
@@ -8,11 +8,11 @@
8
8
  "license": "MIT",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/goodtekxyz/vibeops.git"
11
+ "url": "git+https://github.com/goodtek-xyz/vibeops.git"
12
12
  },
13
- "homepage": "https://github.com/goodtekxyz/vibeops#readme",
13
+ "homepage": "https://github.com/goodtek-xyz/vibeops#readme",
14
14
  "bugs": {
15
- "url": "https://github.com/goodtekxyz/vibeops/issues"
15
+ "url": "https://github.com/goodtek-xyz/vibeops/issues"
16
16
  },
17
17
  "publishConfig": {
18
18
  "access": "public"
@@ -34,9 +34,10 @@
34
34
  "dev": "tsx src/cli.ts",
35
35
  "build": "tsc -p tsconfig.json",
36
36
  "typecheck": "tsc -p tsconfig.json --noEmit",
37
+ "lint": "eslint .",
37
38
  "start": "node dist/cli.js",
38
- "test": "pnpm build && node --test \"tests/*.test.mjs\"",
39
- "smoke": "pnpm typecheck && pnpm build && node --test \"tests/*.test.mjs\" && node scripts/smoke.mjs",
39
+ "test": "pnpm build && node --test tests/*.test.mjs",
40
+ "smoke": "pnpm typecheck && pnpm build && node --test tests/*.test.mjs && node scripts/smoke.mjs",
40
41
  "clean": "rm -rf dist",
41
42
  "prepack": "pnpm clean && pnpm build",
42
43
  "publish:dry": "pnpm publish --dry-run --no-git-checks",
@@ -55,11 +56,15 @@
55
56
  "dependencies": {
56
57
  "@inquirer/prompts": "^8.4.3",
57
58
  "commander": "^12.1.0",
58
- "gray-matter": "^4.0.3"
59
+ "gray-matter": "^4.0.3",
60
+ "yaml": "^2.9.1"
59
61
  },
60
62
  "devDependencies": {
63
+ "@eslint/js": "10.0.1",
61
64
  "@types/node": "^20.14.0",
65
+ "eslint": "10.11.0",
62
66
  "tsx": "^4.19.0",
63
- "typescript": "^5.5.4"
67
+ "typescript": "^5.5.4",
68
+ "typescript-eslint": "8.71.0"
64
69
  }
65
70
  }