@brainervirus/workit-core 8.9.1 → 8.11.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.
@@ -27,7 +27,7 @@ const CONTEXTS = `contexts(first: 100, after: $contexts) { pageInfo { hasNextPag
27
27
 
28
28
  export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threads: String, $contexts: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_FIELDS} ${THREADS} commits(last: 1) { nodes { commit { oid statusCheckRollup { state ${CONTEXTS} } } } } } } }`;
29
29
 
30
- export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state headRefName headRefOid headRepositoryOwner { login } } } } }`;
30
+ export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state baseRefName headRefName headRefOid headRepositoryOwner { login } } } } }`;
31
31
 
32
32
  const READY_MUTATION = `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
33
33
  const DRAFT_MUTATION = `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
@@ -351,6 +351,7 @@ export function createGitHubForge(options: {
351
351
  number: number;
352
352
  url: string;
353
353
  state: string;
354
+ baseRefName?: string;
354
355
  headRefName: string;
355
356
  headRefOid: string | null;
356
357
  headRepositoryOwner: { login: string } | null;
@@ -365,11 +366,21 @@ export function createGitHubForge(options: {
365
366
  const ours = nodes.filter(
366
367
  (node) =>
367
368
  node.headRefName === head &&
369
+ (!match.base || node.baseRefName === undefined || node.baseRefName === match.base) &&
368
370
  ((match.owner !== null &&
369
371
  node.headRepositoryOwner?.login.toLowerCase() === match.owner.toLowerCase()) ||
370
372
  (match.sha !== null && node.headRefOid === match.sha)),
371
373
  );
372
- const pick = ours.find((node) => node.state === "OPEN") ?? ours[0];
374
+ const open = ours.filter((node) => node.state === "OPEN");
375
+ // No base named and open PRs into several bases (a git-flow release
376
+ // into main and its back-merge into develop): never guess.
377
+ if (!match.base && new Set(open.map((node) => node.baseRefName)).size > 1)
378
+ return failure(
379
+ "blocked",
380
+ `ambiguous_pr: ${head} has open pull requests ${open.map((node) => `#${node.number} -> ${node.baseRefName}`).join(", ")}`,
381
+ `pass --pr <n>, e.g. --pr ${open[0].number}`,
382
+ );
383
+ const pick = open[0] ?? ours[0];
373
384
  if (!pick) return success(null);
374
385
  const ref: PrRef = {
375
386
  number: pick.number,
@@ -327,10 +327,18 @@ export function createGitLabForge(options: {
327
327
  const matches = list.data.filter(
328
328
  (mr) =>
329
329
  mr.source_branch === head &&
330
+ (!match.base || mr.target_branch === undefined || mr.target_branch === match.base) &&
330
331
  ((match.projectId !== null && mr.source_project_id === match.projectId) ||
331
332
  (match.sha !== null && mr.sha === match.sha)),
332
333
  );
333
- const pick = matches.find((mr) => mr.state === "opened") ?? matches[0];
334
+ const open = matches.filter((mr) => mr.state === "opened");
335
+ if (!match.base && new Set(open.map((mr) => mr.target_branch)).size > 1)
336
+ return failure(
337
+ "blocked",
338
+ `ambiguous_pr: ${head} has open merge requests ${open.map((mr) => `!${mr.iid} -> ${mr.target_branch}`).join(", ")}`,
339
+ `pass --pr <n>, e.g. --pr ${open[0].iid}`,
340
+ );
341
+ const pick = open[0] ?? matches[0];
334
342
  return success(
335
343
  pick
336
344
  ? {
@@ -22,7 +22,7 @@ import {
22
22
  type AutonomySource,
23
23
  type DefaultEndpoint,
24
24
  } from "../autonomy";
25
- import { isProtectedTarget } from "../core/branch";
25
+ import { branchKindOf, flowRoute, gitFlowOf, isProtectedTarget } from "../core/branch";
26
26
  import { vcsConfig } from "../core/vcs-config";
27
27
  import { workspaceReleaseTracks } from "../core/release-tracks";
28
28
  import { deleteRemoteBranch } from "../git/ops";
@@ -211,6 +211,7 @@ async function verifiedHead(
211
211
  sha: string,
212
212
  first: PrRef,
213
213
  sleep: Sleep,
214
+ base: string,
214
215
  ): Promise<ForgeResult<PrRef>> {
215
216
  let ref = first;
216
217
  for (const delay of [0, ...VERIFY_DELAYS_MS]) {
@@ -220,6 +221,7 @@ async function verifiedHead(
220
221
  owner: resolved.headRepo.split("/")[0] ?? null,
221
222
  projectId: resolved.headProjectId,
222
223
  sha,
224
+ base,
223
225
  });
224
226
  if (!found.ok) return found;
225
227
  if (found.data && found.data.number === first.number) ref = found.data;
@@ -258,10 +260,13 @@ export async function createPullRequest(
258
260
  "pass --base <branch>",
259
261
  );
260
262
 
263
+ // Head AND base: a release branch with an open PR into main still gets
264
+ // its back-merge PR into develop.
261
265
  const existing = resolved.forge.findPr(branch, {
262
266
  owner: resolved.headRepo.split("/")[0] ?? null,
263
267
  projectId: resolved.headProjectId,
264
268
  sha,
269
+ base: input.base,
265
270
  });
266
271
  if (!existing.ok) return existing;
267
272
  let created = false;
@@ -281,7 +286,7 @@ export async function createPullRequest(
281
286
  ref = made.data;
282
287
  created = true;
283
288
  }
284
- const verified = await verifiedHead(resolved, branch, sha, ref, sleep);
289
+ const verified = await verifiedHead(resolved, branch, sha, ref, sleep, input.base);
285
290
  if (!verified.ok) return verified;
286
291
  const row = appendObserved(cwd, {
287
292
  type: "pr.created",
@@ -366,13 +371,26 @@ export type MergeOutcome = {
366
371
  /**
367
372
  * The merge-back a merge into `base` owes: the track whose production branch
368
373
  * `base` is, by ownership alone (no --track, no WORKFLOW_RELEASE_TRACK, no
369
- * history). Best effort: a config problem never stops the pr.merged record.
374
+ * history); without tracks, a git-flow release/* or hotfix/* landing on the
375
+ * production branch owes develop. Best effort: a config problem never stops
376
+ * the pr.merged record.
370
377
  */
371
- const mergeBackFor = (cwd: string, base: string): { track: string | null; branches: string[] } => {
378
+ const mergeBackFor = (
379
+ cwd: string,
380
+ base: string,
381
+ head: string,
382
+ ): { track: string | null; branches: string[] } => {
372
383
  try {
373
- const owners = workspaceReleaseTracks(cwd).tracks.filter(
374
- (track) => track.productionBranch === base,
375
- );
384
+ const read = workspaceReleaseTracks(cwd);
385
+ if (!read.tracks.length) {
386
+ const flow = gitFlowOf(cwd);
387
+ const kind = branchKindOf(head);
388
+ const route = flow && kind ? flowRoute(flow, kind) : null;
389
+ return route?.mergeBack && route.target === base
390
+ ? { track: "git-flow", branches: [route.mergeBack] }
391
+ : { track: null, branches: [] };
392
+ }
393
+ const owners = read.tracks.filter((track) => track.productionBranch === base);
376
394
  if (owners.length !== 1) return { track: null, branches: [] };
377
395
  return {
378
396
  track: owners[0].name,
@@ -604,7 +622,7 @@ export async function mergePullRequest(
604
622
  deletedBranch =
605
623
  deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
606
624
  }
607
- const mergeBack = mergeBackFor(cwd, doc.base);
625
+ const mergeBack = mergeBackFor(cwd, doc.base, branch);
608
626
  const row = appendObserved(cwd, {
609
627
  type: "pr.merged",
610
628
  actor: input.actor,
@@ -42,6 +42,11 @@ export type PrMatch = {
42
42
  projectId: number | null;
43
43
  /** A PR whose head is exactly this commit is ours whatever its owner. */
44
44
  sha: string | null;
45
+ /**
46
+ * Only a PR into this base: one head may have PRs into several bases (a
47
+ * git-flow release into main and its back-merge into develop).
48
+ */
49
+ base?: string | null;
45
50
  };
46
51
 
47
52
  /** One CI check (GitHub check run / commit status, GitLab job or bridge). */
package/src/git/ops.ts CHANGED
@@ -21,11 +21,19 @@ import { spawnSync } from "node:child_process";
21
21
  import { existsSync, readFileSync } from "node:fs";
22
22
  import path from "node:path";
23
23
  import {
24
+ branchKindOf,
24
25
  classifyBranchDirt,
26
+ flowMismatch,
27
+ flowRoute,
28
+ gitFlowOf,
25
29
  isProtectedTarget,
30
+ releaseBranch,
31
+ releaseBranchesOf,
26
32
  resolveCommitPolicyFor,
27
33
  validateBranchNameFor,
28
34
  validateCommitMessageFor,
35
+ type BranchKind,
36
+ type FlowRoute,
29
37
  } from "../core/branch";
30
38
  import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
31
39
  import { pushTargetIsStable } from "../core/pr-create";
@@ -221,12 +229,14 @@ export type BranchOutcome = {
221
229
  notes: string[];
222
230
  /** The release track the base and name came from; null without tracks. */
223
231
  releaseTrack: ResolvedReleaseTrack | null;
232
+ /** The git-flow route the base came from; null outside git-flow or with a release track. */
233
+ flow: FlowRoute | null;
224
234
  };
225
235
 
226
236
  export type BranchInput = {
227
237
  /** An explicit branch name; else `kind` + `slug` through the track's naming. */
228
238
  name?: string;
229
- kind?: "feature" | "bugfix" | "hotfix";
239
+ kind?: BranchKind;
230
240
  slug?: string;
231
241
  base?: string | null;
232
242
  carry?: boolean;
@@ -272,6 +282,21 @@ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOu
272
282
  if (!policy.ok) return failure("blocked", policy.error, policy.correction);
273
283
  if (resolveRef(cwd, `refs/heads/${name}`))
274
284
  return failure("failed", `branch_exists: ${name} already exists`, `git switch ${name}`);
285
+ // git-flow (no release track): the kind decides the base, and an explicit
286
+ // base that is the other long-lived branch is refused.
287
+ const kind = input.kind ?? branchKindOf(name);
288
+ const flow =
289
+ resolved.ok !== false && !releaseTrack?.track
290
+ ? gitFlowOf(cwd, String(resolved.defaultTargetBranch ?? "") || null)
291
+ : null;
292
+ const route = flow && kind ? flowRoute(flow, kind) : null;
293
+ const mismatch = explicitBase ? flowMismatch(flow, kind, explicitBase, "base") : null;
294
+ if (mismatch && route)
295
+ return failure(
296
+ "blocked",
297
+ mismatch,
298
+ `workit git branch ${input.kind ? `--kind ${kind} --slug ${input.slug}` : name} # without --base it starts from ${route.base}`,
299
+ );
275
300
 
276
301
  let base = explicitBase;
277
302
  let defaultTarget: string | null = null;
@@ -287,12 +312,16 @@ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOu
287
312
  defaultTarget = String(resolved.defaultTargetBranch ?? "") || null;
288
313
  defaultBase = String(resolved.baseBranch ?? "") || defaultTarget;
289
314
  }
290
- base ??= defaultBase;
315
+ base ??= route?.base ?? defaultBase;
291
316
  if (!base)
292
317
  return failure("invalid_input", "no default target branch is configured; pass --base <branch>");
293
318
  if (base.startsWith("-")) return failure("invalid_input", "--base must not start with -");
294
319
 
295
320
  const notes: string[] = [...(releaseTrack?.warnings ?? [])];
321
+ if (route?.mergeBack)
322
+ notes.push(
323
+ `git-flow: ${route.kind}/* lands on ${route.target} (workit pr create targets it), then ${route.target} merges back into ${route.mergeBack}`,
324
+ );
296
325
  const remotes = remoteNames(cwd);
297
326
  const remote = remotes.includes("origin") ? "origin" : pushRemoteName(cwd);
298
327
  const localTip = resolveRef(cwd, `refs/heads/${base}`);
@@ -351,6 +380,7 @@ export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOu
351
380
  carried: dirt !== "clean",
352
381
  notes,
353
382
  releaseTrack,
383
+ flow: route,
354
384
  });
355
385
  }
356
386
 
@@ -691,8 +721,10 @@ export function pushPreflight(
691
721
  if (isProtectedTarget(cwd, branch))
692
722
  return failure(
693
723
  "blocked",
694
- `protected_branch: ${branch} is protected by the workspace branch policy; workit never pushes to it`,
695
- "workit git branch <feature/name> # push a feature branch and open a PR",
724
+ `protected_branch: ${branch} is protected by the workspace branch policy; workit git push never pushes to it`,
725
+ releaseBranch(cwd, branch)
726
+ ? `workit git push --release ${branch} # finishing a release or hotfix; needs the release grant`
727
+ : "workit git branch <feature/name> # push a feature branch and open a PR",
696
728
  );
697
729
  const sha = resolveRef(cwd, `refs/heads/${branch}`);
698
730
  if (!sha) return failure("not_found", `branch ${branch} has no commit to push`);
@@ -992,6 +1024,229 @@ export function executePush(
992
1024
  });
993
1025
  }
994
1026
 
1027
+ // ---------------------------------------------------------------------------
1028
+ // release push: finishing a git-flow release or hotfix
1029
+
1030
+ export type ReleaseRef = {
1031
+ kind: "branch" | "tag";
1032
+ /** The short name (`main`, `v4.1.0`). */
1033
+ name: string;
1034
+ /** The full ref (`refs/heads/main`, `refs/tags/v4.1.0`). */
1035
+ ref: string;
1036
+ /** The commit it points at (a tag peeled). */
1037
+ sha: string;
1038
+ };
1039
+
1040
+ export type ReleasePushPlan = Omit<PushPlan, "branch" | "sha"> & {
1041
+ refs: ReleaseRef[];
1042
+ /** The branch a pushed tag's commit must already be on (or a branch in this push). */
1043
+ production: string | null;
1044
+ };
1045
+
1046
+ /** A release tag: `v` and a digit (`v4.1.0`, `v2`). */
1047
+ const RELEASE_TAG = /^v\d/u;
1048
+
1049
+ /**
1050
+ * What `workit git push --release` may push: the git-flow production and
1051
+ * develop branches and `v*` tags, nothing else, each by its local ref. Everything else stays on the feature-branch
1052
+ * path (`workit git push`, `workit pr create`).
1053
+ */
1054
+ export function releasePushPreflight(
1055
+ cwd: string,
1056
+ names: readonly string[],
1057
+ ): ForgeResult<ReleasePushPlan> {
1058
+ const progress = operationInProgress(cwd);
1059
+ if (progress) return inProgressFailure(progress.operation, "push");
1060
+ const wanted = [...new Set(names)];
1061
+ if (!wanted.length) return failure("invalid_input", "name the release refs to push");
1062
+ const { preset, branches, production } = releaseBranchesOf(cwd);
1063
+ const refs: ReleaseRef[] = [];
1064
+ for (const raw of wanted) {
1065
+ if (raw.startsWith("-") || raw.includes(":"))
1066
+ return failure("invalid_input", `${raw} is not a ref name (refspecs are not accepted)`);
1067
+ const tagName = raw.replace(/^refs\/tags\//u, "");
1068
+ const branchName = raw.replace(/^refs\/heads\//u, "");
1069
+ const tag = raw.startsWith("refs/heads/") ? null : resolveRef(cwd, `refs/tags/${tagName}`);
1070
+ const branch = raw.startsWith("refs/tags/")
1071
+ ? null
1072
+ : resolveRef(cwd, `refs/heads/${branchName}`);
1073
+ if (tag && branch)
1074
+ return failure(
1075
+ "invalid_input",
1076
+ `${raw} names both a branch and a tag`,
1077
+ `workit git push --release refs/heads/${raw} # or refs/tags/${raw}`,
1078
+ );
1079
+ if (tag) {
1080
+ if (!RELEASE_TAG.test(tagName))
1081
+ return failure(
1082
+ "blocked",
1083
+ `release_push_refused: tag ${tagName} is not a release tag (v<version>)`,
1084
+ "git tag -a v<version> -m <message> # then: workit git push --release v<version>",
1085
+ );
1086
+ refs.push({ kind: "tag", name: tagName, ref: `refs/tags/${tagName}`, sha: tag });
1087
+ continue;
1088
+ }
1089
+ if (!branch) return failure("not_found", `${raw} is not a local branch or tag`);
1090
+ if (!branches.some((name) => name.toLowerCase() === branchName.toLowerCase()))
1091
+ return failure(
1092
+ "blocked",
1093
+ branches.length
1094
+ ? `release_push_refused: ${branchName} is not a git-flow release branch (${branches.join(", ")})`
1095
+ : `release_push_refused: the ${preset} policy lands ${branchName} only through a reviewed PR; workit git push --release pushes v<version> tags there, not branches`,
1096
+ branches.length
1097
+ ? "workit git push # a feature, bugfix, release or hotfix branch, then: workit pr create"
1098
+ : `workit pr create --base ${branchName} # then tag the merged commit and: workit git push --release v<version>`,
1099
+ );
1100
+ refs.push({ kind: "branch", name: branchName, ref: `refs/heads/${branchName}`, sha: branch });
1101
+ }
1102
+ const remote = pushRemoteName(cwd, refs.find((ref) => ref.kind === "branch")?.name ?? null);
1103
+ if (!remote)
1104
+ return failure(
1105
+ "not_found",
1106
+ "no push remote is configured for this repository",
1107
+ "git remote add origin <url>",
1108
+ );
1109
+ const raw = pushUrl(cwd, remote);
1110
+ if (!raw)
1111
+ return failure(
1112
+ "blocked",
1113
+ `remote "${remote}" has no single push URL`,
1114
+ `git remote get-url --push --all ${remote} # keep exactly one push URL`,
1115
+ );
1116
+ const local = parseRemoteUrl(raw)?.protocol === "file";
1117
+ if (!local && !pushTargetIsStable(cwd, raw))
1118
+ return failure(
1119
+ "blocked",
1120
+ `push_target_rewritten: the push URL of "${remote}" is rewritten by a url.*.insteadOf rule, so the destination is not the one checked`,
1121
+ "git config --get-regexp '^url\\.' # remove the rewrite for this remote",
1122
+ );
1123
+ return success({ refs, remote, url: redactRemote(raw), rawUrl: raw, local, production });
1124
+ }
1125
+
1126
+ export type ReleasePushOutcome = {
1127
+ remote: string;
1128
+ url: string;
1129
+ refs: Array<ReleaseRef & { previous: string | null; pushed: boolean }>;
1130
+ recorded: { id: string } | { error: string };
1131
+ };
1132
+
1133
+ /**
1134
+ * Push the planned release refs in one atomic push: a branch only fast
1135
+ * forward, a tag only when the remote lacks it (a release tag never moves),
1136
+ * never forced. Success only when every remote tip observed afterwards is the
1137
+ * local one; recorded as one `release.pushed` row.
1138
+ */
1139
+ export function executeReleasePush(
1140
+ cwd: string,
1141
+ plan: ReleasePushPlan,
1142
+ input: {
1143
+ actor: LedgerActor;
1144
+ grant: string;
1145
+ timeoutMs?: number;
1146
+ identity?: Record<string, unknown>;
1147
+ },
1148
+ ): ForgeResult<ReleasePushOutcome> {
1149
+ const previous = new Map<string, string | null>();
1150
+ for (const ref of plan.refs) {
1151
+ const before = remoteRefTip(cwd, plan.rawUrl, ref.ref);
1152
+ if (!before.ok) return failure(before.code, before.error);
1153
+ previous.set(ref.ref, before.sha);
1154
+ if (before.sha === null || before.sha === ref.sha) continue;
1155
+ if (ref.kind === "tag")
1156
+ return failure(
1157
+ "blocked",
1158
+ `tag_exists: ${plan.remote} already has ${ref.name} at ${before.sha.slice(0, 12)}, not ${ref.sha.slice(0, 12)}; a release tag never moves`,
1159
+ `git tag -d ${ref.name} # tag the next version instead`,
1160
+ );
1161
+ if (!git(cwd, ["merge-base", "--is-ancestor", before.sha, ref.sha]).ok)
1162
+ return failure(
1163
+ "failed",
1164
+ `non_fast_forward: ${plan.remote}/${ref.name} (${before.sha.slice(0, 12)}) is not part of local ${ref.name}; a release push never rewrites it`,
1165
+ `git fetch ${plan.remote} ${ref.name} && git switch ${ref.name} && git merge --ff-only ${plan.remote}/${ref.name} # then: workit git push --release ${ref.name}`,
1166
+ );
1167
+ }
1168
+ // A release tag marks a commit the production branch already has (or a
1169
+ // branch this push moves): never an unmerged feature commit.
1170
+ const tags = plan.refs.filter((ref) => ref.kind === "tag");
1171
+ if (tags.length) {
1172
+ let productionTip: string | null = null;
1173
+ if (plan.production) {
1174
+ const tip = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.production}`);
1175
+ if (!tip.ok) return failure(tip.code, tip.error);
1176
+ productionTip = tip.sha;
1177
+ }
1178
+ const contains = (tip: string, sha: string) =>
1179
+ git(cwd, ["merge-base", "--is-ancestor", sha, tip]).ok;
1180
+ for (const tag of tags) {
1181
+ const released =
1182
+ (productionTip !== null && contains(productionTip, tag.sha)) ||
1183
+ plan.refs.some((ref) => ref.kind === "branch" && contains(ref.sha, tag.sha));
1184
+ if (!released)
1185
+ return failure(
1186
+ "blocked",
1187
+ `tag_unreleased: ${tag.name} (${tag.sha.slice(0, 12)}) is not on ${plan.remote}/${plan.production ?? "the production branch"}${plan.refs.some((ref) => ref.kind === "branch") ? " or a branch in this push" : ""}; a release tag marks a commit that already landed`,
1188
+ `land it through a PR first (workit pr create), git fetch ${plan.remote}, then tag the merged commit`,
1189
+ );
1190
+ }
1191
+ }
1192
+ const pending = plan.refs.filter((ref) => previous.get(ref.ref) !== ref.sha);
1193
+ if (pending.length) {
1194
+ const run = gitNetwork(
1195
+ cwd,
1196
+ [
1197
+ "push",
1198
+ "--porcelain",
1199
+ "--atomic",
1200
+ "--no-follow-tags",
1201
+ "--recurse-submodules=no",
1202
+ plan.remote,
1203
+ ...pending.map((ref) =>
1204
+ ref.kind === "tag" ? `${ref.ref}:${ref.ref}` : `${ref.sha}:${ref.ref}`,
1205
+ ),
1206
+ ],
1207
+ input.timeoutMs ?? PUSH_TIMEOUT_MS,
1208
+ );
1209
+ if (!run.ok)
1210
+ return run.timedOut
1211
+ ? failure("unavailable", `git push to ${plan.url} timed out`)
1212
+ : failure("failed", `git push to ${plan.url} failed:\n${gitOutput(run)}`);
1213
+ }
1214
+ for (const ref of plan.refs) {
1215
+ const after = remoteRefTip(cwd, plan.rawUrl, ref.ref);
1216
+ if (!after.ok) return failure(after.code, `push sent but not verified: ${after.error}`);
1217
+ if (after.sha !== ref.sha)
1218
+ return failure(
1219
+ "failed",
1220
+ `push_unverified: ${plan.remote} ${ref.ref} is at ${after.sha?.slice(0, 12) ?? "(absent)"}, not ${ref.sha.slice(0, 12)}`,
1221
+ );
1222
+ }
1223
+ const refs = plan.refs.map((ref) => ({
1224
+ ...ref,
1225
+ previous: previous.get(ref.ref) ?? null,
1226
+ pushed: pending.includes(ref),
1227
+ }));
1228
+ const row = appendObserved(cwd, {
1229
+ type: "release.pushed",
1230
+ actor: input.actor,
1231
+ remote: plan.remote,
1232
+ url: plan.url,
1233
+ refs: refs.map(({ ref, sha, previous: before, pushed }) => ({
1234
+ ref,
1235
+ sha,
1236
+ previous: before,
1237
+ pushed,
1238
+ })),
1239
+ grant: input.grant,
1240
+ ...(input.identity ? { identity: input.identity } : {}),
1241
+ });
1242
+ return success({
1243
+ remote: plan.remote,
1244
+ url: plan.url,
1245
+ refs,
1246
+ recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
1247
+ });
1248
+ }
1249
+
995
1250
  /**
996
1251
  * Delete a remote branch only while it still points at `expectedTip`
997
1252
  * (`--force-with-lease=refs/heads/<b>:<tip> --delete`). `lease` is false when
@@ -6,6 +6,8 @@ import {
6
6
  turnContextText,
7
7
  } from "./context";
8
8
  import type { HostDescriptor, Support } from "./descriptor";
9
+ import { branchGuidance } from "../core/branch";
10
+ import { recordIssuedSession } from "../host-session";
9
11
  import { shellPolicy } from "./policy";
10
12
  import {
11
13
  callKey,
@@ -90,16 +92,20 @@ const subagentStartText = (
90
92
  const isolated =
91
93
  descriptor.subagents.worktreeIsolation === "native" &&
92
94
  (role === "implementer" || CLAUDE_WORKTREE.test(input.cwd));
95
+ const guide = role === "implementer" ? branchGuidance(input.cwd) : null;
93
96
  if (role === "implementer" && isolated)
94
- return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${ISOLATED_GIT} Use a policy-compliant branch (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
97
+ return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${ISOLATED_GIT} Plain git is expected here, and the branch name is still policy-checked: a raw \`git switch -c\`, \`git checkout -b\`, \`git branch\` or \`git worktree add -b\` onto a protected or non-compliant name is denied, so use a policy-compliant branch.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
95
98
  if (isolated)
96
99
  return `${ISOLATED_GIT}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
97
100
  if (role === "implementer")
98
101
  // No host isolation: the lead makes the worktree, and workit git works there.
99
- return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree (the lead makes one with \`workit fanout worktree create <slice>\`), never in the lead's checkout, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
102
+ return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree (the lead makes one with \`workit fanout worktree create <slice>\`), never in the lead's checkout, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
100
103
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
101
104
  if (role !== "judge" || !event.agentId) return readOnly;
102
105
  const own = subagentSession(session, event.agentId);
106
+ // `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it.
107
+ // (An isolated judge's text is rebuilt with cwd "": the hook's own cwd then.)
108
+ if (session) recordIssuedSession(input.cwd || process.cwd(), session, own);
103
109
  return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> --session ${own} ...\` so the ledger tells it apart from the author.`;
104
110
  };
105
111
 
@@ -135,7 +141,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
135
141
  const skillRead = skillReadIn(event.command, event.dialect);
136
142
  if (skillRead) recordSkillLoad(input, skillRead, "read");
137
143
  const canDeny = usable(descriptor.shellPolicy.deny);
138
- const policy = canDeny ? shellPolicy(input.cwd, event.command) : NONE;
144
+ const policy = canDeny ? shellPolicy(input.cwd, event.command, event.dialect) : NONE;
139
145
  if (policy.kind !== "none") return policy;
140
146
  // Without a post-tool event, the previous raw commit is recorded now.
141
147
  const postTool = usable(descriptor.events["shell.post"].support);
@@ -362,5 +362,5 @@ export const codexAdapter: HostAdapter = {
362
362
  },
363
363
  render,
364
364
  addendum: (input) =>
365
- `<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. workit acts as this thread (CODEX_THREAD_ID${input.session.id ? `=${input.session.id}` : ""}) unless WORKIT_SESSION_ID is set. A verifier or reviewer records workit ledger verdict under its own session, never the author's: a workit-verifier or workit-reviewer agent uses the --session its SubagentStart context names; any other uses one the lead assigns (WORKIT_HOST=codex_cli WORKIT_SESSION_ID=<lead>-v<n>). Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
365
+ `<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. workit acts as this thread (CODEX_THREAD_ID${input.session.id ? `=${input.session.id}` : ""}) unless WORKIT_SESSION_ID is set. A verifier or reviewer records workit ledger verdict under its own session, never the author's: a workit-verifier or workit-reviewer agent uses the --session its SubagentStart context names; any other passes --as verifier (an id derived from its own thread). A hand-minted id such as <lead>-v1 is refused as forged_session. Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
366
366
  };
@@ -1,13 +1,19 @@
1
1
  import { shellBranchPolicyViolation } from "../core/route-intent";
2
2
  import type { HookDecision } from "./protocol";
3
+ import type { ShellDialect } from "./shell-words";
3
4
 
4
5
  /**
5
- * Branch policy for one shell command: only direct literal branch creation
6
- * onto a noncompliant name is denied. Every other command keeps the host's own
7
- * permission decision. The reason carries the policy's correction.
6
+ * Branch policy for one shell command: a raw branch creation (`git switch -c`,
7
+ * `checkout -b`, `branch`, `worktree add -b`, in any chain, directory or
8
+ * wrapper) onto a noncompliant name is denied. Every other command keeps the
9
+ * host's own permission decision. The reason carries the policy's correction.
8
10
  */
9
- export const shellPolicy = (cwd: string, command: string): HookDecision => {
10
- const policy = shellBranchPolicyViolation(cwd, command);
11
+ export const shellPolicy = (
12
+ cwd: string,
13
+ command: string,
14
+ dialect: ShellDialect = "posix",
15
+ ): HookDecision => {
16
+ const policy = shellBranchPolicyViolation(cwd, command, dialect);
11
17
  return policy && !policy.ok
12
18
  ? { kind: "deny", reason: `branch_policy_denied: ${policy.error}`, unblock: null }
13
19
  : { kind: "none" };