@brainervirus/workit-core 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/skills/workit-babysit/SKILL.md +20 -26
  3. package/skills/workit-behavioral-tdd/SKILL.md +0 -5
  4. package/skills/workit-blast-radius/SKILL.md +0 -5
  5. package/skills/workit-challenge/SKILL.md +30 -47
  6. package/skills/workit-debug/SKILL.md +5 -27
  7. package/skills/workit-deslop/SKILL.md +0 -5
  8. package/skills/workit-diagram/SKILL.md +0 -5
  9. package/skills/workit-green-run/SKILL.md +0 -5
  10. package/skills/workit-handoff/SKILL.md +2 -8
  11. package/skills/workit-implement/SKILL.md +14 -20
  12. package/skills/workit-mockup/SKILL.md +0 -5
  13. package/skills/workit-plan/SKILL.md +30 -63
  14. package/skills/workit-review/SKILL.md +0 -5
  15. package/skills/workit-steer/SKILL.md +27 -27
  16. package/src/core/authority.ts +6 -1
  17. package/src/core/auto-approval.ts +34 -14
  18. package/src/core/branch.ts +293 -42
  19. package/src/core/config-conversion.ts +4 -4
  20. package/src/core/config-guard.ts +1 -7
  21. package/src/core/config.ts +1 -1
  22. package/src/core/cutover.ts +1207 -140
  23. package/src/core/doctor.ts +155 -82
  24. package/src/core/external-action-effects.ts +886 -172
  25. package/src/core/external-action.ts +214 -63
  26. package/src/core/init.ts +25 -115
  27. package/src/core/legacy-ownership.ts +47 -0
  28. package/src/core/methods.ts +56 -52
  29. package/src/core/ports/pr-create.ts +23 -15
  30. package/src/core/pr-create.ts +374 -84
  31. package/src/core/registration.ts +0 -15
  32. package/src/core/repo-tools.ts +0 -1
  33. package/src/core/route-intent.ts +34 -82
  34. package/src/core/safe-write.ts +66 -1
  35. package/src/core/setup-state.ts +7 -0
  36. package/src/core/setup.ts +175 -82
  37. package/src/core/task-contract.ts +7 -1
  38. package/src/core/task-engine.ts +47 -2
  39. package/src/core/task-store.ts +159 -1
  40. package/src/core/tracker-issues.ts +55 -118
  41. package/src/core/vcs-config.ts +178 -187
  42. package/src/core/workspaces.ts +548 -46
  43. package/src/core.ts +1 -2
  44. package/src/core/ports/vcs-token-create-urls.ts +0 -4
@@ -1,35 +1,209 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFileSync, realpathSync } from "node:fs";
2
3
  import path from "node:path";
3
- import { configDir, isConfigObject, type BranchPreset } from "./config";
4
+ import { z } from "zod";
5
+ import {
6
+ COMMIT_PRESETS,
7
+ PRESETS,
8
+ configDir,
9
+ isConfigObject,
10
+ resolveBranchPolicy as resolveConfiguredBranchPolicy,
11
+ resolveCommitPolicy as resolveConfiguredCommitPolicy,
12
+ type BranchPreset,
13
+ type ToolkitConfig,
14
+ } from "./config";
4
15
  import type { CommitFlavorPreset } from "./commit-flavors";
5
16
 
6
17
  export type VcsProvider = "gitlab" | "github";
7
18
 
8
19
  export type IntegrationMode = "pr" | "merge";
9
20
 
10
- export type WorkspaceBranchPolicy = {
11
- preset: BranchPreset;
12
- developBranch?: string;
13
- prefixes?: { feature: string; bugfix: string; release: string; hotfix: string };
14
- allowed?: string[];
15
- protected?: string[];
16
- integration: IntegrationMode;
17
- };
21
+ const nonBlank = z.string().refine((value) => value.trim().length > 0, "must not be blank");
22
+ const branchPreset = z.custom<BranchPreset>(
23
+ (value) => typeof value === "string" && Object.hasOwn(PRESETS, value),
24
+ "unsupported branch preset",
25
+ );
26
+ const commitPreset = z.custom<CommitFlavorPreset>(
27
+ (value) => typeof value === "string" && COMMIT_PRESETS.includes(value),
28
+ "unsupported commit preset",
29
+ );
30
+ const workspaceBranchPolicySchema = z
31
+ .object({
32
+ preset: branchPreset,
33
+ developBranch: nonBlank.optional(),
34
+ prefixes: z
35
+ .object({
36
+ feature: nonBlank,
37
+ bugfix: nonBlank,
38
+ release: nonBlank,
39
+ hotfix: nonBlank,
40
+ })
41
+ .passthrough()
42
+ .optional(),
43
+ allowed: z.array(nonBlank).optional(),
44
+ protected: z.array(nonBlank).optional(),
45
+ integration: z.enum(["pr", "merge"]).optional(),
46
+ })
47
+ .strict();
48
+ const workspaceCommitPolicySchema = z
49
+ .object({ preset: commitPreset, pattern: z.string().optional() })
50
+ .strict();
51
+ const workspaceProfileSchema = z
52
+ .object({
53
+ branchPolicy: workspaceBranchPolicySchema.optional(),
54
+ commitPolicy: workspaceCommitPolicySchema.optional(),
55
+ })
56
+ .strict()
57
+ .refine((profile) => profile.branchPolicy !== undefined || profile.commitPolicy !== undefined, {
58
+ message: "a profile must define branchPolicy or commitPolicy",
59
+ });
60
+ const versionSourceSchema = z.discriminatedUnion("kind", [
61
+ z
62
+ .object({
63
+ kind: z.literal("package-json"),
64
+ path: nonBlank,
65
+ field: nonBlank,
66
+ })
67
+ .strict(),
68
+ z.object({ kind: z.literal("git-tag") }).strict(),
69
+ z.object({ kind: z.literal("manual") }).strict(),
70
+ ]);
71
+ const releaseTrackSchema = z
72
+ .object({
73
+ strategy: branchPreset,
74
+ productionBranch: nonBlank,
75
+ integrationBranch: nonBlank,
76
+ naming: z.object({ feature: nonBlank, release: nonBlank, hotfix: nonBlank }).strict(),
77
+ baseBranch: nonBlank,
78
+ mergeBackBranches: z.array(nonBlank),
79
+ pullRequestTarget: nonBlank,
80
+ tagNamespace: z.string(),
81
+ versionSource: versionSourceSchema,
82
+ requiredChecks: z.array(nonBlank),
83
+ })
84
+ .strict()
85
+ .superRefine((track, context) => {
86
+ for (const [field, values] of [
87
+ ["mergeBackBranches", track.mergeBackBranches],
88
+ ["requiredChecks", track.requiredChecks],
89
+ ] as const) {
90
+ if (new Set(values).size !== values.length) {
91
+ context.addIssue({ code: "custom", path: [field], message: "entries must be unique" });
92
+ }
93
+ }
94
+ if (
95
+ track.versionSource.kind === "package-json" &&
96
+ (path.posix.isAbsolute(track.versionSource.path.replaceAll("\\", "/")) ||
97
+ /^[A-Za-z]:\//u.test(track.versionSource.path.replaceAll("\\", "/")) ||
98
+ track.versionSource.path.replaceAll("\\", "/").split("/").includes(".."))
99
+ ) {
100
+ context.addIssue({
101
+ code: "custom",
102
+ path: ["versionSource", "path"],
103
+ message: "must stay inside the repository",
104
+ });
105
+ }
106
+ });
107
+ const workspaceConfigSchema = z
108
+ .object({
109
+ name: nonBlank,
110
+ glob: nonBlank,
111
+ vcs: z
112
+ .object({
113
+ provider: z.enum(["gitlab", "github"]),
114
+ defaultTargetBranch: nonBlank.optional(),
115
+ tokenFile: nonBlank.optional(),
116
+ account: nonBlank.optional(),
117
+ })
118
+ .passthrough()
119
+ .optional(),
120
+ autoApprove: z.union([z.boolean(), z.array(nonBlank)]).optional(),
121
+ youtrack: z
122
+ .object({ baseUrl: nonBlank.optional(), link_issues: z.boolean().optional() })
123
+ .passthrough()
124
+ .optional(),
125
+ issues: z
126
+ .object({ provider: z.literal("github").optional(), link_on_pr: z.boolean().optional() })
127
+ .passthrough()
128
+ .optional(),
129
+ branchPolicy: workspaceBranchPolicySchema.optional(),
130
+ commitPolicy: workspaceCommitPolicySchema.optional(),
131
+ defaultProfile: nonBlank.optional(),
132
+ profiles: z.record(nonBlank, workspaceProfileSchema).optional(),
133
+ releaseTracks: z.record(nonBlank, releaseTrackSchema).optional(),
134
+ })
135
+ .passthrough()
136
+ .superRefine((workspace, context) => {
137
+ if (workspace.youtrack && workspace.vcs?.provider !== "gitlab") {
138
+ context.addIssue({
139
+ code: "custom",
140
+ path: ["youtrack"],
141
+ message: `YouTrack issue linking requires the gitlab provider, got ${workspace.vcs?.provider ?? "unset"}`,
142
+ });
143
+ }
144
+ if (workspace.issues && workspace.vcs?.provider !== "github") {
145
+ context.addIssue({
146
+ code: "custom",
147
+ path: ["issues"],
148
+ message: `GitHub issue linking requires the github provider, got ${workspace.vcs?.provider ?? "unset"}`,
149
+ });
150
+ }
151
+ if (
152
+ workspace.defaultProfile !== undefined &&
153
+ !Object.hasOwn(workspace.profiles ?? {}, workspace.defaultProfile)
154
+ ) {
155
+ context.addIssue({
156
+ code: "custom",
157
+ path: ["defaultProfile"],
158
+ message: `does not name a configured profile`,
159
+ });
160
+ }
161
+ });
162
+ const workspacesFileSchema = z
163
+ .object({ workspaces: z.array(workspaceConfigSchema).optional() })
164
+ .passthrough()
165
+ .superRefine((file, context) => {
166
+ const names = new Set<string>();
167
+ for (const [index, workspace] of (file.workspaces ?? []).entries()) {
168
+ if (names.has(workspace.name)) {
169
+ context.addIssue({
170
+ code: "custom",
171
+ path: ["workspaces", index, "name"],
172
+ message: `duplicate workspace name ${JSON.stringify(workspace.name)}`,
173
+ });
174
+ }
175
+ names.add(workspace.name);
176
+ }
177
+ });
18
178
 
19
- export type WorkspaceConfig = {
20
- name: string;
21
- glob: string;
22
- vcs?: {
23
- provider: VcsProvider;
24
- defaultTargetBranch?: string;
25
- tokenFile?: string;
26
- account?: string;
27
- };
28
- autoApprove?: boolean | string[];
29
- youtrack?: { baseUrl?: string; link_issues?: boolean };
30
- issues?: { provider?: "github"; link_on_pr?: boolean };
31
- branchPolicy?: WorkspaceBranchPolicy;
32
- commitPolicy?: { preset: CommitFlavorPreset; pattern?: string };
179
+ export type WorkspaceBranchPolicy = z.infer<typeof workspaceBranchPolicySchema>;
180
+ export type WorkspaceCommitPolicy = z.infer<typeof workspaceCommitPolicySchema>;
181
+ export type WorkspaceProfile = z.infer<typeof workspaceProfileSchema>;
182
+ export type ReleaseTrack = z.infer<typeof releaseTrackSchema>;
183
+ export type WorkspaceConfig = z.infer<typeof workspaceConfigSchema>;
184
+
185
+ export type WorkspaceProfileResolution =
186
+ | {
187
+ status: "resolved";
188
+ profileName: string | null;
189
+ workspace: WorkspaceConfig;
190
+ provenance: { branchPolicy: string; commitPolicy: string };
191
+ }
192
+ | { status: "invalid"; error: string };
193
+
194
+ export type ReleaseTrackSelection =
195
+ | { status: "selected"; name: string; track: ReleaseTrack }
196
+ | { status: "not_configured" }
197
+ | { status: "choice_required"; choices: string[] }
198
+ | { status: "invalid"; error: string };
199
+
200
+ export type WorkspacePolicyResolution = {
201
+ status: "resolved";
202
+ workspace: WorkspaceConfig | null;
203
+ profileName: string | null;
204
+ branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
205
+ commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
206
+ provenance: { branchPolicy: string; commitPolicy: string };
33
207
  };
34
208
 
35
209
  export const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
@@ -38,35 +212,89 @@ export const workspacesPath = (): string => path.join(configDir(), "workspaces.j
38
212
  // (empty list); malformed JSON is reported with the exact path so risky
39
213
  // consumers (wizard/installer/doctor) can block instead of silently resetting.
40
214
  export type WorkspacesResult = {
41
- status: "missing" | "valid" | "malformed";
215
+ status: "missing" | "valid" | "malformed" | "invalid";
42
216
  path: string;
43
217
  entries: WorkspaceConfig[];
218
+ document?: Record<string, unknown>;
219
+ revision?: string;
44
220
  error?: string;
45
221
  };
46
222
 
223
+ export const workspacesRevision = (contents: string | null): string =>
224
+ createHash("sha256")
225
+ .update(contents ?? "<missing>")
226
+ .digest("hex");
227
+
228
+ export const validateWorkspacesDocument = (parsed: unknown, file: string): WorkspacesResult => {
229
+ if (!isConfigObject(parsed)) {
230
+ return { status: "malformed", path: file, entries: [], error: `${file} is not a JSON object` };
231
+ }
232
+ const validated = workspacesFileSchema.safeParse(parsed, { reportInput: true });
233
+ if (!validated.success) {
234
+ const detail = validated.error.issues
235
+ .map((issue) => {
236
+ const field = String(issue.path[issue.path.length - 1] ?? "");
237
+ const attempted =
238
+ ["provider", "preset", "strategy"].includes(field) && typeof issue.input === "string"
239
+ ? ` (${JSON.stringify(issue.input)})`
240
+ : "";
241
+ return `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}${attempted}`;
242
+ })
243
+ .join("; ");
244
+ return {
245
+ status: "invalid",
246
+ path: file,
247
+ entries: [],
248
+ error: `${file} has invalid workspace configuration: ${detail}`,
249
+ };
250
+ }
251
+ for (const [index, workspace] of (validated.data.workspaces ?? []).entries()) {
252
+ const glob = validateWorkspaceGlob(workspace.glob);
253
+ if (!glob.ok) {
254
+ return {
255
+ status: "invalid",
256
+ path: file,
257
+ entries: [],
258
+ error: `${file} workspaces.${index}.glob: ${glob.error}`,
259
+ };
260
+ }
261
+ }
262
+ return {
263
+ status: "valid",
264
+ path: file,
265
+ entries: validated.data.workspaces ?? [],
266
+ document: parsed as Record<string, unknown>,
267
+ };
268
+ };
269
+
47
270
  export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResult => {
48
271
  const file = path.join(dir, "workspaces.json");
49
272
  let raw: string;
50
273
  try {
51
274
  raw = readFileSync(file, "utf8");
52
- } catch {
53
- return { status: "missing", path: file, entries: [] };
275
+ } catch (error) {
276
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
277
+ return { status: "missing", path: file, entries: [], revision: workspacesRevision(null) };
278
+ return {
279
+ status: "malformed",
280
+ path: file,
281
+ entries: [],
282
+ error: `${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
283
+ };
54
284
  }
55
285
  let parsed: unknown;
56
286
  try {
57
287
  parsed = JSON.parse(raw);
58
288
  } catch {
59
- return { status: "malformed", path: file, entries: [], error: `${file} is not valid JSON` };
60
- }
61
- if (!isConfigObject(parsed)) {
62
- return { status: "malformed", path: file, entries: [], error: `${file} is not a JSON object` };
289
+ return {
290
+ status: "malformed",
291
+ path: file,
292
+ entries: [],
293
+ revision: workspacesRevision(raw),
294
+ error: `${file} is not valid JSON`,
295
+ };
63
296
  }
64
- const list = (parsed as { workspaces?: unknown }).workspaces;
65
- return {
66
- status: "valid",
67
- path: file,
68
- entries: Array.isArray(list) ? (list as WorkspaceConfig[]) : [],
69
- };
297
+ return { ...validateWorkspacesDocument(parsed, file), revision: workspacesRevision(raw) };
70
298
  };
71
299
 
72
300
  /** Parse the workspaces.json list under an explicit config dir; [] when missing/malformed. */
@@ -182,27 +410,301 @@ const canonicalGlob = (glob: string): string => {
182
410
  };
183
411
 
184
412
  /** Match a cwd against the workspaces.json under an explicit config dir. */
185
- export const resolveWorkspaceFrom = (cwd: string, dir: string): WorkspaceConfig | null => {
413
+ export const resolveWorkspaceFrom = (
414
+ cwd: string,
415
+ dir: string,
416
+ workspaceName?: string,
417
+ ): WorkspaceConfig | null => {
186
418
  // macOS/Windows tmpdir symlinks (/var -> /private/var): git's
187
419
  // --show-toplevel returns the realpath while config globs are usually
188
420
  // written with the logical path, so a workspace would silently stop
189
421
  // matching on macOS. Match both forms on each side — same class as the
190
422
  // docs-migration escape-guard realpath comparison; on Linux both forms
191
423
  // are identical so behavior is unchanged.
424
+ const result = readWorkspacesResult(dir);
425
+ if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
192
426
  const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
193
- for (const entry of loadWorkspacesFrom(dir)) {
194
- if (!entry || typeof entry !== "object") continue;
195
- const ws = entry as WorkspaceConfig;
196
- if (typeof ws.glob !== "string" || !ws.glob) continue;
427
+ const matches: WorkspaceConfig[] = [];
428
+ for (const ws of result.entries) {
197
429
  const glob = ws.glob.replaceAll("\\", "/");
198
430
  const canonical = canonicalGlob(glob);
431
+ let matched = false;
199
432
  for (const target of targets) {
200
- if (matchWorkspace(glob, target)) return ws;
201
- if (canonical !== glob && matchWorkspace(canonical, target)) return ws;
433
+ if (
434
+ matchWorkspace(glob, target) ||
435
+ (canonical !== glob && matchWorkspace(canonical, target))
436
+ ) {
437
+ matched = true;
438
+ break;
439
+ }
202
440
  }
441
+ if (matched) matches.push(ws);
442
+ }
443
+ if (workspaceName !== undefined) {
444
+ const selected = matches.find((ws) => ws.name === workspaceName);
445
+ if (!selected && matches.length > 0) {
446
+ const matchingNames = matches.map((ws) => ws.name);
447
+ throw new Error(
448
+ `workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matchingNames.join(", ")}`,
449
+ );
450
+ }
451
+ if (selected) return selected;
452
+ throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
453
+ }
454
+ if (matches.length > 1) {
455
+ throw new Error(
456
+ `ambiguous workspace for ${cwd}; choose one of: ${matches.map((ws) => ws.name).join(", ")}`,
457
+ );
203
458
  }
204
- return null;
459
+ return matches[0] ?? null;
205
460
  };
206
461
 
207
- export const resolveWorkspace = (cwd: string): WorkspaceConfig | null =>
208
- resolveWorkspaceFrom(cwd, configDir());
462
+ export const resolveWorkspace = (
463
+ cwd: string,
464
+ workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,
465
+ ): WorkspaceConfig | null => resolveWorkspaceFrom(cwd, configDir(), workspaceName);
466
+
467
+ type RuntimeWorkspacePolicy = "branch" | "commit" | "vcs";
468
+ type RuntimeWorkspaceCandidate = {
469
+ name: string;
470
+ glob: string;
471
+ defaultProfile?: string;
472
+ profiles?: Record<string, Record<string, unknown>>;
473
+ branchPolicy?: WorkspaceBranchPolicy;
474
+ commitPolicy?: WorkspaceCommitPolicy;
475
+ vcs?: WorkspaceConfig["vcs"];
476
+ youtrack?: unknown;
477
+ issues?: unknown;
478
+ };
479
+
480
+ const runtimeWorkspacePolicySchema = (kind: RuntimeWorkspacePolicy) => {
481
+ const key = kind === "branch" ? "branchPolicy" : kind === "commit" ? "commitPolicy" : "vcs";
482
+ const policySchema =
483
+ kind === "branch"
484
+ ? workspaceBranchPolicySchema
485
+ : kind === "commit"
486
+ ? workspaceCommitPolicySchema
487
+ : z
488
+ .object({
489
+ provider: z.enum(["gitlab", "github"]),
490
+ defaultTargetBranch: nonBlank.optional(),
491
+ account: nonBlank.optional(),
492
+ })
493
+ .passthrough();
494
+ const profileSchema = z.object({ [key]: policySchema.optional() }).passthrough();
495
+ const entrySchema = z
496
+ .object({
497
+ name: nonBlank,
498
+ glob: nonBlank,
499
+ [key]: policySchema.optional(),
500
+ defaultProfile: nonBlank.optional(),
501
+ profiles: z.record(nonBlank, profileSchema).optional(),
502
+ })
503
+ .passthrough();
504
+ return entrySchema;
505
+ };
506
+
507
+ export type RuntimeWorkspaceVcs = Pick<
508
+ RuntimeWorkspaceCandidate,
509
+ "name" | "branchPolicy" | "vcs" | "youtrack" | "issues"
510
+ >;
511
+
512
+ export const resolveRuntimeWorkspaceVcs = (cwd: string): RuntimeWorkspaceVcs | null =>
513
+ resolveRuntimeWorkspaceCandidate(cwd, "vcs");
514
+
515
+ const runtimeWorkspaceIndexSchema = z
516
+ .object({
517
+ workspaces: z.array(z.object({ name: nonBlank, glob: nonBlank }).passthrough()).optional(),
518
+ })
519
+ .passthrough();
520
+
521
+ const resolveRuntimeWorkspaceCandidate = (
522
+ cwd: string,
523
+ kind: RuntimeWorkspacePolicy,
524
+ ): RuntimeWorkspaceCandidate | null => {
525
+ const file = path.join(configDir(), "workspaces.json");
526
+ let raw: string;
527
+ try {
528
+ raw = readFileSync(file, "utf8");
529
+ } catch (error) {
530
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
531
+ throw new Error(
532
+ `${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
533
+ );
534
+ }
535
+ let parsed: unknown;
536
+ try {
537
+ parsed = JSON.parse(raw);
538
+ } catch {
539
+ throw new Error(`${file} is not valid JSON`);
540
+ }
541
+ const validated = runtimeWorkspaceIndexSchema.safeParse(parsed);
542
+ if (!validated.success) {
543
+ const detail = validated.error.issues
544
+ .map((issue) => `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}`)
545
+ .join("; ");
546
+ throw new Error(`${file} has invalid workspace matching data: ${detail}`);
547
+ }
548
+ const entries = validated.data.workspaces as RuntimeWorkspaceCandidate[] | undefined;
549
+ for (const entry of entries ?? []) {
550
+ const glob = validateWorkspaceGlob(entry.glob);
551
+ if (!glob.ok) throw new Error(`${file} workspace ${entry.name}: ${glob.error}`);
552
+ }
553
+ const targets = [cwd, realpathOf(cwd)].map((value) => value.replaceAll("\\", "/"));
554
+ const matches = (entries ?? []).filter((entry) => {
555
+ const glob = entry.glob.replaceAll("\\", "/");
556
+ const canonical = canonicalGlob(glob);
557
+ return targets.some(
558
+ (target) =>
559
+ matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
560
+ );
561
+ });
562
+ const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
563
+ let selected: RuntimeWorkspaceCandidate | undefined;
564
+ if (workspaceName) {
565
+ const named = matches.filter((entry) => entry.name === workspaceName);
566
+ if (named.length === 1) selected = named[0];
567
+ else if (named.length > 1)
568
+ throw new Error(`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`);
569
+ else if (matches.length)
570
+ throw new Error(
571
+ `workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
572
+ );
573
+ else throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
574
+ } else if (matches.length > 1)
575
+ throw new Error(
576
+ `ambiguous workspace for ${cwd}; choose one of: ${matches.map((entry) => entry.name).join(", ")}`,
577
+ );
578
+ else selected = matches[0];
579
+ if (!selected) return null;
580
+ const policy = runtimeWorkspacePolicySchema(kind).safeParse(selected);
581
+ if (!policy.success) {
582
+ const detail = policy.error.issues
583
+ .map((issue) => `${issue.path.map(String).join(".") || "workspace"}: ${issue.message}`)
584
+ .join("; ");
585
+ throw new Error(`${file} has invalid ${kind} policy configuration: ${detail}`);
586
+ }
587
+ return policy.data as RuntimeWorkspaceCandidate;
588
+ };
589
+
590
+ export const resolveRuntimeWorkspacePolicy = (
591
+ cwd: string,
592
+ kind: RuntimeWorkspacePolicy,
593
+ requestedProfile?: string,
594
+ ): {
595
+ policy: WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
596
+ source: string;
597
+ } => {
598
+ const candidate = resolveRuntimeWorkspaceCandidate(cwd, kind);
599
+ if (!candidate) return { policy: undefined, source: "user-default" };
600
+ const key = kind === "branch" ? "branchPolicy" : "commitPolicy";
601
+ const profileName = requestedProfile?.trim() || candidate.defaultProfile;
602
+ if (
603
+ candidate.defaultProfile &&
604
+ !Object.hasOwn(candidate.profiles ?? {}, candidate.defaultProfile)
605
+ )
606
+ throw new Error(
607
+ `workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(candidate.defaultProfile)}`,
608
+ );
609
+ const profile = profileName ? candidate.profiles?.[profileName] : undefined;
610
+ if (profileName && !profile)
611
+ throw new Error(
612
+ `workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(profileName)}`,
613
+ );
614
+ const profilePolicy = profile?.[key] as WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
615
+ const workspacePolicy = candidate[key] as
616
+ | WorkspaceBranchPolicy
617
+ | WorkspaceCommitPolicy
618
+ | undefined;
619
+ return {
620
+ policy: profilePolicy ?? workspacePolicy,
621
+ source: profilePolicy
622
+ ? `profile:${profileName}`
623
+ : workspacePolicy
624
+ ? `workspace:${candidate.name}`
625
+ : "user-default",
626
+ };
627
+ };
628
+
629
+ export const resolveWorkspaceProfile = (
630
+ workspace: WorkspaceConfig,
631
+ requestedProfile?: string,
632
+ ): WorkspaceProfileResolution => {
633
+ const profileName = requestedProfile ?? workspace.defaultProfile ?? null;
634
+ const profile = profileName ? workspace.profiles?.[profileName] : undefined;
635
+ if (profileName && !profile) {
636
+ return {
637
+ status: "invalid",
638
+ error: `workspace ${JSON.stringify(workspace.name)} has no profile ${JSON.stringify(profileName)}`,
639
+ };
640
+ }
641
+ const effectiveWorkspace = profile
642
+ ? {
643
+ ...workspace,
644
+ ...(profile.branchPolicy ? { branchPolicy: profile.branchPolicy } : {}),
645
+ ...(profile.commitPolicy ? { commitPolicy: profile.commitPolicy } : {}),
646
+ }
647
+ : workspace;
648
+ return {
649
+ status: "resolved",
650
+ profileName,
651
+ workspace: effectiveWorkspace,
652
+ provenance: {
653
+ branchPolicy: profile?.branchPolicy
654
+ ? `profile:${profileName}`
655
+ : workspace.branchPolicy
656
+ ? `workspace:${workspace.name}`
657
+ : "user-default",
658
+ commitPolicy: profile?.commitPolicy
659
+ ? `profile:${profileName}`
660
+ : workspace.commitPolicy
661
+ ? `workspace:${workspace.name}`
662
+ : "user-default",
663
+ },
664
+ };
665
+ };
666
+
667
+ export const resolveWorkspacePolicy = (
668
+ config: ToolkitConfig,
669
+ workspace: WorkspaceConfig | null,
670
+ profileName?: string,
671
+ ): WorkspacePolicyResolution | { status: "invalid"; error: string } => {
672
+ const selected = workspace
673
+ ? resolveWorkspaceProfile(workspace, profileName)
674
+ : {
675
+ status: "resolved" as const,
676
+ profileName: null,
677
+ workspace: null,
678
+ provenance: { branchPolicy: "user-default", commitPolicy: "user-default" },
679
+ };
680
+ if (selected.status === "invalid") return selected;
681
+ return {
682
+ status: "resolved",
683
+ workspace: selected.workspace,
684
+ profileName: selected.profileName,
685
+ branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace),
686
+ commitPolicy: resolveConfiguredCommitPolicy(config, selected.workspace),
687
+ provenance: selected.provenance,
688
+ };
689
+ };
690
+
691
+ export const selectReleaseTrack = (
692
+ workspace: WorkspaceConfig,
693
+ requestedTrack?: string,
694
+ ): ReleaseTrackSelection => {
695
+ const tracks = workspace.releaseTracks ?? {};
696
+ const choices = Object.keys(tracks).sort();
697
+ if (requestedTrack === undefined) {
698
+ if (choices.length === 0) return { status: "not_configured" };
699
+ if (choices.length > 1) return { status: "choice_required", choices };
700
+ const name = choices[0];
701
+ return { status: "selected", name, track: tracks[name] };
702
+ }
703
+ if (!Object.hasOwn(tracks, requestedTrack)) {
704
+ return {
705
+ status: "invalid",
706
+ error: `workspace ${JSON.stringify(workspace.name)} has no release track ${JSON.stringify(requestedTrack)}`,
707
+ };
708
+ }
709
+ return { status: "selected", name: requestedTrack, track: tracks[requestedTrack] };
710
+ };
package/src/core.ts CHANGED
@@ -70,7 +70,7 @@ export type {
70
70
  } from "./core/task-context";
71
71
  export { METHODS, invariantBootstrap, selectMethods } from "./core/methods";
72
72
  export type { MethodId, SelectedMethod } from "./core/methods";
73
- export { hasLiveWorkitTask, shellRouteIntent, shouldDenyShellRoute } from "./core/route-intent";
73
+ export { shellBranchPolicyViolation, shellBranchTarget } from "./core/route-intent";
74
74
  export { classifyBranchDirt } from "./core/branch";
75
75
  export type { BranchDirt } from "./core/branch";
76
76
  export {
@@ -91,7 +91,6 @@ export type {
91
91
  StandingReceipt,
92
92
  } from "./core/auto-approval";
93
93
  export { bundleHashOfFile, isEphemeralCachePath, sha256Hex } from "./core/runtime-identity";
94
- export type { ShellRouteIntent } from "./core/route-intent";
95
94
  export { applicableDecision, reserveAction, settleAction, reconcileAction } from "./core/authority";
96
95
  export { normalizeChainSteps, chainStepKey } from "./core/authority";
97
96
  export type { ChainStep } from "./core/authority";
@@ -1,4 +0,0 @@
1
- // CLI port of scripts/vcs/token-create-urls.sh.
2
- import { vcsTokenCreateUrls } from "../vcs-config";
3
-
4
- console.log(JSON.stringify(vcsTokenCreateUrls(), null, 2));