@brainervirus/workit-core 1.2.0 → 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 (46) 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 +31 -8
  17. package/src/core/auto-approval.ts +44 -32
  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 +1102 -209
  25. package/src/core/external-action.ts +230 -103
  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 +16 -3
  38. package/src/core/task-engine.ts +136 -31
  39. package/src/core/task-evaluation.ts +3 -5
  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/workers.ts +17 -0
  44. package/src/core/workspaces.ts +548 -46
  45. package/src/core.ts +1 -2
  46. 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,16 +36,29 @@ import {
25
36
  } from "./youtrack";
26
37
  import fs from "node:fs";
27
38
  import path from "node:path";
28
- import { failure, success, sha256, type Caller, type Result, type Ref } from "./task-contract";
39
+ import { spawnSync } from "node:child_process";
40
+ import {
41
+ canonicalJson,
42
+ failure,
43
+ success,
44
+ sha256,
45
+ type Caller,
46
+ type Owner,
47
+ type Result,
48
+ type Ref,
49
+ } from "./task-contract";
29
50
  import { changelogApply, changelogApplyPreview } from "./changelog";
30
51
  import type { ExternalActionRequest } from "./external-action";
31
- import { externalActionDescriptor, externalActionRequest } from "./external-action";
52
+ import {
53
+ branchSetupIntent,
54
+ externalActionDescriptor,
55
+ externalActionRequest,
56
+ } from "./external-action";
32
57
  import { normalizeChainSteps } from "./authority";
33
58
  import { resolveInside, run as coreRun } from "../core";
34
- import { vcsConfig } from "./vcs-config";
35
- import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
36
- import { assertProductWriteAllowed } from "./workers";
37
- import { TaskStore } from "./task-store";
59
+ import { hostingApiHostMatches, vcsCliIdentity, vcsConfig } from "./vcs-config";
60
+ import { assertProductWriteAllowed, currentWriterOwnsTask } from "./workers";
61
+ import { sameDirectoryIdentity, TaskStore } from "./task-store";
38
62
 
39
63
  const run = (root: string, args: string[]) => {
40
64
  return coreRun(root, "git", args);
@@ -70,10 +94,6 @@ const stagedPaths = (root: string): string[] | null => {
70
94
  return [...new Set(paths)];
71
95
  };
72
96
 
73
- const pushRemote = (root: string): string | null =>
74
- gitValue(root, ["remote", "get-url", "--push", "origin"]) ??
75
- gitValue(root, ["remote", "get-url", "origin"]);
76
-
77
97
  type RemoteParts = { host: string; path: string; protocol: string };
78
98
 
79
99
  const remoteParts = (value: string): RemoteParts | null => {
@@ -100,11 +120,33 @@ const remoteParts = (value: string): RemoteParts | null => {
100
120
  }
101
121
  };
102
122
 
103
- const credentialFreeRemote = (value: string): string | null => {
104
- const parts = remoteParts(value);
105
- 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
+ }
106
145
  };
107
146
 
147
+ const bindsActionTarget = (operation: ExternalActionRequest["operation"]): boolean =>
148
+ operation === "context.read" || operation.startsWith("git.") || operation.startsWith("hosting.");
149
+
108
150
  const unknown = (operation: string) =>
109
151
  failure("external_outcome_unknown", "external action outcome is unknown", { operation });
110
152
 
@@ -201,26 +243,34 @@ const localAction = (operation: ExternalActionRequest["operation"]): boolean =>
201
243
  operation === "git.commit" ||
202
244
  operation === "git.push" ||
203
245
  operation === "hosting.pull_request" ||
246
+ operation === "hosting.merge" ||
247
+ operation === "hosting.delete_branch" ||
204
248
  operation === "changelog.apply";
205
249
 
250
+ const targetWriterActionLease = Symbol("target-writer-action-lease");
251
+
206
252
  export const assertLocalExternalActionWriter = (
207
253
  root: string,
208
254
  caller: { host: Caller["host"]; actor: string },
209
255
  paths: string[] = ["."],
210
- ): Result<null> => {
256
+ expectedWorkspaceRevision?: string,
257
+ ): Result<Owner> => {
211
258
  const store = new TaskStore(root);
212
259
  const listed = store.listTasks();
213
260
  const workspace = store.readWorkspace();
214
261
  if (!listed.ok) return listed as Result<never>;
215
262
  if (!workspace.ok) return workspace as Result<never>;
216
263
  if (!workspace.data) return failure("not_found", "workspace not found");
217
- const candidates = listed.data.filter(
218
- (task) =>
219
- task.status === "active" &&
220
- task.workspaceId === workspace.data!.id &&
221
- task.intent.provenance.session?.kind === "host" &&
222
- task.intent.provenance.session.host === caller.host &&
223
- task.intent.provenance.session.handle === caller.actor,
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
+ });
272
+ const candidates = listed.data.filter((task) =>
273
+ currentWriterOwnsTask(task, workspace.data!, caller),
224
274
  );
225
275
  if (candidates.length !== 1)
226
276
  return failure("permission_denied", "local external action requires exactly one active task");
@@ -231,7 +281,7 @@ export const assertLocalExternalActionWriter = (
231
281
  paths,
232
282
  store,
233
283
  });
234
- return allowed.ok ? success(null, null, null) : (allowed as Result<never>);
284
+ return allowed;
235
285
  };
236
286
 
237
287
  const safePath = (root: string, value: string): string | null => {
@@ -248,28 +298,41 @@ const branchCreationBaseline = (
248
298
  root: string,
249
299
  target: string,
250
300
  base: string,
251
- ): { base_branch: string; target_exists: boolean; remote_base: string | null } => {
301
+ ): {
302
+ base_branch: string;
303
+ target_exists: boolean;
304
+ local_base: string | null;
305
+ remote_base: string | null;
306
+ target_head: string | null;
307
+ } => {
252
308
  const exists =
253
309
  gitValue(root, ["rev-parse", "--verify", "--quiet", `refs/heads/${target}`]) !== null;
254
- if (exists) return { base_branch: base, target_exists: true, remote_base: null };
310
+ if (exists)
311
+ return {
312
+ base_branch: base,
313
+ target_exists: true,
314
+ local_base: null,
315
+ remote_base: null,
316
+ target_head: gitValue(root, ["rev-parse", `refs/heads/${target}`]),
317
+ };
255
318
  const remote = run(root, ["ls-remote", "origin", `refs/heads/${base}`]);
256
319
  const sha = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : "";
320
+ const remoteBase = /^[0-9a-f]{40}$/.test(sha ?? "") ? (sha as string) : null;
257
321
  return {
258
322
  base_branch: base,
259
323
  target_exists: false,
260
- remote_base: /^[0-9a-f]{40}$/.test(sha ?? "") ? (sha as string) : null,
324
+ local_base: gitValue(root, ["rev-parse", "--verify", `refs/heads/${base}`]),
325
+ remote_base: remoteBase,
326
+ target_head: remoteBase,
261
327
  };
262
328
  };
263
329
 
264
330
  const shortSha = (value: unknown): string =>
265
331
  typeof value === "string" && value ? value.slice(0, 8) : "unknown";
266
332
 
267
- const isProtectedBranch = (root: string, branch: string): boolean =>
268
- resolveBranchPolicyFor(root).protected.has(branch.toLowerCase());
269
-
270
333
  /** Concise, user-facing approval text for one resolved action. The exact
271
334
  * descriptor stays machine-facing; this is what the native question shows. */
272
- export const actionProposalQuestion = (
335
+ const actionProposalAtRoot = (
273
336
  request: ExternalActionRequest,
274
337
  descriptorPayload: unknown,
275
338
  ): { presented: string; approvedText: string } => {
@@ -329,14 +392,19 @@ export const actionProposalQuestion = (
329
392
  approvedText: `Switch to \`${target}\`.`,
330
393
  };
331
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);
332
400
  return stashes
333
401
  ? {
334
- presented: `Workit decision: action — Stash the dirty working tree and create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
335
- 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}).`,
336
404
  }
337
405
  : {
338
- presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
339
- 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}).`,
340
408
  };
341
409
  }
342
410
  case "git.commit": {
@@ -348,18 +416,29 @@ export const actionProposalQuestion = (
348
416
  }
349
417
  case "git.push":
350
418
  return {
351
- presented: `Workit decision: action — Push branch \`${String(payload.branch ?? "")}\` to origin?`,
352
- 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)}` : ""}.`,
353
421
  };
354
- 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
+ : "";
355
428
  return {
356
- presented: `Workit decision: action — Open a PR from \`${String(resolved.source_branch ?? "")}\` to \`${payloadTarget}\` titled "${String(request.payload.title)}"?`,
357
- 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)}".`,
358
431
  };
432
+ }
359
433
  case "hosting.merge":
360
434
  return {
361
- presented: `Workit decision: action — Merge \`${String(resolved.source_branch ?? "")}\` into \`${payloadTarget}\` (squash, delete branch)?`,
362
- 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")}.`,
363
442
  };
364
443
  case "changelog.apply":
365
444
  return {
@@ -389,6 +468,17 @@ export const actionProposalQuestion = (
389
468
  }
390
469
  };
391
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
+
392
482
  export type ResolvedExternalAction = {
393
483
  request: ExternalActionRequest;
394
484
  descriptorPayload: unknown;
@@ -408,8 +498,12 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
408
498
  try {
409
499
  const value = JSON.parse(content) as { operation?: unknown; payload?: unknown };
410
500
  if (
501
+ value.operation !== "git.branch_setup" &&
502
+ value.operation !== "git.commit" &&
503
+ value.operation !== "git.push" &&
411
504
  value.operation !== "hosting.pull_request" &&
412
505
  value.operation !== "hosting.merge" &&
506
+ value.operation !== "hosting.delete_branch" &&
413
507
  value.operation !== "changelog.apply" &&
414
508
  value.operation !== "youtrack.update" &&
415
509
  value.operation !== "youtrack.time" &&
@@ -425,6 +519,28 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
425
519
  if (!request.ok || !resolved || typeof resolved !== "object")
426
520
  return failure("invalid_input", "approved hosting target is incomplete");
427
521
  const target = resolved as Record<string, unknown>;
522
+ if (
523
+ value.operation === "git.commit" &&
524
+ (typeof target.head !== "string" ||
525
+ typeof target.staged !== "string" ||
526
+ typeof payload.message !== "string")
527
+ )
528
+ return failure("invalid_input", "approved Git commit target is incomplete");
529
+ if (
530
+ value.operation === "git.push" &&
531
+ (typeof target.branch !== "string" ||
532
+ typeof target.commit !== "string" ||
533
+ typeof target.remote !== "string")
534
+ )
535
+ return failure("invalid_input", "approved Git push target is incomplete");
536
+ if (
537
+ value.operation === "git.branch_setup" &&
538
+ (typeof target.head !== "string" ||
539
+ (payload.action === "reapply_stash"
540
+ ? typeof target.stash_commit !== "string"
541
+ : typeof payload.target_branch !== "string" || typeof target.target_head !== "string"))
542
+ )
543
+ return failure("invalid_input", "approved Git branch target is incomplete");
428
544
  if (
429
545
  value.operation === "hosting.pull_request" &&
430
546
  (typeof target.marker !== "string" ||
@@ -443,6 +559,13 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
443
559
  typeof payload.target_branch !== "string")
444
560
  )
445
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");
446
569
  if (
447
570
  value.operation === "changelog.apply" &&
448
571
  (typeof target.target !== "string" ||
@@ -452,7 +575,12 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
452
575
  )
453
576
  return failure("invalid_input", "approved changelog target is incomplete");
454
577
  if (
578
+ value.operation !== "git.branch_setup" &&
579
+ value.operation !== "git.commit" &&
580
+ value.operation !== "git.push" &&
455
581
  value.operation !== "hosting.pull_request" &&
582
+ value.operation !== "hosting.merge" &&
583
+ value.operation !== "hosting.delete_branch" &&
456
584
  value.operation !== "changelog.apply" &&
457
585
  (typeof target.marker !== "string" ||
458
586
  typeof target.baseUrl !== "string" ||
@@ -463,7 +591,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
463
591
  return success(null, null, {
464
592
  request: request.data,
465
593
  descriptorPayload: value.payload,
466
- marker: String(target.marker),
594
+ ...(typeof target.marker === "string" ? { marker: target.marker } : {}),
467
595
  });
468
596
  } catch {
469
597
  return failure("invalid_input", "approved action descriptor is invalid");
@@ -477,16 +605,151 @@ const remoteRepo = (value: string): string | null => {
477
605
  return repo.includes("/") ? repo : null;
478
606
  };
479
607
 
480
- const remoteIdentity = (value: string): string | null => {
481
- const parts = remoteParts(value);
482
- return parts ? `${parts.host}${parts.path.replace(/\.git$/, "")}` : null;
483
- };
608
+ const remoteIdentity = (value: string): string | null => pushRemoteIdentity(value);
484
609
 
485
610
  const remoteProvider = (value: string): "github" | "gitlab" | null => {
486
611
  const host = remoteParts(value)?.host;
487
612
  return host === "github.com" ? "github" : host === "gitlab.com" ? "gitlab" : null;
488
613
  };
489
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
+
490
753
  const unknownHostingEvidence = (operation: string): Result<HostingReadEvidence> =>
491
754
  failure("external_outcome_unknown", "hosting action outcome remains unknown", {
492
755
  operation,
@@ -499,17 +762,15 @@ export const readHostingAction = async (
499
762
  resolved: ResolvedExternalAction,
500
763
  actionRef: Ref,
501
764
  ): Promise<Result<HostingReadEvidence>> => {
502
- if (resolved.request.operation !== "hosting.pull_request" || !resolved.marker)
503
- return unknownHostingEvidence("hosting.pull_request");
765
+ const operation = resolved.request.operation;
766
+ if ((operation !== "hosting.pull_request" && operation !== "hosting.merge") || !resolved.marker)
767
+ return unknownHostingEvidence(operation);
504
768
  try {
505
769
  const cfg = vcsConfig("load", root);
506
- if (
507
- !cfg.ok ||
508
- !cfg.tokenPath ||
509
- !cfg.provider ||
510
- (cfg.provider !== "github" && cfg.provider !== "gitlab")
511
- )
512
- return unknownHostingEvidence("hosting.pull_request");
770
+ if (!cfg.ok || !cfg.provider || (cfg.provider !== "github" && cfg.provider !== "gitlab"))
771
+ return unknownHostingEvidence(operation);
772
+ const auth = vcsCliIdentity(root);
773
+ if (!auth.ok || auth.provider !== cfg.provider) return unknownHostingEvidence(operation);
513
774
  const remote = pushRemote(root);
514
775
  const repo = remote ? remoteRepo(remote) : null;
515
776
  const approved = resolved.descriptorPayload as {
@@ -524,18 +785,6 @@ export const readHostingAction = async (
524
785
  const currentIdentity = remote ? remoteIdentity(remote) : null;
525
786
  const approvedIdentity =
526
787
  typeof approvedRemote === "string" ? remoteIdentity(approvedRemote) : null;
527
- const apiHost =
528
- cfg.provider === "github"
529
- ? "github.com"
530
- : (() => {
531
- try {
532
- return new URL(
533
- cfg.gitlab?.apiUrl ?? "https://gitlab.com/api/v4",
534
- ).hostname.toLowerCase();
535
- } catch {
536
- return "";
537
- }
538
- })();
539
788
  if (
540
789
  !repo ||
541
790
  typeof branch !== "string" ||
@@ -545,29 +794,29 @@ export const readHostingAction = async (
545
794
  !currentIdentity ||
546
795
  currentIdentity !== approvedIdentity ||
547
796
  repo !== remoteRepo(approvedRemote) ||
548
- (remoteProvider(approvedRemote) !== null &&
549
- remoteProvider(approvedRemote) !== cfg.provider) ||
550
- remoteParts(approvedRemote)?.host !== apiHost
797
+ hostingProvider(root, approvedRemote) !== cfg.provider ||
798
+ !hostingApiHostMatches(approvedRemote, auth.host) ||
799
+ (typeof source?.apiHost === "string" && source.apiHost !== auth.host)
551
800
  )
552
- return unknownHostingEvidence("hosting.pull_request");
553
- const token = fs.readFileSync(cfg.tokenPath, "utf8").trim();
554
- const headers = { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" };
555
- const url =
801
+ return unknownHostingEvidence(operation);
802
+ const endpoint =
556
803
  cfg.provider === "github"
557
- ? `https://api.github.com/repos/${repo}/pulls?state=all&head=${encodeURIComponent(`${repo.split("/")[0]}:${branch}`)}&base=${encodeURIComponent(target)}&per_page=100&page=1`
558
- : `${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`;
559
- const response = await fetch(url, {
560
- 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),
561
811
  });
562
- if (!response.ok) return unknownHostingEvidence("hosting.pull_request");
563
- const values: unknown = await response.json();
812
+ if (response.status !== 0) return unknownHostingEvidence(operation);
813
+ const values: unknown = JSON.parse(response.stdout ?? "");
564
814
  // A full page is intentionally treated as truncated until pagination is added.
565
- if (!Array.isArray(values) || values.length >= 100)
566
- return unknownHostingEvidence("hosting.pull_request");
815
+ if (!Array.isArray(values) || values.length >= 100) return unknownHostingEvidence(operation);
567
816
  const records: Record<string, unknown>[] = [];
568
817
  for (const item of values) {
569
818
  if (!item || typeof item !== "object" || Array.isArray(item))
570
- return unknownHostingEvidence("hosting.pull_request");
819
+ return unknownHostingEvidence(operation);
571
820
  const record = item as Record<string, unknown>;
572
821
  const body = record.body ?? record.description;
573
822
  const base = record.base;
@@ -609,35 +858,48 @@ export const readHostingAction = async (
609
858
  typeof shaValue !== "string" ||
610
859
  (typeof idValue !== "string" && typeof idValue !== "number")
611
860
  )
612
- return unknownHostingEvidence("hosting.pull_request");
861
+ return unknownHostingEvidence(operation);
613
862
  records.push(record);
614
863
  }
615
- const matches = records.filter((item): item is Record<string, unknown> => {
616
- const body = String(
617
- (item as Record<string, unknown>).body ??
618
- (item as Record<string, unknown>).description ??
619
- "",
620
- );
621
- const base = (item as Record<string, unknown>).base as Record<string, unknown> | undefined;
622
- const head = (item as Record<string, unknown>).head as Record<string, unknown> | undefined;
623
- const itemTarget =
624
- cfg.provider === "github" ? base?.ref : (item as Record<string, unknown>).target_branch;
625
- const itemSource =
626
- cfg.provider === "github" ? head?.ref : (item as Record<string, unknown>).source_branch;
627
- const itemSha = cfg.provider === "github" ? head?.sha : (item as Record<string, unknown>).sha;
864
+ const matches = records.filter((item) => {
865
+ const body = String(item.body ?? item.description ?? "");
866
+ const base = item.base as Record<string, unknown> | undefined;
867
+ const head = item.head as Record<string, unknown> | undefined;
868
+ const itemTarget = cfg.provider === "github" ? base?.ref : item.target_branch;
869
+ const itemSource = cfg.provider === "github" ? head?.ref : item.source_branch;
870
+ const itemSha = cfg.provider === "github" ? head?.sha : item.sha;
871
+ const merged =
872
+ cfg.provider === "github"
873
+ ? typeof item.merged_at === "string"
874
+ : item.state === "merged" || typeof item.merged_at === "string";
628
875
  return (
629
- body.includes(resolved.marker!) &&
876
+ (operation === "hosting.merge" ? merged : body.includes(resolved.marker!)) &&
630
877
  itemTarget === target &&
631
878
  itemSource === branch &&
632
879
  itemSha === sourceCommit
633
880
  );
634
881
  });
635
- if (matches.length !== 1) return unknownHostingEvidence("hosting.pull_request");
882
+ if (
883
+ operation === "hosting.merge" &&
884
+ records.some((item) => {
885
+ const base = item.base as Record<string, unknown> | undefined;
886
+ const head = item.head as Record<string, unknown> | undefined;
887
+ return (
888
+ (cfg.provider === "github" ? base?.ref : item.target_branch) === target &&
889
+ (cfg.provider === "github" ? head?.ref : item.source_branch) === branch &&
890
+ (cfg.provider === "github"
891
+ ? typeof item.merged_at !== "string"
892
+ : item.state !== "merged" && typeof item.merged_at !== "string")
893
+ );
894
+ })
895
+ )
896
+ return unknownHostingEvidence(operation);
897
+ if (matches.length !== 1) return unknownHostingEvidence(operation);
636
898
  const item = matches[0];
637
899
  const id = item.number ?? item.iid ?? item.id;
638
- if (typeof id !== "string" && typeof id !== "number")
639
- return unknownHostingEvidence("hosting.pull_request");
900
+ if (typeof id !== "string" && typeof id !== "number") return unknownHostingEvidence(operation);
640
901
  const evidenceDigest = sha256({
902
+ operation,
641
903
  provider: cfg.provider,
642
904
  id,
643
905
  marker: resolved.marker,
@@ -658,7 +920,7 @@ export const readHostingAction = async (
658
920
  data: { provider: cfg.provider, id },
659
921
  });
660
922
  } catch {
661
- return unknownHostingEvidence("hosting.pull_request");
923
+ return unknownHostingEvidence(operation);
662
924
  }
663
925
  };
664
926
 
@@ -812,14 +1074,152 @@ export const readYouTrackAction = async (
812
1074
  }
813
1075
  };
814
1076
 
1077
+ export const readLocalAction = async (
1078
+ root: string,
1079
+ resolved: ResolvedExternalAction,
1080
+ actionRef: Ref,
1081
+ ): Promise<Result<HostingReadEvidence>> => {
1082
+ const operation = resolved.request.operation;
1083
+ const target = (resolved.descriptorPayload as { resolved?: Record<string, unknown> }).resolved;
1084
+ if (!target) return unknownHostingEvidence(operation);
1085
+ let observed: Record<string, unknown> | null = null;
1086
+ if (operation === "git.commit") {
1087
+ const commit = gitValue(root, ["rev-parse", "HEAD"]);
1088
+ const parent = commit ? gitValue(root, ["rev-parse", `${commit}^`]) : null;
1089
+ const message = commit ? gitValue(root, ["show", "-s", "--format=%B", commit]) : null;
1090
+ const diff =
1091
+ commit && parent
1092
+ ? gitRaw(root, ["diff", "--raw", "-z", "--no-ext-diff", "--no-textconv", parent, commit])
1093
+ : null;
1094
+ if (
1095
+ commit &&
1096
+ parent === target.head &&
1097
+ message === resolved.request.payload.message &&
1098
+ diff !== null &&
1099
+ sha256(diff) === target.staged
1100
+ )
1101
+ observed = { commit, parent, message, staged: target.staged };
1102
+ } else if (operation === "git.push") {
1103
+ const branch = target.branch;
1104
+ const commit = target.commit;
1105
+ const approvedRemote = target.remote;
1106
+ const currentRemote = pushRemote(root);
1107
+ const currentIdentity = currentRemote ? credentialFreeRemote(currentRemote) : null;
1108
+ if (
1109
+ typeof branch === "string" &&
1110
+ typeof commit === "string" &&
1111
+ typeof approvedRemote === "string" &&
1112
+ currentIdentity === approvedRemote
1113
+ ) {
1114
+ const remote = run(root, ["ls-remote", currentRemote!, `refs/heads/${branch}`]);
1115
+ const remoteCommit = remote.exitCode === 0 ? remote.stdout.trim().split(/\s+/)[0] : null;
1116
+ if (remoteCommit === commit) observed = { branch, commit, remote: approvedRemote };
1117
+ }
1118
+ } else if (operation === "git.branch_setup") {
1119
+ const targetBranch = resolved.request.payload.target_branch;
1120
+ const sdd = resolved.request.payload.sdd_dir;
1121
+ if (resolved.request.payload.action === "reapply_stash" && typeof sdd === "string") {
1122
+ try {
1123
+ const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
1124
+ stash_ref?: unknown;
1125
+ };
1126
+ const stashes = gitRaw(root, ["stash", "list", "--format=%H"]);
1127
+ if (
1128
+ typeof target.stash_commit === "string" &&
1129
+ manifest.stash_ref === undefined &&
1130
+ stashes !== null &&
1131
+ !stashes.split("\n").includes(target.stash_commit)
1132
+ )
1133
+ observed = { stash_commit: target.stash_commit };
1134
+ } catch {}
1135
+ } else if (
1136
+ typeof targetBranch === "string" &&
1137
+ typeof sdd === "string" &&
1138
+ gitValue(root, ["branch", "--show-current"]) === targetBranch &&
1139
+ typeof target.target_head === "string" &&
1140
+ gitValue(root, ["rev-parse", "HEAD"]) === target.target_head
1141
+ ) {
1142
+ try {
1143
+ const manifest = JSON.parse(fs.readFileSync(path.join(sdd, "manifest.json"), "utf8")) as {
1144
+ branch?: unknown;
1145
+ };
1146
+ if (manifest.branch === targetBranch) observed = { branch: targetBranch };
1147
+ } catch {}
1148
+ }
1149
+ } else if (operation === "changelog.apply") {
1150
+ const afterDigest = target.afterDigest;
1151
+ const preview = changelogApplyPreview({ ...resolved.request.payload, workspace_root: root });
1152
+ if (!preview.error && typeof afterDigest === "string" && preview.beforeDigest === afterDigest)
1153
+ observed = { target: target.target, afterDigest };
1154
+ }
1155
+ if (!observed) return unknownHostingEvidence(operation);
1156
+ const evidenceDigest = sha256({ operation, observed });
1157
+ return success(null, null, {
1158
+ outcome: "succeeded",
1159
+ evidenceDigest,
1160
+ observation: {
1161
+ kind: "provider_read",
1162
+ actionRef,
1163
+ outcome: "succeeded",
1164
+ evidenceDigest,
1165
+ },
1166
+ });
1167
+ };
1168
+
815
1169
  export const readExternalAction = async (
816
1170
  root: string,
817
1171
  resolved: ResolvedExternalAction,
818
1172
  actionRef: Ref,
819
- ): Promise<Result<HostingReadEvidence>> =>
820
- resolved.request.operation === "hosting.pull_request"
821
- ? readHostingAction(root, resolved, actionRef)
822
- : readYouTrackAction(root, resolved, actionRef);
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"
1181
+ ? readLocalAction(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
+ };
823
1223
 
824
1224
  const youTrackBase = (): string | null => {
825
1225
  try {
@@ -862,11 +1262,13 @@ export const upgradeBranchSetupForStash = (
862
1262
  resolved: Result<ResolvedExternalAction>,
863
1263
  ): Result<ResolvedExternalAction> => {
864
1264
  if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
1265
+ const target = actionRoot(root, parsed);
865
1266
  if (
866
1267
  (resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
867
1268
  true ||
868
1269
  (resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
869
- classifyBranchDirt(root) !== "stash-required"
1270
+ !target.ok ||
1271
+ classifyBranchDirt(target.data) !== "stash-required"
870
1272
  )
871
1273
  return resolved;
872
1274
  const upgraded = resolveExternalActionRequest(root, {
@@ -876,7 +1278,7 @@ export const upgradeBranchSetupForStash = (
876
1278
  return upgraded.ok ? upgraded : resolved;
877
1279
  };
878
1280
 
879
- export const resolveExternalActionRequest = (
1281
+ const resolveExternalActionAtRoot = (
880
1282
  root: string,
881
1283
  request: ExternalActionRequest,
882
1284
  preservedDateMs?: number,
@@ -887,7 +1289,8 @@ export const resolveExternalActionRequest = (
887
1289
  const sdd_dir = safePath(root, request.payload.sdd_dir ?? "docs");
888
1290
  if (!sdd_dir) return failure("invalid_input", "sdd_dir must stay inside the workspace");
889
1291
  const action = request.payload.action ?? "setup";
890
- const policy = resolveBranchPolicyFor(root);
1292
+ const policySnapshot = branchPolicySnapshotFor(root);
1293
+ const policy = policySnapshot.branchPolicy;
891
1294
  const target_branch = request.payload.target_branch;
892
1295
  if (action !== "reapply_stash" && !target_branch)
893
1296
  return failure(
@@ -898,6 +1301,18 @@ export const resolveExternalActionRequest = (
898
1301
  fields: [{ path: "target_branch", reason: "required for action=setup" }],
899
1302
  },
900
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
+ }
901
1316
  const head = gitValue(root, ["rev-parse", "HEAD"]);
902
1317
  if (!head) return failure("storage_error", "current Git commit could not be resolved");
903
1318
  const dirty = Boolean((gitRaw(root, ["status", "--short"]) ?? "").trim());
@@ -905,6 +1320,17 @@ export const resolveExternalActionRequest = (
905
1320
  action === "reapply_stash" || !target_branch
906
1321
  ? { base_branch: policy.defaultTargetBranch }
907
1322
  : branchCreationBaseline(root, target_branch, policy.defaultTargetBranch);
1323
+ let stashTarget: { stash_ref: string; stash_commit: string } | undefined;
1324
+ if (action === "reapply_stash") {
1325
+ try {
1326
+ const manifest = JSON.parse(
1327
+ fs.readFileSync(path.join(sdd_dir, "manifest.json"), "utf8"),
1328
+ ) as { stash_ref?: unknown };
1329
+ const stash_ref = typeof manifest.stash_ref === "string" ? manifest.stash_ref : "";
1330
+ const stash_commit = stash_ref ? gitValue(root, ["rev-parse", stash_ref]) : null;
1331
+ if (stash_ref && stash_commit) stashTarget = { stash_ref, stash_commit };
1332
+ } catch {}
1333
+ }
908
1334
  const normalized = {
909
1335
  operation: request.operation,
910
1336
  payload: {
@@ -919,24 +1345,52 @@ export const resolveExternalActionRequest = (
919
1345
  request: normalized,
920
1346
  descriptorPayload: {
921
1347
  ...normalized.payload,
922
- resolved: { head, dirty, ...creation },
1348
+ resolved: {
1349
+ head,
1350
+ dirty,
1351
+ ...creation,
1352
+ ...stashTarget,
1353
+ ...(target_branch ? { branchPolicyFingerprint: policySnapshot.fingerprint } : {}),
1354
+ },
923
1355
  },
924
1356
  });
925
1357
  }
926
1358
  case "git.push": {
927
1359
  const branch = request.payload.branch ?? gitValue(root, ["branch", "--show-current"]);
928
- if (branch && isProtectedBranch(root, branch))
929
- return failure("invalid_input", `cannot push protected branch ${branch}`, {
930
- outcome: "not_started",
931
- fields: [{ path: "branch", reason: "protected branch" }],
932
- });
933
1360
  const commit = branch ? gitValue(root, ["rev-parse", branch]) : null;
934
1361
  const remote = pushRemote(root);
935
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
+ );
936
1377
  return branch && commit && identity
937
1378
  ? success(null, null, {
938
- request: { operation: request.operation, payload: { branch } },
939
- 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
+ },
940
1394
  })
941
1395
  : failure("invalid_input", "git push requires a resolvable current branch");
942
1396
  }
@@ -957,6 +1411,36 @@ export const resolveExternalActionRequest = (
957
1411
  fields: [{ path: "plan_steps", reason: "unique steps" }],
958
1412
  },
959
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
+ }
960
1444
  if (typeof planBranch !== "string" || !planBranch.trim())
961
1445
  return failure("invalid_input", "plan_branch must name the working branch", {
962
1446
  outcome: "not_started",
@@ -982,17 +1466,18 @@ export const resolveExternalActionRequest = (
982
1466
  ],
983
1467
  },
984
1468
  );
985
- if (isProtectedBranch(root, planBranch))
986
- return failure("invalid_input", "plan_branch must not be a protected branch", {
987
- outcome: "not_started",
988
- fields: [{ path: "plan_branch", reason: "protected branch" }],
989
- });
990
1469
  return success(null, null, {
991
1470
  request,
992
1471
  descriptorPayload: {
993
1472
  plan_steps: planSteps,
994
1473
  plan_branch: planBranch,
995
- 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
+ },
996
1481
  },
997
1482
  });
998
1483
  }
@@ -1014,31 +1499,28 @@ export const resolveExternalActionRequest = (
1014
1499
  });
1015
1500
  if (staged === null || paths === null)
1016
1501
  return failure("storage_error", "Git state could not be resolved");
1017
- let policy: { preset: string; pattern?: string };
1018
- try {
1019
- policy = resolveCommitPolicyFor(root);
1020
- } catch (error) {
1021
- return failure(
1022
- "invalid_input",
1023
- `Commit policy config is malformed: ${error instanceof Error ? error.message : error}`,
1024
- );
1025
- }
1026
- let flavor = policy.preset;
1027
- if (flavor === "auto") {
1028
- const log = gitRaw(root, ["log", "--format=%s", "-30", "HEAD"]);
1029
- const detected = detectCommitFlavor((log ?? "").split("\n").filter(Boolean));
1030
- flavor = detected.flavor ?? "conventional";
1031
- }
1032
- if (!matchCommitFlavor(message, flavor as CommitFlavor, policy.pattern))
1033
- return failure(
1034
- "invalid_input",
1035
- `Commit message does not match the ${flavor} flavor (commitPolicy.preset)`,
1036
- );
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
+ });
1037
1513
  return success(null, null, {
1038
1514
  request,
1039
1515
  descriptorPayload: {
1040
1516
  ...request.payload,
1041
- resolved: { head, branch, staged: sha256(staged), paths },
1517
+ resolved: {
1518
+ head,
1519
+ branch,
1520
+ staged: sha256(staged),
1521
+ paths,
1522
+ commitPolicyFingerprint: commitPolicy.fingerprint,
1523
+ },
1042
1524
  },
1043
1525
  });
1044
1526
  }
@@ -1046,45 +1528,45 @@ export const resolveExternalActionRequest = (
1046
1528
  const target_branch =
1047
1529
  request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
1048
1530
  const source_branch = gitValue(root, ["branch", "--show-current"]);
1049
- if (source_branch && isProtectedBranch(root, source_branch))
1050
- return failure(
1051
- "invalid_input",
1052
- `cannot open a PR from protected branch ${source_branch}`,
1053
- {
1054
- outcome: "not_started",
1055
- fields: [{ path: "source_branch", reason: "protected branch" }],
1056
- },
1057
- );
1058
- 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;
1059
1534
  const remote = pushRemote(root);
1060
1535
  const identity = remote ? credentialFreeRemote(remote) : null;
1536
+ const vcs = vcsConfig("load", root);
1537
+ const provider = remote ? hostingProvider(root, remote) : null;
1061
1538
  if (
1062
1539
  !source_branch ||
1063
1540
  !source_commit ||
1064
1541
  !identity ||
1065
- (remoteProvider(identity) !== null &&
1066
- remoteProvider(identity) !== String(vcsConfig("load", root).provider).toLowerCase())
1542
+ !vcs.ok ||
1543
+ !provider ||
1544
+ provider !== vcs.provider
1067
1545
  )
1068
1546
  return failure(
1069
1547
  "capability_unavailable",
1070
1548
  "hosting target is not bound to the configured remote provider",
1071
- { capability: "hosting.pull_request" },
1549
+ { capability: "hosting.pull_request", outcome: "not_started" },
1072
1550
  );
1073
- const vcs = vcsConfig("load", root);
1074
- const policy = resolveBranchPolicyFor(root);
1075
- if (!vcs.ok || (policy.integration !== "merge" && !vcs.tokenReady))
1076
- return failure("capability_unavailable", "hosting credentials are unavailable", {
1077
- capability: "hosting.pull_request",
1078
- outcome: "not_started",
1079
- });
1080
- if (policy.integration !== "merge" && !hostingCliAvailable(String(vcs.provider)))
1081
- 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", {
1082
1554
  capability: "hosting.pull_request",
1083
1555
  outcome: "not_started",
1084
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;
1085
1567
  const normalized = {
1086
1568
  operation: request.operation,
1087
- payload: { ...request.payload, target_branch, babysit: request.payload.babysit ?? true },
1569
+ payload: { ...request.payload, target_branch },
1088
1570
  } as ExternalActionRequest;
1089
1571
  const marker = `<!-- workit-action:${sha256({ operation: request.operation, payload: normalized.payload, source_commit, remote: identity })} -->`;
1090
1572
  return success(null, null, {
@@ -1092,7 +1574,16 @@ export const resolveExternalActionRequest = (
1092
1574
  marker,
1093
1575
  descriptorPayload: {
1094
1576
  ...normalized.payload,
1095
- 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
+ },
1096
1587
  },
1097
1588
  });
1098
1589
  }
@@ -1101,32 +1592,34 @@ export const resolveExternalActionRequest = (
1101
1592
  request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
1102
1593
  const source_branch =
1103
1594
  request.payload.source_branch ?? gitValue(root, ["branch", "--show-current"]);
1104
- if (source_branch && isProtectedBranch(root, source_branch))
1105
- return failure("invalid_input", `cannot merge protected branch ${source_branch}`, {
1106
- outcome: "not_started",
1107
- fields: [{ path: "source_branch", reason: "protected branch" }],
1108
- });
1109
- const source_commit = gitValue(root, ["rev-parse", "HEAD"]);
1595
+ const source_commit = source_branch
1596
+ ? gitValue(root, ["rev-parse", "--verify", `refs/heads/${source_branch}`])
1597
+ : null;
1110
1598
  const remote = pushRemote(root);
1111
1599
  const identity = remote ? credentialFreeRemote(remote) : null;
1112
- if (
1113
- !source_branch ||
1114
- !source_commit ||
1115
- !identity ||
1116
- (remoteProvider(identity) !== null &&
1117
- remoteProvider(identity) !== String(vcsConfig("load", root).provider).toLowerCase())
1118
- )
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)
1119
1603
  return failure(
1120
1604
  "capability_unavailable",
1121
1605
  "hosting target is not bound to the configured remote provider",
1122
1606
  { capability: "hosting.merge" },
1123
1607
  );
1124
- const vcs = vcsConfig("load", root);
1125
- if (!vcs.ok || !vcs.tokenReady)
1608
+ const auth = vcsCliIdentity(root);
1609
+ if (!vcs.ok || !auth.ok)
1126
1610
  return failure("capability_unavailable", "hosting credentials are unavailable", {
1127
1611
  capability: "hosting.merge",
1128
1612
  outcome: "not_started",
1129
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
+ );
1130
1623
  if (!hostingCliAvailable(String(vcs.provider)))
1131
1624
  return failure("capability_unavailable", "hosting CLI is unavailable", {
1132
1625
  capability: "hosting.merge",
@@ -1142,10 +1635,27 @@ export const resolveExternalActionRequest = (
1142
1635
  marker,
1143
1636
  descriptorPayload: {
1144
1637
  ...normalized.payload,
1145
- 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
+ },
1146
1647
  },
1147
1648
  });
1148
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
+ }
1149
1659
  case "changelog.apply": {
1150
1660
  const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1151
1661
  if (preview.error) return failure("invalid_input", preview.error);
@@ -1268,8 +1778,34 @@ export const resolveExternalActionRequest = (
1268
1778
  }
1269
1779
  };
1270
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
+
1271
1807
  export const prBabysitNext = (output: string): string =>
1272
- `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.`;
1273
1809
 
1274
1810
  export const executeConcreteExternalAction = async (
1275
1811
  request: ExternalActionRequest,
@@ -1281,6 +1817,19 @@ export const executeConcreteExternalAction = async (
1281
1817
  caller?: { host: Caller["host"]; actor: string },
1282
1818
  approvedBeforeDigest?: string,
1283
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,
1284
1833
  ): Promise<Result<unknown>> => {
1285
1834
  if (localAction(request.operation)) {
1286
1835
  if (!caller)
@@ -1301,13 +1850,60 @@ export const executeConcreteExternalAction = async (
1301
1850
  "local external action staged paths are unavailable",
1302
1851
  { outcome: "not_started" },
1303
1852
  );
1304
- const writer = assertLocalExternalActionWriter(root, caller, paths);
1853
+ const writer = assertLocalExternalActionWriter(
1854
+ coordinationRoot,
1855
+ caller,
1856
+ paths,
1857
+ expectedWorkspaceRevision,
1858
+ );
1305
1859
  if (!writer.ok)
1306
1860
  return failure(
1307
1861
  "capability_unavailable",
1308
1862
  "local external action writer authority is unavailable",
1309
1863
  { outcome: "not_started" },
1310
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
+ }
1311
1907
  }
1312
1908
  switch (request.operation) {
1313
1909
  case "git.branch_setup": {
@@ -1315,6 +1911,8 @@ export const executeConcreteExternalAction = async (
1315
1911
  ...request.payload,
1316
1912
  sdd_dir: request.payload.sdd_dir ?? "docs",
1317
1913
  workspace_root: root,
1914
+ expected_remote_base: approvedRemoteBase,
1915
+ expected_local_base: approvedLocalBase,
1318
1916
  });
1319
1917
  if ("error" in result) {
1320
1918
  if (result.phase === "preflight")
@@ -1339,13 +1937,76 @@ export const executeConcreteExternalAction = async (
1339
1937
  : unknown(request.operation);
1340
1938
  }
1341
1939
  case "git.push": {
1342
- 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}`]);
1343
1994
  return result.exitCode === 0
1344
1995
  ? success(null, null, { stdout: result.stdout.trim() })
1345
1996
  : unknown(request.operation);
1346
1997
  }
1347
1998
  case "hosting.pull_request": {
1348
- 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 ?? "");
1349
2010
  const result = prCreate(
1350
2011
  {
1351
2012
  ...process.env,
@@ -1354,19 +2015,29 @@ export const executeConcreteExternalAction = async (
1354
2015
  WF_PR_CONFIRMED: "true",
1355
2016
  WF_PR_DRAFT: request.payload.draft ? "true" : "false",
1356
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
+ : {}),
1357
2026
  },
1358
2027
  root,
1359
2028
  );
1360
- return result.error || result.ok === false
1361
- ? unknown(request.operation)
1362
- : success(null, null, {
1363
- ...result,
1364
- babysit: request.payload.babysit ?? true,
1365
- babysitSkill: "workit-babysit",
1366
- ...((request.payload.babysit ?? true)
1367
- ? { next: prBabysitNext(String((result as { output?: unknown }).output ?? "")) }
1368
- : {}),
1369
- });
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
+ });
1370
2041
  }
1371
2042
  case "hosting.merge": {
1372
2043
  const target = String(request.payload.target_branch ?? "");
@@ -1376,11 +2047,83 @@ export const executeConcreteExternalAction = async (
1376
2047
  outcome: "not_started",
1377
2048
  fields: [{ path: "target_branch", reason: "required" }],
1378
2049
  });
1379
- 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
+ });
1380
2062
  return result.error || result.ok === false
1381
2063
  ? unknown(request.operation)
1382
2064
  : success(null, null, result);
1383
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
+ }
1384
2127
  case "changelog.apply": {
1385
2128
  const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1386
2129
  if (
@@ -1473,12 +2216,19 @@ export const executeResolvedExternalAction = async (
1473
2216
  root: string,
1474
2217
  step?: string,
1475
2218
  caller?: { host: Caller["host"]; actor: string },
2219
+ expectedWorkspaceRevision?: string,
1476
2220
  ): Promise<Result<unknown>> => {
1477
2221
  const approvedDetails =
1478
2222
  resolved.descriptorPayload && typeof resolved.descriptorPayload === "object"
1479
2223
  ? (
1480
2224
  resolved.descriptorPayload as {
1481
- 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
+ };
1482
2232
  }
1483
2233
  ).resolved
1484
2234
  : undefined;
@@ -1489,23 +2239,79 @@ export const executeResolvedExternalAction = async (
1489
2239
  ? approvedDetails.dateMs
1490
2240
  : undefined;
1491
2241
  const fresh = resolveExternalActionRequest(root, resolved.request, preservedDateMs);
1492
- if (!fresh.ok)
2242
+ if (!fresh.ok) {
2243
+ if (fresh.code === "permission_denied") return fresh;
1493
2244
  return failure(
1494
2245
  "capability_unavailable",
1495
2246
  "approved external action target changed before execution",
1496
2247
  { outcome: "not_started" },
1497
2248
  );
1498
- if (
1499
- externalActionDescriptor(resolved.request.operation, fresh.data.descriptorPayload) !==
1500
- externalActionDescriptor(resolved.request.operation, resolved.descriptorPayload)
1501
- )
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)
1502
2293
  return failure(
1503
2294
  "capability_unavailable",
1504
2295
  "approved external action target changed before execution",
1505
2296
  { outcome: "not_started" },
1506
2297
  );
1507
2298
  const resolvedDetails = fresh.data.descriptorPayload as {
1508
- 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
+ };
1509
2315
  };
1510
2316
  const resolvedDate =
1511
2317
  typeof resolvedDetails.resolved?.dateMs === "number"
@@ -1521,9 +2327,60 @@ export const executeResolvedExternalAction = async (
1521
2327
  typeof resolvedDetails.resolved?.beforeExists === "boolean"
1522
2328
  ? resolvedDetails.resolved.beforeExists
1523
2329
  : undefined;
1524
- return executeConcreteExternalAction(
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;
2381
+ const result = await executeConcreteExternalAction(
1525
2382
  fresh.data.request,
1526
- root,
2383
+ target.data,
1527
2384
  fresh.data.marker,
1528
2385
  resolvedDate,
1529
2386
  step,
@@ -1531,5 +2388,41 @@ export const executeResolvedExternalAction = async (
1531
2388
  caller,
1532
2389
  beforeDigest,
1533
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,
1534
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
+ }
2419
+ if (!result.ok && result.code === "external_outcome_unknown" && caller) {
2420
+ const observed = await readExternalAction(root, fresh.data, {
2421
+ kind: "host",
2422
+ host: caller.host,
2423
+ handle: `local-recovery:${fresh.data.request.operation}`,
2424
+ });
2425
+ if (observed.ok) return success(null, null, { recovered: true });
2426
+ }
2427
+ return result;
1535
2428
  };