@brainervirus/workit-core 1.0.11 → 1.1.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.
@@ -1,5 +1,10 @@
1
- import { branchSetup, resolveBranchPolicyFor, resolveCommitPolicyFor } from "./branch";
2
- import { hostingCliAvailable, prCreate } from "./pr-create";
1
+ import {
2
+ branchSetup,
3
+ classifyBranchDirt,
4
+ resolveBranchPolicyFor,
5
+ resolveCommitPolicyFor,
6
+ } from "./branch";
7
+ import { hostingCliAvailable, mergePr, prCreate } from "./pr-create";
3
8
  import {
4
9
  changelogContext,
5
10
  docsRefreshContext,
@@ -24,6 +29,7 @@ import { failure, success, sha256, type Caller, type Result, type Ref } from "./
24
29
  import { changelogApply, changelogApplyPreview } from "./changelog";
25
30
  import type { ExternalActionRequest } from "./external-action";
26
31
  import { externalActionDescriptor, externalActionRequest } from "./external-action";
32
+ import { normalizeChainSteps } from "./authority";
27
33
  import { resolveInside, run as coreRun } from "../core";
28
34
  import { vcsConfig } from "./vcs-config";
29
35
  import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
@@ -271,10 +277,33 @@ export const actionProposalQuestion = (
271
277
  const resolved = (payload.resolved ?? {}) as Record<string, unknown>;
272
278
  const payloadTarget = String(payload.target_branch ?? "");
273
279
  if (request.operation === "git.commit" && Array.isArray(payload.plan_steps)) {
274
- const steps = payload.plan_steps as string[];
280
+ const steps = payload.plan_steps as unknown[];
281
+ const chain = normalizeChainSteps(steps);
282
+ if (!chain)
283
+ return {
284
+ presented: `Workit decision: action — Approve committing the plan's listed tasks?`,
285
+ approvedText: `Approve the listed plan commits.`,
286
+ };
287
+ const kinds = chain.map((step) => step.kind);
288
+ if (kinds.every((kind) => kind === "commit"))
289
+ return {
290
+ presented: `Workit decision: action — Approve committing the plan's ${steps.length} listed tasks on \`${String(payload.plan_branch ?? "")}\`?`,
291
+ approvedText: `Approve the ${steps.length} listed plan commits.`,
292
+ };
293
+ const summary = [
294
+ kinds.filter((kind) => kind === "branch").length
295
+ ? `${kinds.filter((kind) => kind === "branch").length} branch`
296
+ : null,
297
+ kinds.filter((kind) => kind === "commit").length
298
+ ? `${kinds.filter((kind) => kind === "commit").length} commits`
299
+ : null,
300
+ kinds.includes("pr") ? "PR" : null,
301
+ ]
302
+ .filter(Boolean)
303
+ .join(", ");
275
304
  return {
276
- presented: `Workit decision: action — Approve committing the plan's ${steps.length} listed tasks on \`${String(payload.plan_branch ?? "")}\`?`,
277
- approvedText: `Approve the ${steps.length} listed plan commits.`,
305
+ presented: `Workit decision: action — Approve the ${steps.length}-step chain on \`${String(payload.plan_branch ?? "")}\` (${summary})?`,
306
+ approvedText: `Approve the ${steps.length}-step chain (${summary}).`,
278
307
  };
279
308
  }
280
309
  switch (request.operation) {
@@ -327,6 +356,11 @@ export const actionProposalQuestion = (
327
356
  presented: `Workit decision: action — Open a PR from \`${String(resolved.source_branch ?? "")}\` to \`${payloadTarget}\` titled "${String(request.payload.title)}"?`,
328
357
  approvedText: `Open the PR: ${String(resolved.source_branch ?? "")} → ${payloadTarget}, "${String(request.payload.title)}".`,
329
358
  };
359
+ case "hosting.merge":
360
+ return {
361
+ presented: `Workit decision: action — Merge \`${String(resolved.source_branch ?? "")}\` into \`${payloadTarget}\` (squash, delete branch)?`,
362
+ approvedText: `Merge ${String(resolved.source_branch ?? "")} → ${payloadTarget}.`,
363
+ };
330
364
  case "changelog.apply":
331
365
  return {
332
366
  presented: `Workit decision: action — Apply changelog changes at \`${String(payload.path ?? "CHANGELOG.md")}\`?`,
@@ -375,6 +409,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
375
409
  const value = JSON.parse(content) as { operation?: unknown; payload?: unknown };
376
410
  if (
377
411
  value.operation !== "hosting.pull_request" &&
412
+ value.operation !== "hosting.merge" &&
378
413
  value.operation !== "changelog.apply" &&
379
414
  value.operation !== "youtrack.update" &&
380
415
  value.operation !== "youtrack.time" &&
@@ -399,6 +434,15 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
399
434
  typeof payload.target_branch !== "string")
400
435
  )
401
436
  return failure("invalid_input", "approved hosting target is incomplete");
437
+ if (
438
+ value.operation === "hosting.merge" &&
439
+ (typeof target.marker !== "string" ||
440
+ typeof target.source_branch !== "string" ||
441
+ typeof target.source_commit !== "string" ||
442
+ typeof target.remote !== "string" ||
443
+ typeof payload.target_branch !== "string")
444
+ )
445
+ return failure("invalid_input", "approved hosting target is incomplete");
402
446
  if (
403
447
  value.operation === "changelog.apply" &&
404
448
  (typeof target.target !== "string" ||
@@ -806,6 +850,32 @@ const youTrackWritePreflight = (): Result<null> => {
806
850
  return success(null, null, null);
807
851
  };
808
852
 
853
+ /**
854
+ * A dirty tree binds stash up front so the proposal names it and one
855
+ * approval carries the whole effect instead of failing at preflight.
856
+ * Carry-class dirt (untracked or docs-confined) rides along silently.
857
+ * Returns the original resolution untouched unless an upgrade applies.
858
+ */
859
+ export const upgradeBranchSetupForStash = (
860
+ root: string,
861
+ parsed: ExternalActionRequest,
862
+ resolved: Result<ResolvedExternalAction>,
863
+ ): Result<ResolvedExternalAction> => {
864
+ if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
865
+ if (
866
+ (resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
867
+ true ||
868
+ (resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
869
+ classifyBranchDirt(root) !== "stash-required"
870
+ )
871
+ return resolved;
872
+ const upgraded = resolveExternalActionRequest(root, {
873
+ ...parsed,
874
+ payload: { ...(parsed.payload as Record<string, unknown>), stash: "yes" },
875
+ } as typeof parsed);
876
+ return upgraded.ok ? upgraded : resolved;
877
+ };
878
+
809
879
  export const resolveExternalActionRequest = (
810
880
  root: string,
811
881
  request: ExternalActionRequest,
@@ -855,6 +925,11 @@ export const resolveExternalActionRequest = (
855
925
  }
856
926
  case "git.push": {
857
927
  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
+ });
858
933
  const commit = branch ? gitValue(root, ["rev-parse", branch]) : null;
859
934
  const remote = pushRemote(root);
860
935
  const identity = remote ? credentialFreeRemote(remote) : null;
@@ -872,26 +947,41 @@ export const resolveExternalActionRequest = (
872
947
  if (!head) return failure("storage_error", "Git state could not be resolved");
873
948
  const branch = gitValue(root, ["branch", "--show-current"]);
874
949
  if (planSteps !== undefined || planBranch !== undefined) {
875
- if (
876
- !Array.isArray(planSteps) ||
877
- planSteps.some((step) => typeof step !== "string" || !step.trim()) ||
878
- new Set(planSteps).size !== planSteps.length ||
879
- planSteps.length === 0 ||
880
- planSteps.length > 32
881
- )
950
+ const chain = Array.isArray(planSteps) ? normalizeChainSteps(planSteps) : null;
951
+ if (!chain)
882
952
  return failure(
883
953
  "invalid_input",
884
- "plan_steps must be 1-32 unique non-empty commit messages",
954
+ "plan_steps must be 1-32 unique non-empty commit messages, branch targets, or pull_request markers",
885
955
  {
886
956
  outcome: "not_started",
887
- fields: [{ path: "plan_steps", reason: "unique strings" }],
957
+ fields: [{ path: "plan_steps", reason: "unique steps" }],
888
958
  },
889
959
  );
890
- if (typeof planBranch !== "string" || !planBranch.trim() || planBranch !== branch)
891
- return failure("invalid_input", "plan_branch must name the current working branch", {
960
+ if (typeof planBranch !== "string" || !planBranch.trim())
961
+ return failure("invalid_input", "plan_branch must name the working branch", {
892
962
  outcome: "not_started",
893
- fields: [{ path: "plan_branch", reason: "current branch" }],
963
+ fields: [{ path: "plan_branch", reason: "required" }],
894
964
  });
965
+ // A chain that creates its own branch names that branch; otherwise
966
+ // the plan runs on the branch already checked out.
967
+ const first = chain[0];
968
+ const createsBranch = first?.kind === "branch";
969
+ if (createsBranch ? planBranch !== first.target : planBranch !== branch)
970
+ return failure(
971
+ "invalid_input",
972
+ createsBranch
973
+ ? "plan_branch must name the branch the chain creates"
974
+ : "plan_branch must name the current working branch",
975
+ {
976
+ outcome: "not_started",
977
+ fields: [
978
+ {
979
+ path: "plan_branch",
980
+ reason: createsBranch ? "chain branch" : "current branch",
981
+ },
982
+ ],
983
+ },
984
+ );
895
985
  if (isProtectedBranch(root, planBranch))
896
986
  return failure("invalid_input", "plan_branch must not be a protected branch", {
897
987
  outcome: "not_started",
@@ -956,6 +1046,15 @@ export const resolveExternalActionRequest = (
956
1046
  const target_branch =
957
1047
  request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
958
1048
  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
+ );
959
1058
  const source_commit = gitValue(root, ["rev-parse", "HEAD"]);
960
1059
  const remote = pushRemote(root);
961
1060
  const identity = remote ? credentialFreeRemote(remote) : null;
@@ -997,6 +1096,56 @@ export const resolveExternalActionRequest = (
997
1096
  },
998
1097
  });
999
1098
  }
1099
+ case "hosting.merge": {
1100
+ const target_branch =
1101
+ request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
1102
+ const source_branch =
1103
+ 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"]);
1110
+ const remote = pushRemote(root);
1111
+ 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
+ )
1119
+ return failure(
1120
+ "capability_unavailable",
1121
+ "hosting target is not bound to the configured remote provider",
1122
+ { capability: "hosting.merge" },
1123
+ );
1124
+ const vcs = vcsConfig("load", root);
1125
+ if (!vcs.ok || !vcs.tokenReady)
1126
+ return failure("capability_unavailable", "hosting credentials are unavailable", {
1127
+ capability: "hosting.merge",
1128
+ outcome: "not_started",
1129
+ });
1130
+ if (!hostingCliAvailable(String(vcs.provider)))
1131
+ return failure("capability_unavailable", "hosting CLI is unavailable", {
1132
+ capability: "hosting.merge",
1133
+ outcome: "not_started",
1134
+ });
1135
+ const normalized = {
1136
+ operation: request.operation,
1137
+ payload: { ...request.payload, target_branch, source_branch },
1138
+ } as ExternalActionRequest;
1139
+ const marker = `<!-- workit-action:${sha256({ operation: request.operation, payload: normalized.payload, source_commit, remote: identity })} -->`;
1140
+ return success(null, null, {
1141
+ request: normalized,
1142
+ marker,
1143
+ descriptorPayload: {
1144
+ ...normalized.payload,
1145
+ resolved: { source_branch, source_commit, remote: identity, target_branch, marker },
1146
+ },
1147
+ });
1148
+ }
1000
1149
  case "changelog.apply": {
1001
1150
  const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1002
1151
  if (preview.error) return failure("invalid_input", preview.error);
@@ -1219,6 +1368,19 @@ export const executeConcreteExternalAction = async (
1219
1368
  : {}),
1220
1369
  });
1221
1370
  }
1371
+ case "hosting.merge": {
1372
+ const target = String(request.payload.target_branch ?? "");
1373
+ const source = String(request.payload.source_branch ?? "");
1374
+ if (!target || !source)
1375
+ return failure("invalid_input", "hosting.merge requires target and source branches", {
1376
+ outcome: "not_started",
1377
+ fields: [{ path: "target_branch", reason: "required" }],
1378
+ });
1379
+ const result = mergePr(root, { target, source });
1380
+ return result.error || result.ok === false
1381
+ ? unknown(request.operation)
1382
+ : success(null, null, result);
1383
+ }
1222
1384
  case "changelog.apply": {
1223
1385
  const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1224
1386
  if (
@@ -14,6 +14,7 @@ import {
14
14
  } from "./task-contract";
15
15
  import { WorkitCore } from "./task-engine";
16
16
  import { TaskStore } from "./task-store";
17
+ import { chainStepKey, normalizeChainSteps, type ChainStep } from "./authority";
17
18
  import * as z from "zod";
18
19
  import { execFileSync } from "node:child_process";
19
20
 
@@ -85,6 +86,17 @@ const externalActionSchema = z.discriminatedUnion("operation", [
85
86
  .strict(),
86
87
  })
87
88
  .strict(),
89
+ z
90
+ .object({
91
+ operation: z.literal("hosting.merge"),
92
+ payload: z
93
+ .object({
94
+ target_branch: z.string().min(1).optional(),
95
+ source_branch: z.string().min(1).optional(),
96
+ })
97
+ .strict(),
98
+ })
99
+ .strict(),
88
100
  z
89
101
  .object({
90
102
  operation: z.literal("youtrack.update"),
@@ -215,7 +227,7 @@ export const externalActionDescriptor = (operation: string, payload: unknown): s
215
227
 
216
228
  /** Compact host-facing help for the fixed optional-action surface. */
217
229
  export const externalActionHelp =
218
- "Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch(required unless reapply_stash; the working branch to create or switch to),stash?}; git.commit {message}; git.push {branch?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?}; youtrack.update {issueId,markdown,minutes?}; youtrack.time {issueId,minutes,text?,dateMs?}; youtrack.meeting {issueId,minutes,text}; changelog.apply {entries?,path?,normalize_only?}; context.read {kind,range?,issueId?,issueUrl?,issueRef?,mode?,specPath?,planPath?}. context.read is read-only and needs no approval; all other operations require native approval (CLI uses a TTY; caller-unattested MCP cannot mutate).";
230
+ "Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch(required unless reapply_stash; the working branch to create or switch to),stash?}; git.commit {message}; git.push {branch?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?}; hosting.merge {target_branch?,source_branch?}; youtrack.update {issueId,markdown,minutes?}; youtrack.time {issueId,minutes,text?,dateMs?}; youtrack.meeting {issueId,minutes,text}; changelog.apply {entries?,path?,normalize_only?}; context.read {kind,range?,issueId?,issueUrl?,issueRef?,mode?,specPath?,planPath?}. context.read is read-only and needs no approval; all other operations require native approval (CLI uses a TTY; caller-unattested MCP cannot mutate).";
219
231
 
220
232
  export const externalActionRef = (
221
233
  host: "opencode" | "pi" | "workit_cli",
@@ -323,33 +335,58 @@ export const priorExternalAction = (
323
335
  /** Parse a plan-commit authorization descriptor (one approved list per plan). */
324
336
  export const planCommitDescriptor = (
325
337
  content: string,
326
- ): { steps: string[]; branch: string } | null => {
338
+ ): { steps: ChainStep[]; branch: string; snapshotHead: string | null } | null => {
327
339
  try {
328
340
  const value = JSON.parse(content) as {
329
341
  operation?: unknown;
330
- payload?: { plan_steps?: unknown; plan_branch?: unknown };
342
+ payload?: {
343
+ plan_steps?: unknown;
344
+ plan_branch?: unknown;
345
+ resolved?: { head?: unknown; branch?: unknown };
346
+ };
331
347
  };
332
348
  if (value.operation !== "git.commit" || !value.payload) return null;
333
- const steps = value.payload.plan_steps;
349
+ const steps = normalizeChainSteps(value.payload.plan_steps);
334
350
  const branch = value.payload.plan_branch;
335
- if (
336
- !Array.isArray(steps) ||
337
- steps.some((step) => typeof step !== "string" || !step) ||
338
- typeof branch !== "string" ||
339
- !branch
340
- )
341
- return null;
342
- return { steps: steps as string[], branch };
351
+ if (!steps || typeof branch !== "string" || !branch) return null;
352
+ const head = value.payload.resolved?.head;
353
+ return { steps, branch, snapshotHead: typeof head === "string" ? head : null };
343
354
  } catch {
344
355
  return null;
345
356
  }
346
357
  };
347
358
 
359
+ /**
360
+ * Chain lease: the recorded head must still be an ancestor of the current
361
+ * HEAD — history only moved forward. Rebase/reset underneath an approved
362
+ * chain invalidates it instead of executing on rewritten history.
363
+ */
364
+ export const chainLeaseValid = (store: TaskStore, snapshotHead: string | null): boolean => {
365
+ if (!snapshotHead) return true;
366
+ try {
367
+ const head = execFileSync("git", ["rev-parse", "HEAD"], {
368
+ cwd: store.root,
369
+ encoding: "utf8",
370
+ stdio: ["pipe", "pipe", "pipe"],
371
+ }).trim();
372
+ if (head === snapshotHead) return true;
373
+ execFileSync("git", ["merge-base", "--is-ancestor", snapshotHead, head], {
374
+ cwd: store.root,
375
+ encoding: "utf8",
376
+ stdio: ["pipe", "pipe", "pipe"],
377
+ });
378
+ return true;
379
+ } catch {
380
+ return false;
381
+ }
382
+ };
383
+
348
384
  export type PlanCommitAuthorization = {
349
385
  task: TaskRecord;
350
386
  entry: Entry<Decision>;
351
- steps: string[];
387
+ steps: ChainStep[];
352
388
  branch: string;
389
+ snapshotHead: string | null;
353
390
  completed: string[];
354
391
  };
355
392
 
@@ -357,7 +394,10 @@ export type PlanCommitAuthorization = {
357
394
  * Find the single active plan-commit authorization whose next unconsumed step
358
395
  * is this exact commit message. Consumption order is enforced so each listed
359
396
  * commit executes once, in plan order; unlisted or replayed messages find no
360
- * authorization and require a fresh exact approval.
397
+ * authorization and require a fresh exact approval. Typed chain positions
398
+ * never match messages (a pathological message falls through to a fresh
399
+ * approval instead of consuming the wrong step), and a rewritten history
400
+ * breaks the lease.
361
401
  */
362
402
  export const approvedPlanCommit = (
363
403
  store: TaskStore,
@@ -395,8 +435,19 @@ export const approvedPlanCommit = (
395
435
  const completed =
396
436
  task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
397
437
  [];
398
- if (plan.steps[completed.length] !== message) return [];
399
- return [{ task, entry, steps: plan.steps, branch: plan.branch, completed: [...completed] }];
438
+ const next = plan.steps[completed.length];
439
+ if (!next || next.kind !== "commit" || next.message !== message) return [];
440
+ if (!chainLeaseValid(store, plan.snapshotHead)) return [];
441
+ return [
442
+ {
443
+ task,
444
+ entry,
445
+ steps: plan.steps,
446
+ branch: plan.branch,
447
+ snapshotHead: plan.snapshotHead,
448
+ completed: [...completed],
449
+ },
450
+ ];
400
451
  });
401
452
  });
402
453
  if (matches.length !== 1)
@@ -409,6 +460,80 @@ export const approvedPlanCommit = (
409
460
  return success(workspace.data.revision, null, matches[0]);
410
461
  };
411
462
 
463
+ export type ChainStepQuery =
464
+ | { operation: "git.branch_setup"; target: string }
465
+ | { operation: "hosting.pull_request" };
466
+
467
+ /**
468
+ * Match one branch or PR step of a chain authorization: same session-bound
469
+ * search as plan commits, next unconsumed step of the right kind, lease
470
+ * intact. Commit steps stay on the plan path; this covers the steps plans
471
+ * could never express.
472
+ */
473
+ export const approvedChainStep = (
474
+ store: TaskStore,
475
+ host: string,
476
+ actor: string,
477
+ query: ChainStepQuery,
478
+ ): Result<PlanCommitAuthorization> => {
479
+ const listed = store.listTasks();
480
+ const workspace = store.readWorkspace();
481
+ if (!listed.ok || !workspace.ok)
482
+ return failure("storage_error", "external action state is unavailable");
483
+ if (!workspace.data) return failure("not_found", "workspace not found");
484
+ const matches = listed.data.flatMap((task) => {
485
+ if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
486
+ if (
487
+ task.intent.provenance.session?.kind !== "host" ||
488
+ task.intent.provenance.session.host !== host ||
489
+ task.intent.provenance.session.handle !== actor
490
+ )
491
+ return [];
492
+ return task.decisions.flatMap((entry) => {
493
+ const decision = entry.data;
494
+ if (
495
+ decision.purpose !== "action" ||
496
+ decision.response !== "approved" ||
497
+ decision.revoked !== null
498
+ )
499
+ return [];
500
+ if (entry.provenance.kind !== "host_observed" || entry.provenance.receipts.length === 0)
501
+ return [];
502
+ const plan = planCommitDescriptor(decision.binding.approvedContent);
503
+ if (!plan) return [];
504
+ const completed =
505
+ task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
506
+ [];
507
+ const next = plan.steps[completed.length];
508
+ if (!next) return [];
509
+ if (query.operation === "git.branch_setup") {
510
+ if (next.kind !== "branch" || next.target !== query.target) return [];
511
+ } else if (next.kind !== "pr") {
512
+ return [];
513
+ }
514
+ if (!chainLeaseValid(store, plan.snapshotHead)) return [];
515
+ return [
516
+ {
517
+ task,
518
+ entry,
519
+ steps: plan.steps,
520
+ branch: plan.branch,
521
+ snapshotHead: plan.snapshotHead,
522
+ completed: [...completed],
523
+ },
524
+ ];
525
+ });
526
+ });
527
+ if (matches.length !== 1)
528
+ return failure(
529
+ "permission_denied",
530
+ matches.length === 0
531
+ ? "no chain authorization matches this step"
532
+ : "multiple chain authorizations match this step",
533
+ );
534
+ return success(workspace.data.revision, null, matches[0]);
535
+ };
536
+
412
537
  export const commitMessageFromDescriptor = (operation: string): string | undefined => {
413
538
  try {
414
539
  const descriptor = JSON.parse(operation) as {
@@ -423,6 +548,69 @@ export const commitMessageFromDescriptor = (operation: string): string | undefin
423
548
  }
424
549
  };
425
550
 
551
+ /** Resolve a chain authorization into the exact binding an adapter needs
552
+ * to run one branch or PR step under the approved chain. Commit steps stay
553
+ * on the plan path; this covers the steps plans could never express. */
554
+ export const chainStepBinding = (
555
+ store: TaskStore,
556
+ host: string,
557
+ actor: string,
558
+ operation: string,
559
+ ): {
560
+ taskId: Id;
561
+ decisionId: Id;
562
+ binding: Decision["binding"];
563
+ expectedRevision: Revision;
564
+ expectedWorkspaceRevision: Revision;
565
+ step: string;
566
+ } | null => {
567
+ let descriptor: { operation?: unknown; payload?: Record<string, unknown> };
568
+ try {
569
+ descriptor = JSON.parse(operation) as {
570
+ operation?: unknown;
571
+ payload?: Record<string, unknown>;
572
+ };
573
+ } catch {
574
+ return null;
575
+ }
576
+ const query: ChainStepQuery | null =
577
+ descriptor.operation === "git.branch_setup" &&
578
+ typeof descriptor.payload?.target_branch === "string"
579
+ ? { operation: "git.branch_setup", target: descriptor.payload.target_branch as string }
580
+ : descriptor.operation === "hosting.pull_request"
581
+ ? { operation: "hosting.pull_request" }
582
+ : null;
583
+ if (!query) return null;
584
+ const chain = approvedChainStep(store, host, actor, query);
585
+ if (!chain.ok) return null;
586
+ // PR steps run on the chain branch; the branch step itself creates it.
587
+ if (query.operation === "hosting.pull_request") {
588
+ let branch: string;
589
+ try {
590
+ branch = execFileSync("git", ["branch", "--show-current"], {
591
+ cwd: store.root,
592
+ encoding: "utf8",
593
+ stdio: ["pipe", "pipe", "pipe"],
594
+ }).trim();
595
+ } catch {
596
+ return null;
597
+ }
598
+ if (!branch || branch !== chain.data.branch) return null;
599
+ }
600
+ const workspace = store.readWorkspace();
601
+ if (!workspace.ok || !workspace.data) return null;
602
+ const next = chain.data.steps[chain.data.completed.length];
603
+ if (!next) return null;
604
+ return {
605
+ taskId: chain.data.task.id,
606
+ decisionId: chain.data.entry.id,
607
+ binding: chain.data.entry.data.binding,
608
+ expectedRevision: chain.data.task.revision,
609
+ expectedWorkspaceRevision: workspace.data.revision,
610
+ step: chainStepKey(next),
611
+ };
612
+ };
613
+
426
614
  /** Resolve a plan-commit authorization into the exact binding an adapter needs
427
615
  * to run one listed commit under the approved list. */
428
616
  export const planCommitBinding = (
@@ -17,12 +17,14 @@ type MethodDefinition = {
17
17
 
18
18
  export const METHODS: Readonly<Record<MethodId, MethodDefinition>> = {
19
19
  "workit-challenge": { dimensions: ["challenge", "decisions"] },
20
- "workit-behavioral-tdd": { dimensions: ["testing"] },
21
- "workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review"] },
20
+ "workit-behavioral-tdd": { dimensions: ["testing"], ruleIds: ["mechanical-existing-checks"] },
21
+ "workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review", "self-review"] },
22
22
  "workit-plan": { dimensions: ["artifacts", "continuity"] },
23
23
  "workit-implement": { dimensions: ["delegation"] },
24
- "workit-debug": { ruleIds: ["root-cause-investigation"] },
25
- "workit-handoff": { ruleIds: ["durable-handoff"] },
24
+ // workit-debug and workit-handoff stay slash-invoked: no generated rule
25
+ // routes to them, so they never appear spuriously in method selection.
26
+ "workit-debug": {},
27
+ "workit-handoff": {},
26
28
  "workit-deslop": { ruleIds: ["pre-pr-cleanup"] },
27
29
  };
28
30
 
@@ -33,12 +35,10 @@ export type SelectedMethod = {
33
35
  };
34
36
 
35
37
  const methodMatches = (
36
- id: MethodId,
37
38
  definition: MethodDefinition,
38
39
  requirement: Policy["requirements"][number],
39
40
  ): boolean =>
40
- (definition.dimensions?.includes(requirement.dimension) === true &&
41
- !(id === "workit-review" && requirement.ruleId === "self-review")) ||
41
+ definition.dimensions?.includes(requirement.dimension) === true ||
42
42
  definition.ruleIds?.includes(requirement.ruleId) === true;
43
43
 
44
44
  const assuranceFor = (
@@ -71,7 +71,7 @@ const assuranceFor = (
71
71
  export function selectMethods(policy: Policy, capabilities: Capability[]): SelectedMethod[] {
72
72
  return (Object.entries(METHODS) as [MethodId, MethodDefinition][]).flatMap(([id, definition]) => {
73
73
  const requirements = policy.requirements.filter((requirement) =>
74
- methodMatches(id, definition, requirement),
74
+ methodMatches(definition, requirement),
75
75
  );
76
76
  if (requirements.length === 0) return [];
77
77
  const capability = assuranceFor(id, definition, capabilities);
@@ -373,4 +373,54 @@ export function prCreate(env: NodeJS.ProcessEnv, cwd: string): Record<string, an
373
373
  };
374
374
  }
375
375
 
376
+ /** Merge the open PR/MR for a source branch (squash + delete per pr settings). */
377
+ export function mergePr(
378
+ cwd: string,
379
+ opts: { target: string; source: string },
380
+ ): Record<string, any> {
381
+ const root = repoRoot(cwd);
382
+ const cfg = vcsConfig("load", root);
383
+ if (!cfg.ok) return { error: cfg.error ?? "vcs config missing" };
384
+ if (!cfg.tokenReady)
385
+ return { error: "VCS token not ready — run /wk-init and edit token file locally" };
386
+ const provider = cfg.provider as string;
387
+ if (provider !== "gitlab" && provider !== "github")
388
+ return { error: `unsupported provider: ${provider}` };
389
+ if (!hostingCliAvailable(provider))
390
+ return { error: `workflow CLI missing (required for ${provider})` };
391
+ const pr = (cfg.pr ?? {}) as Record<string, any>;
392
+ const token = fs.readFileSync(cfg.tokenPath as string, "utf8").trim();
393
+ const squash = pr.squashOnMerge !== false;
394
+ const removeBranch = pr.removeSourceBranch !== false;
395
+ let cmd: string[];
396
+ let cmdEnv: NodeJS.ProcessEnv;
397
+ if (provider === "gitlab") {
398
+ cmd = ["glab", "mr", "merge", opts.source];
399
+ cmd.push(squash ? "--squash" : "--squash=false");
400
+ if (removeBranch) cmd.push("--remove-source-branch");
401
+ cmd.push("--yes");
402
+ cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GITLAB_TOKEN: token };
403
+ } else {
404
+ cmd = ["gh", "pr", "merge", opts.source];
405
+ cmd.push(squash ? "--squash" : "--merge");
406
+ if (removeBranch) cmd.push("--delete-branch");
407
+ cmd.push("--yes");
408
+ cmdEnv = { ...process.env, PATH: process.env.PATH ?? "", GH_TOKEN: token };
409
+ }
410
+ const result = spawnSync(cmd[0], cmd.slice(1), { cwd: root, encoding: "utf8", env: cmdEnv });
411
+ if (result.status !== 0)
412
+ return {
413
+ error: "merge failed",
414
+ provider,
415
+ stderr: (result.stderr ?? result.stdout ?? "").trim().slice(0, 800),
416
+ };
417
+ return {
418
+ ok: true,
419
+ provider,
420
+ targetBranch: opts.target,
421
+ sourceBranch: opts.source,
422
+ output: (result.stdout ?? "").trim(),
423
+ };
424
+ }
425
+
376
426
  export { buildBody, parseGhRepo, parseGhIssue };