@brainervirus/workit-core 1.2.1 → 1.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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/skills/workit-babysit/SKILL.md +20 -26
  3. package/skills/workit-behavioral-tdd/SKILL.md +0 -5
  4. package/skills/workit-blast-radius/SKILL.md +0 -5
  5. package/skills/workit-challenge/SKILL.md +30 -47
  6. package/skills/workit-debug/SKILL.md +5 -27
  7. package/skills/workit-deslop/SKILL.md +0 -5
  8. package/skills/workit-diagram/SKILL.md +0 -5
  9. package/skills/workit-green-run/SKILL.md +0 -5
  10. package/skills/workit-handoff/SKILL.md +2 -8
  11. package/skills/workit-implement/SKILL.md +14 -20
  12. package/skills/workit-mockup/SKILL.md +0 -5
  13. package/skills/workit-plan/SKILL.md +30 -63
  14. package/skills/workit-review/SKILL.md +0 -5
  15. package/skills/workit-steer/SKILL.md +27 -27
  16. package/src/core/authority.ts +6 -1
  17. package/src/core/auto-approval.ts +34 -14
  18. package/src/core/branch.ts +293 -42
  19. package/src/core/config-conversion.ts +4 -4
  20. package/src/core/config-guard.ts +1 -7
  21. package/src/core/config.ts +1 -1
  22. package/src/core/cutover.ts +1207 -140
  23. package/src/core/doctor.ts +155 -82
  24. package/src/core/external-action-effects.ts +886 -172
  25. package/src/core/external-action.ts +214 -63
  26. package/src/core/init.ts +25 -115
  27. package/src/core/legacy-ownership.ts +47 -0
  28. package/src/core/methods.ts +56 -52
  29. package/src/core/ports/pr-create.ts +23 -15
  30. package/src/core/pr-create.ts +374 -84
  31. package/src/core/registration.ts +0 -15
  32. package/src/core/repo-tools.ts +0 -1
  33. package/src/core/route-intent.ts +34 -82
  34. package/src/core/safe-write.ts +66 -1
  35. package/src/core/setup-state.ts +7 -0
  36. package/src/core/setup.ts +175 -82
  37. package/src/core/task-contract.ts +7 -1
  38. package/src/core/task-engine.ts +47 -2
  39. package/src/core/task-store.ts +159 -1
  40. package/src/core/tracker-issues.ts +55 -118
  41. package/src/core/vcs-config.ts +178 -187
  42. package/src/core/workspaces.ts +548 -46
  43. package/src/core.ts +1 -2
  44. package/src/core/ports/vcs-token-create-urls.ts +0 -4
@@ -1,11 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
- import { vcsConfig } from "./vcs-config";
4
+ import { pathToFileURL } from "node:url";
5
+ import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
6
+ export { hostingApiHostMatches } from "./vcs-config";
5
7
  import { resolveBranchPolicyFor } from "./branch";
6
8
 
7
9
  // Port of scripts/pr-create.sh — build MR/PR body issue linking + create via glab/gh.
8
-
10
+ // Hosted creation is enabled (decision ae03c569): every create path pre-binds
11
+ // the approved source SHA through WORKIT_EXPECTED_* and the shared executor
12
+ // post-verifies the provider PR head before reporting success. The residual
13
+ // non-atomic source-SHA race is accepted.
9
14
  function parseGhRepo(remote: string): string | null {
10
15
  remote = (remote || "").trim().replace(/\/+$/, "");
11
16
  if (!remote) return null;
@@ -15,6 +20,49 @@ function parseGhRepo(remote: string): string | null {
15
20
  return parts.length >= 2 ? parts.slice(-2).join("/") : null;
16
21
  }
17
22
 
23
+ const remoteRepoPath = (
24
+ remote: string,
25
+ ): { host: string; path: string; protocol: string } | null => {
26
+ const raw = remote.trim();
27
+ if (!raw) return null;
28
+ if (!raw.includes("://")) {
29
+ const scp = /^(?:[^@\s]+@)?([^:]+):(.+)$/u.exec(raw);
30
+ if (scp)
31
+ return {
32
+ host: scp[1].toLowerCase(),
33
+ path: scp[2].replace(/^\//u, "").replace(/\.git$/u, ""),
34
+ protocol: "ssh:",
35
+ };
36
+ }
37
+ try {
38
+ const url = new URL(raw);
39
+ return {
40
+ host: url.hostname.toLowerCase(),
41
+ path: url.pathname
42
+ .replace(/^\//u, "")
43
+ .replace(/\.git$/u, "")
44
+ .replace(/\/$/u, ""),
45
+ protocol: url.protocol,
46
+ };
47
+ } catch {
48
+ return null;
49
+ }
50
+ };
51
+
52
+ const cliRepository = (
53
+ remote: string,
54
+ provider: "github" | "gitlab",
55
+ cfg: Record<string, any>,
56
+ ): string | null => {
57
+ const parsed = remoteRepoPath(remote);
58
+ if (!parsed?.path) return null;
59
+ const host = String(
60
+ provider === "github" ? (cfg.github?.host ?? "github.com") : (cfg.gitlab?.host ?? "gitlab.com"),
61
+ ).toLowerCase();
62
+ if (provider === "github") return `${host}/${parsed.path}`;
63
+ return host === "gitlab.com" ? parsed.path : `https://${host}/${parsed.path}`;
64
+ };
65
+
18
66
  function parseGhIssue(value: string): string {
19
67
  const m = /issues\/(\d+)/.exec(value);
20
68
  return m ? m[1] : String(value).trim().replace(/^#/, "");
@@ -109,6 +157,79 @@ function repoRoot(cwd: string): string {
109
157
  return result.status === 0 ? (result.stdout ?? "").trim() : cwd;
110
158
  }
111
159
 
160
+ /** Git pushes to every configured push URL; authorization binds only one target. */
161
+ export const pushRemote = (cwd: string): string | null => {
162
+ const result = spawnSync("git", ["remote", "get-url", "--push", "--all", "origin"], {
163
+ cwd,
164
+ encoding: "utf8",
165
+ });
166
+ if (result.status !== 0) return null;
167
+ const urls = (result.stdout ?? "")
168
+ .split(/\r?\n/u)
169
+ .map((url) => url.trim())
170
+ .filter(Boolean);
171
+ return urls.length === 1 ? urls[0] : null;
172
+ };
173
+
174
+ export const safePushUrl = (remote: string): string | null => {
175
+ const raw = remote.trim();
176
+ if (path.isAbsolute(raw)) return pathToFileURL(raw).href;
177
+ if (!raw.includes("://")) {
178
+ const scp = /^(?:([^@\s]+)@)?([^:]+):(.+)$/u.exec(raw);
179
+ if (scp) return `${scp[1] || "git"}@${scp[2]}:${scp[3]}`;
180
+ }
181
+ try {
182
+ const url = new URL(raw);
183
+ if (!["file:", "git:", "http:", "https:", "ssh:"].includes(url.protocol)) return null;
184
+ url.password = "";
185
+ if (url.protocol !== "ssh:") url.username = "";
186
+ url.search = "";
187
+ url.hash = "";
188
+ return url.toString();
189
+ } catch {
190
+ return null;
191
+ }
192
+ };
193
+
194
+ export const pushRemoteIdentity = (remote: string): string | null => {
195
+ const raw = remote.trim();
196
+ if (!raw) return null;
197
+ if (path.isAbsolute(raw)) return pathToFileURL(raw).href;
198
+ if (!raw.includes("://")) {
199
+ const scp = /^(?:([^@\s]+)@)?([^:]+):(.+)$/u.exec(raw);
200
+ if (scp)
201
+ return `ssh://${scp[1] || "git"}@${scp[2].toLowerCase()}/${scp[3].replace(/^\//u, "")}`;
202
+ }
203
+ try {
204
+ const url = new URL(raw);
205
+ url.password = "";
206
+ if (url.protocol !== "ssh:") url.username = "";
207
+ url.hostname = url.hostname.toLowerCase();
208
+ url.search = "";
209
+ url.hash = "";
210
+ return url.toString().replace(/\/$/u, "");
211
+ } catch {
212
+ return null;
213
+ }
214
+ };
215
+
216
+ /** Git's URL rewrite rules also apply to explicit push URLs. */
217
+ export const pushTargetIsStable = (cwd: string, remote: string): boolean => {
218
+ const target = safePushUrl(remote);
219
+ if (!target) return false;
220
+ const result = spawnSync(
221
+ "git",
222
+ ["config", "--get-regexp", "^url\\..*\\.(insteadof|pushinsteadof)$"],
223
+ { cwd, encoding: "utf8" },
224
+ );
225
+ if (result.status !== 0 && result.status !== 1) return false;
226
+ const prefixes = (result.stdout ?? "")
227
+ .split(/\r?\n/u)
228
+ .map((line) => line.trim().slice(line.trim().indexOf(" ") + 1))
229
+ .filter(Boolean);
230
+ return !prefixes.some((prefix) => target.startsWith(prefix));
231
+ };
232
+
112
233
  /** Port of pr-create.sh --build-body — pure body builder, no network. */
113
234
  export function prBuildBody(env: NodeJS.ProcessEnv, cwd?: string): string {
114
235
  const ghLinkOnPr = truthy(env.GH_LINK_ON_PR);
@@ -138,51 +259,103 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
138
259
  const policy = resolveBranchPolicyFor(root);
139
260
  const cfg = vcsConfig("load", root);
140
261
  if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
141
- // Merge mode finishes the feature locally (git merge + push) — no glab/gh,
142
- // no API token, so token readiness is only required for the PR path.
143
- if (policy.integration !== "merge" && !cfg.tokenReady)
144
- return { error: "VCS token not ready — run /wk-init and edit token file locally" };
145
262
 
146
263
  const provider = cfg.provider as string;
147
264
  if (provider !== "gitlab" && provider !== "github")
148
265
  return { error: `unsupported provider: ${provider}` };
266
+ const cli = provider === "gitlab" ? "glab" : "gh";
267
+ const installUrl =
268
+ provider === "gitlab" ? "https://gitlab.com/gitlab-org/cli" : "https://cli.github.com";
269
+ if (whichOnPath(cli) === null)
270
+ return {
271
+ ok: false,
272
+ cli_missing: true,
273
+ error: `workflow CLI missing: ${cli} (required for ${provider}). Install: ${installUrl}`,
274
+ install_url: installUrl,
275
+ };
276
+ const identity = vcsCliIdentity(root);
277
+ if (!identity.ok) return { error: identity.error };
278
+ if (env.WORKIT_EXPECTED_API_HOST && env.WORKIT_EXPECTED_API_HOST !== identity.host)
279
+ return { error: "approved hosting API host changed before execution" };
280
+ const pushUrl = pushRemote(root);
281
+ const targetUrl = pushUrl ? safePushUrl(pushUrl) : null;
282
+ const targetIdentity = pushUrl ? pushRemoteIdentity(pushUrl) : null;
283
+ const targetRepo = pushUrl ? cliRepository(pushUrl, provider, cfg) : null;
284
+ if (!pushUrl || !targetUrl || !targetIdentity || !pushTargetIsStable(root, pushUrl))
285
+ return {
286
+ error: "origin must have exactly one supported push URL for a reserved hosting action",
287
+ };
288
+ const localOnlyTarget = path.isAbsolute(pushUrl) || pushUrl.startsWith("file://");
289
+ if (
290
+ (!localOnlyTarget || env.WORKIT_EXPECTED_API_HOST) &&
291
+ !hostingApiHostMatches(pushUrl, identity.host)
292
+ )
293
+ return { error: "hosting API host does not match the push destination" };
294
+ if (env.WORKIT_EXPECTED_REMOTE && env.WORKIT_EXPECTED_REMOTE !== targetIdentity)
295
+ return { error: "approved push destination changed before execution" };
296
+ if (
297
+ env.WORKIT_EXPECTED_ACCOUNT &&
298
+ identity.username?.toLowerCase() !== env.WORKIT_EXPECTED_ACCOUNT.toLowerCase()
299
+ )
300
+ return { error: "approved hosting account changed before execution" };
149
301
 
150
- // B1/RL-03: WF_PR_TARGET is the one deliberate override knob on the create
151
- // surface (resolvePrBranchContext/docsBranch have none). Unlike the config
152
- // default — which is authoritative by construction — a caller-supplied
153
- // target is validated against the resolved branch policy so a PR can never
154
- // be aimed at a protected or disallowed branch.
302
+ // The caller selects the target; GitHub/GitLab enforce their own protections.
155
303
  const targetOverride = env.WF_PR_TARGET;
156
304
  const resolvedDefault = String(
157
305
  cfg.defaultTargetBranch ?? policy.defaultTargetBranch ?? "develop",
158
306
  );
159
307
  const target = targetOverride || resolvedDefault;
160
- // CA-06: an explicit override equal to the resolved default (e.g. WF_PR_TARGET
161
- // "main" under github-flow) is authoritative — the same value flows
162
- // unvalidated from config, so it must not be rejected as a protected
163
- // override. Genuine differing overrides keep the strict validation.
164
- if (targetOverride && targetOverride !== resolvedDefault) {
165
- const { allowed, protected: protectedTargets } = policy;
166
- if (protectedTargets.has(targetOverride.toLowerCase()))
167
- return {
168
- error: `PR target ${JSON.stringify(targetOverride)} is a protected branch — override must be an allowed non-protected target`,
169
- };
170
- if (!allowed.some((r) => r.test(targetOverride)))
171
- return {
172
- error: `PR target ${JSON.stringify(targetOverride)} is not allowed by the branch policy`,
173
- };
174
- }
175
-
176
308
  const title = String(env.WF_PR_TITLE ?? "");
177
309
  const br = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
178
310
  cwd: root,
179
311
  encoding: "utf8",
180
312
  });
181
313
  const branch = br.status === 0 ? (br.stdout ?? "").trim() : "";
314
+ const sourceResult = branch
315
+ ? spawnSync("git", ["rev-parse", "--verify", `refs/heads/${branch}`], {
316
+ cwd: root,
317
+ encoding: "utf8",
318
+ })
319
+ : null;
320
+ const sourceCommit = sourceResult?.status === 0 ? (sourceResult.stdout ?? "").trim() : "";
321
+ if (
322
+ env.WORKIT_EXPECTED_REMOTE &&
323
+ (!env.WORKIT_EXPECTED_SOURCE_BRANCH || !env.WORKIT_EXPECTED_SOURCE_COMMIT)
324
+ )
325
+ return { error: "approved PR source branch and commit are incomplete" };
326
+ if (
327
+ (env.WORKIT_EXPECTED_SOURCE_BRANCH && env.WORKIT_EXPECTED_SOURCE_BRANCH !== branch) ||
328
+ (env.WORKIT_EXPECTED_SOURCE_COMMIT && env.WORKIT_EXPECTED_SOURCE_COMMIT !== sourceCommit)
329
+ )
330
+ return { error: "approved PR source branch or commit changed before execution" };
331
+ const mergeBaseResult =
332
+ policy.integration === "merge"
333
+ ? spawnSync("git", ["rev-parse", "--verify", `refs/heads/${target}`], {
334
+ cwd: root,
335
+ encoding: "utf8",
336
+ })
337
+ : null;
338
+ const mergeBaseCommit =
339
+ mergeBaseResult?.status === 0 ? (mergeBaseResult.stdout ?? "").trim() : "";
340
+ if (
341
+ policy.integration === "merge" &&
342
+ ((env.WORKIT_EXPECTED_REMOTE && !env.WORKIT_EXPECTED_MERGE_BASE_COMMIT) ||
343
+ !mergeBaseCommit ||
344
+ (env.WORKIT_EXPECTED_MERGE_BASE_COMMIT &&
345
+ env.WORKIT_EXPECTED_MERGE_BASE_COMMIT !== mergeBaseCommit))
346
+ )
347
+ return { error: "approved merge target branch changed before execution" };
182
348
 
183
349
  if (policy.integration === "merge") {
184
350
  const finish = (): Record<string, any> => {
185
- const merge = spawnSync("git", ["merge", "--no-ff", branch, "-m", title], {
351
+ if (!sourceCommit || !mergeBaseCommit)
352
+ return {
353
+ error: "merge failed",
354
+ mode: "merge",
355
+ targetBranch: target,
356
+ stderr: "source or target branch commit could not be resolved",
357
+ };
358
+ const merge = spawnSync("git", ["merge", "--no-ff", sourceCommit, "-m", title], {
186
359
  cwd: root,
187
360
  encoding: "utf8",
188
361
  });
@@ -193,7 +366,24 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
193
366
  targetBranch: target,
194
367
  stderr: (merge.stderr ?? "").slice(0, 800),
195
368
  };
196
- const push = spawnSync("git", ["push", "origin", target], { cwd: root, encoding: "utf8" });
369
+ const mergedCommit = spawnSync("git", ["rev-parse", "HEAD"], {
370
+ cwd: root,
371
+ encoding: "utf8",
372
+ });
373
+ if (mergedCommit.status !== 0)
374
+ return { error: "merge commit could not be resolved", mode: "merge" };
375
+ const updateTarget = spawnSync(
376
+ "git",
377
+ ["update-ref", `refs/heads/${target}`, mergedCommit.stdout.trim(), mergeBaseCommit],
378
+ { cwd: root, encoding: "utf8" },
379
+ );
380
+ if (updateTarget.status !== 0)
381
+ return { error: "merge target branch changed before update", mode: "merge" };
382
+ const push = spawnSync(
383
+ "git",
384
+ ["push", targetUrl, `${mergedCommit.stdout.trim()}:refs/heads/${target}`],
385
+ { cwd: root, encoding: "utf8", env: { ...process.env, PATH: process.env.PATH ?? "" } },
386
+ );
197
387
  if (push.status !== 0)
198
388
  return {
199
389
  error: "push failed",
@@ -210,7 +400,10 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
210
400
  output: (push.stdout ?? "").trim(),
211
401
  };
212
402
  };
213
- const co = spawnSync("git", ["checkout", target], { cwd: root, encoding: "utf8" });
403
+ const co = spawnSync("git", ["checkout", "--detach", mergeBaseCommit], {
404
+ cwd: root,
405
+ encoding: "utf8",
406
+ });
214
407
  if (co.status !== 0)
215
408
  return {
216
409
  error: `cannot checkout target ${target}`,
@@ -228,20 +421,7 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
228
421
  return result;
229
422
  }
230
423
 
231
- const cli = provider === "gitlab" ? "glab" : "gh";
232
- const installUrl =
233
- provider === "gitlab" ? "https://gitlab.com/gitlab-org/cli" : "https://cli.github.com";
234
- if (whichOnPath(cli) === null) {
235
- return {
236
- ok: false,
237
- cli_missing: true,
238
- error: `workflow CLI missing: ${cli} (required for ${provider}). Install: ${installUrl}`,
239
- install_url: installUrl,
240
- };
241
- }
242
-
243
424
  const pr = (cfg.pr ?? {}) as Record<string, any>;
244
- const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
245
425
  const body = env.WF_PR_BODY ?? "";
246
426
  const draft = String(env.WF_PR_DRAFT ?? "false").toLowerCase() === "true";
247
427
 
@@ -260,8 +440,7 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
260
440
  const ghLinkOnPr = cfg.issues_provider === "github" && cfg.link_on_pr === true;
261
441
  let ghRepo: string | null = null;
262
442
  if (ghLinkOnPr) {
263
- const rr = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" });
264
- if (rr.status === 0) ghRepo = parseGhRepo(rr.stdout ?? "");
443
+ ghRepo = remoteRepoPath(pushUrl)?.path ?? null;
265
444
  }
266
445
  const finalBody = buildBody(
267
446
  body,
@@ -279,51 +458,74 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
279
458
  const removeBranch = pr.removeSourceBranch !== false;
280
459
  const push = pr.pushBranch !== false;
281
460
  const skipConfirm = pr.confirmSkip !== false;
461
+ const remoteBranchTip = (): string | null => {
462
+ const read = spawnSync("git", ["ls-remote", targetUrl, `refs/heads/${branch}`], {
463
+ cwd: root,
464
+ encoding: "utf8",
465
+ env: { ...process.env, PATH: process.env.PATH ?? "" },
466
+ });
467
+ return read.status === 0 ? (read.stdout ?? "").trim().split(/\s+/u)[0] || null : null;
468
+ };
469
+ if (!branch || !sourceCommit)
470
+ return {
471
+ error: "push failed",
472
+ provider,
473
+ mode: "push",
474
+ targetBranch: target,
475
+ stderr: branch
476
+ ? "source branch commit could not be resolved"
477
+ : "empty current branch (detached HEAD or unborn HEAD)",
478
+ };
479
+ if (push) {
480
+ const pushed = spawnSync(
481
+ "git",
482
+ ["push", "-u", targetUrl, `${sourceCommit}:refs/heads/${branch}`],
483
+ { cwd: root, encoding: "utf8", env: { ...process.env, PATH: process.env.PATH ?? "" } },
484
+ );
485
+ if (pushed.status !== 0)
486
+ return { error: "push failed", provider, stderr: (pushed.stderr ?? "").slice(0, 800) };
487
+ }
488
+ if (remoteBranchTip() !== sourceCommit)
489
+ return { error: "push destination branch does not match the approved source commit" };
282
490
 
283
491
  let cmd: string[];
284
492
  let cmdEnv: NodeJS.ProcessEnv;
285
493
  if (provider === "gitlab") {
286
494
  // glab non-interactive mode requires BOTH title and description flags (issue #652).
287
- cmd = ["glab", "mr", "create", "-t", title, "-d", finalBody || "", "-b", target];
495
+ cmd = [
496
+ "glab",
497
+ "mr",
498
+ "create",
499
+ ...(targetRepo ? ["-R", targetRepo] : []),
500
+ "-t",
501
+ title,
502
+ "-d",
503
+ finalBody || "",
504
+ "-b",
505
+ target,
506
+ ];
288
507
  cmd.push(squash ? "--squash-before-merge" : "--squash-before-merge=false");
289
508
  cmd.push(removeBranch ? "--remove-source-branch" : "--remove-source-branch=false");
290
509
  if (draft) cmd.push("--draft");
291
- if (push) cmd.push("--push");
292
510
  if (skipConfirm) cmd.push("--yes");
293
511
  // bun's Windows spawn only consults PATH when the env object carries an
294
512
  // explicit PATH key — a spread-only PATH is invisible to the lookup and
295
513
  // uv_spawn fails with ENOENT even though the CLI is on PATH.
296
- cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_TOKEN: token };
514
+ cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_HOST: identity.host };
297
515
  } else {
298
- // T2: GitHub has no --push flag — push the branch first so `gh pr create`
299
- // never runs against an unpushed branch when pushBranch is enabled.
300
- if (push) {
301
- if (!branch) {
302
- return {
303
- error: "push failed",
304
- provider,
305
- mode: "push",
306
- targetBranch: target,
307
- stderr: "empty current branch (detached HEAD or unborn HEAD)",
308
- };
309
- }
310
- const pushRes = spawnSync("git", ["push", "-u", "origin", branch], {
311
- cwd: root,
312
- encoding: "utf8",
313
- });
314
- if (pushRes.status !== 0) {
315
- return {
316
- error: "push failed",
317
- provider,
318
- targetBranch: target,
319
- stderr: (pushRes.stderr ?? "").slice(0, 800),
320
- };
321
- }
322
- }
323
- cmd = ["gh", "pr", "create", "--title", title, "--base", target];
516
+ cmd = [
517
+ "gh",
518
+ "pr",
519
+ "create",
520
+ ...(targetRepo ? ["--repo", targetRepo] : []),
521
+ "--title",
522
+ title,
523
+ "--base",
524
+ target,
525
+ ];
324
526
  if (finalBody) cmd.push("--body", finalBody);
325
527
  if (draft) cmd.push("--draft");
326
- cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_TOKEN: token };
528
+ cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_HOST: identity.host };
327
529
  }
328
530
 
329
531
  const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });
@@ -373,40 +575,128 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
373
575
  };
374
576
  }
375
577
 
376
- /** Merge the open PR/MR for a source branch (squash + delete per pr settings). */
578
+ /** Merge the exact approved open PR/MR (squash + delete per pr settings). */
377
579
  export function mergePr(
378
580
  cwd: string,
379
- opts: { target: string; source: string },
581
+ opts: {
582
+ target: string;
583
+ source: string;
584
+ sourceCommit: string;
585
+ remote: string;
586
+ account: string;
587
+ apiHost: string;
588
+ },
380
589
  ): Record<string, any> {
381
590
  const root = repoRoot(cwd);
382
591
  const cfg = vcsConfig("load", root);
383
592
  if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
384
- if (!cfg.tokenReady)
385
- return { error: "VCS token not ready — run /wk-init and edit token file locally" };
386
593
  const provider = cfg.provider as string;
387
594
  if (provider !== "gitlab" && provider !== "github")
388
595
  return { error: `unsupported provider: ${provider}` };
596
+ const identity = vcsCliIdentity(root);
597
+ if (!identity.ok) return { error: identity.error };
598
+ if (opts.apiHost !== identity.host)
599
+ return { error: "approved hosting API host changed before execution" };
600
+ const remote = pushRemote(root);
601
+ const remoteIdentity = remote ? pushRemoteIdentity(remote) : null;
602
+ if (
603
+ !remote ||
604
+ !remoteIdentity ||
605
+ !pushTargetIsStable(root, remote) ||
606
+ opts.remote !== remoteIdentity
607
+ )
608
+ return { error: "approved merge destination changed before execution" };
609
+ if (!hostingApiHostMatches(remote, identity.host))
610
+ return { error: "hosting API host does not match the merge destination" };
611
+ if (identity.username?.toLowerCase() !== opts.account.toLowerCase())
612
+ return { error: "approved hosting account changed before execution" };
613
+ if (!/^[a-f0-9]{40,64}$/i.test(opts.sourceCommit))
614
+ return { error: "approved merge source commit is invalid" };
615
+ const currentCommit = spawnSync("git", ["rev-parse", "--verify", `refs/heads/${opts.source}`], {
616
+ cwd: root,
617
+ encoding: "utf8",
618
+ });
619
+ if (currentCommit.status !== 0 || currentCommit.stdout.trim() !== opts.sourceCommit)
620
+ return { error: "approved merge source commit changed before execution" };
621
+ const repository = cliRepository(remote, provider, cfg);
622
+ const repoPath = remoteRepoPath(remote)?.path;
623
+ if (!repository || !repoPath?.includes("/"))
624
+ return { error: "hosting repository could not be resolved" };
389
625
  if (!hostingCliAvailable(provider))
390
626
  return { error: `workflow CLI missing (required for ${provider})` };
627
+ const owner = repoPath.split("/")[0];
628
+ const endpoint =
629
+ provider === "github"
630
+ ? `repos/${repository.replace(/^[^/]+\//u, "")}/pulls?state=open&head=${encodeURIComponent(`${owner}:${opts.source}`)}&base=${encodeURIComponent(opts.target)}&per_page=100`
631
+ : `projects/${encodeURIComponent(repoPath)}/merge_requests?state=opened&source_branch=${encodeURIComponent(opts.source)}&target_branch=${encodeURIComponent(opts.target)}&per_page=100`;
632
+ const listOpen = () =>
633
+ spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
634
+ cwd: root,
635
+ encoding: "utf8",
636
+ env: {
637
+ ...process.env,
638
+ PATH: process.env.PATH ?? "",
639
+ ...(provider === "github" ? { GH_HOST: identity.host } : { GITLAB_HOST: identity.host }),
640
+ },
641
+ });
642
+ const listedId = (stdout: string): string | null => {
643
+ try {
644
+ const records = JSON.parse(stdout) as Array<Record<string, any>>;
645
+ if (!Array.isArray(records) || records.length >= 100) return null;
646
+ const matches = records.filter((record) =>
647
+ provider === "github"
648
+ ? record.state === "open" &&
649
+ record.head?.ref === opts.source &&
650
+ record.head?.sha === opts.sourceCommit &&
651
+ record.base?.ref === opts.target
652
+ : record.state === "opened" &&
653
+ record.source_branch === opts.source &&
654
+ record.sha === opts.sourceCommit &&
655
+ record.target_branch === opts.target,
656
+ );
657
+ if (matches.length !== 1) return null;
658
+ const id = provider === "github" ? matches[0].number : matches[0].iid;
659
+ return typeof id === "number" ? String(id) : null;
660
+ } catch {
661
+ return null;
662
+ }
663
+ };
664
+ const list = listOpen();
665
+ if (list.status !== 0) return { error: "approved merge request could not be resolved" };
666
+ const mergeId = listedId(list.stdout ?? "");
667
+ if (!mergeId) return { error: "exact approved merge request is not open" };
668
+ // The PR/MR target can change after the first lookup. Re-resolve the
669
+ // target-filtered request immediately before calling the provider merge CLI.
670
+ const current = listOpen();
671
+ if (current.status !== 0 || listedId(current.stdout ?? "") !== mergeId)
672
+ return { error: "approved merge target or source changed before merge" };
391
673
  const pr = (cfg.pr ?? {}) as Record<string, any>;
392
- const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
393
674
  const squash = pr.squashOnMerge !== false;
394
675
  const removeBranch = pr.removeSourceBranch !== false;
395
676
  let cmd: string[];
396
677
  let cmdEnv: NodeJS.ProcessEnv;
397
678
  if (provider === "gitlab") {
398
- cmd = ["glab", "mr", "merge", opts.source];
679
+ cmd = ["glab", "mr", "merge", mergeId, "--repo", repository, "--sha", opts.sourceCommit];
399
680
  cmd.push(squash ? "--squash" : "--squash=false");
400
681
  if (removeBranch) cmd.push("--remove-source-branch");
401
682
  cmd.push("--yes");
402
- cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_TOKEN: token };
683
+ cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_HOST: identity.host };
403
684
  } else {
404
- cmd = ["gh", "pr", "merge", opts.source];
685
+ cmd = [
686
+ "gh",
687
+ "pr",
688
+ "merge",
689
+ mergeId,
690
+ "--repo",
691
+ repository,
692
+ "--match-head-commit",
693
+ opts.sourceCommit,
694
+ ];
405
695
  cmd.push(squash ? "--squash" : "--merge");
406
696
  if (removeBranch) cmd.push("--delete-branch");
407
697
  // NOTE: gh pr merge has no --yes flag; fully-specified flags merge
408
698
  // non-interactively on their own.
409
- cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_TOKEN: token };
699
+ cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_HOST: identity.host };
410
700
  }
411
701
  const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });
412
702
  if (result.status !== 0)
@@ -70,21 +70,6 @@ export function mergeOpenCodeConfig(
70
70
  changed.push("plugin");
71
71
  }
72
72
 
73
- // Drop share skills.paths — native ~/.config/opencode/skills links avoid
74
- // triple-load duplicates. Matched by exact path segment, never substring: an
75
- // unrelated dir like `~/projects/my-workflow-toolkit-skills` is preserved (D3).
76
- const skills = base.skills;
77
- if (isRecord(skills) && Array.isArray(skills.paths)) {
78
- const next = skills.paths.filter((p) => {
79
- const segment = String(p).split(/[\\/]/);
80
- return !segment.some((seg) => named(seg, "workflow-toolkit"));
81
- });
82
- if (next.length !== skills.paths.length) {
83
- base.skills = { ...skills, paths: next };
84
- changed.push("skills.paths");
85
- }
86
- }
87
-
88
73
  return { config: base, changed };
89
74
  }
90
75
 
@@ -9,7 +9,6 @@ export type RepoRuntime = {
9
9
  changelogContext(root: string, range: string | undefined): RunResult;
10
10
  docsContext(root: string, range: string | undefined): RunResult;
11
11
  releaseContext(root: string, range: string): RunResult;
12
- prCreate(root: string, env: Record<string, string>): RunResult;
13
12
  initApply(root: string, action: string, env: Record<string, string>): RunResult;
14
13
  initStatus(root: string): RunResult;
15
14
  toolkitStatus(root: string): RunResult | Promise<RunResult>;