@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.
- package/package.json +1 -1
- package/src/core/authority.ts +84 -11
- package/src/core/auto-approval.ts +326 -0
- package/src/core/branch.ts +83 -2
- package/src/core/doctor.ts +25 -12
- package/src/core/external-action-effects.ts +179 -17
- package/src/core/external-action.ts +204 -16
- package/src/core/methods.ts +8 -8
- package/src/core/pr-create.ts +50 -0
- package/src/core/route-intent.ts +7 -1
- package/src/core/runtime-identity.ts +33 -0
- package/src/core/task-contract.ts +121 -0
- package/src/core/task-engine.ts +209 -223
- package/src/core/task-evaluation.ts +127 -22
- package/src/core/task-store.ts +4 -6
- package/src/core/workers.ts +11 -1
- package/src/core/workspaces.ts +7 -1
- package/src/core.ts +29 -0
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
|
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
|
|
277
|
-
approvedText: `Approve the ${steps.length}
|
|
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
|
-
|
|
876
|
-
|
|
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
|
|
957
|
+
fields: [{ path: "plan_steps", reason: "unique steps" }],
|
|
888
958
|
},
|
|
889
959
|
);
|
|
890
|
-
if (typeof planBranch !== "string" || !planBranch.trim()
|
|
891
|
-
return failure("invalid_input", "plan_branch must name the
|
|
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: "
|
|
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:
|
|
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?: {
|
|
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
|
-
|
|
337
|
-
|
|
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:
|
|
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
|
-
|
|
399
|
-
|
|
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 = (
|
package/src/core/methods.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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(
|
|
74
|
+
methodMatches(definition, requirement),
|
|
75
75
|
);
|
|
76
76
|
if (requirements.length === 0) return [];
|
|
77
77
|
const capability = assuranceFor(id, definition, capabilities);
|
package/src/core/pr-create.ts
CHANGED
|
@@ -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 };
|