@brainervirus/workit-core 8.10.0 → 8.12.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 +23 -8
- package/skills/workit-fanout/references/orchestration.md +9 -4
- package/src/autonomy.ts +6 -2
- package/src/check-config.ts +3 -1
- package/src/checks.ts +14 -1
- package/src/core/branch-policy.ts +8 -1
- package/src/core/branch.ts +270 -3
- package/src/core/methods.ts +8 -6
- package/src/core/resources.ts +747 -0
- package/src/core/route-intent.ts +155 -27
- package/src/core/workspaces.ts +46 -3
- package/src/fanout-brief.ts +66 -13
- package/src/forge/exec.ts +8 -2
- package/src/forge/github.ts +27 -6
- package/src/forge/gitlab.ts +29 -5
- package/src/forge/pr-ops.ts +125 -10
- package/src/forge/types.ts +13 -1
- package/src/git/ops.ts +360 -10
- package/src/git/session-hook.ts +68 -0
- package/src/hooks/handle.ts +13 -6
- package/src/hooks/policy.ts +11 -5
- package/src/hooks/raw-git.ts +138 -10
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
|
-
|
|
28
|
+
<identity line, per the lead's host: see "Identity and git rules per host" below>
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
## Worked example
|
|
@@ -97,17 +97,32 @@ is the slice's STUCK threshold in `workit fanout status`. `"fanIn":
|
|
|
97
97
|
}
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
## Identity and git rules per host
|
|
101
|
+
|
|
102
|
+
`workit fanout brief` renders the identity line and RULES 1 and 3 for the
|
|
103
|
+
lead's host (`WORKIT_HOST`, or the host's own session variable):
|
|
104
|
+
|
|
105
|
+
| Lead's host | Identity line | RULES 1 and 3 |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| Claude Code | `Session: your env already has WORKIT_SESSION_ID (the lead's id)`; no `export`, no hand-minted id (SubagentStart issues `<lead>:<agent id>` only to verifiers and reviewers, never to an implementer, so the worker's commits carry the lead's id) | plain git: `git fetch origin && git switch -c <branch> origin/<base>` and `git commit -m <msg> --trailer "Workit-Session: <the env WORKIT_SESSION_ID>" --trailer "<host attribution>" -- <paths in SCOPE>`, because worktree isolation refuses every `workit git` form |
|
|
108
|
+
| OpenCode, Pi, Codex | `Session: your host already sets WORKIT_SESSION_ID in your shell`; export nothing (the host derives the id and an export cannot replace it) | `workit git branch <branch> --base <base>`, `workit git commit -m <msg> -- <paths in SCOPE>` |
|
|
109
|
+
| Cursor, CI, a plain terminal | `export WORKIT_SESSION_ID=<lead>-w-<slice>` (`+try<n>` for a race attempt): declared, not checked | the same `workit git` forms |
|
|
110
|
+
|
|
111
|
+
`MODE: resume` is `git switch <branch>` on every host (the branch exists; the
|
|
112
|
+
lead removed the dead worker's worktree after its exit, so the switch
|
|
113
|
+
succeeds), then continue from its head. A race attempt (`--attempt <n>`)
|
|
114
|
+
follows its host's row with the branch `<branch>-try<n>`.
|
|
115
|
+
|
|
100
116
|
## Worker rules (`workit fanout brief` appends them as RULES)
|
|
101
117
|
|
|
102
|
-
1. First command
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
worker's worktree after its exit, so the switch succeeds), then continue
|
|
106
|
-
from its head.
|
|
118
|
+
1. First command: create the branch (`MODE: new`; the worktree may start on a
|
|
119
|
+
name that breaks branch policy) or `git switch` to it (`MODE: resume`),
|
|
120
|
+
as the table above says for your host.
|
|
107
121
|
2. Decide ambiguities yourself and record them:
|
|
108
122
|
`workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`.
|
|
109
123
|
Stop only for an irreversible action, a security-sensitive one, or a side
|
|
110
124
|
effect outside the worktree.
|
|
111
|
-
3. Commit
|
|
112
|
-
the brief says
|
|
125
|
+
3. Commit as the table says (`workit git commit -m "<msg>" -- <paths in SCOPE>`,
|
|
126
|
+
or plain git with the trailers on Claude Code); push only if the brief says
|
|
127
|
+
so.
|
|
113
128
|
4. Never record a verdict on your own work.
|
|
@@ -73,8 +73,8 @@ Which session id a verdict carries, per host:
|
|
|
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
75
|
(`<lead>:<agent id>`), passed exactly as `--session`: the hook records the
|
|
76
|
-
ids it issues, and only those are accepted. An `implementer`
|
|
77
|
-
|
|
76
|
+
ids it issues, and only those are accepted. An `implementer` is issued
|
|
77
|
+
none: it keeps your id in its env for its workit commands and commits.
|
|
78
78
|
- Codex: the `workit-verifier` and `workit-reviewer` agents get the same
|
|
79
79
|
issued `--session` from SubagentStart; any other verifier passes
|
|
80
80
|
`--as verifier`.
|
|
@@ -84,8 +84,13 @@ Which session id a verdict carries, per host:
|
|
|
84
84
|
- Cursor (no session id in the shell): the brief sets the verifier's
|
|
85
85
|
`WORKIT_SESSION_ID=<lead>:verifier-<random hex>`, a fresh suffix per
|
|
86
86
|
verifier. Cursor cannot check it: it is declared, not derived.
|
|
87
|
-
- A worker's commits
|
|
88
|
-
|
|
87
|
+
- A worker's commits: on Claude Code the lead's `WORKIT_SESSION_ID` already in
|
|
88
|
+
its env (SubagentStart issues no implementer id; plain git, no export, so the
|
|
89
|
+
lead authored those commits: a verifier or reviewer records the verdict, not
|
|
90
|
+
the lead); on OpenCode, Pi and Codex the id the host already
|
|
91
|
+
puts in its shell; on Cursor, CI and a plain terminal the brief's
|
|
92
|
+
`export WORKIT_SESSION_ID=<lead>-w-<slice>` (see `brief.md`, "Identity and
|
|
93
|
+
git rules per host"). A verdict under an id you made up (`<lead>-v1`,
|
|
89
94
|
`<lead>:v1`) is refused as `forged_session` on Claude Code, Codex,
|
|
90
95
|
OpenCode and Pi.
|
|
91
96
|
|
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";
|
package/src/check-config.ts
CHANGED
|
@@ -47,6 +47,8 @@ export type CheckConfig = {
|
|
|
47
47
|
gates: Partial<Record<"testing" | "verification", string[]>>;
|
|
48
48
|
/** Set when the config exists but cannot be used; gates then fail closed. */
|
|
49
49
|
error: string | null;
|
|
50
|
+
/** The raw `resources` block of workit.checks.json (heavy-check limits; core/resources.ts reads it). */
|
|
51
|
+
resources?: unknown;
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -228,7 +230,7 @@ export function loadCheckConfig(cwd: string): CheckConfig {
|
|
|
228
230
|
gates[gate as (typeof GATES)[number]] = names as string[];
|
|
229
231
|
}
|
|
230
232
|
}
|
|
231
|
-
return { source: CHECKS_FILE, root, checks, gates, error: null };
|
|
233
|
+
return { source: CHECKS_FILE, root, checks, gates, error: null, resources: parsed.resources };
|
|
232
234
|
}
|
|
233
235
|
|
|
234
236
|
const sameArgv = (left: readonly string[], right: readonly string[]): boolean =>
|
package/src/checks.ts
CHANGED
|
@@ -169,6 +169,13 @@ export type RunOptions = {
|
|
|
169
169
|
onStderr?: (text: string) => void;
|
|
170
170
|
/** For tests: the platform whose spawn rules apply (default: process.platform). */
|
|
171
171
|
platform?: NodeJS.Platform;
|
|
172
|
+
/**
|
|
173
|
+
* POSIX only: argv that runs the command (e.g. a `systemd-run … --` scope
|
|
174
|
+
* prefix). A --shell command then runs as `<wrapper> /bin/sh -c <cmd>`.
|
|
175
|
+
*/
|
|
176
|
+
wrapper?: readonly string[];
|
|
177
|
+
/** Called with the child's pid once it started (on POSIX also its process group id). */
|
|
178
|
+
onSpawn?: (pid: number) => void;
|
|
172
179
|
};
|
|
173
180
|
|
|
174
181
|
// cmd.exe metacharacters (the cross-spawn set).
|
|
@@ -272,7 +279,12 @@ export function runCheckCommand(argv: readonly string[], options: RunOptions): P
|
|
|
272
279
|
const buffer = new TailBuffer(MAX_LOG_BYTES);
|
|
273
280
|
const redactor = new StreamRedactor((text) => buffer.push(text));
|
|
274
281
|
const env = options.env ?? process.env;
|
|
275
|
-
const
|
|
282
|
+
const wrapped = Boolean(options.wrapper?.length) && platform !== "win32";
|
|
283
|
+
const command = wrapped
|
|
284
|
+
? [...options.wrapper!, ...(options.shell ? ["/bin/sh", "-c", argv[0]] : argv)]
|
|
285
|
+
: argv;
|
|
286
|
+
const shell = wrapped ? false : options.shell;
|
|
287
|
+
const plan = planSpawn(command, { platform, env, cwd: options.cwd, shell });
|
|
276
288
|
return new Promise((resolve) => {
|
|
277
289
|
let settled = false;
|
|
278
290
|
let timedOut = false;
|
|
@@ -334,6 +346,7 @@ export function runCheckCommand(argv: readonly string[], options: RunOptions): P
|
|
|
334
346
|
finish(null, null, (error as NodeJS.ErrnoException).code ?? (error as Error).message);
|
|
335
347
|
return;
|
|
336
348
|
}
|
|
349
|
+
if (child.pid) options.onSpawn?.(child.pid);
|
|
337
350
|
if (!windows)
|
|
338
351
|
for (const name of FORWARDED_SIGNALS) {
|
|
339
352
|
const handler = () => {
|
|
@@ -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
|
@@ -105,9 +105,9 @@ Autonomy contract:
|
|
|
105
105
|
ship), go to the effective endpoint in \`workit grant show\`: \`pr\` opens
|
|
106
106
|
the PR; \`green\` babysits it without asking (workit-ship) to merge-ready,
|
|
107
107
|
never merging; \`merged\` also lands it with \`workit pr merge\`.
|
|
108
|
-
Merge and release need a workspace grant
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
Merge and release need a workspace grant only the user raises from their
|
|
109
|
+
own terminal (a blocked verb names how); never edit workspaces.json or route
|
|
110
|
+
around a grant_required.
|
|
111
111
|
- Ask only for a product or preference choice, or for authority you lack, with
|
|
112
112
|
your recommended answer. Facts are yours: read, run or prototype.
|
|
113
113
|
- Label claims measured (run and seen this session), inferred, or guess.
|
|
@@ -116,17 +116,19 @@ Autonomy contract:
|
|
|
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
118
|
merge need a verifier with its own host-derived session (a subagent's
|
|
119
|
-
\`<lead>:<child
|
|
119
|
+
\`<lead>:<child>\`); never hand-mint one (\`<lead>-v1\`).
|
|
120
120
|
- A local-commit endpoint does not imply PR readiness, and a local commit alone
|
|
121
121
|
is not evidence of a requested remote push. Before reporting delivery,
|
|
122
122
|
reconcile every requested item and observe it (\`workit verify-delivery\`).
|
|
123
123
|
|
|
124
124
|
CLI first: if a step has one right answer, use the \`workit\` verb instead of
|
|
125
125
|
hand-running git, gh or glab (\`workit git|pr|ci|check\`; \`workit help\` lists
|
|
126
|
-
the rest;
|
|
126
|
+
the rest; lacking \`workit\`, \`npx -y @brainervirus/workit-cli\`).
|
|
127
|
+
Add Co-Authored-By via \`workit git commit --trailer "K: v" -- <paths>\`, never
|
|
128
|
+
an amend.
|
|
127
129
|
The task families (task, policy, evidence, finding, decision, worker, state)
|
|
128
130
|
are optional continuity for tracked work; each branch has one implicit task,
|
|
129
|
-
|
|
131
|
+
and a solo edit needs no task. A decision
|
|
130
132
|
records a user choice; it never authorizes an effect. Judge work once with
|
|
131
133
|
\`workit policy assess --judge risk= behavior= product-choice= plan=\`.
|
|
132
134
|
Across repositories, bind each item to its checkout, branch and endpoint,
|