@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,85 +1,37 @@
1
- import { TaskStore } from "./task-store";
2
-
3
- export type ShellRouteIntent = {
4
- route: "git.branch_setup" | "hosting.pull_request" | "hosting.merge";
5
- guidance: string;
6
- };
7
-
8
- const GUIDANCE: Record<ShellRouteIntent["route"], string> = {
9
- "git.branch_setup":
10
- "use the Workit git.branch_setup action (workit_external_action in hosts, or `workit action git.branch_setup --payload '{}' --confirm --json` in the CLI)",
11
- "hosting.pull_request":
12
- "use the Workit hosting.pull_request action (workit_external_action in hosts, or `workit action hosting.pull_request --payload '{...}' --confirm --json` in the CLI), then drive the created PR through workit-babysit",
13
- "hosting.merge":
14
- "use the Workit hosting.merge action (workit_external_action in hosts, or `workit action hosting.merge --payload '{...}' --confirm --json` in the CLI)",
15
- };
16
-
17
- const unbalancedQuotes = (segment: string): boolean => {
18
- const text = segment.replace(/\\./gu, "");
19
- return (text.match(/"/gu)?.length ?? 0) % 2 !== 0 || (text.match(/'/gu)?.length ?? 0) % 2 !== 0;
1
+ import { validateBranchNameFor, type BranchNamePolicyCheck } from "./branch";
2
+
3
+ // ponytail: recognize direct literal Git forms only; expand coverage if users need shell AST support.
4
+ export const shellBranchTarget = (command: string): string | null => {
5
+ if (
6
+ !command.trim() ||
7
+ /[\r\n;&|<>`'"$()\\*?{}~!#]/u.test(command) ||
8
+ command.includes("[") ||
9
+ command.includes("]")
10
+ )
11
+ return null;
12
+
13
+ const args = command.trim().split(/\s+/u);
14
+ if (args[0] !== "git") return null;
15
+ const option = args[2];
16
+ const target =
17
+ args[1] === "switch" &&
18
+ ["-c", "--create", "-C", "--force-create"].includes(option ?? "") &&
19
+ (args.length === 4 || args.length === 5)
20
+ ? args[3]
21
+ : args[1] === "checkout" &&
22
+ ["-b", "-B"].includes(option ?? "") &&
23
+ (args.length === 4 || args.length === 5)
24
+ ? args[3]
25
+ : args[1] === "branch" && (args.length === 3 || args.length === 4)
26
+ ? args[2]
27
+ : null;
28
+ return target && !target.startsWith("-") ? target : null;
20
29
  };
21
30
 
22
- const segmentTokens = (segment: string): string[] | null => {
23
- if (segment.includes("`") || segment.includes("$(")) return null;
24
- if (unbalancedQuotes(segment)) return null;
25
- const parts = segment.trim().split(/\s+/u);
26
- while (parts.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/u.test(parts[0])) parts.shift();
27
- return parts.length > 0 ? parts : null;
31
+ export const shellBranchPolicyViolation = (
32
+ root: string,
33
+ command: string,
34
+ ): BranchNamePolicyCheck | null => {
35
+ const target = shellBranchTarget(command);
36
+ return target === null ? null : validateBranchNameFor(root, target);
28
37
  };
29
-
30
- const unquote = (token: string): string => token.replace(/^['"]|['"]$/gu, "");
31
-
32
- /**
33
- * Narrow recognizer for direct branch and PR creation commands. It returns null
34
- * for everything it cannot classify unambiguously, so hosts deny only the exact
35
- * routes that must go through the shared action reservation.
36
- */
37
- export function shellRouteIntent(command: string): ShellRouteIntent | null {
38
- if (typeof command !== "string" || command.trim() === "") return null;
39
- for (const raw of command.split(/&&|\|\||;|\n/u)) {
40
- const parts = segmentTokens(raw);
41
- if (!parts) continue;
42
- const head = unquote(parts[0]);
43
- const second = unquote(parts[1] ?? "");
44
- const third = unquote(parts[2] ?? "");
45
- if (head === "git" && second === "switch" && (third === "-c" || third === "--create"))
46
- return { route: "git.branch_setup", guidance: GUIDANCE["git.branch_setup"] };
47
- if (head === "git" && second === "checkout" && (third === "-b" || third === "-B"))
48
- return { route: "git.branch_setup", guidance: GUIDANCE["git.branch_setup"] };
49
- if (head === "gh" && second === "pr" && third === "create")
50
- return { route: "hosting.pull_request", guidance: GUIDANCE["hosting.pull_request"] };
51
- if (head === "glab" && second === "mr" && third === "create")
52
- return { route: "hosting.pull_request", guidance: GUIDANCE["hosting.pull_request"] };
53
- if (head === "gh" && second === "pr" && third === "merge")
54
- return { route: "hosting.merge", guidance: GUIDANCE["hosting.merge"] };
55
- if (head === "glab" && second === "mr" && third === "merge")
56
- return { route: "hosting.merge", guidance: GUIDANCE["hosting.merge"] };
57
- }
58
- return null;
59
- }
60
-
61
- /**
62
- * Session-scoped enforcement: the denial protects live Workit coordination,
63
- * so it fires only where an active or paused task exists. Any filesystem or
64
- * storage failure means "no live task" — never deny on uncertainty.
65
- */
66
- export function hasLiveWorkitTask(dir: string): boolean {
67
- try {
68
- if (typeof dir !== "string" || dir === "") return false;
69
- const listed = new TaskStore(dir).listTasks();
70
- if (!listed.ok) return false;
71
- return listed.data.some((task) => task.status === "active" || task.status === "paused");
72
- } catch {
73
- return false;
74
- }
75
- }
76
-
77
- /**
78
- * Single core gate for the shell route denial. Adapters pass their
79
- * session/project directory and delete local deny duplication; outside
80
- * attributed sessions the command is silently allowed.
81
- */
82
- export function shouldDenyShellRoute(dir: string, command: string): ShellRouteIntent | null {
83
- if (!hasLiveWorkitTask(dir)) return null;
84
- return shellRouteIntent(command);
85
- }
@@ -1,4 +1,16 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ fchmodSync,
5
+ fsyncSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ openSync,
9
+ renameSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import path from "node:path";
2
14
 
3
15
  // Shared credential-safe write (Task 14 Step 7 / CA-13): `wx` exclusive create
4
16
  // closes the TOCTOU window between an existence check and the write, and EEXIST
@@ -20,3 +32,56 @@ export function writeFileExclusive(
20
32
  throw err;
21
33
  }
22
34
  }
35
+
36
+ export function writeFileAtomic(file: string, content: string, mode?: number): void {
37
+ const absolute = path.resolve(file);
38
+ let parent = path.dirname(absolute);
39
+ const root = path.parse(parent).root;
40
+ for (;;) {
41
+ try {
42
+ if (lstatSync(parent).isSymbolicLink()) throw new Error(`symlinked write path: ${parent}`);
43
+ } catch (error) {
44
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
45
+ }
46
+ const next = path.dirname(parent);
47
+ if (parent === root || next === parent) break;
48
+ parent = next;
49
+ }
50
+
51
+ const directory = path.dirname(absolute);
52
+ mkdirSync(directory, { recursive: true });
53
+ let existing: ReturnType<typeof lstatSync> | null = null;
54
+ try {
55
+ existing = lstatSync(absolute);
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
58
+ }
59
+ if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
60
+ throw new Error(`refusing to replace non-regular file: ${absolute}`);
61
+ }
62
+ const temporary = `${absolute}.${randomUUID()}.tmp`;
63
+ let fd: number | undefined;
64
+ try {
65
+ fd = openSync(temporary, "wx", mode ?? 0o666);
66
+ writeFileSync(fd, content, "utf8");
67
+ if (existing) fchmodSync(fd, existing.mode & 0o777);
68
+ fsyncSync(fd);
69
+ closeSync(fd);
70
+ fd = undefined;
71
+ renameSync(temporary, absolute);
72
+ } catch (error) {
73
+ if (fd !== undefined) {
74
+ try {
75
+ closeSync(fd);
76
+ } catch {
77
+ // Keep the original write error.
78
+ }
79
+ }
80
+ try {
81
+ unlinkSync(temporary);
82
+ } catch {
83
+ // The temporary may already have been renamed or removed.
84
+ }
85
+ throw error;
86
+ }
87
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isConfigObject, resolveConfigDir } from "./config";
4
+ import { readWorkspacesResult } from "./workspaces";
4
5
 
5
6
  // WZ-06: typed setup-state reader distinguishing missing from malformed
6
7
  // configuration. Purely read-only — nothing here ever writes; Apply decision
@@ -42,6 +43,12 @@ export const classifySetupFile = (dir: string, name: string): FileState => {
42
43
  } catch {
43
44
  return { file, status: "malformed", error: `${file} is not valid JSON` };
44
45
  }
46
+ if (name === "workspaces.json") {
47
+ const workspaces = readWorkspacesResult(dir);
48
+ if (workspaces.status === "malformed" || workspaces.status === "invalid") {
49
+ return { file, status: "malformed", error: workspaces.error };
50
+ }
51
+ }
45
52
  return { file, status: "valid" };
46
53
  };
47
54
 
package/src/core/setup.ts CHANGED
@@ -26,10 +26,11 @@ import {
26
26
  import { detectBranchPolicy } from "./branch-policy";
27
27
  import { readSetupState, type SetupState } from "./setup-state";
28
28
  import {
29
- loadWorkspacesFrom,
30
- matchWorkspace,
31
29
  readWorkspacesResult,
30
+ resolveWorkspaceFrom,
31
+ validateWorkspacesDocument,
32
32
  validateWorkspaceGlob,
33
+ workspacesRevision,
33
34
  type WorkspaceConfig,
34
35
  } from "./workspaces";
35
36
  import { GITIGNORE_ENTRIES } from "./gitignore";
@@ -86,7 +87,12 @@ export type SetupPreviewInput = {
86
87
  export type SetupMutation =
87
88
  | { type: "create-file"; path: string; content: string; mode?: number }
88
89
  | { type: "merge-json"; path: string; value: unknown }
89
- | { type: "update-workspaces"; path: string; entries: WorkspaceConfig[] }
90
+ | {
91
+ type: "update-workspaces";
92
+ path: string;
93
+ entries: WorkspaceConfig[];
94
+ expectedRevision: string;
95
+ }
90
96
  | { type: "append-gitignore"; path: string; entries: string[] }
91
97
  // AR-09: host registration merges and the adapter package copy are planned
92
98
  // during preview so the reviewed mutation set IS the Apply write set.
@@ -170,27 +176,15 @@ function youtrackDraft(values: SetupPreviewInput, tokenPath: string): Record<str
170
176
  };
171
177
  }
172
178
 
173
- function vcsDraft(
174
- provider: VcsProvider,
175
- gitlabToken: string,
176
- githubToken: string,
177
- ): Record<string, unknown> {
179
+ function vcsDraft(provider: VcsProvider): Record<string, unknown> {
178
180
  return {
179
181
  provider,
180
182
  gitlab: {
181
183
  host: "gitlab.com",
182
184
  apiUrl: "https://gitlab.com/api/v4",
183
- tokenFile: gitlabToken,
184
185
  },
185
- github: { host: "github.com", tokenFile: githubToken },
186
+ github: { host: "github.com" },
186
187
  pr: { squashOnMerge: true, removeSourceBranch: true, pushBranch: true, confirmSkip: true },
187
- tokenDefaults: {
188
- name: "workit",
189
- description: "OpenCode workit — /wk-pr and glab/gh",
190
- gitlabScopes: ["api"],
191
- githubPermissions: { pull_requests: "write", contents: "write", metadata: "read" },
192
- githubClassicScopes: ["repo"],
193
- },
194
188
  };
195
189
  }
196
190
 
@@ -231,17 +225,6 @@ const readConfigRecord = (file: string): Record<string, unknown> | null => {
231
225
  }
232
226
  };
233
227
 
234
- const deleteAtPath = (obj: Record<string, unknown>, keyPath: string): void => {
235
- const keys = keyPath.split(".");
236
- let cursor = obj;
237
- for (const key of keys.slice(0, -1)) {
238
- const next = cursor[key];
239
- if (!isRecord(next)) return;
240
- cursor = next;
241
- }
242
- delete cursor[keys[keys.length - 1]];
243
- };
244
-
245
228
  export function buildSetupPreview(
246
229
  values: SetupPreviewInput,
247
230
  opts: SetupPreviewOptions = {},
@@ -281,7 +264,8 @@ export function buildSetupPreview(
281
264
  // WZ-12 parity: the draft is authoritative but a no-op workspaces section
282
265
  // (unchanged vs disk, including the initial seed) must not claim a rewrite.
283
266
  // Removing every workspace differs from disk and therefore still writes [].
284
- const diskWorkspaces = loadWorkspacesFrom(state.configDir);
267
+ const diskWorkspaceResult = readWorkspacesResult(state.configDir);
268
+ const diskWorkspaces = diskWorkspaceResult.entries;
285
269
  if (!isDeepStrictEqual(values.workspaces, diskWorkspaces)) {
286
270
  // RL-08: unsupported matcher grammar is rejected before any write —
287
271
  // same gate the direct writeWorkspaces path and the wizard enforce.
@@ -298,6 +282,7 @@ export function buildSetupPreview(
298
282
  type: "update-workspaces",
299
283
  path: path.join(state.configDir, "workspaces.json"),
300
284
  entries: values.workspaces,
285
+ expectedRevision: diskWorkspaceResult.revision!,
301
286
  });
302
287
  }
303
288
  }
@@ -340,48 +325,11 @@ export function buildSetupPreview(
340
325
 
341
326
  if (values.vcsProvider !== "skip") {
342
327
  const vcsPath = path.join(state.configDir, "vcs.json");
343
- const vcsExisting = readConfigRecord(vcsPath);
344
- const glConfigured = configuredTokenPath(vcsExisting, "gitlab", "tokenFile");
345
- const ghConfigured = configuredTokenPath(vcsExisting, "github", "tokenFile");
346
- const gitlabToken = resolveTokenPath(
347
- glConfigured,
348
- path.join(state.configDir, "gitlab.token"),
349
- values.tokenPaths?.gitlab,
350
- );
351
- const githubToken = resolveTokenPath(
352
- ghConfigured,
353
- path.join(state.configDir, "github.token"),
354
- values.tokenPaths?.github,
355
- );
356
- const draft = vcsDraft(values.vcsProvider, gitlabToken, githubToken);
357
- for (const [configured, token, key] of [
358
- [glConfigured, gitlabToken, "gitlab.tokenFile"],
359
- [ghConfigured, githubToken, "github.tokenFile"],
360
- ] as const) {
361
- if (configured !== null && token !== configured) {
362
- deleteAtPath(draft, key);
363
- mutations.push({
364
- type: "set-token-path",
365
- path: vcsPath,
366
- key,
367
- value: token,
368
- });
369
- }
370
- }
371
328
  mutations.push({
372
329
  type: "merge-json",
373
330
  path: vcsPath,
374
- value: draft,
331
+ value: vcsDraft(values.vcsProvider),
375
332
  });
376
- const activeToken = values.vcsProvider === "gitlab" ? gitlabToken : githubToken;
377
- if (existsSync(activeToken)) preserved.push(activeToken);
378
- else
379
- mutations.push({
380
- type: "create-file",
381
- path: activeToken,
382
- content: TOKEN_PLACEHOLDER + "\n",
383
- mode: 0o600,
384
- });
385
333
  }
386
334
 
387
335
  // AR-09: host registrations and the adapter package copy are planned here
@@ -583,6 +531,22 @@ type CoreMutation = Exclude<
583
531
  { type: "register-platform" } | { type: "install-adapter" }
584
532
  >;
585
533
 
534
+ const workspaceEntriesAlreadyMatch = (
535
+ contents: string | null,
536
+ file: string,
537
+ entries: WorkspaceConfig[],
538
+ ): boolean => {
539
+ if (contents === null) return entries.length === 0;
540
+ try {
541
+ const current = validateWorkspacesDocument(JSON.parse(contents), file);
542
+ return (
543
+ current.status === "valid" && JSON.stringify(current.entries) === JSON.stringify(entries)
544
+ );
545
+ } catch {
546
+ return false;
547
+ }
548
+ };
549
+
586
550
  function applyMutation(m: CoreMutation): SetupResultEntry {
587
551
  const dir = path.dirname(m.path);
588
552
  switch (m.type) {
@@ -622,19 +586,65 @@ function applyMutation(m: CoreMutation): SetupResultEntry {
622
586
  : { platform: "core", file: m.path, status: "Installed" };
623
587
  }
624
588
  case "update-workspaces": {
625
- // RL-08: a mutation built by a non-wizard caller still cannot write an
626
- // unsupported matcher pattern — reported Failed, never silently stored.
627
- for (const entry of m.entries) {
628
- if (!entry || typeof entry.glob !== "string") continue;
629
- const v = validateWorkspaceGlob(entry.glob);
630
- if (!v.ok) return { platform: "core", file: m.path, status: "Failed", detail: v.error };
631
- }
632
- const next = JSON.stringify({ workspaces: m.entries }, null, 2) + "\n";
633
589
  const prev = readFileSafe(m.path);
590
+ if (workspaceEntriesAlreadyMatch(prev, m.path, m.entries))
591
+ return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
592
+ if (workspacesRevision(prev) !== m.expectedRevision) {
593
+ return {
594
+ platform: "core",
595
+ file: m.path,
596
+ status: "Failed",
597
+ detail:
598
+ "workspace config changed after preview; rebuild the preview and review the new values",
599
+ };
600
+ }
601
+ let document: Record<string, unknown> = {};
602
+ if (prev !== null) {
603
+ try {
604
+ const current = validateWorkspacesDocument(JSON.parse(prev), m.path);
605
+ if (current.status === "malformed" || current.status === "invalid") {
606
+ return { platform: "core", file: m.path, status: "Failed", detail: current.error };
607
+ }
608
+ document = current.document ?? {};
609
+ } catch {
610
+ return {
611
+ platform: "core",
612
+ file: m.path,
613
+ status: "Failed",
614
+ detail: `${m.path} is not valid JSON`,
615
+ };
616
+ }
617
+ }
618
+ const proposed = validateWorkspacesDocument({ ...document, workspaces: m.entries }, m.path);
619
+ if (proposed.status !== "valid") {
620
+ return { platform: "core", file: m.path, status: "Failed", detail: proposed.error };
621
+ }
622
+ const next = JSON.stringify({ ...document, workspaces: m.entries }, null, 2) + "\n";
634
623
  if (prev === next)
635
624
  return { platform: "core", file: m.path, status: "Skipped", detail: "already configured" };
636
625
  mkdirSync(dir, { recursive: true });
637
- writeFileSync(m.path, next, "utf8");
626
+ const tmp = `${m.path}.${process.pid}.tmp`;
627
+ try {
628
+ writeFileSync(tmp, next, "utf8");
629
+ if (workspacesRevision(readFileSafe(m.path)) !== m.expectedRevision) {
630
+ rmSync(tmp, { force: true });
631
+ return {
632
+ platform: "core",
633
+ file: m.path,
634
+ status: "Failed",
635
+ detail: "workspace config changed while applying the preview; rebuild and review it",
636
+ };
637
+ }
638
+ renameSync(tmp, m.path);
639
+ } catch (error) {
640
+ rmSync(tmp, { force: true });
641
+ return {
642
+ platform: "core",
643
+ file: m.path,
644
+ status: "Failed",
645
+ detail: `failed to write ${m.path}: ${error instanceof Error ? error.message : String(error)}`,
646
+ };
647
+ }
638
648
  return prev === null
639
649
  ? { platform: "core", file: m.path, status: "Installed" }
640
650
  : { platform: "core", file: m.path, status: "Configured" };
@@ -1060,6 +1070,38 @@ export function applySetupPreview(
1060
1070
  };
1061
1071
  }
1062
1072
 
1073
+ const workspaceMutation = preview.mutations.find(
1074
+ (mutation): mutation is Extract<SetupMutation, { type: "update-workspaces" }> =>
1075
+ mutation.type === "update-workspaces",
1076
+ );
1077
+ const currentWorkspaceContents = workspaceMutation ? readFileSafe(workspaceMutation.path) : null;
1078
+ if (
1079
+ workspaceMutation &&
1080
+ workspacesRevision(currentWorkspaceContents) !== workspaceMutation.expectedRevision &&
1081
+ !workspaceEntriesAlreadyMatch(
1082
+ currentWorkspaceContents,
1083
+ workspaceMutation.path,
1084
+ workspaceMutation.entries,
1085
+ )
1086
+ ) {
1087
+ return {
1088
+ ok: false,
1089
+ exitCode: 1,
1090
+ entries: [
1091
+ {
1092
+ platform: "core",
1093
+ file: workspaceMutation.path,
1094
+ status: "Failed",
1095
+ detail:
1096
+ "workspace config changed after preview; rebuild the preview and review the new values",
1097
+ },
1098
+ ],
1099
+ preserved: preview.preserved,
1100
+ blocked: [],
1101
+ doctor: [],
1102
+ };
1103
+ }
1104
+
1063
1105
  const res = resolveApply(preview, options);
1064
1106
  const entries: SetupResultEntry[] = [];
1065
1107
  // AR-09/AR-13: Apply dispatches ONLY the reviewed mutation union. The adapter
@@ -1218,8 +1260,16 @@ export function applyWorkspaceBranchPolicy(opts: {
1218
1260
  }): Record<string, any> {
1219
1261
  const { workspace_root, env = process.env } = opts;
1220
1262
  const dir = path.join(env.WORKFLOW_TOOLKIT_CONFIG ?? configDir());
1221
- const { status, path: wsPath, entries } = readWorkspacesResult(dir);
1222
- if (status === "malformed") return { ok: false, error: `malformed workspaces.json: ${wsPath}` };
1263
+ const {
1264
+ status,
1265
+ path: wsPath,
1266
+ entries,
1267
+ document,
1268
+ revision,
1269
+ error: workspacesError,
1270
+ } = readWorkspacesResult(dir);
1271
+ if (status === "malformed" || status === "invalid")
1272
+ return { ok: false, error: workspacesError ?? `invalid workspaces.json: ${wsPath}` };
1223
1273
  const detection = detectBranchPolicy(workspace_root);
1224
1274
  const name = String(env.WORKFLOW_BP_NAME ?? path.basename(workspace_root));
1225
1275
  const integration = (env.WORKFLOW_BP_INTEGRATION ?? detection.integration) as "pr" | "merge";
@@ -1234,8 +1284,22 @@ export function applyWorkspaceBranchPolicy(opts: {
1234
1284
  const glob = `${workspace_root.replace(/[\\/]+$/, "")}/**`;
1235
1285
  if (!validateWorkspaceGlob(glob).ok)
1236
1286
  return { ok: false, error: `invalid workspace glob: ${glob}` };
1237
- const idx = entries.findIndex((w) => matchWorkspace(w.glob, workspace_root));
1238
- const existing = idx >= 0 ? entries[idx] : null;
1287
+ let existing: WorkspaceConfig | null;
1288
+ try {
1289
+ existing = resolveWorkspaceFrom(workspace_root, dir);
1290
+ } catch (error) {
1291
+ const selectedName = env.WORKFLOW_BP_NAME?.trim();
1292
+ if (!selectedName)
1293
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1294
+ try {
1295
+ existing = resolveWorkspaceFrom(workspace_root, dir, selectedName);
1296
+ } catch (selectionError) {
1297
+ return {
1298
+ ok: false,
1299
+ error: selectionError instanceof Error ? selectionError.message : String(selectionError),
1300
+ };
1301
+ }
1302
+ }
1239
1303
  if (existing?.branchPolicy && isDeepStrictEqual(existing.branchPolicy, policy)) {
1240
1304
  return {
1241
1305
  ok: true,
@@ -1246,10 +1310,38 @@ export function applyWorkspaceBranchPolicy(opts: {
1246
1310
  };
1247
1311
  }
1248
1312
  const next = existing
1249
- ? entries.map((w, i) => (i === idx ? { ...w, branchPolicy: policy } : w))
1313
+ ? entries.map((w) => (w.name === existing?.name ? { ...w, branchPolicy: policy } : w))
1250
1314
  : [...entries, { name, glob, branchPolicy: policy }];
1315
+ if (!existing && entries.some((entry) => entry.name === name))
1316
+ return { ok: false, error: `workspace name ${JSON.stringify(name)} is already configured` };
1317
+ const proposed = validateWorkspacesDocument({ ...document, workspaces: next }, wsPath);
1318
+ if (proposed.status !== "valid") return { ok: false, error: proposed.error };
1319
+ if (workspacesRevision(readFileSafe(wsPath)) !== revision)
1320
+ return {
1321
+ ok: false,
1322
+ error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
1323
+ };
1251
1324
  mkdirSync(path.dirname(wsPath), { recursive: true });
1252
- writeFileSync(wsPath, JSON.stringify({ workspaces: next }, null, 2) + "\n", "utf8");
1325
+ const tmp = `${wsPath}.${process.pid}.tmp`;
1326
+ try {
1327
+ writeFileSync(tmp, JSON.stringify({ ...document, workspaces: next }, null, 2) + "\n", "utf8");
1328
+ if (workspacesRevision(readFileSafe(wsPath)) !== revision) {
1329
+ rmSync(tmp, { force: true });
1330
+ return {
1331
+ ok: false,
1332
+ error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
1333
+ };
1334
+ }
1335
+ renameSync(tmp, wsPath);
1336
+ } catch (error) {
1337
+ try {
1338
+ rmSync(tmp, { force: true });
1339
+ } catch {}
1340
+ return {
1341
+ ok: false,
1342
+ error: `failed to write ${wsPath}: ${error instanceof Error ? error.message : String(error)}`,
1343
+ };
1344
+ }
1253
1345
  return {
1254
1346
  ok: true,
1255
1347
  status: existing ? "updated" : "configured",
@@ -1264,6 +1356,7 @@ export function applyWorkspaceBranchPolicy(opts: {
1264
1356
  export {
1265
1357
  applyCutover,
1266
1358
  applyRollback,
1359
+ resumeCutover,
1267
1360
  classifyHostGeneration,
1268
1361
  readCutoverReceipt,
1269
1362
  detectCursorLatest,