@brainervirus/workit-core 1.0.10 → 1.0.12
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 +53 -11
- package/src/core/branch.ts +39 -2
- package/src/core/doctor.ts +25 -12
- package/src/core/external-action-effects.ts +107 -24
- package/src/core/external-action.ts +343 -27
- package/src/core/methods.ts +8 -8
- package/src/core/route-intent.ts +28 -0
- package/src/core/runtime-identity.ts +33 -0
- package/src/core/task-contract.ts +109 -1
- package/src/core/task-engine.ts +207 -240
- package/src/core/task-evaluation.ts +129 -24
- package/src/core/task-store.ts +4 -6
- package/src/core/workers.ts +11 -1
- package/src/core.ts +15 -1
package/package.json
CHANGED
package/src/core/authority.ts
CHANGED
|
@@ -181,12 +181,57 @@ export type ReconcileActionInput = {
|
|
|
181
181
|
|
|
182
182
|
type Workflow = { steps: string[]; completed: string[] };
|
|
183
183
|
|
|
184
|
+
/** One typed step of a chain reservation. Plain strings are commit
|
|
185
|
+
* messages (the historical plan_steps shape); branch and pr steps carry
|
|
186
|
+
* their match key. Keys are the canonical consumption alphabet. */
|
|
187
|
+
export type ChainStep =
|
|
188
|
+
| { kind: "commit"; message: string }
|
|
189
|
+
| { kind: "branch"; target: string }
|
|
190
|
+
| { kind: "pr" };
|
|
191
|
+
|
|
192
|
+
export const chainStepKey = (step: ChainStep): string =>
|
|
193
|
+
step.kind === "commit" ? step.message : step.kind === "branch" ? `branch:${step.target}` : "pr";
|
|
194
|
+
|
|
195
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
196
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
197
|
+
|
|
198
|
+
/** Normalize mixed plan steps to typed entries. Returns null for empty,
|
|
199
|
+
* over-long, malformed, or key-colliding lists (a commit message that
|
|
200
|
+
* spells exactly like a typed key is rejected rather than misrouted). */
|
|
201
|
+
export const normalizeChainSteps = (steps: unknown): ChainStep[] | null => {
|
|
202
|
+
if (!Array.isArray(steps) || steps.length === 0 || steps.length > 32) return null;
|
|
203
|
+
const normalized: ChainStep[] = [];
|
|
204
|
+
for (const step of steps) {
|
|
205
|
+
if (typeof step === "string") {
|
|
206
|
+
if (!step.trim()) return null;
|
|
207
|
+
normalized.push({ kind: "commit", message: step });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!isRecord(step)) return null;
|
|
211
|
+
if (typeof step.branch === "string" && step.branch.trim() && Object.keys(step).length === 1) {
|
|
212
|
+
normalized.push({ kind: "branch", target: step.branch });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (step.pr === true && Object.keys(step).length === 1) {
|
|
216
|
+
normalized.push({ kind: "pr" });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const keys = normalized.map(chainStepKey);
|
|
222
|
+
if (new Set(keys).size !== keys.length) return null;
|
|
223
|
+
return normalized;
|
|
224
|
+
};
|
|
225
|
+
|
|
184
226
|
const scopeEqual = (
|
|
185
227
|
left: Decision["binding"]["scope"],
|
|
186
228
|
right: Decision["binding"]["scope"],
|
|
187
229
|
): boolean => canonicalJson(left) === canonicalJson(right);
|
|
188
230
|
|
|
189
|
-
|
|
231
|
+
/** Parse the durable approved text into typed chain steps. Single commits
|
|
232
|
+
* and branch/PR operations carry no step list (empty, not an invalid
|
|
233
|
+
* chain); an invalid list is null and rejected by callers. */
|
|
234
|
+
const parseSteps = (content: string): ChainStep[] | null => {
|
|
190
235
|
let value: unknown;
|
|
191
236
|
try {
|
|
192
237
|
value = JSON.parse(content) as unknown;
|
|
@@ -195,7 +240,9 @@ const parseSteps = (content: string): string[] | null => {
|
|
|
195
240
|
}
|
|
196
241
|
if (typeof value !== "object" || value === null || !("steps" in value)) return [];
|
|
197
242
|
const steps = (value as { steps?: unknown }).steps;
|
|
198
|
-
|
|
243
|
+
if (!Array.isArray(steps)) return null;
|
|
244
|
+
const normalized = normalizeChainSteps(steps);
|
|
245
|
+
return normalized;
|
|
199
246
|
};
|
|
200
247
|
|
|
201
248
|
const decisionMatches = (
|
|
@@ -543,22 +590,17 @@ const validateAction = (
|
|
|
543
590
|
if (!content.ok) return content as Result<never>;
|
|
544
591
|
const requestedSteps = parseSteps(decision.binding.approvedContent);
|
|
545
592
|
if (requestedSteps === null)
|
|
546
|
-
return failure("invalid_input", "bounded action steps must be a
|
|
547
|
-
if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps))
|
|
593
|
+
return failure("invalid_input", "bounded action steps must be a unique non-empty array");
|
|
594
|
+
if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps.map(chainStepKey)))
|
|
548
595
|
return failure("permission_denied", "bounded action steps do not match approved content");
|
|
549
|
-
if (
|
|
550
|
-
requestedSteps.some((step) => !step || typeof step !== "string") ||
|
|
551
|
-
new Set(requestedSteps).size !== requestedSteps.length
|
|
552
|
-
)
|
|
553
|
-
return failure("invalid_input", "bounded action steps must be unique and non-empty");
|
|
554
596
|
const stored = task.actionProgress?.find((item) => item.decisionId === input.decisionId);
|
|
555
597
|
const workflow = stored
|
|
556
598
|
? { steps: stored.steps, completed: stored.completedSteps }
|
|
557
|
-
: { steps: requestedSteps, completed: [] };
|
|
599
|
+
: { steps: requestedSteps.map(chainStepKey), completed: [] };
|
|
558
600
|
if (
|
|
559
601
|
workflow.steps.length &&
|
|
560
602
|
requestedSteps.length &&
|
|
561
|
-
canonicalJson(workflow.steps) !== canonicalJson(requestedSteps)
|
|
603
|
+
canonicalJson(workflow.steps) !== canonicalJson(requestedSteps.map(chainStepKey))
|
|
562
604
|
)
|
|
563
605
|
return failure("permission_denied", "bounded action scope changed");
|
|
564
606
|
return success(null, null, { entry, workflow });
|
package/src/core/branch.ts
CHANGED
|
@@ -281,6 +281,43 @@ export type BranchSetupResult =
|
|
|
281
281
|
| { error: string; phase: "preflight" | "post_mutation" };
|
|
282
282
|
|
|
283
283
|
// Port of scripts/branch/setup-branch.sh
|
|
284
|
+
export type BranchDirt = "clean" | "carry" | "stash-required";
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Classify working-tree dirt for branch setup. Untracked files ride along
|
|
288
|
+
* (`git switch -c` never touches them), and tracked modifications confined
|
|
289
|
+
* to `docs/` are branch-owned by construction in this workflow — both
|
|
290
|
+
* auto-carry with no stash question. Anything else keeps the stash-bound
|
|
291
|
+
* proposal, since the base-checkout sequence could disturb it. Any
|
|
292
|
+
* inspection failure reads as clean so a broken git surfaces at its own
|
|
293
|
+
* error instead of inventing a stash demand.
|
|
294
|
+
*/
|
|
295
|
+
export const classifyBranchDirt = (workspaceRoot: string): BranchDirt => {
|
|
296
|
+
let raw: string;
|
|
297
|
+
try {
|
|
298
|
+
raw = execFileSync("git", ["status", "--porcelain=v1"], {
|
|
299
|
+
cwd: path.resolve(workspaceRoot),
|
|
300
|
+
encoding: "utf8",
|
|
301
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
302
|
+
});
|
|
303
|
+
} catch {
|
|
304
|
+
return "clean";
|
|
305
|
+
}
|
|
306
|
+
let dirty = false;
|
|
307
|
+
for (const line of raw.split("\n")) {
|
|
308
|
+
if (!line.trim()) continue;
|
|
309
|
+
dirty = true;
|
|
310
|
+
const code = line.slice(0, 2);
|
|
311
|
+
if (code === "??") continue;
|
|
312
|
+
let entry = line.slice(3);
|
|
313
|
+
const arrow = entry.indexOf(" -> ");
|
|
314
|
+
if (arrow >= 0) entry = entry.slice(arrow + 4);
|
|
315
|
+
const unquoted = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry;
|
|
316
|
+
if (unquoted !== "docs" && !unquoted.startsWith("docs/")) return "stash-required";
|
|
317
|
+
}
|
|
318
|
+
return dirty ? "carry" : "clean";
|
|
319
|
+
};
|
|
320
|
+
|
|
284
321
|
export const branchSetup = ({
|
|
285
322
|
action,
|
|
286
323
|
sdd_dir,
|
|
@@ -398,7 +435,7 @@ export const branchSetup = ({
|
|
|
398
435
|
};
|
|
399
436
|
if (current !== target) {
|
|
400
437
|
const dirty = Boolean(gitContext(cwd).status_short.trim());
|
|
401
|
-
if (
|
|
438
|
+
if (classifyBranchDirt(cwd) === "stash-required" && stash !== "yes") {
|
|
402
439
|
return {
|
|
403
440
|
error:
|
|
404
441
|
"dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
|
|
@@ -416,7 +453,7 @@ export const branchSetup = ({
|
|
|
416
453
|
return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
|
|
417
454
|
validatedBase = base;
|
|
418
455
|
}
|
|
419
|
-
if (dirty) {
|
|
456
|
+
if (dirty && stash === "yes") {
|
|
420
457
|
try {
|
|
421
458
|
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
|
|
422
459
|
} catch (error) {
|
package/src/core/doctor.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import os from "node:os";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { SUPPORT_MATRIX } from "./support-matrix";
|
|
25
|
+
import { bundleHashOfFile, isEphemeralCachePath } from "./runtime-identity";
|
|
25
26
|
import { EVENT } from "./boundary";
|
|
26
27
|
import { getDiagnosticLogger, isConfigObject } from "./config";
|
|
27
28
|
import { packageRoot } from "./package-root";
|
|
@@ -682,12 +683,7 @@ const checkUtility = (res: Resolved): DoctorCheck => {
|
|
|
682
683
|
const isFragileCachePin = (entry: string): boolean => {
|
|
683
684
|
const n = entry.replaceAll("\\", "/").toLowerCase();
|
|
684
685
|
if (!n.startsWith("file:")) return false;
|
|
685
|
-
return (
|
|
686
|
-
n.includes("/caches/pnpm/dlx/") ||
|
|
687
|
-
n.includes("/_npx/") ||
|
|
688
|
-
n.includes("/.pacquet/") ||
|
|
689
|
-
n.includes("/library/caches/pnpm/")
|
|
690
|
-
);
|
|
686
|
+
return isEphemeralCachePath(entry);
|
|
691
687
|
};
|
|
692
688
|
|
|
693
689
|
const staleEntry = (entry: string): "ok" | "stale" | "missing-file" | "fragile-cache" => {
|
|
@@ -936,12 +932,7 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
|
|
|
936
932
|
} catch {
|
|
937
933
|
target = "(unreadable)";
|
|
938
934
|
}
|
|
939
|
-
const
|
|
940
|
-
const fragile =
|
|
941
|
-
n.includes("/caches/pnpm/dlx/") ||
|
|
942
|
-
n.includes("/_npx/") ||
|
|
943
|
-
n.includes("/.pacquet/") ||
|
|
944
|
-
n.includes("/library/caches/pnpm/");
|
|
935
|
+
const fragile = isEphemeralCachePath(target);
|
|
945
936
|
return {
|
|
946
937
|
id: "stale_install",
|
|
947
938
|
status: "fail",
|
|
@@ -1013,6 +1004,28 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
|
|
|
1013
1004
|
fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory and rewrites the workit MCP/hook entries",
|
|
1014
1005
|
};
|
|
1015
1006
|
}
|
|
1007
|
+
// Same version, different bytes: the install was hand-edited or built from
|
|
1008
|
+
// a dirty tree. Version strings cannot see that; the bundle hash can. The
|
|
1009
|
+
// dev dist is byte-deterministic, so a mismatch heals with reinstall.
|
|
1010
|
+
if (localDist && installed !== null && source !== null && res.dev) {
|
|
1011
|
+
const entry = /^node\s+(.+)$/.exec((hookCmd ?? "").trim())?.[1]?.trim() ?? "";
|
|
1012
|
+
const sourceBundle =
|
|
1013
|
+
entry && path.isAbsolute(entry)
|
|
1014
|
+
? path.join(res.dev, "packages", "workit-cursor", "dist", path.basename(entry))
|
|
1015
|
+
: "";
|
|
1016
|
+
if (entry && sourceBundle) {
|
|
1017
|
+
const installedHash = bundleHashOfFile(entry);
|
|
1018
|
+
const sourceHash = bundleHashOfFile(sourceBundle);
|
|
1019
|
+
if (installedHash !== null && sourceHash !== null && installedHash !== sourceHash) {
|
|
1020
|
+
return {
|
|
1021
|
+
id: "stale_install",
|
|
1022
|
+
status: "fail",
|
|
1023
|
+
detail: `stale_install: installed workit-cursor ${installed} runs different bytes than the current runtime build (${path.basename(entry)} hash mismatch)`,
|
|
1024
|
+
fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory with the current build",
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1016
1029
|
if (installed !== null && localDist) {
|
|
1017
1030
|
const expected = registryLatestVersion(res);
|
|
1018
1031
|
if (expected === null) {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
branchSetup,
|
|
3
|
+
classifyBranchDirt,
|
|
4
|
+
resolveBranchPolicyFor,
|
|
5
|
+
resolveCommitPolicyFor,
|
|
6
|
+
} from "./branch";
|
|
2
7
|
import { hostingCliAvailable, prCreate } from "./pr-create";
|
|
3
8
|
import {
|
|
4
9
|
changelogContext,
|
|
@@ -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) {
|
|
@@ -286,16 +315,29 @@ export const actionProposalQuestion = (
|
|
|
286
315
|
approvedText: "Reapply the recorded stash.",
|
|
287
316
|
};
|
|
288
317
|
const target = String(payload.target_branch ?? "");
|
|
318
|
+
// A dirty tree binds stash: the approval carries the stash through,
|
|
319
|
+
// so the question names it instead of failing later at preflight.
|
|
320
|
+
const stashes = payload.stash === "yes" && resolved.dirty === true;
|
|
289
321
|
if (resolved.target_exists === true)
|
|
290
|
-
return
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
322
|
+
return stashes
|
|
323
|
+
? {
|
|
324
|
+
presented: `Workit decision: action — Stash the dirty working tree and switch to branch \`${target}\`?`,
|
|
325
|
+
approvedText: `Stash and switch to \`${target}\`.`,
|
|
326
|
+
}
|
|
327
|
+
: {
|
|
328
|
+
presented: `Workit decision: action — Switch to branch \`${target}\`?`,
|
|
329
|
+
approvedText: `Switch to \`${target}\`.`,
|
|
330
|
+
};
|
|
294
331
|
const base = String(resolved.base_branch ?? "the base branch");
|
|
295
|
-
return
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
332
|
+
return stashes
|
|
333
|
+
? {
|
|
334
|
+
presented: `Workit decision: action — Stash the dirty working tree and create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
|
|
335
|
+
approvedText: `Stash and create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
|
|
336
|
+
}
|
|
337
|
+
: {
|
|
338
|
+
presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
|
|
339
|
+
approvedText: `Create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
|
|
340
|
+
};
|
|
299
341
|
}
|
|
300
342
|
case "git.commit": {
|
|
301
343
|
const paths = Array.isArray(resolved.paths) ? (resolved.paths as string[]) : [];
|
|
@@ -793,6 +835,32 @@ const youTrackWritePreflight = (): Result<null> => {
|
|
|
793
835
|
return success(null, null, null);
|
|
794
836
|
};
|
|
795
837
|
|
|
838
|
+
/**
|
|
839
|
+
* A dirty tree binds stash up front so the proposal names it and one
|
|
840
|
+
* approval carries the whole effect instead of failing at preflight.
|
|
841
|
+
* Carry-class dirt (untracked or docs-confined) rides along silently.
|
|
842
|
+
* Returns the original resolution untouched unless an upgrade applies.
|
|
843
|
+
*/
|
|
844
|
+
export const upgradeBranchSetupForStash = (
|
|
845
|
+
root: string,
|
|
846
|
+
parsed: ExternalActionRequest,
|
|
847
|
+
resolved: Result<ResolvedExternalAction>,
|
|
848
|
+
): Result<ResolvedExternalAction> => {
|
|
849
|
+
if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
|
|
850
|
+
if (
|
|
851
|
+
(resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
|
|
852
|
+
true ||
|
|
853
|
+
(resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
|
|
854
|
+
classifyBranchDirt(root) !== "stash-required"
|
|
855
|
+
)
|
|
856
|
+
return resolved;
|
|
857
|
+
const upgraded = resolveExternalActionRequest(root, {
|
|
858
|
+
...parsed,
|
|
859
|
+
payload: { ...(parsed.payload as Record<string, unknown>), stash: "yes" },
|
|
860
|
+
} as typeof parsed);
|
|
861
|
+
return upgraded.ok ? upgraded : resolved;
|
|
862
|
+
};
|
|
863
|
+
|
|
796
864
|
export const resolveExternalActionRequest = (
|
|
797
865
|
root: string,
|
|
798
866
|
request: ExternalActionRequest,
|
|
@@ -859,26 +927,41 @@ export const resolveExternalActionRequest = (
|
|
|
859
927
|
if (!head) return failure("storage_error", "Git state could not be resolved");
|
|
860
928
|
const branch = gitValue(root, ["branch", "--show-current"]);
|
|
861
929
|
if (planSteps !== undefined || planBranch !== undefined) {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
planSteps.some((step) => typeof step !== "string" || !step.trim()) ||
|
|
865
|
-
new Set(planSteps).size !== planSteps.length ||
|
|
866
|
-
planSteps.length === 0 ||
|
|
867
|
-
planSteps.length > 32
|
|
868
|
-
)
|
|
930
|
+
const chain = Array.isArray(planSteps) ? normalizeChainSteps(planSteps) : null;
|
|
931
|
+
if (!chain)
|
|
869
932
|
return failure(
|
|
870
933
|
"invalid_input",
|
|
871
|
-
"plan_steps must be 1-32 unique non-empty commit messages",
|
|
934
|
+
"plan_steps must be 1-32 unique non-empty commit messages, branch targets, or pull_request markers",
|
|
872
935
|
{
|
|
873
936
|
outcome: "not_started",
|
|
874
|
-
fields: [{ path: "plan_steps", reason: "unique
|
|
937
|
+
fields: [{ path: "plan_steps", reason: "unique steps" }],
|
|
875
938
|
},
|
|
876
939
|
);
|
|
877
|
-
if (typeof planBranch !== "string" || !planBranch.trim()
|
|
878
|
-
return failure("invalid_input", "plan_branch must name the
|
|
940
|
+
if (typeof planBranch !== "string" || !planBranch.trim())
|
|
941
|
+
return failure("invalid_input", "plan_branch must name the working branch", {
|
|
879
942
|
outcome: "not_started",
|
|
880
|
-
fields: [{ path: "plan_branch", reason: "
|
|
943
|
+
fields: [{ path: "plan_branch", reason: "required" }],
|
|
881
944
|
});
|
|
945
|
+
// A chain that creates its own branch names that branch; otherwise
|
|
946
|
+
// the plan runs on the branch already checked out.
|
|
947
|
+
const first = chain[0];
|
|
948
|
+
const createsBranch = first?.kind === "branch";
|
|
949
|
+
if (createsBranch ? planBranch !== first.target : planBranch !== branch)
|
|
950
|
+
return failure(
|
|
951
|
+
"invalid_input",
|
|
952
|
+
createsBranch
|
|
953
|
+
? "plan_branch must name the branch the chain creates"
|
|
954
|
+
: "plan_branch must name the current working branch",
|
|
955
|
+
{
|
|
956
|
+
outcome: "not_started",
|
|
957
|
+
fields: [
|
|
958
|
+
{
|
|
959
|
+
path: "plan_branch",
|
|
960
|
+
reason: createsBranch ? "chain branch" : "current branch",
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
},
|
|
964
|
+
);
|
|
882
965
|
if (isProtectedBranch(root, planBranch))
|
|
883
966
|
return failure("invalid_input", "plan_branch must not be a protected branch", {
|
|
884
967
|
outcome: "not_started",
|