@brainervirus/workit-core 1.2.1 → 1.3.1

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 (45) 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 +222 -64
  26. package/src/core/init.ts +25 -115
  27. package/src/core/legacy-ownership.ts +47 -0
  28. package/src/core/methods.ts +65 -52
  29. package/src/core/policy-resolver.ts +13 -5
  30. package/src/core/ports/pr-create.ts +23 -15
  31. package/src/core/pr-create.ts +374 -84
  32. package/src/core/registration.ts +0 -15
  33. package/src/core/repo-tools.ts +0 -1
  34. package/src/core/route-intent.ts +34 -82
  35. package/src/core/safe-write.ts +66 -1
  36. package/src/core/setup-state.ts +7 -0
  37. package/src/core/setup.ts +175 -82
  38. package/src/core/task-contract.ts +66 -6
  39. package/src/core/task-engine.ts +47 -2
  40. package/src/core/task-store.ts +159 -1
  41. package/src/core/tracker-issues.ts +55 -118
  42. package/src/core/vcs-config.ts +178 -187
  43. package/src/core/workspaces.ts +554 -46
  44. package/src/core.ts +3 -2
  45. 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. */
@@ -181,28 +409,308 @@ const canonicalGlob = (glob: string): string => {
181
409
  return real + (prefix.endsWith("/") ? "/" : "") + rest;
182
410
  };
183
411
 
412
+ const globSpecificity = (glob: string): number =>
413
+ glob
414
+ .replaceAll("\\", "/")
415
+ .split("/")
416
+ .filter((segment) => segment.length > 0 && !/[*?[\]{]/.test(segment)).length;
417
+
418
+ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
419
+ matches: T[],
420
+ cwd: string,
421
+ workspaceName?: string,
422
+ ): T | undefined => {
423
+ if (workspaceName !== undefined) {
424
+ const named = matches.filter((entry) => entry.name === workspaceName);
425
+ if (named.length === 1) return named[0];
426
+ if (named.length > 1)
427
+ throw new Error(`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`);
428
+ if (matches.length > 0) {
429
+ throw new Error(
430
+ `workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
431
+ );
432
+ }
433
+ throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
434
+ }
435
+
436
+ if (matches.length < 2) return matches[0];
437
+ const highestSpecificity = Math.max(...matches.map((entry) => globSpecificity(entry.glob)));
438
+ const mostSpecific = matches.filter(
439
+ (entry) => globSpecificity(entry.glob) === highestSpecificity,
440
+ );
441
+ if (mostSpecific.length > 1) {
442
+ throw new Error(
443
+ `ambiguous workspace for ${cwd}; choose one of: ${mostSpecific.map((entry) => entry.name).join(", ")}`,
444
+ );
445
+ }
446
+ return mostSpecific[0];
447
+ };
448
+
184
449
  /** Match a cwd against the workspaces.json under an explicit config dir. */
185
- export const resolveWorkspaceFrom = (cwd: string, dir: string): WorkspaceConfig | null => {
450
+ export const resolveWorkspaceFrom = (
451
+ cwd: string,
452
+ dir: string,
453
+ workspaceName?: string,
454
+ ): WorkspaceConfig | null => {
186
455
  // macOS/Windows tmpdir symlinks (/var -> /private/var): git's
187
456
  // --show-toplevel returns the realpath while config globs are usually
188
457
  // written with the logical path, so a workspace would silently stop
189
458
  // matching on macOS. Match both forms on each side — same class as the
190
459
  // docs-migration escape-guard realpath comparison; on Linux both forms
191
460
  // are identical so behavior is unchanged.
461
+ const result = readWorkspacesResult(dir);
462
+ if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
192
463
  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;
464
+ const matches: WorkspaceConfig[] = [];
465
+ for (const ws of result.entries) {
197
466
  const glob = ws.glob.replaceAll("\\", "/");
198
467
  const canonical = canonicalGlob(glob);
468
+ let matched = false;
199
469
  for (const target of targets) {
200
- if (matchWorkspace(glob, target)) return ws;
201
- if (canonical !== glob && matchWorkspace(canonical, target)) return ws;
470
+ if (
471
+ matchWorkspace(glob, target) ||
472
+ (canonical !== glob && matchWorkspace(canonical, target))
473
+ ) {
474
+ matched = true;
475
+ break;
476
+ }
202
477
  }
478
+ if (matched) matches.push(ws);
203
479
  }
204
- return null;
480
+ return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
205
481
  };
206
482
 
207
- export const resolveWorkspace = (cwd: string): WorkspaceConfig | null =>
208
- resolveWorkspaceFrom(cwd, configDir());
483
+ export const resolveWorkspace = (
484
+ cwd: string,
485
+ workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,
486
+ ): WorkspaceConfig | null => resolveWorkspaceFrom(cwd, configDir(), workspaceName);
487
+
488
+ type RuntimeWorkspacePolicy = "branch" | "commit" | "vcs";
489
+ type RuntimeWorkspaceCandidate = {
490
+ name: string;
491
+ glob: string;
492
+ defaultProfile?: string;
493
+ profiles?: Record<string, Record<string, unknown>>;
494
+ branchPolicy?: WorkspaceBranchPolicy;
495
+ commitPolicy?: WorkspaceCommitPolicy;
496
+ vcs?: WorkspaceConfig["vcs"];
497
+ youtrack?: unknown;
498
+ issues?: unknown;
499
+ };
500
+
501
+ const runtimeWorkspacePolicySchema = (kind: RuntimeWorkspacePolicy) => {
502
+ const key = kind === "branch" ? "branchPolicy" : kind === "commit" ? "commitPolicy" : "vcs";
503
+ const policySchema =
504
+ kind === "branch"
505
+ ? workspaceBranchPolicySchema
506
+ : kind === "commit"
507
+ ? workspaceCommitPolicySchema
508
+ : z
509
+ .object({
510
+ provider: z.enum(["gitlab", "github"]),
511
+ defaultTargetBranch: nonBlank.optional(),
512
+ account: nonBlank.optional(),
513
+ })
514
+ .passthrough();
515
+ const profileSchema = z.object({ [key]: policySchema.optional() }).passthrough();
516
+ const entrySchema = z
517
+ .object({
518
+ name: nonBlank,
519
+ glob: nonBlank,
520
+ [key]: policySchema.optional(),
521
+ defaultProfile: nonBlank.optional(),
522
+ profiles: z.record(nonBlank, profileSchema).optional(),
523
+ })
524
+ .passthrough();
525
+ return entrySchema;
526
+ };
527
+
528
+ export type RuntimeWorkspaceVcs = Pick<
529
+ RuntimeWorkspaceCandidate,
530
+ "name" | "branchPolicy" | "vcs" | "youtrack" | "issues"
531
+ >;
532
+
533
+ export const resolveRuntimeWorkspaceVcs = (cwd: string): RuntimeWorkspaceVcs | null =>
534
+ resolveRuntimeWorkspaceCandidate(cwd, "vcs");
535
+
536
+ const runtimeWorkspaceIndexSchema = z
537
+ .object({
538
+ workspaces: z.array(z.object({ name: nonBlank, glob: nonBlank }).passthrough()).optional(),
539
+ })
540
+ .passthrough();
541
+
542
+ const resolveRuntimeWorkspaceCandidate = (
543
+ cwd: string,
544
+ kind: RuntimeWorkspacePolicy,
545
+ ): RuntimeWorkspaceCandidate | null => {
546
+ const file = path.join(configDir(), "workspaces.json");
547
+ let raw: string;
548
+ try {
549
+ raw = readFileSync(file, "utf8");
550
+ } catch (error) {
551
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
552
+ throw new Error(
553
+ `${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
554
+ );
555
+ }
556
+ let parsed: unknown;
557
+ try {
558
+ parsed = JSON.parse(raw);
559
+ } catch {
560
+ throw new Error(`${file} is not valid JSON`);
561
+ }
562
+ const validated = runtimeWorkspaceIndexSchema.safeParse(parsed);
563
+ if (!validated.success) {
564
+ const detail = validated.error.issues
565
+ .map((issue) => `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}`)
566
+ .join("; ");
567
+ throw new Error(`${file} has invalid workspace matching data: ${detail}`);
568
+ }
569
+ const entries = validated.data.workspaces as RuntimeWorkspaceCandidate[] | undefined;
570
+ for (const entry of entries ?? []) {
571
+ const glob = validateWorkspaceGlob(entry.glob);
572
+ if (!glob.ok) throw new Error(`${file} workspace ${entry.name}: ${glob.error}`);
573
+ }
574
+ const targets = [cwd, realpathOf(cwd)].map((value) => value.replaceAll("\\", "/"));
575
+ const matches = (entries ?? []).filter((entry) => {
576
+ const glob = entry.glob.replaceAll("\\", "/");
577
+ const canonical = canonicalGlob(glob);
578
+ return targets.some(
579
+ (target) =>
580
+ matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
581
+ );
582
+ });
583
+ const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
584
+ const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
585
+ if (!selected) return null;
586
+ const policy = runtimeWorkspacePolicySchema(kind).safeParse(selected);
587
+ if (!policy.success) {
588
+ const detail = policy.error.issues
589
+ .map((issue) => `${issue.path.map(String).join(".") || "workspace"}: ${issue.message}`)
590
+ .join("; ");
591
+ throw new Error(`${file} has invalid ${kind} policy configuration: ${detail}`);
592
+ }
593
+ return policy.data as RuntimeWorkspaceCandidate;
594
+ };
595
+
596
+ export const resolveRuntimeWorkspacePolicy = (
597
+ cwd: string,
598
+ kind: RuntimeWorkspacePolicy,
599
+ requestedProfile?: string,
600
+ ): {
601
+ policy: WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
602
+ source: string;
603
+ } => {
604
+ const candidate = resolveRuntimeWorkspaceCandidate(cwd, kind);
605
+ if (!candidate) return { policy: undefined, source: "user-default" };
606
+ const key = kind === "branch" ? "branchPolicy" : "commitPolicy";
607
+ const profileName = requestedProfile?.trim() || candidate.defaultProfile;
608
+ if (
609
+ candidate.defaultProfile &&
610
+ !Object.hasOwn(candidate.profiles ?? {}, candidate.defaultProfile)
611
+ )
612
+ throw new Error(
613
+ `workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(candidate.defaultProfile)}`,
614
+ );
615
+ const profile = profileName ? candidate.profiles?.[profileName] : undefined;
616
+ if (profileName && !profile)
617
+ throw new Error(
618
+ `workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(profileName)}`,
619
+ );
620
+ const profilePolicy = profile?.[key] as WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
621
+ const workspacePolicy = candidate[key] as
622
+ | WorkspaceBranchPolicy
623
+ | WorkspaceCommitPolicy
624
+ | undefined;
625
+ return {
626
+ policy: profilePolicy ?? workspacePolicy,
627
+ source: profilePolicy
628
+ ? `profile:${profileName}`
629
+ : workspacePolicy
630
+ ? `workspace:${candidate.name}`
631
+ : "user-default",
632
+ };
633
+ };
634
+
635
+ export const resolveWorkspaceProfile = (
636
+ workspace: WorkspaceConfig,
637
+ requestedProfile?: string,
638
+ ): WorkspaceProfileResolution => {
639
+ const profileName = requestedProfile ?? workspace.defaultProfile ?? null;
640
+ const profile = profileName ? workspace.profiles?.[profileName] : undefined;
641
+ if (profileName && !profile) {
642
+ return {
643
+ status: "invalid",
644
+ error: `workspace ${JSON.stringify(workspace.name)} has no profile ${JSON.stringify(profileName)}`,
645
+ };
646
+ }
647
+ const effectiveWorkspace = profile
648
+ ? {
649
+ ...workspace,
650
+ ...(profile.branchPolicy ? { branchPolicy: profile.branchPolicy } : {}),
651
+ ...(profile.commitPolicy ? { commitPolicy: profile.commitPolicy } : {}),
652
+ }
653
+ : workspace;
654
+ return {
655
+ status: "resolved",
656
+ profileName,
657
+ workspace: effectiveWorkspace,
658
+ provenance: {
659
+ branchPolicy: profile?.branchPolicy
660
+ ? `profile:${profileName}`
661
+ : workspace.branchPolicy
662
+ ? `workspace:${workspace.name}`
663
+ : "user-default",
664
+ commitPolicy: profile?.commitPolicy
665
+ ? `profile:${profileName}`
666
+ : workspace.commitPolicy
667
+ ? `workspace:${workspace.name}`
668
+ : "user-default",
669
+ },
670
+ };
671
+ };
672
+
673
+ export const resolveWorkspacePolicy = (
674
+ config: ToolkitConfig,
675
+ workspace: WorkspaceConfig | null,
676
+ profileName?: string,
677
+ ): WorkspacePolicyResolution | { status: "invalid"; error: string } => {
678
+ const selected = workspace
679
+ ? resolveWorkspaceProfile(workspace, profileName)
680
+ : {
681
+ status: "resolved" as const,
682
+ profileName: null,
683
+ workspace: null,
684
+ provenance: { branchPolicy: "user-default", commitPolicy: "user-default" },
685
+ };
686
+ if (selected.status === "invalid") return selected;
687
+ return {
688
+ status: "resolved",
689
+ workspace: selected.workspace,
690
+ profileName: selected.profileName,
691
+ branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace),
692
+ commitPolicy: resolveConfiguredCommitPolicy(config, selected.workspace),
693
+ provenance: selected.provenance,
694
+ };
695
+ };
696
+
697
+ export const selectReleaseTrack = (
698
+ workspace: WorkspaceConfig,
699
+ requestedTrack?: string,
700
+ ): ReleaseTrackSelection => {
701
+ const tracks = workspace.releaseTracks ?? {};
702
+ const choices = Object.keys(tracks).sort();
703
+ if (requestedTrack === undefined) {
704
+ if (choices.length === 0) return { status: "not_configured" };
705
+ if (choices.length > 1) return { status: "choice_required", choices };
706
+ const name = choices[0];
707
+ return { status: "selected", name, track: tracks[name] };
708
+ }
709
+ if (!Object.hasOwn(tracks, requestedTrack)) {
710
+ return {
711
+ status: "invalid",
712
+ error: `workspace ${JSON.stringify(workspace.name)} has no release track ${JSON.stringify(requestedTrack)}`,
713
+ };
714
+ }
715
+ return { status: "selected", name: requestedTrack, track: tracks[requestedTrack] };
716
+ };
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";
@@ -125,6 +124,8 @@ export {
125
124
  externalActionRef,
126
125
  externalActionDescriptor,
127
126
  externalActionHelp,
127
+ externalActionJsonSchema,
128
+ externalActionSchema,
128
129
  planReservationLength,
129
130
  externalActionRequest,
130
131
  matchesNativeExternalAction,
@@ -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));