@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,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFileSync } from "node:fs";
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import path from "node:path";
3
4
  import { resolveWorkspaceFrom, type WorkspaceConfig } from "./workspaces";
4
5
  import { configDir } from "./config";
5
6
  import { verifyPushIdentity } from "./branch";
@@ -101,6 +102,18 @@ export const autoApproves = (root: string, cls: unknown, dir?: string): boolean
101
102
  const isStandingReceipt = (value: unknown): value is StandingReceipt =>
102
103
  typeof value === "object" && value !== null && (value as { kind?: unknown }).kind === "standing";
103
104
 
105
+ const targetOf = (root: string, descriptor: unknown): string | null => {
106
+ try {
107
+ const parsed = typeof descriptor === "string" ? JSON.parse(descriptor) : descriptor;
108
+ const cwd = (parsed as { payload?: { cwd?: unknown } })?.payload?.cwd;
109
+ if (cwd === undefined) return root;
110
+ if (typeof cwd !== "string" || !path.isAbsolute(cwd)) return null;
111
+ return realpathSync(cwd);
112
+ } catch {
113
+ return null;
114
+ }
115
+ };
116
+
104
117
  /**
105
118
  * A standing receipt authorizes only while the referenced workspace rule
106
119
  * still covers the class. Removing the flag (or narrowing the list) fails
@@ -111,11 +124,14 @@ export const standingApprovalLive = (
111
124
  receipt: unknown,
112
125
  cls: unknown,
113
126
  dir?: string,
127
+ descriptor?: unknown,
114
128
  ): boolean => {
115
129
  if (!isStandingReceipt(receipt)) return false;
116
130
  if (typeof cls !== "string" || !(AUTO_CLASSES as readonly string[]).includes(cls)) return false;
117
131
  if (receipt.class !== cls) return false;
118
- const resolved = resolveAutoApproval(root, dir);
132
+ const target = targetOf(root, descriptor);
133
+ if (!target) return false;
134
+ const resolved = resolveAutoApproval(target, dir);
119
135
  if (resolved.status !== "on") return false;
120
136
  if (resolved.workspace !== receipt.workspace) return false;
121
137
  return resolved.classes.includes(cls as AutoClass);
@@ -174,7 +190,9 @@ export const verifyStandingApproval = (
174
190
  const cls = operationAutoClass(operation);
175
191
  if (!cls || cls !== standing.class)
176
192
  return failure("permission_denied", "standing approval does not cover this operation");
177
- const resolved = resolveAutoApproval(root);
193
+ const target = targetOf(root, binding.approvedContent);
194
+ if (!target) return failure("permission_denied", "standing action target is invalid");
195
+ const resolved = resolveAutoApproval(target);
178
196
  if (
179
197
  resolved.status !== "on" ||
180
198
  resolved.workspace !== standing.workspace ||
@@ -182,7 +200,7 @@ export const verifyStandingApproval = (
182
200
  )
183
201
  return failure("permission_denied", "standing auto-approval is not live for this operation");
184
202
  if (cls === "push") {
185
- const workspace = resolveWorkspaceFrom(root, configDir());
203
+ const workspace = resolveWorkspaceFrom(target, configDir());
186
204
  const provider = workspace?.vcs?.provider;
187
205
  const account = resolved.account;
188
206
  if (!provider || !account)
@@ -190,7 +208,7 @@ export const verifyStandingApproval = (
190
208
  "permission_denied",
191
209
  "auto-push requires a workspace provider and area account",
192
210
  );
193
- const identity = verifyPushIdentity(root, provider, account);
211
+ const identity = verifyPushIdentity(target, provider, account);
194
212
  if (!identity.ok) return failure("permission_denied", identity.error);
195
213
  }
196
214
  return success(null, null, {
@@ -198,7 +216,7 @@ export const verifyStandingApproval = (
198
216
  host: caller.host,
199
217
  session: { kind: "host", host: caller.host, handle: caller.actor },
200
218
  workerId: null,
201
- receipts: [standingReceiptFor(root, standing.workspace, cls)],
219
+ receipts: [standingReceiptFor(target, standing.workspace, cls)],
202
220
  } as Provenance);
203
221
  };
204
222
 
@@ -229,14 +247,15 @@ export const standingAutoApplies = (
229
247
  actor: string,
230
248
  operation: string,
231
249
  ): StandingMatch | null => {
232
- let descriptor: { operation?: unknown };
250
+ let descriptor: { operation?: unknown; payload?: unknown };
233
251
  try {
234
- descriptor = JSON.parse(operation) as { operation?: unknown };
252
+ descriptor = JSON.parse(operation) as { operation?: unknown; payload?: unknown };
235
253
  } catch {
236
254
  return null;
237
255
  }
238
256
  const cls = operationAutoClass(descriptor.operation);
239
- if (!cls || !autoApproves(store.root, cls)) return null;
257
+ const target = targetOf(store.root, descriptor);
258
+ if (!cls || !target || !autoApproves(target, cls)) return null;
240
259
  const listed = store.listTasks();
241
260
  const workspace = store.readWorkspace();
242
261
  if (!listed.ok || !workspace.ok || !workspace.data) return null;
@@ -244,7 +263,7 @@ export const standingAutoApplies = (
244
263
  currentWriterOwnsTask(task, workspace.data!, { host, actor }),
245
264
  );
246
265
  if (!task) return null;
247
- const resolved = resolveAutoApproval(store.root);
266
+ const resolved = resolveAutoApproval(target);
248
267
  if (resolved.status !== "on") return null;
249
268
  return {
250
269
  task,
@@ -271,18 +290,19 @@ export const standingAutoBinding = (
271
290
  actor: string,
272
291
  operation: string,
273
292
  ): StandingAutoBinding | null => {
274
- let descriptor: { operation?: unknown };
293
+ let descriptor: { operation?: unknown; payload?: unknown };
275
294
  try {
276
- descriptor = JSON.parse(operation) as { operation?: unknown };
295
+ descriptor = JSON.parse(operation) as { operation?: unknown; payload?: unknown };
277
296
  } catch {
278
297
  return null;
279
298
  }
280
299
  const cls = operationAutoClass(descriptor.operation);
281
- if (!cls || !autoApproves(store.root, cls)) return null;
300
+ const target = targetOf(store.root, descriptor);
301
+ if (!cls || !target || !autoApproves(target, cls)) return null;
282
302
  const applies = standingAutoApplies(store, host, actor, operation);
283
303
  if (!applies) return null;
284
304
  const { task } = applies;
285
- const resolved = resolveAutoApproval(store.root);
305
+ const resolved = resolveAutoApproval(target);
286
306
  if (resolved.status !== "on") return null;
287
307
  const presented =
288
308
  `Workit decision: action — auto-approved ${String(descriptor.operation)} ` +
@@ -1,18 +1,229 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
3
4
  import path from "node:path";
5
+ import { matchCommitFlavor } from "./commit-flavors";
4
6
  import { gitContext } from "./git";
5
- import { readConfig, resolveBranchPolicy, resolveCommitPolicy } from "./config";
6
- import { resolveWorkspace } from "./workspaces";
7
- import { vcsConfig } from "./vcs-config";
7
+ import {
8
+ readConfig,
9
+ resolveBranchPolicy as resolveConfiguredBranchPolicy,
10
+ resolveCommitPolicy as resolveConfiguredCommitPolicy,
11
+ } from "./config";
12
+ import {
13
+ resolveRuntimeWorkspacePolicy,
14
+ type WorkspaceBranchPolicy,
15
+ type WorkspaceCommitPolicy,
16
+ } from "./workspaces";
17
+ import { vcsCliIdentity, vcsConfig } from "./vcs-config";
8
18
 
9
- /** CA-09: the one policy resolver every consumer calls. */
10
- export const resolveBranchPolicyFor = (workspaceRoot: string) =>
11
- resolveBranchPolicy(readConfig(), resolveWorkspace(workspaceRoot));
19
+ function effectiveWorkspacePolicy(
20
+ workspaceRoot: string,
21
+ kind: "branch",
22
+ profileName?: string,
23
+ ): {
24
+ branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
25
+ provenance: { branchPolicy: string };
26
+ };
27
+ function effectiveWorkspacePolicy(
28
+ workspaceRoot: string,
29
+ kind: "commit",
30
+ profileName?: string,
31
+ ): {
32
+ commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
33
+ provenance: { commitPolicy: string };
34
+ };
35
+ function effectiveWorkspacePolicy(
36
+ workspaceRoot: string,
37
+ kind: "branch" | "commit",
38
+ profileName?: string,
39
+ ):
40
+ | {
41
+ branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
42
+ provenance: { branchPolicy: string };
43
+ }
44
+ | {
45
+ commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
46
+ provenance: { commitPolicy: string };
47
+ } {
48
+ const selected = resolveRuntimeWorkspacePolicy(
49
+ workspaceRoot,
50
+ kind,
51
+ profileName?.trim() || process.env.WORKFLOW_PROFILE?.trim() || undefined,
52
+ );
53
+ const config = readConfig();
54
+ return kind === "branch"
55
+ ? {
56
+ branchPolicy: resolveConfiguredBranchPolicy(
57
+ config,
58
+ selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
59
+ ),
60
+ provenance: { branchPolicy: selected.source },
61
+ }
62
+ : {
63
+ commitPolicy: resolveConfiguredCommitPolicy(
64
+ config,
65
+ selected.policy ? { commitPolicy: selected.policy as WorkspaceCommitPolicy } : null,
66
+ ),
67
+ provenance: { commitPolicy: selected.source },
68
+ };
69
+ }
70
+
71
+ export type BranchNamePolicy = {
72
+ allowed: readonly RegExp[];
73
+ protected: ReadonlySet<string>;
74
+ };
75
+
76
+ export type BranchNamePolicyCheck =
77
+ | { ok: true }
78
+ | {
79
+ ok: false;
80
+ rule: "protected_ref" | "allowed_pattern" | "policy_unavailable";
81
+ source: string;
82
+ attempted: string;
83
+ correction: string;
84
+ error: string;
85
+ };
86
+
87
+ export type CommitMessagePolicyCheck =
88
+ | { ok: true }
89
+ | {
90
+ ok: false;
91
+ rule: "commit_style" | "policy_unavailable";
92
+ source: string;
93
+ attempted: string;
94
+ correction: string;
95
+ error: string;
96
+ };
97
+
98
+ export const validateBranchNamePolicy = (
99
+ name: string,
100
+ policy: BranchNamePolicy,
101
+ source: string,
102
+ ): BranchNamePolicyCheck => {
103
+ const allowed = policy.allowed.map((pattern) => `/${pattern.source}/${pattern.flags}`);
104
+ if (policy.protected.has(name.toLowerCase())) {
105
+ const correction = `choose a non-protected branch matching one of: ${allowed.join(", ")}`;
106
+ return {
107
+ ok: false,
108
+ rule: "protected_ref",
109
+ source,
110
+ attempted: name,
111
+ correction,
112
+ error: `protected_ref: branch ${JSON.stringify(name)} is protected by ${source}; ${correction}`,
113
+ };
114
+ }
115
+ if (!policy.allowed.some((pattern) => pattern.test(name))) {
116
+ const correction = `choose a branch matching one of: ${allowed.join(", ")}`;
117
+ return {
118
+ ok: false,
119
+ rule: "allowed_pattern",
120
+ source,
121
+ attempted: name,
122
+ correction,
123
+ error: `allowed_pattern: branch ${JSON.stringify(name)} violates ${source} policy; ${correction}`,
124
+ };
125
+ }
126
+ return { ok: true };
127
+ };
128
+
129
+ export const branchPolicySnapshotFor = (workspaceRoot: string) => {
130
+ const resolved = effectiveWorkspacePolicy(workspaceRoot, "branch");
131
+ const branchPolicy = resolved.branchPolicy;
132
+ const source = resolved.provenance.branchPolicy;
133
+ const allowed = branchPolicy.allowed
134
+ .map((pattern) => `${pattern.source}/${pattern.flags}`)
135
+ .sort();
136
+ const protectedRefs = [...branchPolicy.protected].map((name) => name.toLowerCase()).sort();
137
+ const fingerprint = createHash("sha256")
138
+ .update(JSON.stringify({ allowed, protectedRefs, source }))
139
+ .digest("hex");
140
+ return {
141
+ branchPolicy,
142
+ namePolicy: { allowed: branchPolicy.allowed, protected: branchPolicy.protected },
143
+ source,
144
+ fingerprint,
145
+ };
146
+ };
147
+
148
+ export const commitPolicySnapshotFor = (workspaceRoot: string) => {
149
+ const resolved = effectiveWorkspacePolicy(workspaceRoot, "commit");
150
+ const policy = resolved.commitPolicy;
151
+ const source = resolved.provenance.commitPolicy;
152
+ const fingerprint = createHash("sha256")
153
+ .update(JSON.stringify({ preset: policy.preset, pattern: policy.pattern ?? null, source }))
154
+ .digest("hex");
155
+ return { policy, source, fingerprint };
156
+ };
157
+
158
+ export const validateCommitMessagePolicy = (
159
+ message: string,
160
+ policy: { preset: Parameters<typeof matchCommitFlavor>[1]; pattern?: string },
161
+ source: string,
162
+ ): CommitMessagePolicyCheck => {
163
+ const attempted = message.split("\n", 1)[0] ?? "";
164
+ if (matchCommitFlavor(message, policy.preset, policy.pattern)) return { ok: true };
165
+ const correction =
166
+ policy.preset === "custom" && policy.pattern
167
+ ? `use a commit subject matching ${JSON.stringify(policy.pattern)}`
168
+ : `use a ${policy.preset} commit subject`;
169
+ return {
170
+ ok: false,
171
+ rule: "commit_style",
172
+ source,
173
+ attempted,
174
+ correction,
175
+ error: `commit_style: commit subject ${JSON.stringify(attempted)} violates ${source} ${policy.preset} policy; ${correction}`,
176
+ };
177
+ };
12
178
 
13
- /** Commit-flavor equivalent: workspace commitPolicy override, else global. */
14
- export const resolveCommitPolicyFor = (workspaceRoot: string) =>
15
- resolveCommitPolicy(readConfig(), resolveWorkspace(workspaceRoot));
179
+ export const validateBranchNameFor = (
180
+ workspaceRoot: string,
181
+ name: string,
182
+ ): BranchNamePolicyCheck => {
183
+ try {
184
+ const resolved = branchPolicySnapshotFor(workspaceRoot);
185
+ return validateBranchNamePolicy(name, resolved.namePolicy, resolved.source);
186
+ } catch (error) {
187
+ const correction = "repair or select a valid workspace policy before creating a branch";
188
+ return {
189
+ ok: false,
190
+ rule: "policy_unavailable",
191
+ source: "workspaces.json",
192
+ attempted: name,
193
+ correction,
194
+ error: `policy_unavailable: cannot validate branch ${JSON.stringify(name)} because ${error instanceof Error ? error.message : String(error)}; ${correction}`,
195
+ };
196
+ }
197
+ };
198
+
199
+ export const validateCommitMessageFor = (
200
+ workspaceRoot: string,
201
+ message: string,
202
+ ): CommitMessagePolicyCheck => {
203
+ const attempted = message.split("\n", 1)[0] ?? "";
204
+ try {
205
+ const resolved = commitPolicySnapshotFor(workspaceRoot);
206
+ return validateCommitMessagePolicy(message, resolved.policy, resolved.source);
207
+ } catch (error) {
208
+ const correction = "repair or select a valid workspace policy before committing";
209
+ return {
210
+ ok: false,
211
+ rule: "policy_unavailable",
212
+ source: "workspaces.json",
213
+ attempted,
214
+ correction,
215
+ error: `policy_unavailable: cannot validate commit subject ${JSON.stringify(attempted)} because ${error instanceof Error ? error.message : String(error)}; ${correction}`,
216
+ };
217
+ }
218
+ };
219
+
220
+ /** CA-09: one policy resolver; profiles layer above the matched workspace. */
221
+ export const resolveBranchPolicyFor = (workspaceRoot: string, profileName?: string) =>
222
+ effectiveWorkspacePolicy(workspaceRoot, "branch", profileName).branchPolicy;
223
+
224
+ /** Commit-flavor equivalent using the same profile and provenance chain. */
225
+ export const resolveCommitPolicyFor = (workspaceRoot: string, profileName?: string) =>
226
+ effectiveWorkspacePolicy(workspaceRoot, "commit", profileName).commitPolicy;
16
227
 
17
228
  const policy = (root: string) => resolveBranchPolicyFor(root);
18
229
  const allowedBranch = (root: string, name: string) =>
@@ -40,25 +251,13 @@ export const verifyPushIdentity = (
40
251
  ): { ok: true } | { ok: false; error: string } => {
41
252
  if (!account || typeof account !== "string")
42
253
  return { ok: false, error: "push identity requires a configured area account" };
43
- const probe =
44
- provider === "gitlab"
45
- ? (["api", "user", "--jq", ".username"] as const)
46
- : (["api", "user", "--jq", ".login"] as const);
47
- const bin = provider === "gitlab" ? "glab" : "gh";
48
- let login: string;
49
- try {
50
- login = execFileSync(bin, [...probe], {
51
- cwd: root,
52
- encoding: "utf8",
53
- stdio: ["pipe", "pipe", "pipe"],
54
- }).trim();
55
- } catch {
56
- return { ok: false, error: `${bin} identity could not be resolved` };
57
- }
58
- if (!login || login.toLowerCase() !== account.toLowerCase())
254
+ const identity = vcsCliIdentity(root);
255
+ if (!identity.ok || identity.provider !== provider)
256
+ return { ok: false, error: identity.error ?? "hosting CLI identity could not be resolved" };
257
+ if (identity.username?.toLowerCase() !== account.toLowerCase())
59
258
  return {
60
259
  ok: false,
61
- error: `push identity ${login || "(unknown)"} does not match area account ${account}`,
260
+ error: `push identity ${identity.username || "(unknown)"} does not match area account ${account}`,
62
261
  };
63
262
  return { ok: true };
64
263
  };
@@ -83,8 +282,7 @@ const readSafe = (p: string): string | null => {
83
282
 
84
283
  const normalizeBranch = (root: string, name: string): string | null => {
85
284
  const n = name.trim().replace(/`/g, "").replace(/\.+$/, "");
86
- if (isProtected(root, n)) return null;
87
- if (!allowedBranch(root, n)) return null;
285
+ if (!validateBranchNameFor(root, n).ok) return null;
88
286
  const parts = n
89
287
  .toLowerCase()
90
288
  .split("/")
@@ -155,9 +353,10 @@ export const resolveBranch = ({
155
353
  const text = readSafe(file);
156
354
  if (!text) continue;
157
355
  if (USE_CURRENT_RE.test(text)) {
158
- if (!current || !allowedBranch(cwd, current) || isProtected(cwd, current)) {
159
- return { error: `use-current but HEAD ${current} is not an allowed branch` };
160
- }
356
+ if (!current) return { error: `use-current but HEAD ${current} is not an allowed branch` };
357
+ const policyCheck = validateBranchNameFor(cwd, current);
358
+ if (!policyCheck.ok)
359
+ return { error: `use-current but HEAD ${current} is not allowed: ${policyCheck.error}` };
161
360
  return finish(current, "use-current");
162
361
  }
163
362
  }
@@ -176,8 +375,11 @@ export const resolveBranch = ({
176
375
  }
177
376
  }
178
377
  if (declaredButInvalid) {
378
+ const policyCheck = validateBranchNameFor(cwd, declaredButInvalid);
179
379
  return {
180
- error: `declared branch ${JSON.stringify(declaredButInvalid)} is not allowed by the branch policy`,
380
+ error: policyCheck.ok
381
+ ? `declared branch ${JSON.stringify(declaredButInvalid)} is not a valid branch name`
382
+ : policyCheck.error,
181
383
  };
182
384
  }
183
385
 
@@ -271,7 +473,12 @@ const originBaseReady = (cwd: string, base: string): { ok: boolean; error?: stri
271
473
 
272
474
  // Mutating half of ensureBaseBranch: fast-forwards the local base (creating
273
475
  // it from origin/<base> if needed). Only safe on a clean tree.
274
- const fastForwardBase = (cwd: string, base: string): { ok: boolean; error?: string } => {
476
+ const fastForwardBase = (
477
+ cwd: string,
478
+ base: string,
479
+ expectedRemoteBase?: string,
480
+ expectedLocalBase?: string | null,
481
+ ): { ok: boolean; error?: string } => {
275
482
  const run = (args: string[]) =>
276
483
  execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
277
484
  try {
@@ -284,13 +491,26 @@ const fastForwardBase = (cwd: string, base: string): { ok: boolean; error?: stri
284
491
  } catch {
285
492
  hasLocalBase = false;
286
493
  }
494
+ if (expectedLocalBase !== undefined) {
495
+ let localBase: string | null = null;
496
+ try {
497
+ localBase = run(["rev-parse", "--verify", `refs/heads/${base}`]).trim();
498
+ } catch {}
499
+ if (localBase !== expectedLocalBase)
500
+ return { ok: false, error: "approved local base changed before branch setup" };
501
+ }
287
502
  if (hasLocalBase) {
288
503
  run(["checkout", base]);
504
+ if (expectedLocalBase && run(["rev-parse", "HEAD"]).trim() !== expectedLocalBase)
505
+ return { ok: false, error: "approved local base changed before branch setup" };
289
506
  try {
290
- run(["merge", "--ff-only", `origin/${base}`]);
507
+ run(["merge", "--ff-only", expectedRemoteBase ?? `origin/${base}`]);
291
508
  } catch {
292
509
  /* non-fast-forward: keep local */
293
510
  }
511
+ } else if (expectedRemoteBase) {
512
+ run(["checkout", "-b", base, expectedRemoteBase]);
513
+ run(["branch", "--set-upstream-to", `origin/${base}`, base]);
294
514
  } else {
295
515
  run(["checkout", "-b", base, "--track", `origin/${base}`]);
296
516
  }
@@ -369,6 +589,8 @@ export const branchSetup = ({
369
589
  stash,
370
590
  workspace_root,
371
591
  log,
592
+ expected_remote_base,
593
+ expected_local_base,
372
594
  }: {
373
595
  action?: string;
374
596
  sdd_dir?: string;
@@ -376,6 +598,8 @@ export const branchSetup = ({
376
598
  stash?: string;
377
599
  workspace_root: string;
378
600
  log?: (message: string) => void;
601
+ expected_remote_base?: string;
602
+ expected_local_base?: string | null;
379
603
  }): BranchSetupResult => {
380
604
  const cwd = path.resolve(workspace_root);
381
605
  const exec = (args: string[]): string =>
@@ -432,12 +656,13 @@ export const branchSetup = ({
432
656
  error: "target_branch (the working branch to create or switch to) is required for setup",
433
657
  phase: "preflight",
434
658
  };
435
- if (isProtected(cwd, target)) return { error: `protected branch ${target}`, phase: "preflight" };
436
- if (!allowedBranch(cwd, target))
437
- return {
438
- error: `target branch ${target} is not allowed by the branch policy`,
439
- phase: "preflight",
440
- };
659
+ if (expected_remote_base && !/^[a-f0-9]{40,64}$/i.test(expected_remote_base))
660
+ return { error: "approved remote base commit is invalid", phase: "preflight" };
661
+ try {
662
+ exec(["check-ref-format", "--branch", target]);
663
+ } catch {
664
+ return { error: `invalid Git branch name ${target}`, phase: "preflight" };
665
+ }
441
666
 
442
667
  // CA-02: resolve the base up front so an unresolvable base fails before
443
668
  // any mutation. The origin/<base> validation runs after the stash gate
@@ -449,6 +674,10 @@ export const branchSetup = ({
449
674
  } catch {
450
675
  targetExists = false;
451
676
  }
677
+ if (!targetExists) {
678
+ const policy = validateBranchNameFor(cwd, target);
679
+ if (!policy.ok) return { error: policy.error, phase: "preflight" };
680
+ }
452
681
  if (!targetExists) {
453
682
  const baseResolved = baseBranch(cwd);
454
683
  if ("error" in baseResolved) return { error: baseResolved.error, phase: "preflight" };
@@ -491,10 +720,25 @@ export const branchSetup = ({
491
720
  // tree untouched. The mutating fast-forward stays below: it checks out
492
721
  // the base branch — unsafe on a dirty tree.
493
722
  let validatedBase: string | undefined;
723
+ let validatedRemoteBase: string | undefined;
724
+ let validatedLocalBase: string | null | undefined;
494
725
  if (!targetExists && base !== undefined) {
495
726
  const ready = originBaseReady(cwd, base);
496
727
  if (!ready.ok)
497
728
  return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
729
+ const fetchedBase = exec(["rev-parse", "--verify", `refs/remotes/origin/${base}`]).trim();
730
+ if (expected_remote_base && fetchedBase !== expected_remote_base)
731
+ return { error: "approved remote base changed before branch setup", phase: "preflight" };
732
+ if (expected_local_base !== undefined) {
733
+ let localBase: string | null = null;
734
+ try {
735
+ localBase = exec(["rev-parse", "--verify", `refs/heads/${base}`]).trim();
736
+ } catch {}
737
+ if (localBase !== expected_local_base)
738
+ return { error: "approved local base changed before branch setup", phase: "preflight" };
739
+ validatedLocalBase = expected_local_base;
740
+ }
741
+ if (expected_remote_base) validatedRemoteBase = expected_remote_base;
498
742
  validatedBase = base;
499
743
  }
500
744
  if (dirty && stash === "yes") {
@@ -531,10 +775,17 @@ export const branchSetup = ({
531
775
  // fast-forward mutation remains post-stash.
532
776
  const baseResult =
533
777
  validatedBase !== undefined
534
- ? fastForwardBase(cwd, effectiveBase)
778
+ ? fastForwardBase(cwd, effectiveBase, validatedRemoteBase, validatedLocalBase)
535
779
  : ensureBaseBranch(cwd, effectiveBase);
536
780
  if (!baseResult.ok) return failAfterStash(baseResult.error ?? "ensure-base-branch failed");
537
- exec(["checkout", "-b", target]);
781
+ const targetBase = exec(["rev-parse", "HEAD"]).trim();
782
+ if (
783
+ validatedRemoteBase &&
784
+ validatedLocalBase !== undefined &&
785
+ ![validatedRemoteBase, validatedLocalBase].includes(targetBase)
786
+ )
787
+ return failAfterStash("approved branch base changed before branch creation", "preflight");
788
+ exec(["checkout", "-b", target, targetBase]);
538
789
  journal("post-create");
539
790
  } catch (createError) {
540
791
  return failAfterStash(
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { isConfigObject, mergePreset, type BranchPreset, type ToolkitConfig } from "./config";
5
+ import { writeFileAtomic } from "./safe-write";
5
6
 
6
7
  export type ConversionInput = {
7
8
  configDir: string;
@@ -170,11 +171,10 @@ export function applyConversionConfig(
170
171
  delete next.workflowMode;
171
172
  }
172
173
 
173
- mkdirSync(configDir, { recursive: true });
174
- writeFileSync(configFile, JSON.stringify(next, null, 2) + "\n");
174
+ writeFileAtomic(configFile, JSON.stringify(next, null, 2) + "\n");
175
175
 
176
176
  const choicesPath = path.join(configDir, "cutover-choices.json");
177
- writeFileSync(
177
+ writeFileAtomic(
178
178
  choicesPath,
179
179
  JSON.stringify({ resolutions, applied_at: new Date().toISOString() }, null, 2) + "\n",
180
180
  );
@@ -1,12 +1,6 @@
1
1
  import { initStatus } from "./init";
2
2
 
3
- export const ALL_ITEM_IDS = [
4
- "youtrack_json",
5
- "youtrack_token",
6
- "vcs_json",
7
- "gitlab_token",
8
- "github_token",
9
- ];
3
+ export const ALL_ITEM_IDS = ["youtrack_json", "youtrack_token", "vcs_json"];
10
4
  export const CONFIG_GAP_MARKER = "workflow config missing";
11
5
 
12
6
  export function describeConfigGaps(scope?: string[]): { missing: string[]; ok: boolean } {
@@ -332,7 +332,7 @@ export const resolveBranchPolicy = (
332
332
  };
333
333
  };
334
334
 
335
- const COMMIT_PRESETS: readonly string[] = [
335
+ export const COMMIT_PRESETS: readonly string[] = [
336
336
  "conventional",
337
337
  "gitmoji",
338
338
  "ticket-prefix",