@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/package.json
CHANGED
|
@@ -25,7 +25,7 @@ REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
|
|
|
25
25
|
each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
|
|
26
26
|
anything out of scope as a follow-up, not a diff.
|
|
27
27
|
STANDING: <the standing orders, verbatim: `workit ledger standing add` records each one>
|
|
28
|
-
export WORKIT_SESSION_ID=<lead>-w<n> (set by the lead; a verifier brief
|
|
28
|
+
export WORKIT_SESSION_ID=<lead>-w<n> (set by the lead for a worker's commits; a verifier brief sets none: the host derives it)
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
## Worked example
|
|
@@ -72,12 +72,22 @@ Which session id a verdict carries, per host:
|
|
|
72
72
|
|
|
73
73
|
- Claude Code: subagents inherit your `WORKIT_SESSION_ID`, so the
|
|
74
74
|
SubagentStart hook names each `verifier` and `reviewer` its own session
|
|
75
|
-
(`<lead>:<agent id>`), passed as `--session
|
|
76
|
-
|
|
77
|
-
exports for its workit commands.
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
(`<lead>:<agent id>`), passed exactly as `--session`: the hook records the
|
|
76
|
+
ids it issues, and only those are accepted. An `implementer` uses the
|
|
77
|
+
session its brief exports for its workit commands.
|
|
78
|
+
- Codex: the `workit-verifier` and `workit-reviewer` agents get the same
|
|
79
|
+
issued `--session` from SubagentStart; any other verifier passes
|
|
80
|
+
`--as verifier`.
|
|
81
|
+
- OpenCode: a child session's shell already has
|
|
82
|
+
`WORKIT_SESSION_ID=<lead>:<child>`; Pi: a supervised worker's shell has
|
|
83
|
+
`<lead>:<worker session>`. Set nothing for a verifier there.
|
|
84
|
+
- Cursor (no session id in the shell): the brief sets the verifier's
|
|
85
|
+
`WORKIT_SESSION_ID=<lead>:verifier-<random hex>`, a fresh suffix per
|
|
86
|
+
verifier. Cursor cannot check it: it is declared, not derived.
|
|
87
|
+
- A worker's commits use `export WORKIT_SESSION_ID=<lead>-w-<slice>` from
|
|
88
|
+
`workit fanout brief`. A verdict under an id you made up (`<lead>-v1`,
|
|
89
|
+
`<lead>:v1`) is refused as `forged_session` on Claude Code, Codex,
|
|
90
|
+
OpenCode and Pi.
|
|
81
91
|
|
|
82
92
|
A batch verifier takes several slices on one surface in one session: for
|
|
83
93
|
each branch it checks out the head, runs that slice's VERIFY, and records its
|
|
@@ -33,8 +33,9 @@ description: Build a requested change in small verified steps - follow local pat
|
|
|
33
33
|
`workit ledger verdict tests-verified --self --how "<what you ran>"`; it
|
|
34
34
|
reads self-reviewed, never verified. High risk, a workspace with
|
|
35
35
|
`verification: independent` (`workit grant show`) or a verified merge needs
|
|
36
|
-
a fresh verifier with its own session (
|
|
37
|
-
|
|
36
|
+
a fresh verifier with its own host-derived session (Claude Code and Codex:
|
|
37
|
+
the `verifier` agent; OpenCode: a child session; Pi: a supervised worker),
|
|
38
|
+
never a hand-minted id. Before saying done, reconcile
|
|
38
39
|
every named deliverable against the target checkout and observe it (for a push:
|
|
39
40
|
`workit verify-delivery push`).
|
|
40
41
|
|
|
@@ -7,8 +7,10 @@ description: Independent review of a diff, branch or PR - intent fidelity and st
|
|
|
7
7
|
|
|
8
8
|
Review the candidate, never the author's summary of it. A session that wrote a
|
|
9
9
|
commit on the branch cannot record a passing verdict: if that is you, hand the
|
|
10
|
-
review to a fresh agent with its own session
|
|
11
|
-
|
|
10
|
+
review to a fresh agent with its own host-derived session: Claude Code and
|
|
11
|
+
Codex, the `reviewer` or `verifier` agent (`--session` from SubagentStart);
|
|
12
|
+
OpenCode, a child session; Pi, a supervised worker. Never hand-mint one such
|
|
13
|
+
as `<lead>-v1`: it is refused as `forged_session`.
|
|
12
14
|
An author's `--self` verdict reads self-reviewed and never counts as
|
|
13
15
|
independent; `type-check-only` never proves a behavior change.
|
|
14
16
|
|
package/src/autonomy.ts
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
// agent from raising its own ceiling by accident, not an adversarial one —
|
|
14
14
|
// a process that drives a pseudo-terminal or edits the file directly can
|
|
15
15
|
// still defeat it; the host's permission prompt is the hard boundary.
|
|
16
|
-
// - `release`
|
|
16
|
+
// - `release` gates `workit git push --release`: pushing the git-flow
|
|
17
|
+
// production and develop branches (fast-forward only) and `v*` tags whose
|
|
18
|
+
// commit already landed (a tag never moves). Outside git-flow it pushes
|
|
19
|
+
// tags only. It needs `push` too.
|
|
17
20
|
// - Defaults (D4) when a kind is absent: push, pr and rerun are allowed;
|
|
18
21
|
// merge and release need an explicit grant. The default ceiling is a stack
|
|
19
22
|
// opened, CI green and independently verified: "verified, ready".
|
|
@@ -25,7 +28,8 @@
|
|
|
25
28
|
// - Explicitly configured grants require an account (design §2.0 Identity):
|
|
26
29
|
// a workspace with grants but no `vcs.account` cannot push, open or merge
|
|
27
30
|
// through a forge, so a personal grant never rides a work credential.
|
|
28
|
-
// - Protected-branch pushes stay denied by the git verbs
|
|
31
|
+
// - Protected-branch pushes stay denied by the git verbs (except the git-flow
|
|
32
|
+
// release push above), and the host
|
|
29
33
|
// permission prompt applies in addition: a host deny always wins.
|
|
30
34
|
import { copyFileSync, existsSync } from "node:fs";
|
|
31
35
|
import os from "node:os";
|
|
@@ -13,7 +13,7 @@ const PREFIXES = {
|
|
|
13
13
|
* Local branch names plus origin's remote-tracking ones (`origin/develop`
|
|
14
14
|
* counts as `develop`; a fork or mirror remote's branches do not).
|
|
15
15
|
*/
|
|
16
|
-
const branchNames = (workspaceRoot: string): Set<string> => {
|
|
16
|
+
export const branchNames = (workspaceRoot: string): Set<string> => {
|
|
17
17
|
const r = spawnSync(
|
|
18
18
|
"git",
|
|
19
19
|
["for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes/origin"],
|
|
@@ -32,6 +32,13 @@ const branchNames = (workspaceRoot: string): Set<string> => {
|
|
|
32
32
|
);
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The git-flow production branch: main when it exists (locally or on
|
|
37
|
+
* origin), else master, else main.
|
|
38
|
+
*/
|
|
39
|
+
export const productionBranchOf = (names: ReadonlySet<string>): string =>
|
|
40
|
+
names.has("main") ? "main" : names.has("master") ? "master" : "main";
|
|
41
|
+
|
|
35
42
|
export const detectBranchPolicy = (workspaceRoot: string) => {
|
|
36
43
|
const names = branchNames(workspaceRoot);
|
|
37
44
|
const develop = names.has("develop");
|
package/src/core/branch.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { repoBranchPreset } from "./branch-policy";
|
|
5
|
+
import { branchNames, productionBranchOf, repoBranchPreset } from "./branch-policy";
|
|
6
6
|
import { matchCommitFlavor } from "./commit-flavors";
|
|
7
7
|
import { gitContext } from "./git";
|
|
8
8
|
import {
|
|
@@ -142,14 +142,105 @@ export type CommitMessagePolicyCheck =
|
|
|
142
142
|
error: string;
|
|
143
143
|
};
|
|
144
144
|
|
|
145
|
+
/** Prefixes agents reach for, mapped to the git-flow kind that means the same. */
|
|
146
|
+
const KIND_SYNONYMS: Readonly<Record<string, string>> = {
|
|
147
|
+
fix: "bugfix",
|
|
148
|
+
fixes: "bugfix",
|
|
149
|
+
bug: "bugfix",
|
|
150
|
+
bugs: "bugfix",
|
|
151
|
+
bugfixes: "bugfix",
|
|
152
|
+
feat: "feature",
|
|
153
|
+
features: "feature",
|
|
154
|
+
chore: "feature",
|
|
155
|
+
docs: "feature",
|
|
156
|
+
doc: "feature",
|
|
157
|
+
refactor: "feature",
|
|
158
|
+
test: "feature",
|
|
159
|
+
tests: "feature",
|
|
160
|
+
ci: "feature",
|
|
161
|
+
build: "feature",
|
|
162
|
+
perf: "feature",
|
|
163
|
+
style: "feature",
|
|
164
|
+
deps: "feature",
|
|
165
|
+
task: "feature",
|
|
166
|
+
enhancement: "feature",
|
|
167
|
+
hot: "hotfix",
|
|
168
|
+
hotfixes: "hotfix",
|
|
169
|
+
releases: "release",
|
|
170
|
+
rel: "release",
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** The `<kind>/` prefixes an allowed-pattern list names (`feature/*` -> feature). */
|
|
174
|
+
export const allowedKinds = (allowed: readonly RegExp[]): string[] => [
|
|
175
|
+
...new Set(
|
|
176
|
+
allowed
|
|
177
|
+
// RegExp#source escapes the slash: `^feature\/.*$`.
|
|
178
|
+
.map((pattern) => /^\^([\w.-]+)\\?\/\.\*\$$/u.exec(pattern.source)?.[1]?.toLowerCase())
|
|
179
|
+
.filter((kind): kind is string => Boolean(kind)),
|
|
180
|
+
),
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const editDistance = (a: string, b: string): number => {
|
|
184
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
185
|
+
for (let i = 1; i <= a.length; i++) {
|
|
186
|
+
let previous = row[0];
|
|
187
|
+
row[0] = i;
|
|
188
|
+
for (let j = 1; j <= b.length; j++) {
|
|
189
|
+
const saved = row[j];
|
|
190
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
191
|
+
previous = saved;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return row[b.length];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The allowed name closest to `name`: its prefix mapped through the usual
|
|
199
|
+
* synonyms (fix/ -> bugfix/, chore/ -> feature/), else the kind within two
|
|
200
|
+
* edits, else feature/ (or the first kind); the rest of the name is kept.
|
|
201
|
+
* Null when the policy names no kinds.
|
|
202
|
+
*/
|
|
203
|
+
export const suggestBranchName = (name: string, kinds: readonly string[]): string | null => {
|
|
204
|
+
if (!kinds.length) return null;
|
|
205
|
+
const slash = name.indexOf("/");
|
|
206
|
+
const prefix = slash > 0 ? name.slice(0, slash).toLowerCase() : "";
|
|
207
|
+
const rest = (slash > 0 ? name.slice(slash + 1) : name) || "name";
|
|
208
|
+
const synonym = KIND_SYNONYMS[prefix];
|
|
209
|
+
const near = kinds
|
|
210
|
+
.map((kind) => ({ kind, distance: editDistance(prefix, kind) }))
|
|
211
|
+
.filter((entry) => prefix && entry.distance <= 2)
|
|
212
|
+
.toSorted((a, b) => a.distance - b.distance)[0]?.kind;
|
|
213
|
+
const kind =
|
|
214
|
+
(synonym && kinds.includes(synonym) ? synonym : null) ??
|
|
215
|
+
near ??
|
|
216
|
+
(kinds.includes("feature") ? "feature" : kinds[0]);
|
|
217
|
+
return `${kind}/${rest}`;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const kindList = (kinds: readonly string[]) => kinds.map((kind) => `${kind}/`).join(", ");
|
|
221
|
+
|
|
222
|
+
/** ` (fix/ is bugfix/, chore/ is feature/)`, naming only the kinds this policy allows. */
|
|
223
|
+
const aliasNote = (kinds: readonly string[]): string => {
|
|
224
|
+
const notes = [
|
|
225
|
+
["fix", "bugfix"],
|
|
226
|
+
["chore", "feature"],
|
|
227
|
+
]
|
|
228
|
+
.filter(([, kind]) => kinds.includes(kind))
|
|
229
|
+
.map(([alias, kind]) => `${alias}/ is ${kind}/`);
|
|
230
|
+
return notes.length ? ` (${notes.join(", ")})` : "";
|
|
231
|
+
};
|
|
232
|
+
|
|
145
233
|
export const validateBranchNamePolicy = (
|
|
146
234
|
name: string,
|
|
147
235
|
policy: BranchNamePolicy,
|
|
148
236
|
source: string,
|
|
149
237
|
): BranchNamePolicyCheck => {
|
|
150
238
|
const allowed = policy.allowed.map((pattern) => `/${pattern.source}/${pattern.flags}`);
|
|
239
|
+
const kinds = allowedKinds(policy.allowed);
|
|
151
240
|
if (policy.protected.has(name.toLowerCase())) {
|
|
152
|
-
const correction =
|
|
241
|
+
const correction = kinds.length
|
|
242
|
+
? `choose a non-protected branch of an allowed kind (${kindList(kinds)}), e.g. ${kinds[0]}/<slug>: workit git branch --kind ${kinds[0]} --slug <slug>`
|
|
243
|
+
: `choose a non-protected branch matching one of: ${allowed.join(", ")}`;
|
|
153
244
|
return {
|
|
154
245
|
ok: false,
|
|
155
246
|
rule: "protected_ref",
|
|
@@ -160,7 +251,11 @@ export const validateBranchNamePolicy = (
|
|
|
160
251
|
};
|
|
161
252
|
}
|
|
162
253
|
if (!policy.allowed.some((pattern) => pattern.test(name))) {
|
|
163
|
-
const
|
|
254
|
+
const suggestion = suggestBranchName(name, kinds);
|
|
255
|
+
const correction =
|
|
256
|
+
suggestion && policy.allowed.some((pattern) => pattern.test(suggestion))
|
|
257
|
+
? `use ${JSON.stringify(suggestion)}; the allowed kinds are ${kindList(kinds)} (workit git branch ${suggestion})`
|
|
258
|
+
: `choose a branch matching one of: ${allowed.join(", ")}`;
|
|
164
259
|
return {
|
|
165
260
|
ok: false,
|
|
166
261
|
rule: "allowed_pattern",
|
|
@@ -272,6 +367,178 @@ export const resolveBranchPolicyFor = (workspaceRoot: string, profileName?: stri
|
|
|
272
367
|
export const resolveCommitPolicyFor = (workspaceRoot: string, profileName?: string) =>
|
|
273
368
|
effectiveWorkspacePolicy(workspaceRoot, "commit", profileName).commitPolicy;
|
|
274
369
|
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// git-flow routes: where each branch kind starts and lands
|
|
372
|
+
|
|
373
|
+
export const BRANCH_KINDS = ["feature", "bugfix", "release", "hotfix"] as const;
|
|
374
|
+
export type BranchKind = (typeof BRANCH_KINDS)[number];
|
|
375
|
+
|
|
376
|
+
/** The git-flow kind a branch name's prefix names (`release/4.1.0` -> release), or null. */
|
|
377
|
+
export const branchKindOf = (name: string | null | undefined): BranchKind | null => {
|
|
378
|
+
const prefix = name?.split("/", 1)[0]?.toLowerCase() ?? "";
|
|
379
|
+
return name?.includes("/") && (BRANCH_KINDS as readonly string[]).includes(prefix)
|
|
380
|
+
? (prefix as BranchKind)
|
|
381
|
+
: null;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
export type GitFlow = {
|
|
385
|
+
/** The integration branch: the workspace's `developBranch`, else develop. */
|
|
386
|
+
develop: string;
|
|
387
|
+
/** The production branch: main, else master. */
|
|
388
|
+
production: string;
|
|
389
|
+
/** The develop branch exists locally or on origin. */
|
|
390
|
+
developExists: boolean;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export type FlowRoute = {
|
|
394
|
+
kind: BranchKind;
|
|
395
|
+
/** Where a new branch of this kind starts. */
|
|
396
|
+
base: string;
|
|
397
|
+
/** Where its PR lands. */
|
|
398
|
+
target: string;
|
|
399
|
+
/** The branch that must receive the landed work afterwards (release/hotfix -> develop). */
|
|
400
|
+
mergeBack: string | null;
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The repository's git-flow branches when the resolved branch policy is the
|
|
405
|
+
* gitflow preset; null for github-flow, trunk-based or custom (they route
|
|
406
|
+
* every kind to the default target). `integration` is the resolved default
|
|
407
|
+
* target: used as develop when the workspace names no developBranch and it
|
|
408
|
+
* is not the production branch.
|
|
409
|
+
*/
|
|
410
|
+
export const gitFlowOf = (root: string, integration?: string | null): GitFlow | null => {
|
|
411
|
+
try {
|
|
412
|
+
if (resolveBranchPolicyFor(root).preset !== "gitflow") return null;
|
|
413
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
414
|
+
root,
|
|
415
|
+
"branch",
|
|
416
|
+
process.env.WORKFLOW_PROFILE?.trim() || undefined,
|
|
417
|
+
).policy as WorkspaceBranchPolicy | undefined;
|
|
418
|
+
const names = branchNames(root);
|
|
419
|
+
const production = productionBranchOf(names);
|
|
420
|
+
const develop =
|
|
421
|
+
selected?.developBranch?.trim() ||
|
|
422
|
+
(integration && integration.toLowerCase() !== production ? integration : "develop");
|
|
423
|
+
return { develop, production, developExists: names.has(develop) };
|
|
424
|
+
} catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/** git-flow: feature, bugfix and release start from develop; hotfix from production. */
|
|
430
|
+
export const flowRoute = (flow: GitFlow, kind: BranchKind): FlowRoute =>
|
|
431
|
+
kind === "hotfix"
|
|
432
|
+
? { kind, base: flow.production, target: flow.production, mergeBack: flow.develop }
|
|
433
|
+
: kind === "release"
|
|
434
|
+
? { kind, base: flow.develop, target: flow.production, mergeBack: flow.develop }
|
|
435
|
+
: { kind, base: flow.develop, target: flow.develop, mergeBack: null };
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* What `workit git push --release` may push, per preset: git-flow its
|
|
439
|
+
* production and develop branches; github-flow, trunk-based and custom no
|
|
440
|
+
* branch (only tags). `production` is the branch a release tag's commit must
|
|
441
|
+
* already be on (git-flow production, else the default target). Empty when
|
|
442
|
+
* the policy cannot be read.
|
|
443
|
+
*/
|
|
444
|
+
export const releaseBranchesOf = (
|
|
445
|
+
cwd: string,
|
|
446
|
+
): { preset: string; branches: string[]; production: string | null } => {
|
|
447
|
+
try {
|
|
448
|
+
const resolved = resolveBranchPolicyFor(cwd);
|
|
449
|
+
const flow = resolved.preset === "gitflow" ? gitFlowOf(cwd) : null;
|
|
450
|
+
// Outside git-flow a protected trunk only moves through a reviewed PR:
|
|
451
|
+
// --release pushes tags there, nothing else.
|
|
452
|
+
return {
|
|
453
|
+
preset: resolved.preset,
|
|
454
|
+
branches: flow ? [flow.production, flow.develop] : [],
|
|
455
|
+
production: flow ? flow.production : resolved.defaultTargetBranch,
|
|
456
|
+
};
|
|
457
|
+
} catch {
|
|
458
|
+
return { preset: "unknown", branches: [], production: null };
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/** A branch `workit git push --release` may push here. */
|
|
463
|
+
export const releaseBranch = (cwd: string, branch: string): boolean =>
|
|
464
|
+
releaseBranchesOf(cwd).branches.some((name) => name.toLowerCase() === branch.toLowerCase());
|
|
465
|
+
|
|
466
|
+
/** `origin/develop`, `refs/remotes/origin/develop`, `refs/heads/develop` -> develop. */
|
|
467
|
+
export const shortBranch = (ref: string): string =>
|
|
468
|
+
ref.replace(/^refs\/heads\//u, "").replace(/^(?:refs\/remotes\/)?origin\//u, "");
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A git-flow mismatch: an explicit base or PR target that is the other
|
|
472
|
+
* long-lived branch than the kind's route (a feature from main, a hotfix
|
|
473
|
+
* from develop). Only once develop exists, so a main-only repository on the
|
|
474
|
+
* gitflow preset keeps working. Null when it fits.
|
|
475
|
+
*/
|
|
476
|
+
export const flowMismatch = (
|
|
477
|
+
flow: GitFlow | null,
|
|
478
|
+
kind: BranchKind | null,
|
|
479
|
+
ref: string,
|
|
480
|
+
role: "base" | "target",
|
|
481
|
+
): string | null => {
|
|
482
|
+
if (!flow || !kind || !flow.developExists) return null;
|
|
483
|
+
const route = flowRoute(flow, kind);
|
|
484
|
+
const expected = role === "base" ? route.base : route.target;
|
|
485
|
+
const given = shortBranch(ref).toLowerCase();
|
|
486
|
+
const longLived = [flow.develop, flow.production].map((name) => name.toLowerCase());
|
|
487
|
+
// A release or hotfix PR into develop is the git-flow back-merge.
|
|
488
|
+
const backMerge = role === "target" && route.mergeBack?.toLowerCase() === given;
|
|
489
|
+
if (!longLived.includes(given) || given === expected.toLowerCase() || backMerge) return null;
|
|
490
|
+
return role === "base"
|
|
491
|
+
? `gitflow_base: git-flow starts ${kind}/* from ${expected}, not ${shortBranch(ref)}`
|
|
492
|
+
: `gitflow_target: git-flow lands ${kind}/* on ${expected}, not ${shortBranch(ref)}`;
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* One line for an agent about to create a branch at `cwd`: the allowed kinds
|
|
497
|
+
* and where each starts. Plain text (no `workit git` command), so it fits an
|
|
498
|
+
* agent that runs git itself. Null when the policy cannot be read.
|
|
499
|
+
*/
|
|
500
|
+
export const branchGuidance = (cwd: string): string | null => {
|
|
501
|
+
try {
|
|
502
|
+
const patterns = branchPolicySnapshotFor(cwd).namePolicy.allowed;
|
|
503
|
+
const kinds = allowedKinds(patterns);
|
|
504
|
+
const kindShaped = new Set(kinds.map((kind) => branchGlobPattern(`${kind}/*`).source));
|
|
505
|
+
const anyName = patterns.some((pattern) => pattern.source === "^.*$");
|
|
506
|
+
const others = patterns
|
|
507
|
+
.filter((pattern) => !kindShaped.has(pattern.source))
|
|
508
|
+
.map((pattern) => globOf(pattern));
|
|
509
|
+
const allowed = anyName
|
|
510
|
+
? "Any non-protected branch name is allowed here."
|
|
511
|
+
: `${kinds.length ? `Allowed branch kinds here: ${kindList(kinds)}${aliasNote(kinds)}` : "Allowed branch names here:"}${others.length ? `${kinds.length ? "; also" : ""} names matching ${others.join(", ")}` : ""}; anything else is denied.`;
|
|
512
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
513
|
+
const integration =
|
|
514
|
+
resolved.ok === false ? null : String(resolved.defaultTargetBranch ?? "") || null;
|
|
515
|
+
const flow = resolved.releaseTrack?.track ? null : gitFlowOf(cwd, integration);
|
|
516
|
+
if (flow?.developExists)
|
|
517
|
+
return `${allowed} git-flow: feature/, bugfix/ and release/ start from origin/${flow.develop}, hotfix/ from origin/${flow.production}; feature/ and bugfix/ land on ${flow.develop}, release/ and hotfix/ on ${flow.production}.`;
|
|
518
|
+
// Name only a base that exists: a preset default (custom's develop,
|
|
519
|
+
// github-flow's main) may not.
|
|
520
|
+
const names = branchNames(cwd);
|
|
521
|
+
const configured = String(resolved.baseBranch ?? integration ?? "") || null;
|
|
522
|
+
const trunk = productionBranchOf(names);
|
|
523
|
+
const base =
|
|
524
|
+
configured && names.has(configured)
|
|
525
|
+
? `Branch from origin/${configured}.`
|
|
526
|
+
: names.has(trunk)
|
|
527
|
+
? `Branch from origin/${trunk}${configured ? ` (the default target ${configured} does not exist here)` : ""}.`
|
|
528
|
+
: "No base branch exists here yet; pass the base explicitly.";
|
|
529
|
+
return `${allowed} ${base}`;
|
|
530
|
+
} catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/** An allowed pattern back as its glob (`^fix-.*$` -> fix-*). */
|
|
536
|
+
const globOf = (pattern: RegExp): string =>
|
|
537
|
+
pattern.source
|
|
538
|
+
.replace(/^\^|\$$/gu, "")
|
|
539
|
+
.replaceAll(".*", "*")
|
|
540
|
+
.replace(/\\(.)/gu, "$1");
|
|
541
|
+
|
|
275
542
|
const policy = (root: string) => resolveBranchPolicyFor(root);
|
|
276
543
|
const allowedBranch = (root: string, name: string) =>
|
|
277
544
|
policy(root).allowed.some((r) => r.test(name));
|
package/src/core/methods.ts
CHANGED
|
@@ -115,7 +115,8 @@ Autonomy contract:
|
|
|
115
115
|
satisfies a gate; an ad-hoc \`workit check -- <cmd>\` never does.
|
|
116
116
|
- Author is not verifier: your own verdict is \`--self\`, shown as
|
|
117
117
|
self-reviewed, never verified. High risk, \`verification: independent\` and
|
|
118
|
-
merge need a verifier with its own session (
|
|
118
|
+
merge need a verifier with its own host-derived session (a subagent's
|
|
119
|
+
\`<lead>:<child>\`, set by the host); never hand-mint one such as \`<lead>-v1\`.
|
|
119
120
|
- A local-commit endpoint does not imply PR readiness, and a local commit alone
|
|
120
121
|
is not evidence of a requested remote push. Before reporting delivery,
|
|
121
122
|
reconcile every requested item and observe it (\`workit verify-delivery\`).
|
package/src/core/route-intent.ts
CHANGED
|
@@ -1,37 +1,165 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { inWorkitWorkspace, rawInvocations } from "../hooks/raw-git";
|
|
5
|
+
import type { ShellDialect } from "../hooks/shell-words";
|
|
1
6
|
import { validateBranchNameFor, type BranchNamePolicyCheck } from "./branch";
|
|
2
7
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
/** A name a shell cannot settle before running (`$X`, `` `cmd` ``): never judged. */
|
|
9
|
+
const UNKNOWABLE = /[$`*?[\]{}~]/u;
|
|
10
|
+
|
|
11
|
+
const SWITCH_CREATE = new Set(["-c", "--create", "-C", "--force-create", "--orphan"]);
|
|
12
|
+
const CHECKOUT_CREATE = new Set(["-b", "-B", "--orphan"]);
|
|
13
|
+
const WORKTREE_CREATE = new Set(["-b", "-B"]);
|
|
14
|
+
/** `git branch` options that list, delete or configure: no branch is created. */
|
|
15
|
+
const BRANCH_NO_CREATE = new Set(
|
|
16
|
+
["-d", "-D", "--delete", "-l", "--list", "-a", "--all", "-r", "--remotes", "--show-current"]
|
|
17
|
+
.concat(["-v", "-vv", "--verbose", "--contains", "--no-contains", "--merged", "--no-merged"])
|
|
18
|
+
.concat(["--points-at", "--sort", "--format", "--column", "--no-column", "-u"])
|
|
19
|
+
.concat(["--set-upstream-to", "--unset-upstream", "--edit-description", "-i", "--ignore-case"]),
|
|
20
|
+
);
|
|
21
|
+
/** `git branch -m|-c [<old>] <new>`: a rename or copy creates `<new>`. */
|
|
22
|
+
const BRANCH_RENAME = new Set(["-m", "-M", "--move", "-c", "-C", "--copy"]);
|
|
23
|
+
|
|
24
|
+
/** The value of `flag` in `args` (`-c x`, `-cx`, `--create=x`), or null. */
|
|
25
|
+
const flagValue = (args: readonly string[], flags: ReadonlySet<string>): string | null => {
|
|
26
|
+
for (let index = 1; index < args.length; index++) {
|
|
27
|
+
const arg = args[index];
|
|
28
|
+
if (arg === "--") return null;
|
|
29
|
+
if (flags.has(arg)) return args[index + 1] ?? null;
|
|
30
|
+
const eq = arg.indexOf("=");
|
|
31
|
+
if (arg.startsWith("--") && eq > 0 && flags.has(arg.slice(0, eq))) return arg.slice(eq + 1);
|
|
32
|
+
// An attached short value: `-cchore/x`.
|
|
33
|
+
if (!arg.startsWith("--") && arg.length > 2 && flags.has(arg.slice(0, 2))) return arg.slice(2);
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** `git worktree add` options that take a value. */
|
|
39
|
+
const WORKTREE_VALUES = new Set(["--reason"]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `git worktree add [-b|-B <b>] <path> [<commit-ish>]`: the -b name, or, with
|
|
43
|
+
* only a path (no -b, -B, --detach or commit-ish), the branch git names after
|
|
44
|
+
* the path's last component.
|
|
45
|
+
*/
|
|
46
|
+
const worktreeTarget = (args: readonly string[]): string | null => {
|
|
47
|
+
const named = flagValue(args, WORKTREE_CREATE);
|
|
48
|
+
if (named !== null) return named;
|
|
49
|
+
const positionals: string[] = [];
|
|
50
|
+
for (let index = 1; index < args.length; index++) {
|
|
51
|
+
const arg = args[index];
|
|
52
|
+
if (arg === "--detach" || arg === "-d") return null;
|
|
53
|
+
if (WORKTREE_VALUES.has(arg)) index++;
|
|
54
|
+
else if (!arg.startsWith("-")) positionals.push(arg);
|
|
55
|
+
}
|
|
56
|
+
if (positionals.length !== 1) return null;
|
|
57
|
+
const base = positionals[0].split(/[\\/]/u).findLast(Boolean) ?? "";
|
|
58
|
+
return base && base !== "." && base !== ".." ? base : null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The branch `git branch …` creates (plain, rename or copy), or null. */
|
|
62
|
+
const branchCommandTarget = (args: readonly string[]): string | null => {
|
|
63
|
+
let rename = false;
|
|
64
|
+
const positionals: string[] = [];
|
|
65
|
+
for (let index = 1; index < args.length; index++) {
|
|
66
|
+
const arg = args[index];
|
|
67
|
+
if (arg === "--") {
|
|
68
|
+
positionals.push(...args.slice(index + 1));
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
const flag = arg.split("=", 1)[0];
|
|
72
|
+
if (BRANCH_NO_CREATE.has(flag)) return null;
|
|
73
|
+
if (BRANCH_RENAME.has(flag)) rename = true;
|
|
74
|
+
else if (!arg.startsWith("-")) positionals.push(arg);
|
|
75
|
+
}
|
|
76
|
+
if (positionals.length < 1 || positionals.length > 2) return null;
|
|
77
|
+
return rename ? (positionals.at(-1) ?? null) : positionals[0];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The branch one git invocation creates: `switch -c|-C|--create <b>`,
|
|
82
|
+
* `checkout -b|-B <b>`, `branch [-m|-c] <b> [start]` and `worktree add -b|-B
|
|
83
|
+
* <b>`, wherever the option stands. `args` start at the subcommand (after
|
|
84
|
+
* git's global options). Null when it creates none.
|
|
85
|
+
*/
|
|
86
|
+
export const gitBranchCreation = (args: readonly string[]): string | null => {
|
|
87
|
+
const [sub, second] = args;
|
|
16
88
|
const target =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
89
|
+
sub === "switch"
|
|
90
|
+
? flagValue(args, SWITCH_CREATE)
|
|
91
|
+
: sub === "checkout"
|
|
92
|
+
? flagValue(args, CHECKOUT_CREATE)
|
|
93
|
+
: sub === "branch"
|
|
94
|
+
? branchCommandTarget(args)
|
|
95
|
+
: sub === "worktree" && second === "add"
|
|
96
|
+
? worktreeTarget(args.slice(1))
|
|
97
|
+
: null;
|
|
98
|
+
return target && !target.startsWith("-") && !UNKNOWABLE.test(target) ? target : null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type ShellBranchTarget = {
|
|
102
|
+
branch: string;
|
|
103
|
+
/** The directory git runs in, relative to the hook's cwd; undefined when unknowable. */
|
|
104
|
+
dir: string | null | undefined;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every branch a shell command creates, through the raw-git tokenizer: `&&`,
|
|
109
|
+
* `;` and `|` chains, `cd`/`pushd` and `git -C`, env prefixes and wrappers,
|
|
110
|
+
* quoted names and `bash -c` scripts.
|
|
111
|
+
*/
|
|
112
|
+
export const shellBranchTargets = (
|
|
113
|
+
command: string,
|
|
114
|
+
dialect: ShellDialect = "posix",
|
|
115
|
+
): ShellBranchTarget[] => {
|
|
116
|
+
if (!/\bgit\b/u.test(command)) return [];
|
|
117
|
+
const found: ShellBranchTarget[] = [];
|
|
118
|
+
for (const invocation of rawInvocations(command, dialect)) {
|
|
119
|
+
if (invocation.tool !== "git") continue;
|
|
120
|
+
const branch = gitBranchCreation(invocation.args);
|
|
121
|
+
if (branch) found.push({ branch, dir: invocation.dir });
|
|
122
|
+
}
|
|
123
|
+
return found;
|
|
29
124
|
};
|
|
30
125
|
|
|
126
|
+
/** The first branch a shell command creates, or null. */
|
|
127
|
+
export const shellBranchTarget = (command: string): string | null =>
|
|
128
|
+
shellBranchTargets(command)[0]?.branch ?? null;
|
|
129
|
+
|
|
130
|
+
/** The branch already exists in `dir`: resetting or checking it out is not a new name. */
|
|
131
|
+
const localBranchExists = (dir: string, branch: string): boolean =>
|
|
132
|
+
spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
133
|
+
cwd: dir,
|
|
134
|
+
stdio: "ignore",
|
|
135
|
+
timeout: 5_000,
|
|
136
|
+
}).status === 0;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The branch policy verdict for every new branch a shell command creates,
|
|
140
|
+
* each checked in the directory git runs in (a linked worktree resolves to
|
|
141
|
+
* its main checkout's workspace). A command that runs in the hook's cwd is
|
|
142
|
+
* judged there. One that changes directory is judged only when that
|
|
143
|
+
* directory is known, already exists and is in a Workit workspace: a scratch
|
|
144
|
+
* repository (`cd "$(mktemp -d)"`, `git init x && cd x`, a fresh clone) is
|
|
145
|
+
* not the cwd workspace's business. A name that already exists locally is
|
|
146
|
+
* skipped (`git branch -f legacy HEAD`). Null when no new branch is judged;
|
|
147
|
+
* the first violation otherwise.
|
|
148
|
+
*/
|
|
31
149
|
export const shellBranchPolicyViolation = (
|
|
32
150
|
root: string,
|
|
33
151
|
command: string,
|
|
152
|
+
dialect: ShellDialect = "posix",
|
|
34
153
|
): BranchNamePolicyCheck | null => {
|
|
35
|
-
|
|
36
|
-
|
|
154
|
+
let judged = false;
|
|
155
|
+
for (const target of shellBranchTargets(command, dialect)) {
|
|
156
|
+
if (target.dir === undefined) continue;
|
|
157
|
+
const dir = target.dir === null ? root : path.resolve(root, target.dir);
|
|
158
|
+
if (target.dir !== null && (!existsSync(dir) || !inWorkitWorkspace(dir))) continue;
|
|
159
|
+
if (localBranchExists(dir, target.branch)) continue;
|
|
160
|
+
judged = true;
|
|
161
|
+
const check = validateBranchNameFor(dir, target.branch);
|
|
162
|
+
if (!check.ok) return check;
|
|
163
|
+
}
|
|
164
|
+
return judged ? { ok: true } : null;
|
|
37
165
|
};
|