@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.
- package/package.json +1 -1
- package/skills/workit-fanout/references/brief.md +1 -1
- package/skills/workit-fanout/references/orchestration.md +16 -6
- package/skills/workit-implement/SKILL.md +3 -2
- package/skills/workit-review/SKILL.md +4 -2
- package/src/autonomy.ts +6 -2
- package/src/core/branch-policy.ts +8 -1
- package/src/core/branch.ts +270 -3
- package/src/core/methods.ts +2 -1
- package/src/core/route-intent.ts +155 -27
- package/src/forge/github.ts +13 -2
- package/src/forge/gitlab.ts +9 -1
- package/src/forge/pr-ops.ts +26 -8
- package/src/forge/types.ts +5 -0
- package/src/git/ops.ts +259 -4
- package/src/hooks/handle.ts +9 -3
- package/src/hooks/hosts/codex.ts +1 -1
- package/src/hooks/policy.ts +11 -5
- package/src/hooks/raw-git.ts +138 -10
- package/src/host-session.ts +189 -0
- package/src/ledger.ts +26 -4
package/src/forge/github.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/forge/gitlab.ts
CHANGED
|
@@ -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
|
|
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
|
? {
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -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)
|
|
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 = (
|
|
378
|
+
const mergeBackFor = (
|
|
379
|
+
cwd: string,
|
|
380
|
+
base: string,
|
|
381
|
+
head: string,
|
|
382
|
+
): { track: string | null; branches: string[] } => {
|
|
372
383
|
try {
|
|
373
|
-
const
|
|
374
|
-
|
|
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,
|
package/src/forge/types.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
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
|
package/src/hooks/handle.ts
CHANGED
|
@@ -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}
|
|
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>\` (
|
|
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);
|
package/src/hooks/hosts/codex.ts
CHANGED
|
@@ -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
|
|
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
|
};
|
package/src/hooks/policy.ts
CHANGED
|
@@ -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:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 = (
|
|
10
|
-
|
|
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" };
|