@brainervirus/workit-core 1.0.12 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "1.0.12",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { operationAutoClass, standingApprovalLive } from "./auto-approval";
4
5
  import {
5
6
  canonicalJson,
6
7
  decisionDigest,
@@ -579,6 +580,36 @@ const validateAction = (
579
580
  !provenanceMatches(entry.provenance, authority.provenance)
580
581
  )
581
582
  return failure("permission_denied", "action approval lacks native receipt assurance");
583
+ // Standing auto-approval receipts carry no live question. They authorize
584
+ // only while the referenced workspace rule still covers the operation —
585
+ // verified here, at reserve time, against current config. Question-backed
586
+ // approvals (host-kind receipts) skip this; anything else was already
587
+ // denied above.
588
+ const receipts = entry.provenance.receipts as unknown[];
589
+ const standing = receipts.filter(
590
+ (receipt) =>
591
+ typeof receipt === "object" &&
592
+ receipt !== null &&
593
+ (receipt as { kind?: unknown }).kind === "standing",
594
+ );
595
+ const questionBacked = receipts.some(
596
+ (receipt) =>
597
+ typeof receipt === "object" &&
598
+ receipt !== null &&
599
+ (receipt as { kind?: unknown }).kind === "host",
600
+ );
601
+ if (!questionBacked && standing.length > 0) {
602
+ let operation: unknown;
603
+ try {
604
+ operation = (JSON.parse(decision.binding.approvedContent) as { operation?: unknown })
605
+ .operation;
606
+ } catch {
607
+ operation = null;
608
+ }
609
+ const cls = operationAutoClass(operation);
610
+ if (!cls || !standing.some((receipt) => standingApprovalLive(store.root, receipt, cls)))
611
+ return failure("permission_denied", "standing auto-approval is not live for this operation");
612
+ }
582
613
  if (decision.revoked) return failure("permission_denied", "decision is revoked");
583
614
  if (decision.digest !== decisionDigest(decision))
584
615
  return failure("permission_denied", "decision binding is invalid");
@@ -0,0 +1,326 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolveWorkspaceFrom, type WorkspaceConfig } from "./workspaces";
4
+ import { configDir } from "./config";
5
+ import { verifyPushIdentity } from "./branch";
6
+ import {
7
+ failure,
8
+ success,
9
+ type Caller,
10
+ type Id,
11
+ type Provenance,
12
+ type Result,
13
+ type Revision,
14
+ type TaskRecord,
15
+ } from "./task-contract";
16
+ import type { TaskStore } from "./task-store";
17
+ import type { WorkitCore } from "./task-engine";
18
+ import type { Decision } from "./task-contract";
19
+
20
+ /** Action classes auto-approval can cover. Unknown classes never match. */
21
+ export const AUTO_CLASSES = ["branch", "commit", "push", "pr", "merge"] as const;
22
+ export type AutoClass = (typeof AUTO_CLASSES)[number];
23
+
24
+ export type AutoApproval =
25
+ | { status: "off" }
26
+ | {
27
+ status: "on";
28
+ workspace: string;
29
+ classes: AutoClass[];
30
+ account: string | null;
31
+ configDigest: string | null;
32
+ };
33
+
34
+ export type StandingReceipt = {
35
+ kind: "standing";
36
+ workspace: string;
37
+ class: string;
38
+ configDigest: string | null;
39
+ };
40
+
41
+ const OPERATION_CLASS: Record<string, AutoClass> = {
42
+ "git.branch_setup": "branch",
43
+ "git.commit": "commit",
44
+ "git.push": "push",
45
+ "hosting.pull_request": "pr",
46
+ "hosting.merge": "merge",
47
+ };
48
+
49
+ /** Map an external-action operation to its auto-approval class, if any. */
50
+ export const operationAutoClass = (operation: unknown): AutoClass | null =>
51
+ typeof operation === "string" ? (OPERATION_CLASS[operation] ?? null) : null;
52
+
53
+ const digestOf = (configDirPath: string): string | null => {
54
+ try {
55
+ const raw = readFileSync(`${configDirPath}/workspaces.json`, "utf8");
56
+ return createHash("sha256").update(raw).digest("hex");
57
+ } catch {
58
+ return null;
59
+ }
60
+ };
61
+
62
+ const normalizeClasses = (value: unknown): AutoClass[] => {
63
+ if (value === true) return [...AUTO_CLASSES];
64
+ if (!Array.isArray(value)) return [];
65
+ const seen = new Set<AutoClass>();
66
+ for (const item of value) {
67
+ if (typeof item === "string" && (AUTO_CLASSES as readonly string[]).includes(item))
68
+ seen.add(item as AutoClass);
69
+ }
70
+ return [...seen];
71
+ };
72
+
73
+ /** Resolve the auto-approval rule for a checkout root (live read, never cached). */
74
+ export const resolveAutoApproval = (root: string, dir: string = configDir()): AutoApproval => {
75
+ let match: WorkspaceConfig | null;
76
+ try {
77
+ match = resolveWorkspaceFrom(root, dir);
78
+ } catch {
79
+ return { status: "off" };
80
+ }
81
+ if (!match) return { status: "off" };
82
+ const classes = normalizeClasses(match.autoApprove);
83
+ if (classes.length === 0) return { status: "off" };
84
+ return {
85
+ status: "on",
86
+ workspace: match.name,
87
+ classes,
88
+ account: typeof match.vcs?.account === "string" && match.vcs.account ? match.vcs.account : null,
89
+ configDigest: digestOf(dir),
90
+ };
91
+ };
92
+
93
+ /** True when the workspace rule covers the class right now. */
94
+ export const autoApproves = (root: string, cls: unknown, dir?: string): boolean => {
95
+ if (typeof cls !== "string" || !(AUTO_CLASSES as readonly string[]).includes(cls)) return false;
96
+ const resolved = resolveAutoApproval(root, dir);
97
+ return resolved.status === "on" && resolved.classes.includes(cls as AutoClass);
98
+ };
99
+
100
+ const isStandingReceipt = (value: unknown): value is StandingReceipt =>
101
+ typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === "standing";
102
+
103
+ /**
104
+ * A standing receipt authorizes only while the referenced workspace rule
105
+ * still covers the class. Removing the flag (or narrowing the list) fails
106
+ * closed on the very next reserve — no revocation step needed.
107
+ */
108
+ export const standingApprovalLive = (
109
+ root: string,
110
+ receipt: unknown,
111
+ cls: unknown,
112
+ dir?: string,
113
+ ): boolean => {
114
+ if (!isStandingReceipt(receipt)) return false;
115
+ if (typeof cls !== "string" || !(AUTO_CLASSES as readonly string[]).includes(cls)) return false;
116
+ if (receipt.class !== cls) return false;
117
+ const resolved = resolveAutoApproval(root, dir);
118
+ if (resolved.status !== "on") return false;
119
+ if (resolved.workspace !== receipt.workspace) return false;
120
+ return resolved.classes.includes(cls as AutoClass);
121
+ };
122
+
123
+ /** Build the receipt an auto-recorded decision carries (current digest). */
124
+ export const standingReceiptFor = (
125
+ root: string,
126
+ workspace: string,
127
+ cls: AutoClass,
128
+ dir?: string,
129
+ ): StandingReceipt => {
130
+ const resolved = resolveAutoApproval(root, dir);
131
+ return {
132
+ kind: "standing",
133
+ workspace,
134
+ class: cls,
135
+ configDigest:
136
+ resolved.status === "on" && resolved.workspace === workspace ? resolved.configDigest : null,
137
+ };
138
+ };
139
+
140
+ /**
141
+ * Verify a standing approval record request: active task, lead session
142
+ * match (helpers never auto-approve), operation class equals the claimed
143
+ * class, rule live right now, push identity enforced for push.
144
+ * Returns the host-observed provenance the entry carries on success.
145
+ */
146
+ export const verifyStandingApproval = (
147
+ root: string,
148
+ task: TaskRecord,
149
+ caller: Caller,
150
+ binding: {
151
+ standing?: { workspace?: unknown; class?: unknown };
152
+ approvedContent?: unknown;
153
+ },
154
+ ): Result<Provenance> => {
155
+ if (task.status !== "active")
156
+ return failure("invalid_transition", "only active tasks can authorize actions");
157
+ const session = task.intent?.provenance?.session;
158
+ if (
159
+ !session ||
160
+ session.kind !== "host" ||
161
+ session.host !== caller.host ||
162
+ session.handle !== caller.actor
163
+ )
164
+ return failure("permission_denied", "standing approval requires the lead session");
165
+ const standing = binding.standing;
166
+ if (!standing || typeof standing.workspace !== "string" || typeof standing.class !== "string")
167
+ return failure("invalid_input", "standing approval needs a workspace and class");
168
+ let operation: unknown;
169
+ try {
170
+ operation =
171
+ typeof binding.approvedContent === "string"
172
+ ? (JSON.parse(binding.approvedContent) as { operation?: unknown }).operation
173
+ : null;
174
+ } catch {
175
+ operation = null;
176
+ }
177
+ const cls = operationAutoClass(operation);
178
+ if (!cls || cls !== standing.class)
179
+ return failure("permission_denied", "standing approval does not cover this operation");
180
+ const resolved = resolveAutoApproval(root);
181
+ if (
182
+ resolved.status !== "on" ||
183
+ resolved.workspace !== standing.workspace ||
184
+ !resolved.classes.includes(cls)
185
+ )
186
+ return failure("permission_denied", "standing auto-approval is not live for this operation");
187
+ if (cls === "push") {
188
+ const workspace = resolveWorkspaceFrom(root, configDir());
189
+ const provider = workspace?.vcs?.provider;
190
+ const account = resolved.account;
191
+ if (!provider || !account)
192
+ return failure(
193
+ "permission_denied",
194
+ "auto-push requires a workspace provider and area account",
195
+ );
196
+ const identity = verifyPushIdentity(root, provider, account);
197
+ if (!identity.ok) return failure("permission_denied", identity.error);
198
+ }
199
+ return success(null, null, {
200
+ kind: "host_observed",
201
+ host: caller.host,
202
+ session: { kind: "host", host: caller.host, handle: caller.actor },
203
+ workerId: null,
204
+ receipts: [standingReceiptFor(root, standing.workspace, cls)],
205
+ } as Provenance);
206
+ };
207
+
208
+ export type StandingAutoBinding = {
209
+ taskId: Id;
210
+ decisionId: Id;
211
+ binding: Decision["binding"];
212
+ expectedRevision: Revision;
213
+ expectedWorkspaceRevision: Revision;
214
+ step?: string;
215
+ };
216
+
217
+ type StandingMatch = {
218
+ task: TaskRecord;
219
+ workspaceId: Id;
220
+ workspaceRevision: Revision;
221
+ cls: AutoClass;
222
+ workspaceName: string;
223
+ };
224
+
225
+ /**
226
+ * Dry-run half of standingAutoBinding: same matching, no recording. Tool
227
+ * gates use this to decide between executing and asking; the runner records.
228
+ */
229
+ export const standingAutoApplies = (
230
+ store: TaskStore,
231
+ host: string,
232
+ actor: string,
233
+ operation: string,
234
+ ): StandingMatch | null => {
235
+ let descriptor: { operation?: unknown };
236
+ try {
237
+ descriptor = JSON.parse(operation) as { operation?: unknown };
238
+ } catch {
239
+ return null;
240
+ }
241
+ const cls = operationAutoClass(descriptor.operation);
242
+ if (!cls || !autoApproves(store.root, cls)) return null;
243
+ const listed = store.listTasks();
244
+ const workspace = store.readWorkspace();
245
+ if (!listed.ok || !workspace.ok || !workspace.data) return null;
246
+ const matches = listed.data.filter(
247
+ (task) =>
248
+ task.status === "active" &&
249
+ task.workspaceId === workspace.data!.id &&
250
+ task.intent?.provenance?.session?.kind === "host" &&
251
+ (task.intent.provenance.session as { host?: unknown; handle?: unknown }).host === host &&
252
+ (task.intent.provenance.session as { host?: unknown; handle?: unknown }).handle === actor,
253
+ );
254
+ if (matches.length !== 1) return null;
255
+ const resolved = resolveAutoApproval(store.root);
256
+ if (resolved.status !== "on") return null;
257
+ return {
258
+ task: matches[0],
259
+ workspaceId: workspace.data.id,
260
+ workspaceRevision: workspace.data.revision,
261
+ cls,
262
+ workspaceName: resolved.workspace,
263
+ };
264
+ };
265
+
266
+ /**
267
+ * Shared adapter path: resolve one gated operation under the workspace
268
+ * standing rule without asking. Returns the exact binding the runner needs
269
+ * for reserve/settle, or null when anything is uncovered, ambiguous, or
270
+ * unresolvable — callers fall through to the question proposal.
271
+ *
272
+ * Single active task per session (ambiguity asks), plan-before-mutation
273
+ * (no task asks), guardrails enforced at record time, revocation instant.
274
+ */
275
+ export const standingAutoBinding = (
276
+ core: WorkitCore,
277
+ store: TaskStore,
278
+ host: string,
279
+ actor: string,
280
+ operation: string,
281
+ ): StandingAutoBinding | null => {
282
+ let descriptor: { operation?: unknown };
283
+ try {
284
+ descriptor = JSON.parse(operation) as { operation?: unknown };
285
+ } catch {
286
+ return null;
287
+ }
288
+ const cls = operationAutoClass(descriptor.operation);
289
+ if (!cls || !autoApproves(store.root, cls)) return null;
290
+ const applies = standingAutoApplies(store, host, actor, operation);
291
+ if (!applies) return null;
292
+ const { task } = applies;
293
+ const resolved = resolveAutoApproval(store.root);
294
+ if (resolved.status !== "on") return null;
295
+ const presented =
296
+ `Workit decision: action — auto-approved ${String(descriptor.operation)} ` +
297
+ `under workspace rule "${resolved.workspace}" (no question asked).`;
298
+ const recorded = core.observeStandingDecision({
299
+ schemaVersion: 1,
300
+ action: "record",
301
+ taskId: task.id,
302
+ expectedRevision: task.revision,
303
+ purpose: "action",
304
+ binding: {
305
+ taskId: task.id,
306
+ workspaceId: applies.workspaceId,
307
+ scope: task.intent.data.scope,
308
+ presented,
309
+ approvedContent: operation,
310
+ contentRefs: [],
311
+ standing: { workspace: resolved.workspace, class: cls },
312
+ },
313
+ response: "approved",
314
+ requirementIds: [],
315
+ });
316
+ if (!recorded.ok) return null;
317
+ const fresh = store.readTask(task.id);
318
+ if (!fresh.ok) return null;
319
+ return {
320
+ taskId: task.id,
321
+ decisionId: recorded.data.id,
322
+ binding: recorded.data.data.binding,
323
+ expectedRevision: fresh.data.revision,
324
+ expectedWorkspaceRevision: applies.workspaceRevision,
325
+ };
326
+ };
@@ -18,6 +18,50 @@ const policy = (root: string) => resolveBranchPolicyFor(root);
18
18
  const allowedBranch = (root: string, name: string) =>
19
19
  policy(root).allowed.some((r) => r.test(name));
20
20
  const isProtected = (root: string, name: string) => policy(root).protected.has(name.toLowerCase());
21
+
22
+ /** Public guardrail: protected refs fail closed anywhere, never ask. */
23
+ export const isProtectedTarget = (root: string, name: string): boolean => {
24
+ try {
25
+ return isProtected(root, name);
26
+ } catch {
27
+ return true;
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Push identity check as code: the credential actually used must belong to
33
+ * the workspace area account. Anything unresolvable fails closed — an
34
+ * unreachable host never reads as a match.
35
+ */
36
+ export const verifyPushIdentity = (
37
+ root: string,
38
+ provider: string,
39
+ account: string,
40
+ ): { ok: true } | { ok: false; error: string } => {
41
+ if (!account || typeof account !== "string")
42
+ return { ok: false, error: "push identity requires a configured area account" };
43
+ const probe =
44
+ provider === "gitlab"
45
+ ? (["api", "user", "--jq", ".username"] as const)
46
+ : (["api", "user", "--jq", ".login"] as const);
47
+ const bin = provider === "gitlab" ? "glab" : "gh";
48
+ let login: string;
49
+ try {
50
+ login = execFileSync(bin, [...probe], {
51
+ cwd: root,
52
+ encoding: "utf8",
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ }).trim();
55
+ } catch {
56
+ return { ok: false, error: `${bin} identity could not be resolved` };
57
+ }
58
+ if (!login || login.toLowerCase() !== account.toLowerCase())
59
+ return {
60
+ ok: false,
61
+ error: `push identity ${login || "(unknown)"} does not match area account ${account}`,
62
+ };
63
+ return { ok: true };
64
+ };
21
65
  // RL-01: malformed vcs.json blocks branch resolution with an exact-path error.
22
66
  const baseBranch = (cwd: string): { base: string } | { error: string } => {
23
67
  const resolved = vcsConfig("resolve", cwd);
@@ -4,7 +4,7 @@ import {
4
4
  resolveBranchPolicyFor,
5
5
  resolveCommitPolicyFor,
6
6
  } from "./branch";
7
- import { hostingCliAvailable, prCreate } from "./pr-create";
7
+ import { hostingCliAvailable, mergePr, prCreate } from "./pr-create";
8
8
  import {
9
9
  changelogContext,
10
10
  docsRefreshContext,
@@ -356,6 +356,11 @@ export const actionProposalQuestion = (
356
356
  presented: `Workit decision: action — Open a PR from \`${String(resolved.source_branch ?? "")}\` to \`${payloadTarget}\` titled "${String(request.payload.title)}"?`,
357
357
  approvedText: `Open the PR: ${String(resolved.source_branch ?? "")} → ${payloadTarget}, "${String(request.payload.title)}".`,
358
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
+ };
359
364
  case "changelog.apply":
360
365
  return {
361
366
  presented: `Workit decision: action — Apply changelog changes at \`${String(payload.path ?? "CHANGELOG.md")}\`?`,
@@ -404,6 +409,7 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
404
409
  const value = JSON.parse(content) as { operation?: unknown; payload?: unknown };
405
410
  if (
406
411
  value.operation !== "hosting.pull_request" &&
412
+ value.operation !== "hosting.merge" &&
407
413
  value.operation !== "changelog.apply" &&
408
414
  value.operation !== "youtrack.update" &&
409
415
  value.operation !== "youtrack.time" &&
@@ -428,6 +434,15 @@ export const approvedResolvedExternalAction = (content: string): Result<Resolved
428
434
  typeof payload.target_branch !== "string")
429
435
  )
430
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");
431
446
  if (
432
447
  value.operation === "changelog.apply" &&
433
448
  (typeof target.target !== "string" ||
@@ -910,6 +925,11 @@ export const resolveExternalActionRequest = (
910
925
  }
911
926
  case "git.push": {
912
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
+ });
913
933
  const commit = branch ? gitValue(root, ["rev-parse", branch]) : null;
914
934
  const remote = pushRemote(root);
915
935
  const identity = remote ? credentialFreeRemote(remote) : null;
@@ -1026,6 +1046,15 @@ export const resolveExternalActionRequest = (
1026
1046
  const target_branch =
1027
1047
  request.payload.target_branch ?? resolveBranchPolicyFor(root).defaultTargetBranch;
1028
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
+ );
1029
1058
  const source_commit = gitValue(root, ["rev-parse", "HEAD"]);
1030
1059
  const remote = pushRemote(root);
1031
1060
  const identity = remote ? credentialFreeRemote(remote) : null;
@@ -1067,6 +1096,56 @@ export const resolveExternalActionRequest = (
1067
1096
  },
1068
1097
  });
1069
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
+ }
1070
1149
  case "changelog.apply": {
1071
1150
  const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1072
1151
  if (preview.error) return failure("invalid_input", preview.error);
@@ -1289,6 +1368,19 @@ export const executeConcreteExternalAction = async (
1289
1368
  : {}),
1290
1369
  });
1291
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
+ }
1292
1384
  case "changelog.apply": {
1293
1385
  const preview = changelogApplyPreview({ ...request.payload, workspace_root: root });
1294
1386
  if (
@@ -86,6 +86,17 @@ const externalActionSchema = z.discriminatedUnion("operation", [
86
86
  .strict(),
87
87
  })
88
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(),
89
100
  z
90
101
  .object({
91
102
  operation: z.literal("youtrack.update"),
@@ -216,7 +227,7 @@ export const externalActionDescriptor = (operation: string, payload: unknown): s
216
227
 
217
228
  /** Compact host-facing help for the fixed optional-action surface. */
218
229
  export const externalActionHelp =
219
- "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).";
220
231
 
221
232
  export const externalActionRef = (
222
233
  host: "opencode" | "pi" | "workit_cli",
@@ -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 };
@@ -1,7 +1,7 @@
1
1
  import { TaskStore } from "./task-store";
2
2
 
3
3
  export type ShellRouteIntent = {
4
- route: "git.branch_setup" | "hosting.pull_request";
4
+ route: "git.branch_setup" | "hosting.pull_request" | "hosting.merge";
5
5
  guidance: string;
6
6
  };
7
7
 
@@ -10,6 +10,8 @@ const GUIDANCE: Record<ShellRouteIntent["route"], string> = {
10
10
  "use the Workit git.branch_setup action (workit_external_action in hosts, or `workit action git.branch_setup --payload '{}' --confirm --json` in the CLI)",
11
11
  "hosting.pull_request":
12
12
  "use the Workit hosting.pull_request action (workit_external_action in hosts, or `workit action hosting.pull_request --payload '{...}' --confirm --json` in the CLI), then drive the created PR through workit-babysit",
13
+ "hosting.merge":
14
+ "use the Workit hosting.merge action (workit_external_action in hosts, or `workit action hosting.merge --payload '{...}' --confirm --json` in the CLI)",
13
15
  };
14
16
 
15
17
  const unbalancedQuotes = (segment: string): boolean => {
@@ -48,6 +50,10 @@ export function shellRouteIntent(command: string): ShellRouteIntent | null {
48
50
  return { route: "hosting.pull_request", guidance: GUIDANCE["hosting.pull_request"] };
49
51
  if (head === "glab" && second === "mr" && third === "create")
50
52
  return { route: "hosting.pull_request", guidance: GUIDANCE["hosting.pull_request"] };
53
+ if (head === "gh" && second === "pr" && third === "merge")
54
+ return { route: "hosting.merge", guidance: GUIDANCE["hosting.merge"] };
55
+ if (head === "glab" && second === "mr" && third === "merge")
56
+ return { route: "hosting.merge", guidance: GUIDANCE["hosting.merge"] };
51
57
  }
52
58
  return null;
53
59
  }
@@ -144,6 +144,19 @@ export const refSchema = z.discriminatedUnion("kind", [
144
144
  .strict(),
145
145
  z.object({ kind: z.literal("host"), host: hostSchema, handle: nonEmpty }).strict(),
146
146
  z.object({ kind: z.literal("external"), url: text.url() }).strict(),
147
+ // Standing auto-approval receipt: authorizes without a live question while
148
+ // the referenced workspace rule covers the operation class. Verified live
149
+ // at reserve time against current config; dropped on export/import (a rule
150
+ // from another machine must never authorize here). Scalar-only shape keeps
151
+ // advertised operation schemas within provider depth limits.
152
+ z
153
+ .object({
154
+ kind: z.literal("standing"),
155
+ workspace: nonEmpty,
156
+ class: nonEmpty,
157
+ configDigest: nullableDigest,
158
+ })
159
+ .strict(),
147
160
  ]);
148
161
  export type Ref = z.infer<typeof refSchema>;
149
162
 
@@ -563,6 +576,7 @@ export const decisionSchema = z
563
576
  displayed: text.optional(),
564
577
  contentRefs: z.array(refSchema),
565
578
  statedChoice: z.object({ ref: text, text: text }).strict().optional(),
579
+ standing: z.object({ workspace: nonEmpty, class: nonEmpty }).strict().optional(),
566
580
  })
567
581
  .strict(),
568
582
  digest,
@@ -56,6 +56,7 @@ import {
56
56
  type ReconcileActionInput,
57
57
  } from "./authority";
58
58
  import { diffPolicy, resolvePolicy } from "./policy-resolver";
59
+ import { verifyStandingApproval } from "./auto-approval";
59
60
  import { TaskStore, type MetadataLock, type ProcessEvidence } from "./task-store";
60
61
  import {
61
62
  exportDigest,
@@ -1087,10 +1088,21 @@ export class WorkitCore {
1087
1088
  return this.recordDecision(request, observation, true);
1088
1089
  }
1089
1090
 
1091
+ /**
1092
+ * Record a standing auto-approval: no live question, authorized by the
1093
+ * workspace rule named in binding.standing while it covers the operation.
1094
+ * Lead-only, task-scoped, verified live here — revocation is config
1095
+ * removal, which fails the next reserve closed.
1096
+ */
1097
+ observeStandingDecision(request: unknown): Result<Entry<Decision>> {
1098
+ return this.recordDecision(request, undefined, true, true);
1099
+ }
1100
+
1090
1101
  private recordDecision(
1091
1102
  request: unknown,
1092
1103
  nativeObservation?: unknown,
1093
1104
  nativeRequired = false,
1105
+ standing = false,
1094
1106
  ): Result<Entry<Decision>> {
1095
1107
  const root = this.contextRootError();
1096
1108
  if (!root.ok) return root as Result<never>;
@@ -1110,8 +1122,16 @@ export class WorkitCore {
1110
1122
  return failure("permission_denied", "stated choices cannot authorize mutating actions");
1111
1123
  if (stated && !input.binding?.statedChoice)
1112
1124
  return failure("invalid_input", "stated choices require binding.statedChoice");
1113
- if (nativeRequired && nativeObservation === undefined && !stated)
1125
+ if (nativeRequired && nativeObservation === undefined && !stated && !standing)
1114
1126
  return failure("permission_denied", "native decision observation is required");
1127
+ if (
1128
+ standing &&
1129
+ (!input.binding?.standing || input.purpose !== "action" || input.response !== "approved")
1130
+ )
1131
+ return failure(
1132
+ "invalid_input",
1133
+ "standing approvals need an approved action binding with a standing rule",
1134
+ );
1115
1135
  const task = this.store.readTask(input.taskId);
1116
1136
  if (!task.ok) return task as Result<never>;
1117
1137
  if ((this.context.workerId ?? null) !== null)
@@ -1141,8 +1161,13 @@ export class WorkitCore {
1141
1161
  consumption: null,
1142
1162
  } satisfies Omit<Decision, "digest">;
1143
1163
  const data: Decision = { ...base, digest: decisionDigest(base) };
1164
+ const standingApproval =
1165
+ standing && !stated
1166
+ ? verifyStandingApproval(this.store.root, task.data, this.context.caller, input.binding)
1167
+ : null;
1168
+ if (standingApproval && !standingApproval.ok) return standingApproval as Result<never>;
1144
1169
  const native =
1145
- nativeRequired && !stated
1170
+ nativeRequired && !stated && !standingApproval
1146
1171
  ? verifyNativeDecision(
1147
1172
  this.context.nativeAuthority,
1148
1173
  {
@@ -1189,7 +1214,10 @@ export class WorkitCore {
1189
1214
  const entry: Entry<Decision> = {
1190
1215
  id: newId(),
1191
1216
  recordedAt: mutation.now,
1192
- provenance: nativeProvenance ?? provenance(this.context, "agent_reported"),
1217
+ provenance:
1218
+ standingApproval?.ok === true
1219
+ ? standingApproval.data
1220
+ : (nativeProvenance ?? provenance(this.context, "agent_reported")),
1193
1221
  data,
1194
1222
  };
1195
1223
  return success(mutation.revision, null, {
@@ -19,7 +19,13 @@ export type WorkspaceBranchPolicy = {
19
19
  export type WorkspaceConfig = {
20
20
  name: string;
21
21
  glob: string;
22
- vcs?: { provider: VcsProvider; defaultTargetBranch?: string; tokenFile?: string };
22
+ vcs?: {
23
+ provider: VcsProvider;
24
+ defaultTargetBranch?: string;
25
+ tokenFile?: string;
26
+ account?: string;
27
+ };
28
+ autoApprove?: boolean | string[];
23
29
  youtrack?: { baseUrl?: string; link_issues?: boolean };
24
30
  issues?: { provider?: "github"; link_on_pr?: boolean };
25
31
  branchPolicy?: WorkspaceBranchPolicy;
package/src/core.ts CHANGED
@@ -73,6 +73,23 @@ export type { MethodId, SelectedMethod } from "./core/methods";
73
73
  export { hasLiveWorkitTask, shellRouteIntent, shouldDenyShellRoute } from "./core/route-intent";
74
74
  export { classifyBranchDirt } from "./core/branch";
75
75
  export type { BranchDirt } from "./core/branch";
76
+ export {
77
+ AUTO_CLASSES,
78
+ autoApproves,
79
+ operationAutoClass,
80
+ resolveAutoApproval,
81
+ standingApprovalLive,
82
+ standingAutoApplies,
83
+ standingAutoBinding,
84
+ standingReceiptFor,
85
+ verifyStandingApproval,
86
+ } from "./core/auto-approval";
87
+ export type {
88
+ AutoApproval,
89
+ AutoClass,
90
+ StandingAutoBinding,
91
+ StandingReceipt,
92
+ } from "./core/auto-approval";
76
93
  export { bundleHashOfFile, isEphemeralCachePath, sha256Hex } from "./core/runtime-identity";
77
94
  export type { ShellRouteIntent } from "./core/route-intent";
78
95
  export { applicableDecision, reserveAction, settleAction, reconcileAction } from "./core/authority";