@brainervirus/workit-core 1.2.1 → 1.3.1

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 (45) 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 +222 -64
  26. package/src/core/init.ts +25 -115
  27. package/src/core/legacy-ownership.ts +47 -0
  28. package/src/core/methods.ts +65 -52
  29. package/src/core/policy-resolver.ts +13 -5
  30. package/src/core/ports/pr-create.ts +23 -15
  31. package/src/core/pr-create.ts +374 -84
  32. package/src/core/registration.ts +0 -15
  33. package/src/core/repo-tools.ts +0 -1
  34. package/src/core/route-intent.ts +34 -82
  35. package/src/core/safe-write.ts +66 -1
  36. package/src/core/setup-state.ts +7 -0
  37. package/src/core/setup.ts +175 -82
  38. package/src/core/task-contract.ts +66 -6
  39. package/src/core/task-engine.ts +47 -2
  40. package/src/core/task-store.ts +159 -1
  41. package/src/core/tracker-issues.ts +55 -118
  42. package/src/core/vcs-config.ts +178 -187
  43. package/src/core/workspaces.ts +554 -46
  44. package/src/core.ts +3 -2
  45. package/src/core/ports/vcs-token-create-urls.ts +0 -4
@@ -1,10 +1,21 @@
1
1
  import {
2
+ branchPolicySnapshotFor,
2
3
  branchSetup,
3
4
  classifyBranchDirt,
5
+ commitPolicySnapshotFor,
4
6
  resolveBranchPolicyFor,
5
- resolveCommitPolicyFor,
7
+ validateBranchNamePolicy,
8
+ validateCommitMessagePolicy,
6
9
  } from "./branch";
7
- import { hostingCliAvailable, mergePr, prCreate } from "./pr-create";
10
+ import {
11
+ hostingCliAvailable,
12
+ mergePr,
13
+ prCreate,
14
+ pushRemote,
15
+ pushRemoteIdentity,
16
+ pushTargetIsStable,
17
+ safePushUrl,
18
+ } from "./pr-create";
8
19
  import {
9
20
  changelogContext,
10
21
  docsRefreshContext,
@@ -25,7 +36,9 @@ import {
25
36
  } from "./youtrack";
26
37
  import fs from "node:fs";
27
38
  import path from "node:path";
39
+ import { spawnSync } from "node:child_process";
28
40
  import {
41
+ canonicalJson,
29
42
  failure,
30
43
  success,
31
44
  sha256,
@@ -36,13 +49,16 @@ import {
36
49
  } from "./task-contract";
37
50
  import { changelogApply, changelogApplyPreview } from "./changelog";
38
51
  import type { ExternalActionRequest } from "./external-action";
39
- import { externalActionDescriptor, externalActionRequest } from "./external-action";
52
+ import {
53
+ branchSetupIntent,
54
+ externalActionDescriptor,
55
+ externalActionRequest,
56
+ } from "./external-action";
40
57
  import { normalizeChainSteps } from "./authority";
41
58
  import { resolveInside, run as coreRun } from "../core";
42
- import { vcsConfig } from "./vcs-config";
43
- import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
59
+ import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
44
60
  import { assertProductWriteAllowed, currentWriterOwnsTask } from "./workers";
45
- import { TaskStore } from "./task-store";
61
+ import { sameDirectoryIdentity, TaskStore } from "./task-store";
46
62
 
47
63
  const run = (root: string, args: string[]) => {
48
64
  return coreRun(root, "git", args);
@@ -78,10 +94,6 @@ const stagedPaths = (root: string): string[] | null => {
78
94
  return [...new Set(paths)];
79
95
  };
80
96
 
81
- const pushRemote = (root: string): string | null =>
82
- gitValue(root, ["remote", "get-url", "--push", "origin"]) ??
83
- gitValue(root, ["remote", "get-url", "origin"]);
84
-
85
97
  type RemoteParts = { host: string; path: string; protocol: string };
86
98
 
87
99
  const remoteParts = (value: string): RemoteParts | null => {
@@ -108,11 +120,33 @@ const remoteParts = (value: string): RemoteParts | null => {
108
120
  }
109
121
  };
110
122
 
111
- const credentialFreeRemote = (value: string): string | null => {
112
- const parts = remoteParts(value);
113
- return parts ? `${parts.protocol}//${parts.host}${parts.path}` : null;
123
+ const credentialFreeRemote = (value: string): string | null => pushRemoteIdentity(value);
124
+
125
+ /** The task root stores coordination; a Git effect runs in the checkout named by the action. */
126
+ const actionRoot = (taskRoot: string, request: ExternalActionRequest): Result<string> => {
127
+ const chosen = "cwd" in request.payload ? request.payload.cwd : undefined;
128
+ try {
129
+ const directory = fs.realpathSync(chosen ? path.resolve(taskRoot, chosen) : taskRoot);
130
+ if (request.operation === "context.read") return success(null, null, directory);
131
+ const gitAction =
132
+ request.operation.startsWith("git.") || request.operation.startsWith("hosting.");
133
+ if (!gitAction) return success(null, null, directory);
134
+ const checkout = gitValue(directory, ["rev-parse", "--show-toplevel"]);
135
+ if (!checkout)
136
+ return failure("capability_unavailable", "action target is not a Git repository", {
137
+ outcome: "not_started",
138
+ });
139
+ return success(null, null, fs.realpathSync(checkout));
140
+ } catch {
141
+ return failure("invalid_input", "action target directory cannot be resolved", {
142
+ outcome: "not_started",
143
+ });
144
+ }
114
145
  };
115
146
 
147
+ const bindsActionTarget = (operation: ExternalActionRequest["operation"]): boolean =>
148
+ operation === "context.read" || operation.startsWith("git.") || operation.startsWith("hosting.");
149
+
116
150
  const unknown = (operation: string) =>
117
151
  failure("external_outcome_unknown", "external action outcome is unknown", { operation });
118
152
 
@@ -210,12 +244,16 @@ const localAction = (operation: ExternalActionRequest["operation"]): boolean =>
210
244
  operation === "git.push" ||
211
245
  operation === "hosting.pull_request" ||
212
246
  operation === "hosting.merge" ||
247
+ operation === "hosting.delete_branch" ||
213
248
  operation === "changelog.apply";
214
249
 
250
+ const targetWriterActionLease = Symbol("target-writer-action-lease");
251
+
215
252
  export const assertLocalExternalActionWriter = (
216
253
  root: string,
217
254
  caller: { host: Caller["host"]; actor: string },
218
255
  paths: string[] = ["."],
256
+ expectedWorkspaceRevision?: string,
219
257
  ): Result<Owner> => {
220
258
  const store = new TaskStore(root);
221
259
  const listed = store.listTasks();
@@ -223,6 +261,14 @@ export const assertLocalExternalActionWriter = (
223
261
  if (!listed.ok) return listed as Result<never>;
224
262
  if (!workspace.ok) return workspace as Result<never>;
225
263
  if (!workspace.data) return failure("not_found", "workspace not found");
264
+ if (
265
+ expectedWorkspaceRevision !== undefined &&
266
+ workspace.data.revision !== expectedWorkspaceRevision
267
+ )
268
+ return failure("revision_conflict", "checkout writer generation changed before the effect", {
269
+ expectedWorkspaceRevision,
270
+ actualWorkspaceRevision: workspace.data.revision,
271
+ });
226
272
  const candidates = listed.data.filter((task) =>
227
273
  currentWriterOwnsTask(task, workspace.data!, caller),
228
274
  );
@@ -255,6 +301,7 @@ const branchCreationBaseline = (
255
301
  ): {
256
302
  base_branch: string;
257
303
  target_exists: boolean;
304
+ local_base: string | null;
258
305
  remote_base: string | null;
259
306
  target_head: string | null;
260
307
  } => {
@@ -264,6 +311,7 @@ const branchCreationBaseline = (
264
311
  return {
265
312
  base_branch: base,
266
313
  target_exists: true,
314
+ local_base: null,
267
315
  remote_base: null,
268
316
  target_head: gitValue(root, ["rev-parse", `refs/heads/${target}`]),
269
317
  };
@@ -273,6 +321,7 @@ const branchCreationBaseline = (
273
321
  return {
274
322
  base_branch: base,
275
323
  target_exists: false,
324
+ local_base: gitValue(root, ["rev-parse", "--verify", `refs/heads/${base}`]),
276
325
  remote_base: remoteBase,
277
326
  target_head: remoteBase,
278
327
  };
@@ -281,12 +330,9 @@ const branchCreationBaseline = (
281
330
  const shortSha = (value: unknown): string =>
282
331
  typeof value === "string" && value ? value.slice(0, 8) : "unknown";
283
332
 
284
- const isProtectedBranch = (root: string, branch: string): boolean =>
285
- resolveBranchPolicyFor(root).protected.has(branch.toLowerCase());
286
-
287
333
  /** Concise, user-facing approval text for one resolved action. The exact
288
334
  * descriptor stays machine-facing; this is what the native question shows. */
289
- export const actionProposalQuestion = (
335
+ const actionProposalAtRoot = (
290
336
  request: ExternalActionRequest,
291
337
  descriptorPayload: unknown,
292
338
  ): { presented: string; approvedText: string } => {
@@ -346,14 +392,19 @@ export const actionProposalQuestion = (
346
392
  approvedText: `Switch to \`${target}\`.`,
347
393
  };
348
394
  const base = String(resolved.base_branch ?? "the base branch");
395
+ const localBase =
396
+ typeof resolved.local_base === "string"
397
+ ? `local ${shortSha(resolved.local_base)}`
398
+ : "no local base";
399
+ const remoteBase = shortSha(resolved.remote_base);
349
400
  return stashes
350
401
  ? {
351
- presented: `Workit decision: action — Stash the dirty working tree and create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
352
- approvedText: `Stash and create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
402
+ presented: `Workit decision: action — Stash and create branch \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase})?`,
403
+ approvedText: `Stash and create \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase}).`,
353
404
  }
354
405
  : {
355
- presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
356
- approvedText: `Create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
406
+ presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase})?`,
407
+ approvedText: `Create \`${target}\` from \`${base}\` (${localBase}; remote ${remoteBase}).`,
357
408
  };
358
409
  }
359
410
  case "git.commit": {
@@ -365,18 +416,29 @@ export const actionProposalQuestion = (
365
416
  }
366
417
  case "git.push":
367
418
  return {
368
- presented: `Workit decision: action — Push branch \`${String(payload.branch ?? "")}\` to origin?`,
369
- approvedText: `Push \`${String(payload.branch ?? "")}\` to origin.`,
419
+ presented: `Workit decision: action — Push branch \`${String(payload.branch ?? "")}\` to \`${String(resolved.remote ?? "unknown remote")}\` via \`${String(resolved.apiHost ?? "Git transport")}\`${resolved.account ? ` as ${String(resolved.account)}` : ""}?`,
420
+ approvedText: `Push \`${String(payload.branch ?? "")}\` to ${String(resolved.remote ?? "unknown remote")} via ${String(resolved.apiHost ?? "Git transport")}${resolved.account ? ` as ${String(resolved.account)}` : ""}.`,
370
421
  };
371
- case "hosting.pull_request":
422
+ case "hosting.pull_request": {
423
+ const sourceCommit = shortSha(resolved.source_commit);
424
+ const mergeBase =
425
+ typeof resolved.merge_base_commit === "string"
426
+ ? ` on local merge base ${shortSha(resolved.merge_base_commit)}`
427
+ : "";
372
428
  return {
373
- presented: `Workit decision: action — Open a PR from \`${String(resolved.source_branch ?? "")}\` to \`${payloadTarget}\` titled "${String(request.payload.title)}"?`,
374
- approvedText: `Open the PR: ${String(resolved.source_branch ?? "")} → ${payloadTarget}, "${String(request.payload.title)}".`,
429
+ presented: `Workit decision: action — Open a PR in \`${String(resolved.remote ?? "unknown remote")}\` via \`${String(resolved.apiHost ?? "unknown host")}\`${resolved.account ? ` as ${String(resolved.account)}` : ""} from \`${String(resolved.source_branch ?? "")}\`@${sourceCommit} to \`${payloadTarget}\`${mergeBase} titled "${String(request.payload.title)}"?`,
430
+ approvedText: `Open the PR in ${String(resolved.remote ?? "unknown remote")} via ${String(resolved.apiHost ?? "unknown host")}${resolved.account ? ` as ${String(resolved.account)}` : ""}: ${String(resolved.source_branch ?? "")}@${sourceCommit} → ${payloadTarget}${mergeBase}, "${String(request.payload.title)}".`,
375
431
  };
432
+ }
376
433
  case "hosting.merge":
377
434
  return {
378
- presented: `Workit decision: action — Merge \`${String(resolved.source_branch ?? "")}\` into \`${payloadTarget}\` (squash, delete branch)?`,
379
- approvedText: `Merge ${String(resolved.source_branch ?? "")} → ${payloadTarget}.`,
435
+ presented: `Workit decision: action — Merge \`${String(resolved.source_branch ?? "")}\` into \`${payloadTarget}\` in \`${String(resolved.remote ?? "unknown remote")}\` via \`${String(resolved.apiHost ?? "unknown host")}\`${resolved.account ? ` as ${String(resolved.account)}` : ""} (squash, delete branch)?`,
436
+ approvedText: `Merge ${String(resolved.source_branch ?? "")} → ${payloadTarget} in ${String(resolved.remote ?? "unknown remote")} via ${String(resolved.apiHost ?? "unknown host")}${resolved.account ? ` as ${String(resolved.account)}` : ""}.`,
437
+ };
438
+ case "hosting.delete_branch":
439
+ return {
440
+ presented: `Workit decision: action — Delete merged branch \`${String(payload.branch ?? "")}\` from \`${String(resolved.remote ?? "")}\` via \`${String(resolved.apiHost ?? "unknown host")}\` as \`${String(resolved.account ?? "unknown account")}\` at ${shortSha(resolved.tip)} (PR/MR #${String(resolved.pr ?? "")})?`,
441
+ approvedText: `Delete merged branch ${String(payload.branch ?? "")} from ${String(resolved.remote ?? "unknown remote")} via ${String(resolved.apiHost ?? "unknown host")} as ${String(resolved.account ?? "unknown account")}.`,
380
442
  };
381
443
  case "changelog.apply":
382
444
  return {
@@ -406,6 +468,17 @@ export const actionProposalQuestion = (
406
468
  }
407
469
  };
408
470
 
471
+ export const actionProposalQuestion = (
472
+ request: ExternalActionRequest,
473
+ descriptorPayload: unknown,
474
+ ): { presented: string; approvedText: string } => {
475
+ const proposal = actionProposalAtRoot(request, descriptorPayload);
476
+ const cwd = (descriptorPayload as { cwd?: unknown })?.cwd;
477
+ return typeof cwd === "string"
478
+ ? { ...proposal, presented: `${proposal.presented} Target directory: ${cwd}` }
479
+ : proposal;
480
+ };
481
+
409
482
  export type ResolvedExternalAction = {
410
483
  request: ExternalActionRequest;
411
484
  descriptorPayload: unknown;
@@ -430,6 +503,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
430
503
  value.operation !== "git.push" &&
431
504
  value.operation !== "hosting.pull_request" &&
432
505
  value.operation !== "hosting.merge" &&
506
+ value.operation !== "hosting.delete_branch" &&
433
507
  value.operation !== "changelog.apply" &&
434
508
  value.operation !== "youtrack.update" &&
435
509
  value.operation !== "youtrack.time" &&
@@ -485,6 +559,13 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
485
559
  typeof payload.target_branch !== "string")
486
560
  )
487
561
  return failure("invalid_input", "approved hosting target is incomplete");
562
+ if (
563
+ value.operation === "hosting.delete_branch" &&
564
+ (typeof target.tip !== "string" ||
565
+ typeof target.remote !== "string" ||
566
+ typeof target.pr !== "string")
567
+ )
568
+ return failure("invalid_input", "approved branch deletion target is incomplete");
488
569
  if (
489
570
  value.operation === "changelog.apply" &&
490
571
  (typeof target.target !== "string" ||
@@ -499,6 +580,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
499
580
  value.operation !== "git.push" &&
500
581
  value.operation !== "hosting.pull_request" &&
501
582
  value.operation !== "hosting.merge" &&
583
+ value.operation !== "hosting.delete_branch" &&
502
584
  value.operation !== "changelog.apply" &&
503
585
  (typeof target.marker !== "string" ||
504
586
  typeof target.baseUrl !== "string" ||
@@ -523,16 +605,151 @@ const remoteRepo = (value: string): string | null => {
523
605
  return repo.includes("/") ? repo : null;
524
606
  };
525
607
 
526
- const remoteIdentity = (value: string): string | null => {
527
- const parts = remoteParts(value);
528
- return parts ? `${parts.host}${parts.path.replace(/\.git$/, "")}` : null;
529
- };
608
+ const remoteIdentity = (value: string): string | null => pushRemoteIdentity(value);
530
609
 
531
610
  const remoteProvider = (value: string): "github" | "gitlab" | null => {
532
611
  const host = remoteParts(value)?.host;
533
612
  return host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null;
534
613
  };
535
614
 
615
+ const hostingCliEnv = (provider: "github" | "gitlab", host: string): NodeJS.ProcessEnv => ({
616
+ ...process.env,
617
+ PATH: process.env.PATH ?? "",
618
+ ...(provider === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
619
+ });
620
+
621
+ const hostingProvider = (root: string, remote: string): "github" | "gitlab" | null => {
622
+ const known = remoteProvider(remote);
623
+ if (known) return known;
624
+ const parts = remoteParts(remote);
625
+ if (!parts?.host || (parts.protocol !== "ssh:" && parts.protocol !== "https:")) return null;
626
+ const cfg = vcsConfig("load", root);
627
+ return cfg.ok && (cfg.provider === "github" || cfg.provider === "gitlab") ? cfg.provider : null;
628
+ };
629
+
630
+ const remoteBranchTip = (
631
+ root: string,
632
+ provider: "github" | "gitlab",
633
+ host: string,
634
+ repo: string,
635
+ branch: string,
636
+ ): Result<string | null> => {
637
+ const endpoint =
638
+ provider === "github"
639
+ ? `repos/${repo}/git/ref/heads/${branch.split("/").map(encodeURIComponent).join("/")}`
640
+ : `projects/${encodeURIComponent(repo)}/repository/branches/${encodeURIComponent(branch)}`;
641
+ const response = spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
642
+ cwd: root,
643
+ encoding: "utf8",
644
+ env: hostingCliEnv(provider, host),
645
+ });
646
+ if (response.status !== 0) {
647
+ if (/\b404\b/.test(response.stderr ?? "")) {
648
+ const project =
649
+ provider === "github" ? `repos/${repo}` : `projects/${encodeURIComponent(repo)}`;
650
+ const visible = spawnSync(provider === "github" ? "gh" : "glab", ["api", project], {
651
+ cwd: root,
652
+ encoding: "utf8",
653
+ env: hostingCliEnv(provider, host),
654
+ });
655
+ try {
656
+ const record = JSON.parse(visible.stdout ?? "") as { id?: unknown };
657
+ if (visible.status === 0 && typeof record.id === "number") return success(null, null, null);
658
+ } catch {}
659
+ }
660
+ return failure("capability_unavailable", "remote branch could not be read", {
661
+ outcome: "not_started",
662
+ });
663
+ }
664
+ try {
665
+ const parsed = JSON.parse(response.stdout ?? "") as Record<string, any>;
666
+ const tip = provider === "github" ? parsed.object?.sha : parsed.commit?.id;
667
+ return typeof tip === "string" && /^[a-f0-9]{40,64}$/.test(tip)
668
+ ? success(null, null, tip)
669
+ : failure("capability_unavailable", "remote branch response is incomplete", {
670
+ outcome: "not_started",
671
+ });
672
+ } catch {
673
+ return failure("capability_unavailable", "remote branch response is invalid", {
674
+ outcome: "not_started",
675
+ });
676
+ }
677
+ };
678
+
679
+ const mergedBranch = (root: string, branch: string): Result<Record<string, string>> => {
680
+ if (run(root, ["check-ref-format", "--branch", branch]).exitCode !== 0)
681
+ return failure("invalid_input", "branch name is invalid", { outcome: "not_started" });
682
+ const remote = pushRemote(root);
683
+ const identity = remote ? credentialFreeRemote(remote) : null;
684
+ const repo = remote ? remoteRepo(remote) : null;
685
+ const provider = remote ? hostingProvider(root, remote) : null;
686
+ if (!remote || !identity || !repo || !provider)
687
+ return failure("capability_unavailable", "branch remote is unavailable", {
688
+ outcome: "not_started",
689
+ });
690
+ const auth = vcsCliIdentity(root);
691
+ if (!auth.ok || auth.provider !== provider)
692
+ return failure("capability_unavailable", "hosting CLI identity is unavailable", {
693
+ outcome: "not_started",
694
+ });
695
+ if (!hostingApiHostMatches(remote, auth.host))
696
+ return failure(
697
+ "capability_unavailable",
698
+ "hosting API host does not match the push destination",
699
+ {
700
+ outcome: "not_started",
701
+ },
702
+ );
703
+ const live = remoteBranchTip(root, provider, auth.host, repo, branch);
704
+ if (!live.ok) return live as Result<never>;
705
+ const tip = live.data;
706
+ if (!tip)
707
+ return failure("invalid_input", "remote branch is absent or could not be read", {
708
+ outcome: "not_started",
709
+ });
710
+ const endpoint =
711
+ provider === "github"
712
+ ? `repos/${repo}/pulls?state=closed&head=${encodeURIComponent(`${repo.split("/")[0]}:${branch}`)}&per_page=100`
713
+ : `projects/${encodeURIComponent(repo)}/merge_requests?state=merged&source_branch=${encodeURIComponent(branch)}&per_page=100`;
714
+ const response = spawnSync(provider === "github" ? "gh" : "glab", ["api", endpoint], {
715
+ cwd: root,
716
+ encoding: "utf8",
717
+ env: hostingCliEnv(provider, auth.host),
718
+ });
719
+ if (response.status !== 0)
720
+ return failure("capability_unavailable", "merged branch evidence is unavailable", {
721
+ outcome: "not_started",
722
+ });
723
+ try {
724
+ const records = JSON.parse(response.stdout ?? "") as unknown;
725
+ if (!Array.isArray(records) || records.length >= 100) throw new Error("incomplete PR listing");
726
+ const match = records.find(
727
+ (record) =>
728
+ record &&
729
+ typeof record === "object" &&
730
+ (provider === "github" ? record.head?.sha === tip : record.sha === tip) &&
731
+ typeof record.merged_at === "string" &&
732
+ (provider === "github" ? record.head?.ref === branch : record.source_branch === branch),
733
+ );
734
+ const id = match?.number ?? match?.iid;
735
+ if (typeof id !== "number") throw new Error("no exact merged head");
736
+ return success(null, null, {
737
+ branch,
738
+ tip,
739
+ remote: identity,
740
+ provider,
741
+ repo,
742
+ pr: String(id),
743
+ account: String(auth.username),
744
+ apiHost: String(auth.host),
745
+ });
746
+ } catch {
747
+ return failure("capability_unavailable", "branch tip does not match a merged PR head", {
748
+ outcome: "not_started",
749
+ });
750
+ }
751
+ };
752
+
536
753
  const unknownHostingEvidence = (operation: string): Result<HostingReadEvidence> =>
537
754
  failure("external_outcome_unknown", "hosting action outcome remains unknown", {
538
755
  operation,
@@ -550,13 +767,10 @@ export const readHostingAction = async (
550
767
  return unknownHostingEvidence(operation);
551
768
  try {
552
769
  const cfg = vcsConfig("load", root);
553
- if (
554
- !cfg.ok ||
555
- !cfg.tokenPath ||
556
- !cfg.provider ||
557
- (cfg.provider !== "github" && cfg.provider !== "gitlab")
558
- )
770
+ if (!cfg.ok || !cfg.provider || (cfg.provider !== "github" && cfg.provider !== "gitlab"))
559
771
  return unknownHostingEvidence(operation);
772
+ const auth = vcsCliIdentity(root);
773
+ if (!auth.ok || auth.provider !== cfg.provider) return unknownHostingEvidence(operation);
560
774
  const remote = pushRemote(root);
561
775
  const repo = remote ? remoteRepo(remote) : null;
562
776
  const approved = resolved.descriptorPayload as {
@@ -571,18 +785,6 @@ export const readHostingAction = async (
571
785
  const currentIdentity = remote ? remoteIdentity(remote) : null;
572
786
  const approvedIdentity =
573
787
  typeof approvedRemote === "string" ? remoteIdentity(approvedRemote) : null;
574
- const apiHost =
575
- cfg.provider === "github"
576
- ? "github.com"
577
- : (() => {
578
- try {
579
- return new URL(
580
- cfg.gitlab?.apiUrl ?? "https://gitlab.com/api/v4",
581
- ).hostname.toLowerCase();
582
- } catch {
583
- return "";
584
- }
585
- })();
586
788
  if (
587
789
  !repo ||
588
790
  typeof branch !== "string" ||
@@ -592,22 +794,23 @@ export const readHostingAction = async (
592
794
  !currentIdentity ||
593
795
  currentIdentity !== approvedIdentity ||
594
796
  repo !== remoteRepo(approvedRemote) ||
595
- (remoteProvider(approvedRemote) !== null &&
596
- remoteProvider(approvedRemote) !== cfg.provider) ||
597
- remoteParts(approvedRemote)?.host !== apiHost
797
+ hostingProvider(root, approvedRemote) !== cfg.provider ||
798
+ !hostingApiHostMatches(approvedRemote, auth.host) ||
799
+ (typeof source?.apiHost === "string" && source.apiHost !== auth.host)
598
800
  )
599
801
  return unknownHostingEvidence(operation);
600
- const token = fs.readFileSync(cfg.tokenPath, "utf8").trim();
601
- const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" };
602
- const url =
802
+ const endpoint =
603
803
  cfg.provider === "github"
604
- ? `https://api.github.com/repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${repo.split("/")[0]}:${branch}`)}&base=${encodeURIComponent(target)}&per_page=100&page=1`
605
- : `${cfg.gitlab?.apiUrl ?? "https://gitlab.com/api/v4"}/projects/${encodeURIComponent(repo)}/merge_requests?state=all&source_branch=${encodeURIComponent(branch)}&target_branch=${encodeURIComponent(target)}&per_page=100&page=1`;
606
- const response = await fetch(url, {
607
- headers: cfg.provider === "gitlab" ? { "PRIVATE-TOKEN": token } : headers,
804
+ ? `repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${repo.split("/")[0]}:${branch}`)}&base=${encodeURIComponent(target)}&per_page=100&page=1`
805
+ : `projects/${encodeURIComponent(repo)}/merge_requests?state=all&source_branch=${encodeURIComponent(branch)}&target_branch=${encodeURIComponent(target)}&per_page=100&page=1`;
806
+ const cli = cfg.provider === "github" ? "gh" : "glab";
807
+ const response = spawnSync(cli, ["api", endpoint], {
808
+ cwd: root,
809
+ encoding: "utf8",
810
+ env: hostingCliEnv(cfg.provider, auth.host),
608
811
  });
609
- if (!response.ok) return unknownHostingEvidence(operation);
610
- const values: unknown = await response.json();
812
+ if (response.status !== 0) return unknownHostingEvidence(operation);
813
+ const values: unknown = JSON.parse(response.stdout ?? "");
611
814
  // A full page is intentionally treated as truncated until pagination is added.
612
815
  if (!Array.isArray(values) || values.length >= 100) return unknownHostingEvidence(operation);
613
816
  const records: Record<string, unknown>[] = [];
@@ -967,16 +1170,56 @@ export const readExternalAction = async (
967
1170
  root: string,
968
1171
  resolved: ResolvedExternalAction,
969
1172
  actionRef: Ref,
970
- ): Promise<Result<HostingReadEvidence>> =>
971
- resolved.request.operation === "git.branch_setup" ||
972
- resolved.request.operation === "git.commit" ||
973
- resolved.request.operation === "git.push" ||
974
- resolved.request.operation === "changelog.apply"
1173
+ ): Promise<Result<HostingReadEvidence>> => {
1174
+ const target = actionRoot(root, resolved.request);
1175
+ if (!target.ok) return unknownHostingEvidence(resolved.request.operation);
1176
+ root = target.data;
1177
+ return resolved.request.operation === "git.branch_setup" ||
1178
+ resolved.request.operation === "git.commit" ||
1179
+ resolved.request.operation === "git.push" ||
1180
+ resolved.request.operation === "changelog.apply"
975
1181
  ? readLocalAction(root, resolved, actionRef)
976
- : resolved.request.operation === "hosting.pull_request" ||
977
- resolved.request.operation === "hosting.merge"
978
- ? readHostingAction(root, resolved, actionRef)
979
- : readYouTrackAction(root, resolved, actionRef);
1182
+ : resolved.request.operation === "hosting.delete_branch"
1183
+ ? readDeletedBranch(root, resolved, actionRef)
1184
+ : resolved.request.operation === "hosting.pull_request" ||
1185
+ resolved.request.operation === "hosting.merge"
1186
+ ? readHostingAction(root, resolved, actionRef)
1187
+ : readYouTrackAction(root, resolved, actionRef);
1188
+ };
1189
+
1190
+ const readDeletedBranch = async (
1191
+ root: string,
1192
+ resolved: ResolvedExternalAction,
1193
+ actionRef: Ref,
1194
+ ): Promise<Result<HostingReadEvidence>> => {
1195
+ const details = (resolved.descriptorPayload as { resolved?: Record<string, unknown> }).resolved;
1196
+ if (
1197
+ !details ||
1198
+ typeof details.branch !== "string" ||
1199
+ typeof details.tip !== "string" ||
1200
+ typeof details.remote !== "string" ||
1201
+ credentialFreeRemote(pushRemote(root) ?? "") !== details.remote
1202
+ )
1203
+ return unknownHostingEvidence("hosting.delete_branch");
1204
+ const provider = details.provider;
1205
+ const repo = details.repo;
1206
+ const apiHost = details.apiHost;
1207
+ if (
1208
+ (provider !== "github" && provider !== "gitlab") ||
1209
+ typeof repo !== "string" ||
1210
+ typeof apiHost !== "string" ||
1211
+ !hostingApiHostMatches(pushRemote(root) ?? "", apiHost)
1212
+ )
1213
+ return unknownHostingEvidence("hosting.delete_branch");
1214
+ const live = remoteBranchTip(root, provider, apiHost, repo, details.branch);
1215
+ if (!live.ok || live.data !== null) return unknownHostingEvidence("hosting.delete_branch");
1216
+ const evidenceDigest = sha256(details);
1217
+ return success(null, null, {
1218
+ outcome: "succeeded",
1219
+ evidenceDigest,
1220
+ observation: { kind: "provider_read", actionRef, outcome: "succeeded", evidenceDigest },
1221
+ });
1222
+ };
980
1223
 
981
1224
  const youTrackBase = (): string | null => {
982
1225
  try {
@@ -1019,11 +1262,13 @@ export const upgradeBranchSetupForStash = (
1019
1262
  resolved: Result<ResolvedExternalAction>,
1020
1263
  ): Result<ResolvedExternalAction> => {
1021
1264
  if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
1265
+ const target = actionRoot(root, parsed);
1022
1266
  if (
1023
1267
  (resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
1024
1268
  true ||
1025
1269
  (resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
1026
- classifyBranchDirt(root) !== "stash-required"
1270
+ !target.ok ||
1271
+ classifyBranchDirt(target.data) !== "stash-required"
1027
1272
  )
1028
1273
  return resolved;
1029
1274
  const upgraded = resolveExternalActionRequest(root, {
@@ -1033,7 +1278,7 @@ export const upgradeBranchSetupForStash = (
1033
1278
  return upgraded.ok ? upgraded : resolved;
1034
1279
  };
1035
1280
 
1036
- export const resolveExternalActionRequest = (
1281
+ const resolveExternalActionAtRoot = (
1037
1282
  root: string,
1038
1283
  request: ExternalActionRequest,
1039
1284
  preservedDateMs?: number,
@@ -1044,7 +1289,8 @@ export const resolveExternalActionRequest = (
1044
1289
  const sdd_dir = safePath(root, request.payload.sdd_dir ?? "docs");
1045
1290
  if (!sdd_dir) return failure("invalid_input", "sdd_dir must stay inside the workspace");
1046
1291
  const action = request.payload.action ?? "setup";
1047
- const policy = resolveBranchPolicyFor(root);
1292
+ const policySnapshot = branchPolicySnapshotFor(root);
1293
+ const policy = policySnapshot.branchPolicy;
1048
1294
  const target_branch = request.payload.target_branch;
1049
1295
  if (action !== "reapply_stash" && !target_branch)
1050
1296
  return failure(
@@ -1055,6 +1301,18 @@ export const resolveExternalActionRequest = (
1055
1301
  fields: [{ path: "target_branch", reason: "required for action=setup" }],
1056
1302
  },
1057
1303
  );
1304
+ if (target_branch) {
1305
+ const check = validateBranchNamePolicy(
1306
+ target_branch,
1307
+ policySnapshot.namePolicy,
1308
+ policySnapshot.source,
1309
+ );
1310
+ if (!check.ok)
1311
+ return failure("permission_denied", check.error, {
1312
+ outcome: "not_started",
1313
+ fields: [{ path: "target_branch", reason: check.rule }],
1314
+ });
1315
+ }
1058
1316
  const head = gitValue(root, ["rev-parse", "HEAD"]);
1059
1317
  if (!head) return failure("storage_error", "current Git commit could not be resolved");
1060
1318
  const dirty = Boolean((gitRaw(root, ["status", "--short"]) ?? "").trim());
@@ -1087,24 +1345,52 @@ export const resolveExternalActionRequest = (
1087
1345
  request: normalized,
1088
1346
  descriptorPayload: {
1089
1347
  ...normalized.payload,
1090
- resolved: { head, dirty, ...creation, ...stashTarget },
1348
+ resolved: {
1349
+ head,
1350
+ dirty,
1351
+ ...creation,
1352
+ ...stashTarget,
1353
+ ...(target_branch ? { branchPolicyFingerprint: policySnapshot.fingerprint } : {}),
1354
+ },
1091
1355
  },
1092
1356
  });
1093
1357
  }
1094
1358
  case "git.push": {
1095
1359
  const branch = request.payload.branch ?? gitValue(root, ["branch", "--show-current"]);
1096
- if (branch && isProtectedBranch(root, branch))
1097
- return failure("invalid_input", `cannot push protected branch ${branch}`, {
1098
- outcome: "not_started",
1099
- fields: [{ path: "branch", reason: "protected branch" }],
1100
- });
1101
1360
  const commit = branch ? gitValue(root, ["rev-parse", branch]) : null;
1102
1361
  const remote = pushRemote(root);
1103
1362
  const identity = remote ? credentialFreeRemote(remote) : null;
1363
+ const provider = remote ? hostingProvider(root, remote) : null;
1364
+ const auth = provider ? vcsCliIdentity(root) : null;
1365
+ if (provider && (!auth?.ok || auth.provider !== provider))
1366
+ return failure("capability_unavailable", "hosting CLI identity is unavailable", {
1367
+ outcome: "not_started",
1368
+ });
1369
+ if (provider && auth?.ok && remote && !hostingApiHostMatches(remote, auth.host))
1370
+ return failure(
1371
+ "capability_unavailable",
1372
+ "hosting API host does not match the push destination",
1373
+ {
1374
+ outcome: "not_started",
1375
+ },
1376
+ );
1104
1377
  return branch && commit && identity
1105
1378
  ? success(null, null, {
1106
- request: { operation: request.operation, payload: { branch } },
1107
- descriptorPayload: { branch, resolved: { branch, commit, remote: identity } },
1379
+ request: {
1380
+ operation: request.operation,
1381
+ payload: { branch, ...(request.payload.cwd ? { cwd: request.payload.cwd } : {}) },
1382
+ },
1383
+ descriptorPayload: {
1384
+ branch,
1385
+ ...(request.payload.cwd ? { cwd: request.payload.cwd } : {}),
1386
+ resolved: {
1387
+ branch,
1388
+ commit,
1389
+ remote: identity,
1390
+ ...(auth?.ok ? { account: auth.username } : {}),
1391
+ ...(auth?.ok ? { apiHost: auth.host } : {}),
1392
+ },
1393
+ },
1108
1394
  })
1109
1395
  : failure("invalid_input", "git push requires a resolvable current branch");
1110
1396
  }
@@ -1125,6 +1411,36 @@ export const resolveExternalActionRequest = (
1125
1411
  fields: [{ path: "plan_steps", reason: "unique steps" }],
1126
1412
  },
1127
1413
  );
1414
+ const commitSteps = chain.filter((step) => step.kind === "commit");
1415
+ const branchSteps = chain.filter((step) => step.kind === "branch");
1416
+ const commitPolicy = commitSteps.length ? commitPolicySnapshotFor(root) : null;
1417
+ const branchPolicy = branchSteps.length ? branchPolicySnapshotFor(root) : null;
1418
+ for (const step of commitSteps) {
1419
+ if (step.kind !== "commit" || !commitPolicy) continue;
1420
+ const check = validateCommitMessagePolicy(
1421
+ step.message,
1422
+ commitPolicy.policy,
1423
+ commitPolicy.source,
1424
+ );
1425
+ if (!check.ok)
1426
+ return failure("permission_denied", check.error, {
1427
+ outcome: "not_started",
1428
+ fields: [{ path: "plan_steps", reason: check.rule }],
1429
+ });
1430
+ }
1431
+ for (const step of branchSteps) {
1432
+ if (step.kind !== "branch" || !branchPolicy) continue;
1433
+ const check = validateBranchNamePolicy(
1434
+ step.target,
1435
+ branchPolicy.namePolicy,
1436
+ branchPolicy.source,
1437
+ );
1438
+ if (!check.ok)
1439
+ return failure("permission_denied", check.error, {
1440
+ outcome: "not_started",
1441
+ fields: [{ path: "plan_steps", reason: check.rule }],
1442
+ });
1443
+ }
1128
1444
  if (typeof planBranch !== "string" || !planBranch.trim())
1129
1445
  return failure("invalid_input", "plan_branch must name the working branch", {
1130
1446
  outcome: "not_started",
@@ -1150,17 +1466,18 @@ export const resolveExternalActionRequest = (
1150
1466
  ],
1151
1467
  },
1152
1468
  );
1153
- if (isProtectedBranch(root, planBranch))
1154
- return failure("invalid_input", "plan_branch must not be a protected branch", {
1155
- outcome: "not_started",
1156
- fields: [{ path: "plan_branch", reason: "protected branch" }],
1157
- });
1158
1469
  return success(null, null, {
1159
1470
  request,
1160
1471
  descriptorPayload: {
1161
1472
  plan_steps: planSteps,
1162
1473
  plan_branch: planBranch,
1163
- resolved: { head, branch, steps: planSteps },
1474
+ resolved: {
1475
+ head,
1476
+ branch,
1477
+ steps: planSteps,
1478
+ ...(commitPolicy ? { commitPolicyFingerprint: commitPolicy.fingerprint } : {}),
1479
+ ...(branchPolicy ? { branchPolicyFingerprint: branchPolicy.fingerprint } : {}),
1480
+ },
1164
1481
  },
1165
1482
  });
1166
1483
  }
@@ -1182,31 +1499,28 @@ export const resolveExternalActionRequest = (
1182
1499
  });
1183
1500
  if (staged === null || paths === null)
1184
1501
  return failure("storage_error", "Git state could not be resolved");
1185
- let policy: { preset: string; pattern?: string };
1186
- try {
1187
- policy = resolveCommitPolicyFor(root);
1188
- } catch (error) {
1189
- return failure(
1190
- "invalid_input",
1191
- `Commit policy config is malformed: ${error instanceof Error ? error.message : error}`,
1192
- );
1193
- }
1194
- let flavor = policy.preset;
1195
- if (flavor === "auto") {
1196
- const log = gitRaw(root, ["log", "--format=%s", "-30", "HEAD"]);
1197
- const detected = detectCommitFlavor((log ?? "").split("\n").filter(Boolean));
1198
- flavor = detected.flavor ?? "conventional";
1199
- }
1200
- if (!matchCommitFlavor(message, flavor as CommitFlavor, policy.pattern))
1201
- return failure(
1202
- "invalid_input",
1203
- `Commit message does not match the ${flavor} flavor (commitPolicy.preset)`,
1204
- );
1502
+ const commitPolicy = commitPolicySnapshotFor(root);
1503
+ const commitCheck = validateCommitMessagePolicy(
1504
+ message,
1505
+ commitPolicy.policy,
1506
+ commitPolicy.source,
1507
+ );
1508
+ if (!commitCheck.ok)
1509
+ return failure("permission_denied", commitCheck.error, {
1510
+ outcome: "not_started",
1511
+ fields: [{ path: "message", reason: commitCheck.rule }],
1512
+ });
1205
1513
  return success(null, null, {
1206
1514
  request,
1207
1515
  descriptorPayload: {
1208
1516
  ...request.payload,
1209
- resolved: { head, branch, staged: sha256(staged), paths },
1517
+ resolved: {
1518
+ head,
1519
+ branch,
1520
+ staged: sha256(staged),
1521
+ paths,
1522
+ commitPolicyFingerprint: commitPolicy.fingerprint,
1523
+ },
1210
1524
  },
1211
1525
  });
1212
1526
  }
@@ -1214,45 +1528,45 @@ export const resolveExternalActionRequest = (
1214
1528
  const target_branch =
1215
1529
  request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
1216
1530
  const source_branch = gitValue(root, ["branch", "--show-current"]);
1217
- if (source_branch && isProtectedBranch(root, source_branch))
1218
- return failure(
1219
- "invalid_input",
1220
- `cannot open a PR from protected branch ${source_branch}`,
1221
- {
1222
- outcome: "not_started",
1223
- fields: [{ path: "source_branch", reason: "protected branch" }],
1224
- },
1225
- );
1226
- const source_commit = gitValue(root, ["rev-parse", "HEAD"]);
1531
+ const source_commit = source_branch
1532
+ ? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
1533
+ : null;
1227
1534
  const remote = pushRemote(root);
1228
1535
  const identity = remote ? credentialFreeRemote(remote) : null;
1536
+ const vcs = vcsConfig("load", root);
1537
+ const provider = remote ? hostingProvider(root, remote) : null;
1229
1538
  if (
1230
1539
  !source_branch ||
1231
1540
  !source_commit ||
1232
1541
  !identity ||
1233
- (remoteProvider(identity) !== null &&
1234
- remoteProvider(identity) !== String(vcsConfig("load", root).provider).toLowerCase())
1542
+ !vcs.ok ||
1543
+ !provider ||
1544
+ provider !== vcs.provider
1235
1545
  )
1236
1546
  return failure(
1237
1547
  "capability_unavailable",
1238
1548
  "hosting target is not bound to the configured remote provider",
1239
- { capability: "hosting.pull_request" },
1549
+ { capability: "hosting.pull_request", outcome: "not_started" },
1240
1550
  );
1241
- const vcs = vcsConfig("load", root);
1242
- const policy = resolveBranchPolicyFor(root);
1243
- if (!vcs.ok || (policy.integration !== "merge" && !vcs.tokenReady))
1244
- return failure("capability_unavailable", "hosting credentials are unavailable", {
1245
- capability: "hosting.pull_request",
1246
- outcome: "not_started",
1247
- });
1248
- if (policy.integration !== "merge" && !hostingCliAvailable(String(vcs.provider)))
1249
- return failure("capability_unavailable", "hosting CLI is unavailable", {
1551
+ const auth = vcsCliIdentity(root);
1552
+ if (!auth.ok || auth.provider !== provider || !hostingCliAvailable(provider))
1553
+ return failure("capability_unavailable", "hosting CLI identity is unavailable", {
1250
1554
  capability: "hosting.pull_request",
1251
1555
  outcome: "not_started",
1252
1556
  });
1557
+ if (!remote || !hostingApiHostMatches(remote, auth.host))
1558
+ return failure(
1559
+ "capability_unavailable",
1560
+ "hosting API host does not match the push destination",
1561
+ { capability: "hosting.pull_request", outcome: "not_started" },
1562
+ );
1563
+ const merge_base_commit =
1564
+ resolveBranchPolicyFor(root).integration === "merge"
1565
+ ? gitValue(root, ["rev-parse", "--verify", `refs/heads/${target_branch}`])
1566
+ : null;
1253
1567
  const normalized = {
1254
1568
  operation: request.operation,
1255
- payload: { ...request.payload, target_branch, babysit: request.payload.babysit ?? true },
1569
+ payload: { ...request.payload, target_branch },
1256
1570
  } as ExternalActionRequest;
1257
1571
  const marker = `<!-- workit-action:${sha256({ operation: request.operation, payload: normalized.payload, source_commit, remote: identity })} -->`;
1258
1572
  return success(null, null, {
@@ -1260,7 +1574,16 @@ export const resolveExternalActionRequest = (
1260
1574
  marker,
1261
1575
  descriptorPayload: {
1262
1576
  ...normalized.payload,
1263
- resolved: { source_branch, source_commit, remote: identity, target_branch, marker },
1577
+ resolved: {
1578
+ source_branch,
1579
+ source_commit,
1580
+ remote: identity,
1581
+ target_branch,
1582
+ marker,
1583
+ account: auth.username,
1584
+ apiHost: auth.host,
1585
+ ...(merge_base_commit ? { merge_base_commit } : {}),
1586
+ },
1264
1587
  },
1265
1588
  });
1266
1589
  }
@@ -1269,34 +1592,34 @@ export const resolveExternalActionRequest = (
1269
1592
  request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
1270
1593
  const source_branch =
1271
1594
  request.payload.source_branch ?? gitValue(root, ["branch", "--show-current"]);
1272
- if (source_branch && isProtectedBranch(root, source_branch))
1273
- return failure("invalid_input", `cannot merge protected branch ${source_branch}`, {
1274
- outcome: "not_started",
1275
- fields: [{ path: "source_branch", reason: "protected branch" }],
1276
- });
1277
1595
  const source_commit = source_branch
1278
1596
  ? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
1279
1597
  : null;
1280
1598
  const remote = pushRemote(root);
1281
1599
  const identity = remote ? credentialFreeRemote(remote) : null;
1282
- if (
1283
- !source_branch ||
1284
- !source_commit ||
1285
- !identity ||
1286
- (remoteProvider(identity) !== null &&
1287
- remoteProvider(identity) !== String(vcsConfig("load", root).provider).toLowerCase())
1288
- )
1600
+ const vcs = vcsConfig("load", root);
1601
+ const provider = remote ? hostingProvider(root, remote) : null;
1602
+ if (!source_branch || !source_commit || !identity || !vcs.ok || provider !== vcs.provider)
1289
1603
  return failure(
1290
1604
  "capability_unavailable",
1291
1605
  "hosting target is not bound to the configured remote provider",
1292
1606
  { capability: "hosting.merge" },
1293
1607
  );
1294
- const vcs = vcsConfig("load", root);
1295
- if (!vcs.ok || !vcs.tokenReady)
1608
+ const auth = vcsCliIdentity(root);
1609
+ if (!vcs.ok || !auth.ok)
1296
1610
  return failure("capability_unavailable", "hosting credentials are unavailable", {
1297
1611
  capability: "hosting.merge",
1298
1612
  outcome: "not_started",
1299
1613
  });
1614
+ if (!remote || !hostingApiHostMatches(remote, auth.host))
1615
+ return failure(
1616
+ "capability_unavailable",
1617
+ "hosting API host does not match the push destination",
1618
+ {
1619
+ capability: "hosting.merge",
1620
+ outcome: "not_started",
1621
+ },
1622
+ );
1300
1623
  if (!hostingCliAvailable(String(vcs.provider)))
1301
1624
  return failure("capability_unavailable", "hosting CLI is unavailable", {
1302
1625
  capability: "hosting.merge",
@@ -1312,10 +1635,27 @@ export const resolveExternalActionRequest = (
1312
1635
  marker,
1313
1636
  descriptorPayload: {
1314
1637
  ...normalized.payload,
1315
- resolved: { source_branch, source_commit, remote: identity, target_branch, marker },
1638
+ resolved: {
1639
+ source_branch,
1640
+ source_commit,
1641
+ remote: identity,
1642
+ target_branch,
1643
+ marker,
1644
+ account: auth.username,
1645
+ apiHost: auth.host,
1646
+ },
1316
1647
  },
1317
1648
  });
1318
1649
  }
1650
+ case "hosting.delete_branch": {
1651
+ const checked = mergedBranch(root, request.payload.branch);
1652
+ return checked.ok
1653
+ ? success(null, null, {
1654
+ request,
1655
+ descriptorPayload: { ...request.payload, resolved: checked.data },
1656
+ })
1657
+ : (checked as Result<never>);
1658
+ }
1319
1659
  case "changelog.apply": {
1320
1660
  const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1321
1661
  if (preview.error) return failure("invalid_input", preview.error);
@@ -1438,8 +1778,34 @@ export const resolveExternalActionRequest = (
1438
1778
  }
1439
1779
  };
1440
1780
 
1781
+ export const resolveExternalActionRequest = (
1782
+ taskRoot: string,
1783
+ request: ExternalActionRequest,
1784
+ preservedDateMs?: number,
1785
+ ): Result<ResolvedExternalAction> => {
1786
+ const target = actionRoot(taskRoot, request);
1787
+ if (!target.ok) return target as Result<never>;
1788
+ const bindTarget = bindsActionTarget(request.operation);
1789
+ const normalized = bindTarget
1790
+ ? ({ ...request, payload: { ...request.payload, cwd: target.data } } as ExternalActionRequest)
1791
+ : request;
1792
+ const resolved = resolveExternalActionAtRoot(target.data, normalized, preservedDateMs);
1793
+ if (!resolved.ok || !bindTarget) return resolved;
1794
+ return success(null, null, {
1795
+ ...resolved.data,
1796
+ request: {
1797
+ ...resolved.data.request,
1798
+ payload: { ...resolved.data.request.payload, cwd: target.data },
1799
+ } as ExternalActionRequest,
1800
+ descriptorPayload: {
1801
+ ...(resolved.data.descriptorPayload as Record<string, unknown>),
1802
+ cwd: target.data,
1803
+ },
1804
+ });
1805
+ };
1806
+
1441
1807
  export const prBabysitNext = (output: string): string =>
1442
- `Babysit ${output} to merge-ready (drive mode default). Follow skill workit-babysit: declare mode in this turn, work conflicts, review threads, then CI, and record a frontier brief in task progress per pass. Opt out with babysit:false.`;
1808
+ `PR ready: ${output}. Babysitting was explicitly requested. Follow workit-babysit to resolve conflicts, review threads, and get checks green. Stop at PR-ready; a PR or babysit request does not authorize merge or release.`;
1443
1809
 
1444
1810
  export const executeConcreteExternalAction = async (
1445
1811
  request: ExternalActionRequest,
@@ -1451,6 +1817,19 @@ export const executeConcreteExternalAction = async (
1451
1817
  caller?: { host: Caller["host"]; actor: string },
1452
1818
  approvedBeforeDigest?: string,
1453
1819
  approvedBeforeExists?: boolean,
1820
+ coordinationRoot: string = root,
1821
+ approvedTip?: string,
1822
+ approvedRemote?: string,
1823
+ approvedAccount?: string,
1824
+ approvedApiHost?: string,
1825
+ approvedSourceCommit?: string,
1826
+ approvedCommit?: string,
1827
+ approvedRemoteBase?: string,
1828
+ approvedLocalBase?: string | null,
1829
+ writerActionLease?: symbol,
1830
+ approvedSourceBranch?: string,
1831
+ approvedMergeBaseCommit?: string,
1832
+ expectedWorkspaceRevision?: string,
1454
1833
  ): Promise<Result<unknown>> => {
1455
1834
  if (localAction(request.operation)) {
1456
1835
  if (!caller)
@@ -1471,13 +1850,60 @@ export const executeConcreteExternalAction = async (
1471
1850
  "local external action staged paths are unavailable",
1472
1851
  { outcome: "not_started" },
1473
1852
  );
1474
- const writer = assertLocalExternalActionWriter(root, caller, paths);
1853
+ const writer = assertLocalExternalActionWriter(
1854
+ coordinationRoot,
1855
+ caller,
1856
+ paths,
1857
+ expectedWorkspaceRevision,
1858
+ );
1475
1859
  if (!writer.ok)
1476
1860
  return failure(
1477
1861
  "capability_unavailable",
1478
1862
  "local external action writer authority is unavailable",
1479
1863
  { outcome: "not_started" },
1480
1864
  );
1865
+ if (writerActionLease !== targetWriterActionLease)
1866
+ return new TaskStore(root).withExternalActionLock(() =>
1867
+ executeConcreteExternalAction(
1868
+ request,
1869
+ root,
1870
+ marker,
1871
+ dateMs,
1872
+ step,
1873
+ workText,
1874
+ caller,
1875
+ approvedBeforeDigest,
1876
+ approvedBeforeExists,
1877
+ coordinationRoot,
1878
+ approvedTip,
1879
+ approvedRemote,
1880
+ approvedAccount,
1881
+ approvedApiHost,
1882
+ approvedSourceCommit,
1883
+ approvedCommit,
1884
+ approvedRemoteBase,
1885
+ approvedLocalBase,
1886
+ targetWriterActionLease,
1887
+ approvedSourceBranch,
1888
+ approvedMergeBaseCommit,
1889
+ expectedWorkspaceRevision,
1890
+ ),
1891
+ );
1892
+ if (!sameDirectoryIdentity(root, coordinationRoot)) {
1893
+ const targetWorkspace = new TaskStore(root).readWorkspace();
1894
+ if (!targetWorkspace.ok) return targetWorkspace as Result<never>;
1895
+ const owner = targetWorkspace.data?.writer?.owner;
1896
+ if (
1897
+ owner &&
1898
+ (owner.taskId !== writer.data.taskId ||
1899
+ owner.workerId !== writer.data.workerId ||
1900
+ owner.session.host !== caller.host ||
1901
+ owner.session.handle !== caller.actor)
1902
+ )
1903
+ return failure("writer_conflict", "target checkout is owned by another writer", {
1904
+ outcome: "not_started",
1905
+ });
1906
+ }
1481
1907
  }
1482
1908
  switch (request.operation) {
1483
1909
  case "git.branch_setup": {
@@ -1485,6 +1911,8 @@ export const executeConcreteExternalAction = async (
1485
1911
  ...request.payload,
1486
1912
  sdd_dir: request.payload.sdd_dir ?? "docs",
1487
1913
  workspace_root: root,
1914
+ expected_remote_base: approvedRemoteBase,
1915
+ expected_local_base: approvedLocalBase,
1488
1916
  });
1489
1917
  if ("error" in result) {
1490
1918
  if (result.phase === "preflight")
@@ -1509,13 +1937,76 @@ export const executeConcreteExternalAction = async (
1509
1937
  : unknown(request.operation);
1510
1938
  }
1511
1939
  case "git.push": {
1512
- const result = run(root, ["push", "origin", request.payload.branch!]);
1940
+ const remote = pushRemote(root);
1941
+ const identity = remote ? pushRemoteIdentity(remote) : null;
1942
+ const target = remote ? safePushUrl(remote) : null;
1943
+ const provider = remote ? hostingProvider(root, remote) : null;
1944
+ const auth = provider ? vcsCliIdentity(root) : null;
1945
+ if (
1946
+ !remote ||
1947
+ !identity ||
1948
+ !target ||
1949
+ !pushTargetIsStable(root, remote) ||
1950
+ (approvedRemote && identity !== approvedRemote)
1951
+ )
1952
+ return failure(
1953
+ "capability_unavailable",
1954
+ "push destination is ambiguous, rewritten, or changed after approval",
1955
+ {
1956
+ outcome: "not_started",
1957
+ },
1958
+ );
1959
+ if (provider && (!auth?.ok || !hostingApiHostMatches(remote, auth.host)))
1960
+ return failure(
1961
+ "capability_unavailable",
1962
+ "hosting API host does not match the push destination",
1963
+ {
1964
+ outcome: "not_started",
1965
+ },
1966
+ );
1967
+ if (
1968
+ approvedAccount &&
1969
+ (!auth?.ok || auth.username?.toLowerCase() !== approvedAccount.toLowerCase())
1970
+ )
1971
+ return failure(
1972
+ "capability_unavailable",
1973
+ "approved hosting account changed before execution",
1974
+ {
1975
+ outcome: "not_started",
1976
+ },
1977
+ );
1978
+ if (approvedApiHost && (!auth?.ok || auth.host !== approvedApiHost))
1979
+ return failure("capability_unavailable", "approved hosting host changed before execution", {
1980
+ outcome: "not_started",
1981
+ });
1982
+ const branch = request.payload.branch!;
1983
+ if (!approvedCommit || !/^[a-f0-9]{40,64}$/i.test(approvedCommit))
1984
+ return failure("capability_unavailable", "approved push commit is unavailable", {
1985
+ outcome: "not_started",
1986
+ });
1987
+ if (run(root, ["check-ref-format", "--branch", branch]).exitCode !== 0)
1988
+ return failure("invalid_input", "push branch name is invalid", { outcome: "not_started" });
1989
+ if (gitValue(root, ["rev-parse", "--verify", `refs/heads/${branch}`]) !== approvedCommit)
1990
+ return failure("capability_unavailable", "approved push branch changed before execution", {
1991
+ outcome: "not_started",
1992
+ });
1993
+ const result = run(root, ["push", target, `${approvedCommit}:refs/heads/${branch}`]);
1513
1994
  return result.exitCode === 0
1514
1995
  ? success(null, null, { stdout: result.stdout.trim() })
1515
1996
  : unknown(request.operation);
1516
1997
  }
1517
1998
  case "hosting.pull_request": {
1518
- const body = `${request.payload.body ?? ""}${marker ? `${request.payload.body ? "\n\n" : ""}${marker}` : ""}`;
1999
+ if (
2000
+ !approvedSourceCommit ||
2001
+ !approvedSourceBranch ||
2002
+ !approvedRemote ||
2003
+ !approvedAccount ||
2004
+ !approvedApiHost
2005
+ )
2006
+ return failure("capability_unavailable", "approved PR source or target is incomplete", {
2007
+ outcome: "not_started",
2008
+ });
2009
+ const body = appendMarker(request.payload.body ?? "", marker ?? "");
1519
2010
  const result = prCreate(
1520
2011
  {
1521
2012
  ...process.env,
@@ -1524,19 +2015,29 @@ export const executeConcreteExternalAction = async (
1524
2015
  WF_PR_CONFIRMED: "true",
1525
2016
  WF_PR_DRAFT: request.payload.draft ? "true" : "false",
1526
2017
  WF_PR_TARGET: request.payload.target_branch ?? "",
2018
+ WORKIT_EXPECTED_SOURCE_BRANCH: approvedSourceBranch,
2019
+ WORKIT_EXPECTED_SOURCE_COMMIT: approvedSourceCommit,
2020
+ WORKIT_EXPECTED_REMOTE: approvedRemote,
2021
+ WORKIT_EXPECTED_ACCOUNT: approvedAccount,
2022
+ WORKIT_EXPECTED_API_HOST: approvedApiHost,
2023
+ ...(approvedMergeBaseCommit
2024
+ ? { WORKIT_EXPECTED_MERGE_BASE_COMMIT: approvedMergeBaseCommit }
2025
+ : {}),
1527
2026
  },
1528
2027
  root,
1529
2028
  );
1530
- return result.error || result.ok === false
1531
- ? unknown(request.operation)
1532
- : success(null, null, {
1533
- ...result,
1534
- babysit: request.payload.babysit ?? true,
1535
- babysitSkill: "workit-babysit",
1536
- ...((request.payload.babysit ?? true)
1537
- ? { next: prBabysitNext(String((result as { output?: unknown }).output ?? "")) }
1538
- : {}),
1539
- });
2029
+ if (result.error || result.ok === false) return unknown(request.operation);
2030
+ const babysit = request.payload.babysit === true;
2031
+ return success(null, null, {
2032
+ ...result,
2033
+ ...(request.payload.babysit !== undefined ? { babysit: request.payload.babysit } : {}),
2034
+ ...(babysit
2035
+ ? {
2036
+ babysitSkill: "workit-babysit",
2037
+ next: prBabysitNext(String(result.output ?? "")),
2038
+ }
2039
+ : {}),
2040
+ });
1540
2041
  }
1541
2042
  case "hosting.merge": {
1542
2043
  const target = String(request.payload.target_branch ?? "");
@@ -1546,11 +2047,83 @@ export const executeConcreteExternalAction = async (
1546
2047
  outcome: "not_started",
1547
2048
  fields: [{ path: "target_branch", reason: "required" }],
1548
2049
  });
1549
- const result = mergePr(root, { target, source });
2050
+ if (!approvedSourceCommit || !approvedRemote || !approvedAccount || !approvedApiHost)
2051
+ return failure("capability_unavailable", "approved merge target is incomplete", {
2052
+ outcome: "not_started",
2053
+ });
2054
+ const result = mergePr(root, {
2055
+ target,
2056
+ source,
2057
+ sourceCommit: approvedSourceCommit,
2058
+ remote: approvedRemote,
2059
+ account: approvedAccount,
2060
+ apiHost: approvedApiHost,
2061
+ });
1550
2062
  return result.error || result.ok === false
1551
2063
  ? unknown(request.operation)
1552
2064
  : success(null, null, result);
1553
2065
  }
2066
+ case "hosting.delete_branch": {
2067
+ const remote = pushRemote(root);
2068
+ const identity = remote ? pushRemoteIdentity(remote) : null;
2069
+ const target = remote ? safePushUrl(remote) : null;
2070
+ const auth = vcsCliIdentity(root);
2071
+ if (!approvedTip || !/^[a-f0-9]{40,64}$/.test(approvedTip))
2072
+ return failure("invalid_input", "approved branch tip is unavailable", {
2073
+ outcome: "not_started",
2074
+ });
2075
+ if (
2076
+ !remote ||
2077
+ !identity ||
2078
+ !target ||
2079
+ !pushTargetIsStable(root, remote) ||
2080
+ (approvedRemote && identity !== approvedRemote)
2081
+ )
2082
+ return failure(
2083
+ "capability_unavailable",
2084
+ "deletion destination is ambiguous, rewritten, or changed after approval",
2085
+ {
2086
+ outcome: "not_started",
2087
+ },
2088
+ );
2089
+ if (!auth.ok || !hostingApiHostMatches(remote, auth.host))
2090
+ return failure(
2091
+ "capability_unavailable",
2092
+ "hosting API host does not match the deletion destination",
2093
+ {
2094
+ outcome: "not_started",
2095
+ },
2096
+ );
2097
+ if (
2098
+ approvedAccount &&
2099
+ (!auth.ok || auth.username?.toLowerCase() !== approvedAccount.toLowerCase())
2100
+ )
2101
+ return failure(
2102
+ "capability_unavailable",
2103
+ "approved hosting account changed before execution",
2104
+ {
2105
+ outcome: "not_started",
2106
+ },
2107
+ );
2108
+ if (approvedApiHost && (!auth.ok || auth.host !== approvedApiHost))
2109
+ return failure("capability_unavailable", "approved hosting host changed before execution", {
2110
+ outcome: "not_started",
2111
+ });
2112
+ const branch = request.payload.branch;
2113
+ const result = run(root, [
2114
+ "push",
2115
+ `--force-with-lease=refs/heads/${branch}:${approvedTip}`,
2116
+ "--delete",
2117
+ target,
2118
+ branch,
2119
+ ]);
2120
+ if (result.exitCode === 0) return success(null, null, { branch, deletedTip: approvedTip });
2121
+ return /stale info|cannot lock ref|fetch first|non-fast-forward/i.test(result.stderr)
2122
+ ? failure("capability_unavailable", "remote branch tip changed before deletion", {
2123
+ outcome: "not_started",
2124
+ })
2125
+ : unknown(request.operation);
2126
+ }
1554
2127
  case "changelog.apply": {
1555
2128
  const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1556
2129
  if (
@@ -1643,12 +2216,19 @@ export const executeResolvedExternalAction = async (
1643
2216
  root: string,
1644
2217
  step?: string,
1645
2218
  caller?: { host: Caller["host"]; actor: string },
2219
+ expectedWorkspaceRevision?: string,
1646
2220
  ): Promise<Result<unknown>> => {
1647
2221
  const approvedDetails =
1648
2222
  resolved.descriptorPayload && typeof resolved.descriptorPayload === "object"
1649
2223
  ? (
1650
2224
  resolved.descriptorPayload as {
1651
- resolved?: { dateMs?: unknown; beforeDigest?: unknown; beforeExists?: unknown };
2225
+ resolved?: {
2226
+ dateMs?: unknown;
2227
+ beforeDigest?: unknown;
2228
+ beforeExists?: unknown;
2229
+ branchPolicyFingerprint?: unknown;
2230
+ commitPolicyFingerprint?: unknown;
2231
+ };
1652
2232
  }
1653
2233
  ).resolved
1654
2234
  : undefined;
@@ -1659,23 +2239,79 @@ export const executeResolvedExternalAction = async (
1659
2239
  ? approvedDetails.dateMs
1660
2240
  : undefined;
1661
2241
  const fresh = resolveExternalActionRequest(root, resolved.request, preservedDateMs);
1662
- if (!fresh.ok)
2242
+ if (!fresh.ok) {
2243
+ if (fresh.code === "permission_denied") return fresh;
1663
2244
  return failure(
1664
2245
  "capability_unavailable",
1665
2246
  "approved external action target changed before execution",
1666
2247
  { outcome: "not_started" },
1667
2248
  );
1668
- if (
1669
- externalActionDescriptor(resolved.request.operation, fresh.data.descriptorPayload) !==
1670
- externalActionDescriptor(resolved.request.operation, resolved.descriptorPayload)
1671
- )
2249
+ }
2250
+ const currentResolved = (
2251
+ fresh.data.descriptorPayload as {
2252
+ resolved?: {
2253
+ branchPolicyFingerprint?: unknown;
2254
+ commitPolicyFingerprint?: unknown;
2255
+ };
2256
+ }
2257
+ ).resolved;
2258
+ for (const [key, label] of [
2259
+ ["branchPolicyFingerprint", "branch naming policy"],
2260
+ ["commitPolicyFingerprint", "commit style policy"],
2261
+ ] as const) {
2262
+ const approvedFingerprint = approvedDetails?.[key];
2263
+ const currentFingerprint = currentResolved?.[key];
2264
+ if (
2265
+ (typeof approvedFingerprint === "string" || typeof currentFingerprint === "string") &&
2266
+ approvedFingerprint !== currentFingerprint
2267
+ )
2268
+ return failure(
2269
+ "permission_denied",
2270
+ `${label} changed since approval; approve the operation again`,
2271
+ { outcome: "not_started" },
2272
+ );
2273
+ }
2274
+ const freshDescriptor = externalActionDescriptor(
2275
+ resolved.request.operation,
2276
+ fresh.data.descriptorPayload,
2277
+ );
2278
+ const approvedDescriptor = externalActionDescriptor(
2279
+ resolved.request.operation,
2280
+ resolved.descriptorPayload,
2281
+ );
2282
+ const freshBranchIntent =
2283
+ resolved.request.operation === "git.branch_setup" ? branchSetupIntent(freshDescriptor) : null;
2284
+ const approvedBranchIntent =
2285
+ resolved.request.operation === "git.branch_setup"
2286
+ ? branchSetupIntent(approvedDescriptor)
2287
+ : null;
2288
+ const unchangedBranchIntent =
2289
+ freshBranchIntent !== null &&
2290
+ approvedBranchIntent !== null &&
2291
+ canonicalJson(freshBranchIntent) === canonicalJson(approvedBranchIntent);
2292
+ if (freshDescriptor !== approvedDescriptor && !unchangedBranchIntent)
1672
2293
  return failure(
1673
2294
  "capability_unavailable",
1674
2295
  "approved external action target changed before execution",
1675
2296
  { outcome: "not_started" },
1676
2297
  );
1677
2298
  const resolvedDetails = fresh.data.descriptorPayload as {
1678
- resolved?: { dateMs?: unknown; beforeDigest?: unknown; beforeExists?: unknown };
2299
+ resolved?: {
2300
+ dateMs?: unknown;
2301
+ beforeDigest?: unknown;
2302
+ beforeExists?: unknown;
2303
+ remote_base?: unknown;
2304
+ local_base?: unknown;
2305
+ target_exists?: unknown;
2306
+ tip?: unknown;
2307
+ remote?: unknown;
2308
+ account?: unknown;
2309
+ apiHost?: unknown;
2310
+ source_commit?: unknown;
2311
+ commit?: unknown;
2312
+ source_branch?: unknown;
2313
+ merge_base_commit?: unknown;
2314
+ };
1679
2315
  };
1680
2316
  const resolvedDate =
1681
2317
  typeof resolvedDetails.resolved?.dateMs === "number"
@@ -1691,9 +2327,60 @@ export const executeResolvedExternalAction = async (
1691
2327
  typeof resolvedDetails.resolved?.beforeExists === "boolean"
1692
2328
  ? resolvedDetails.resolved.beforeExists
1693
2329
  : undefined;
2330
+ const approvedTip =
2331
+ typeof resolvedDetails.resolved?.tip === "string" ? resolvedDetails.resolved.tip : undefined;
2332
+ const approvedRemote =
2333
+ typeof resolvedDetails.resolved?.remote === "string"
2334
+ ? resolvedDetails.resolved.remote
2335
+ : undefined;
2336
+ const approvedAccount =
2337
+ typeof resolvedDetails.resolved?.account === "string"
2338
+ ? resolvedDetails.resolved.account
2339
+ : undefined;
2340
+ const approvedApiHost =
2341
+ typeof resolvedDetails.resolved?.apiHost === "string"
2342
+ ? resolvedDetails.resolved.apiHost
2343
+ : undefined;
2344
+ const approvedSourceCommit =
2345
+ typeof resolvedDetails.resolved?.source_commit === "string"
2346
+ ? resolvedDetails.resolved.source_commit
2347
+ : undefined;
2348
+ const approvedCommit =
2349
+ typeof resolvedDetails.resolved?.commit === "string"
2350
+ ? resolvedDetails.resolved.commit
2351
+ : undefined;
2352
+ const approvedRemoteBase =
2353
+ typeof resolvedDetails.resolved?.remote_base === "string"
2354
+ ? resolvedDetails.resolved.remote_base
2355
+ : undefined;
2356
+ const approvedLocalBase =
2357
+ typeof resolvedDetails.resolved?.local_base === "string"
2358
+ ? resolvedDetails.resolved.local_base
2359
+ : resolvedDetails.resolved?.local_base === null
2360
+ ? null
2361
+ : undefined;
2362
+ const approvedSourceBranch =
2363
+ typeof resolvedDetails.resolved?.source_branch === "string"
2364
+ ? resolvedDetails.resolved.source_branch
2365
+ : undefined;
2366
+ const approvedMergeBaseCommit =
2367
+ typeof resolvedDetails.resolved?.merge_base_commit === "string"
2368
+ ? resolvedDetails.resolved.merge_base_commit
2369
+ : undefined;
2370
+ if (
2371
+ fresh.data.request.operation === "git.branch_setup" &&
2372
+ fresh.data.request.payload.action !== "reapply_stash" &&
2373
+ resolvedDetails.resolved?.target_exists === false &&
2374
+ !approvedRemoteBase
2375
+ )
2376
+ return failure("capability_unavailable", "approved remote base SHA could not be resolved", {
2377
+ outcome: "not_started",
2378
+ });
2379
+ const target = actionRoot(root, fresh.data.request);
2380
+ if (!target.ok) return target;
1694
2381
  const result = await executeConcreteExternalAction(
1695
2382
  fresh.data.request,
1696
- root,
2383
+ target.data,
1697
2384
  fresh.data.marker,
1698
2385
  resolvedDate,
1699
2386
  step,
@@ -1701,9 +2388,36 @@ export const executeResolvedExternalAction = async (
1701
2388
  caller,
1702
2389
  beforeDigest,
1703
2390
  beforeExists,
2391
+ root,
2392
+ approvedTip,
2393
+ approvedRemote,
2394
+ approvedAccount,
2395
+ approvedApiHost,
2396
+ approvedSourceCommit,
2397
+ approvedCommit,
2398
+ approvedRemoteBase,
2399
+ approvedLocalBase,
2400
+ undefined,
2401
+ approvedSourceBranch,
2402
+ approvedMergeBaseCommit,
2403
+ expectedWorkspaceRevision,
1704
2404
  );
2405
+ if (result.ok && fresh.data.request.operation === "hosting.pull_request") {
2406
+ const observed = await readHostingAction(target.data, fresh.data, {
2407
+ kind: "host",
2408
+ host: caller?.host ?? "workit_cli",
2409
+ handle: `post-create:${fresh.data.marker ?? "unknown"}`,
2410
+ });
2411
+ const output = (result.data as { output?: unknown }).output;
2412
+ const id =
2413
+ typeof output === "string"
2414
+ ? /\/(?:pull|merge_requests)\/(\d+)(?:[/?#]|$)/.exec(output)?.[1]
2415
+ : null;
2416
+ if (!observed.ok || !id || String(observed.data.data?.id) !== id)
2417
+ return unknown("hosting.pull_request");
2418
+ }
1705
2419
  if (!result.ok && result.code === "external_outcome_unknown" && caller) {
1706
- const observed = await readLocalAction(root, fresh.data, {
2420
+ const observed = await readExternalAction(root, fresh.data, {
1707
2421
  kind: "host",
1708
2422
  host: caller.host,
1709
2423
  handle: `local-recovery:${fresh.data.request.operation}`,