@brainervirus/workit-core 8.24.0 → 8.26.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/SKILL.md +4 -4
- package/skills/workit-fanout/references/brief.md +4 -4
- package/skills/workit-fanout/references/orchestration.md +26 -17
- package/skills/workit-implement/SKILL.md +1 -1
- package/skills/workit-review/SKILL.md +4 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/core/methods.ts +2 -2
- package/src/core/policy/carry.ts +74 -0
- package/src/core/policy/lane.ts +63 -14
- package/src/core/role-models.ts +209 -0
- package/src/core/workspaces.ts +56 -35
- package/src/fanout-brief.ts +26 -10
- package/src/fanout-status.ts +3 -3
- package/src/fanout-worktree.ts +1 -1
- package/src/forge/pr-ops.ts +11 -2
- package/src/git/default-base.ts +49 -0
- package/src/git/rev.ts +111 -0
- package/src/hooks/cwd-normalize.ts +44 -10
- package/src/hooks/delivery-verbs.ts +129 -0
- package/src/hooks/handle.ts +21 -6
- package/src/hooks/hosts/claude-code.ts +11 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/protocol.ts +2 -0
- package/src/hooks/raw-git.ts +34 -8
- package/src/hooks/shell-words.ts +32 -1
- package/src/hooks/worktree-confine.ts +3 -3
- package/src/hooks/write-gate.ts +10 -1
- package/src/ledger.ts +148 -50
- package/src/stack.ts +14 -4
- package/src/verdict-cover.ts +151 -0
package/src/core/workspaces.ts
CHANGED
|
@@ -166,6 +166,12 @@ const workspaceConfigSchema = z
|
|
|
166
166
|
* name or per host family; read leniently (workspaceTierModel).
|
|
167
167
|
*/
|
|
168
168
|
fanout: z.object({ tierModels: z.unknown().optional() }).passthrough().optional(),
|
|
169
|
+
/**
|
|
170
|
+
* Model and effort per role and host (`models.roles`, core/role-models.ts);
|
|
171
|
+
* read leniently there. The same key at the top of workspaces.json is the
|
|
172
|
+
* global default.
|
|
173
|
+
*/
|
|
174
|
+
models: z.object({ roles: z.unknown().optional() }).passthrough().optional(),
|
|
169
175
|
/** Autonomy grants (D15): only ever set here, by the user. */
|
|
170
176
|
autonomy: z
|
|
171
177
|
.object(
|
|
@@ -219,7 +225,10 @@ const workspaceConfigSchema = z
|
|
|
219
225
|
}
|
|
220
226
|
});
|
|
221
227
|
const workspacesFileSchema = z
|
|
222
|
-
.object({
|
|
228
|
+
.object({
|
|
229
|
+
workspaces: z.array(workspaceConfigSchema).optional(),
|
|
230
|
+
models: z.object({ roles: z.unknown().optional() }).passthrough().optional(),
|
|
231
|
+
})
|
|
223
232
|
.passthrough()
|
|
224
233
|
.superRefine((file, context) => {
|
|
225
234
|
const names = new Set<string>();
|
|
@@ -788,7 +797,52 @@ export const workspaceDelegation = (cwd: string, dir?: string): DelegationMode =
|
|
|
788
797
|
return isDelegation(source?.delegation) ? source.delegation : "inline-first";
|
|
789
798
|
};
|
|
790
799
|
|
|
791
|
-
|
|
800
|
+
/**
|
|
801
|
+
* Where `models.roles` and `fanout.tierModels` can come from for cwd, nearest
|
|
802
|
+
* first: the selected workspace, each enclosing one (most specific first),
|
|
803
|
+
* then the global default at the top of workspaces.json. Read leniently: an
|
|
804
|
+
* unreadable file reads as no sources.
|
|
805
|
+
*/
|
|
806
|
+
export const modelSources = (
|
|
807
|
+
cwd: string,
|
|
808
|
+
dir?: string,
|
|
809
|
+
): Array<{ name: string; global: boolean; models: unknown; tierModels: unknown }> => {
|
|
810
|
+
const out: Array<{ name: string; global: boolean; models: unknown; tierModels: unknown }> = [];
|
|
811
|
+
try {
|
|
812
|
+
const matched = matchRuntimeWorkspace(cwd, dir);
|
|
813
|
+
if (matched) {
|
|
814
|
+
const enclosing = matched.matches
|
|
815
|
+
.filter((entry) => entry !== matched.selected)
|
|
816
|
+
.toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
|
|
817
|
+
for (const entry of [matched.selected, ...enclosing])
|
|
818
|
+
out.push({
|
|
819
|
+
name: entry.name,
|
|
820
|
+
global: false,
|
|
821
|
+
models: entry.models,
|
|
822
|
+
tierModels: (entry.fanout as { tierModels?: unknown } | undefined)?.tierModels,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
} catch {
|
|
826
|
+
// An unreadable or invalid file: no workspace sources.
|
|
827
|
+
}
|
|
828
|
+
try {
|
|
829
|
+
const document: unknown = JSON.parse(
|
|
830
|
+
readFileSync(path.join(dir ?? configDir(), "workspaces.json"), "utf8"),
|
|
831
|
+
);
|
|
832
|
+
if (document && typeof document === "object" && !Array.isArray(document))
|
|
833
|
+
out.push({
|
|
834
|
+
name: "global",
|
|
835
|
+
global: true,
|
|
836
|
+
models: (document as Record<string, unknown>).models,
|
|
837
|
+
tierModels: undefined,
|
|
838
|
+
});
|
|
839
|
+
} catch {
|
|
840
|
+
// No global default.
|
|
841
|
+
}
|
|
842
|
+
return out;
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
export const hostFamily = (host: string): string => host.replace(/_(?:cli|desktop)$/u, "");
|
|
792
846
|
|
|
793
847
|
const TIER_KEYS = new Set(["mundane", "standard", "hard"]);
|
|
794
848
|
const TIER_HOSTS = new Set([
|
|
@@ -841,39 +895,6 @@ export const workspaceTierModelIssues = (cwd: string, dir?: string): string[] =>
|
|
|
841
895
|
return issues;
|
|
842
896
|
};
|
|
843
897
|
|
|
844
|
-
/**
|
|
845
|
-
* The model `fanout.tierModels` names for a tier on a host, or null (the
|
|
846
|
-
* fanout skill's TIER guidance applies). A tier maps to one model name for
|
|
847
|
-
* every host, or to `{ <host>: <model> }` keyed by host family (claude_code,
|
|
848
|
-
* opencode, codex, cursor, pi) with an optional `default`. Inherited like
|
|
849
|
-
* `trailers`, per tier; anything malformed reads as unset.
|
|
850
|
-
*/
|
|
851
|
-
export const workspaceTierModel = (
|
|
852
|
-
cwd: string,
|
|
853
|
-
tier: string,
|
|
854
|
-
host: string | null,
|
|
855
|
-
dir?: string,
|
|
856
|
-
): { model: string; workspace: string } | null => {
|
|
857
|
-
const pick = (entry: RuntimeEntry): string | null => {
|
|
858
|
-
const fanout = entry.fanout as { tierModels?: unknown } | undefined;
|
|
859
|
-
const models = fanout?.tierModels;
|
|
860
|
-
if (!models || typeof models !== "object" || !Object.hasOwn(models, tier)) return null;
|
|
861
|
-
const value = (models as Record<string, unknown>)[tier];
|
|
862
|
-
if (typeof value === "string") return value.trim() || null;
|
|
863
|
-
if (!value || typeof value !== "object") return null;
|
|
864
|
-
const byHost = value as Record<string, unknown>;
|
|
865
|
-
const family = host ? hostFamily(host) : null;
|
|
866
|
-
const named =
|
|
867
|
-
(family && Object.hasOwn(byHost, family) ? byHost[family] : undefined) ??
|
|
868
|
-
(host && Object.hasOwn(byHost, host) ? byHost[host] : undefined) ??
|
|
869
|
-
(Object.hasOwn(byHost, "default") ? byHost.default : undefined);
|
|
870
|
-
return typeof named === "string" && named.trim() ? named.trim() : null;
|
|
871
|
-
};
|
|
872
|
-
const found = inheritedSetting(cwd, dir, (entry) => pick(entry) !== null);
|
|
873
|
-
const model = found?.source ? pick(found.source) : null;
|
|
874
|
-
return model && found?.source ? { model, workspace: found.source.name } : null;
|
|
875
|
-
};
|
|
876
|
-
|
|
877
898
|
const resolveRuntimeWorkspaceCandidate = (
|
|
878
899
|
cwd: string,
|
|
879
900
|
kind: RuntimeWorkspacePolicy,
|
package/src/fanout-brief.ts
CHANGED
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
// workers export nothing (the host derives the id);
|
|
14
14
|
// Cursor, CI and plain terminals export `<lead>-w-<slice>`. Once
|
|
15
15
|
// `workit fanout worktree create` made the slice's worktree, WORKTREE and the
|
|
16
|
-
// RULES name it: every git and check command runs as `workit --cwd <worktree> …`.
|
|
16
|
+
// RULES name it: every git and check command runs as `workit <verb> --cwd <worktree> …`.
|
|
17
17
|
// The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
|
|
18
18
|
// worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
|
|
19
19
|
// one PR per slice forbids every merge; integration mode has the worker merge
|
|
20
20
|
// the integration tip before reporting, and nothing else. It only reads, apart
|
|
21
21
|
// from hiding `.workit-scratch/` in the repository's info/exclude.
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
|
-
import {
|
|
23
|
+
import { resolveRoleModel } from "./core/role-models";
|
|
24
24
|
import { activeStanding, readLedger, type LedgerActor } from "./ledger";
|
|
25
25
|
import { branchRef, fanoutFail, planLeads, type FanoutFile, type FanoutResult } from "./fanout";
|
|
26
26
|
import {
|
|
@@ -44,8 +44,12 @@ export type BriefOutcome = {
|
|
|
44
44
|
tier: string;
|
|
45
45
|
/** Issues the slice's PR links (the brief's ISSUE line). */
|
|
46
46
|
issues: string[];
|
|
47
|
-
/** The model `fanout.tierModels` names for the tier on the lead's host, else null. */
|
|
47
|
+
/** The model `models.roles` (or `fanout.tierModels`) names for the tier on the lead's host, else null. */
|
|
48
48
|
model: string | null;
|
|
49
|
+
/** Its effort, when one is configured. */
|
|
50
|
+
effort: string | null;
|
|
51
|
+
/** The model and effort the lead spawns this slice's non-author verifier with, else null. */
|
|
52
|
+
verifier: { model: string; effort: string | null } | null;
|
|
49
53
|
/** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
|
|
50
54
|
scratch: string;
|
|
51
55
|
/** The id a worker exports, or null when its host issues or derives it. */
|
|
@@ -96,8 +100,9 @@ const identityLines = (identity: BriefIdentity, session: string): string[] => {
|
|
|
96
100
|
|
|
97
101
|
/**
|
|
98
102
|
* RULES. With the slice's worktree made (`worktree`), every git and check
|
|
99
|
-
* command names it (`workit --cwd <worktree> …`,
|
|
100
|
-
*
|
|
103
|
+
* command names it (`workit <verb> --cwd <worktree> …`, verb first so host
|
|
104
|
+
* allow rules match, one verb per call) and it is already on the branch;
|
|
105
|
+
* Workit's hooks keep an implementer inside it (#310).
|
|
101
106
|
*/
|
|
102
107
|
const rules = (
|
|
103
108
|
mode: BriefMode,
|
|
@@ -105,17 +110,18 @@ const rules = (
|
|
|
105
110
|
base: string,
|
|
106
111
|
worktree: string | null,
|
|
107
112
|
): string[] => {
|
|
108
|
-
const workit =
|
|
113
|
+
const workit = (verb: string) =>
|
|
114
|
+
worktree ? `workit ${verb} --cwd ${worktree}` : `workit ${verb}`;
|
|
109
115
|
let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
|
|
110
116
|
if (worktree)
|
|
111
|
-
first = ` 1. Work only in WORKTREE, already on ${branch}${mode === "resume" ? " at its head" : ""}: edit files under it and run each git and check command against it, one verb per call (\`${workit}
|
|
117
|
+
first = ` 1. Work only in WORKTREE, already on ${branch}${mode === "resume" ? " at its head" : ""}: edit files under it and run each git and check command against it, one verb per call (\`${workit("git")} commit …\`, \`${workit("check")} <name>\`), never \`cd … &&\` chains or \`$VAR\` paths. An implementer's change outside it is denied.`;
|
|
112
118
|
else if (mode === "resume")
|
|
113
119
|
first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
|
|
114
120
|
return [
|
|
115
121
|
"RULES:",
|
|
116
122
|
first,
|
|
117
123
|
' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
|
|
118
|
-
` 3. Commit with \`${workit}
|
|
124
|
+
` 3. Commit with \`${workit("git")} commit -m <msg> -- <paths in SCOPE>\`; push only if this brief says so.`,
|
|
119
125
|
" 4. Never record a verdict on your own work.",
|
|
120
126
|
" 5. Run tests, lint, typecheck and builds only through `workit check` (a named check, or `workit check --heavy -- <cmd>`): one machine-wide lease queues every agent, so never a hand-made flock; `workit lock status` shows who holds it.",
|
|
121
127
|
" 6. A failed review gets one fix round and one recheck. Still failing: stop and report the open finding, never a third round (the lead spawns a decider). A product choice is the user's: report it, do not decide it.",
|
|
@@ -183,7 +189,8 @@ export function renderBrief(
|
|
|
183
189
|
what: String(row.what),
|
|
184
190
|
}));
|
|
185
191
|
const identity = identityOf(input.actor.host);
|
|
186
|
-
const model =
|
|
192
|
+
const model = resolveRoleModel(cwd, { role: "implementer", tier: slice.tier }, input.actor.host);
|
|
193
|
+
const verifier = resolveRoleModel(cwd, { role: "verifier" }, input.actor.host);
|
|
187
194
|
const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
|
|
188
195
|
const session = identity === "declared" ? minted : null;
|
|
189
196
|
const fanIn =
|
|
@@ -220,7 +227,14 @@ export function renderBrief(
|
|
|
220
227
|
...(slice.reason ? [`REASON: ${slice.reason}`] : []),
|
|
221
228
|
`TIER: ${slice.tier}`,
|
|
222
229
|
...(model
|
|
223
|
-
? [
|
|
230
|
+
? [
|
|
231
|
+
` model: ${model.model}${model.effort ? ` effort: ${model.effort}` : ""} (${model.source})`,
|
|
232
|
+
]
|
|
233
|
+
: []),
|
|
234
|
+
...(verifier
|
|
235
|
+
? [
|
|
236
|
+
` verifier: model ${verifier.model}${verifier.effort ? ` effort ${verifier.effort}` : ""} (${verifier.source})`,
|
|
237
|
+
]
|
|
224
238
|
: []),
|
|
225
239
|
`TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
|
|
226
240
|
scratchLine,
|
|
@@ -249,6 +263,8 @@ export function renderBrief(
|
|
|
249
263
|
tier: slice.tier,
|
|
250
264
|
issues: slice.issues,
|
|
251
265
|
model: model?.model ?? null,
|
|
266
|
+
effort: model?.effort ?? null,
|
|
267
|
+
verifier: verifier ? { model: verifier.model, effort: verifier.effort } : null,
|
|
252
268
|
scratch,
|
|
253
269
|
session,
|
|
254
270
|
standing,
|
package/src/fanout-status.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { parseDuration } from "./duration";
|
|
19
19
|
import { checksState, gatingChecks, missingRequired, type ChecksState } from "./forge/report";
|
|
20
20
|
import type { ResolvedForge } from "./forge/resolve";
|
|
21
|
-
import {
|
|
21
|
+
import { resolveRoleModel } from "./core/role-models";
|
|
22
22
|
import { actorFromEnv, checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
|
|
23
23
|
import { sliceLandings } from "./fanout-check";
|
|
24
24
|
import { forgeBreaker, inSliceRun } from "./fanout-landed";
|
|
@@ -75,7 +75,7 @@ export type StatusOutcome = {
|
|
|
75
75
|
landingOrder: string[];
|
|
76
76
|
/** Not started; every dependency landed (or, stacked, its parent has an accepted verdict). */
|
|
77
77
|
spawnable: string[];
|
|
78
|
-
/** The model `fanout.tierModels` names for each spawnable slice's TIER on this host. */
|
|
78
|
+
/** The model `models.roles` (or `fanout.tierModels`) names for each spawnable slice's TIER on this host. */
|
|
79
79
|
spawnModels: Record<string, string>;
|
|
80
80
|
stuck: string[];
|
|
81
81
|
next: string;
|
|
@@ -288,7 +288,7 @@ export function fanoutStatus(
|
|
|
288
288
|
const spawnModels: Record<string, string> = {};
|
|
289
289
|
for (const id of spawnable) {
|
|
290
290
|
const tier = plan.slices.find((slice) => slice.id === id)?.tier;
|
|
291
|
-
const model = tier ?
|
|
291
|
+
const model = tier ? resolveRoleModel(cwd, { role: "implementer", tier }, host) : null;
|
|
292
292
|
if (model) spawnModels[id] = model.model;
|
|
293
293
|
}
|
|
294
294
|
const spawnList = spawnable
|
package/src/fanout-worktree.ts
CHANGED
|
@@ -480,7 +480,7 @@ export function releaseSliceWorktree(
|
|
|
480
480
|
return fanoutFail(
|
|
481
481
|
"blocked",
|
|
482
482
|
`this command runs inside ${entry.path}`,
|
|
483
|
-
`run it from the main checkout: workit --cwd <main checkout>
|
|
483
|
+
`run it from the main checkout: workit fanout --cwd <main checkout> worktree release ${slice.id}`,
|
|
484
484
|
);
|
|
485
485
|
if (entry.branch !== null && entry.branch !== `refs/heads/${slice.branch}`)
|
|
486
486
|
return fanoutFail(
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -35,12 +35,13 @@ import { spawnSync } from "node:child_process";
|
|
|
35
35
|
import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
|
|
36
36
|
import {
|
|
37
37
|
appendObserved,
|
|
38
|
-
checkVerdicts,
|
|
39
38
|
tamperedUnblock,
|
|
40
39
|
readLedger,
|
|
40
|
+
type CarryNote,
|
|
41
41
|
type LedgerActor,
|
|
42
42
|
type VerdictCheck,
|
|
43
43
|
} from "../ledger";
|
|
44
|
+
import { checkVerdictsCovered, type StackCover } from "../verdict-cover";
|
|
44
45
|
import type { LaneNeed, ReviewLane } from "../core/policy/lane";
|
|
45
46
|
import { laneLabel, prLaneGate } from "../core/policy/pr-lane";
|
|
46
47
|
import { buildStatusDoc, selectPr, type PrStatusDoc } from "./report";
|
|
@@ -379,6 +380,10 @@ export type MergeOutcome = {
|
|
|
379
380
|
lane: ReviewLane;
|
|
380
381
|
need: LaneNeed;
|
|
381
382
|
verification: VerificationMode;
|
|
383
|
+
/** The stack verdict above this PR that covers it (#334), when it has none of its own. */
|
|
384
|
+
cover?: StackCover | null;
|
|
385
|
+
/** How a verdict on an older head carried to this one over a follow-up change (#334). */
|
|
386
|
+
carry?: CarryNote | null;
|
|
382
387
|
};
|
|
383
388
|
/** Set when `--unverified` bypassed the verdict: the reason and its ledger row. */
|
|
384
389
|
unverified: { reason: string; recorded: string } | null;
|
|
@@ -662,7 +667,8 @@ export async function mergePullRequest(
|
|
|
662
667
|
}
|
|
663
668
|
const ledger = readLedger(cwd, { integrity: true });
|
|
664
669
|
if (!ledger.ok) return ledger;
|
|
665
|
-
|
|
670
|
+
// A lower PR of a stack may be covered by the verdict at its top (#334).
|
|
671
|
+
const check = checkVerdictsCovered(cwd, branch, ledger.value.rows);
|
|
666
672
|
const gate = prLaneGate(cwd, check, { branch, base: doc.base, head });
|
|
667
673
|
const summary = {
|
|
668
674
|
head: check.head,
|
|
@@ -681,6 +687,9 @@ export async function mergePullRequest(
|
|
|
681
687
|
lane: gate.lane.lane,
|
|
682
688
|
need: gate.need,
|
|
683
689
|
verification: gate.verification,
|
|
690
|
+
...(covered && gate.review === "verified"
|
|
691
|
+
? { cover: check.cover, carry: check.cover ? null : check.carry }
|
|
692
|
+
: {}),
|
|
684
693
|
};
|
|
685
694
|
let unverified: MergeOutcome["unverified"] = null;
|
|
686
695
|
// The bypass covers a missing verdict, never a rejection.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The default base for patch-ids, author trailers and review lanes. Its own
|
|
2
|
+
// module so the lane (core/policy/lane.ts) and the ledger can both read it
|
|
3
|
+
// without importing each other.
|
|
4
|
+
import { vcsConfig } from "../core/vcs-config";
|
|
5
|
+
import { originHeadRef, resolveRef } from "./rev";
|
|
6
|
+
|
|
7
|
+
const safeRef = (value: string): boolean => value.length > 0 && !value.startsWith("-");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The PR target of the release track the checkout is on, when the workspace
|
|
11
|
+
* configures tracks (as `fanout plan` and `stack plan` pick their trunk); a
|
|
12
|
+
* plain workspace default does not override origin's default branch.
|
|
13
|
+
*/
|
|
14
|
+
const trackTrunk = (cwd: string): string | null => {
|
|
15
|
+
try {
|
|
16
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
17
|
+
if (resolved.ok === false || !resolved.releaseTrack || resolved.releaseTrack.blocking)
|
|
18
|
+
return null;
|
|
19
|
+
const target = String(resolved.defaultTargetBranch ?? "").trim();
|
|
20
|
+
return target && safeRef(target) ? target : null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The default base for patch-ids and author trailers: the release track's PR
|
|
28
|
+
* target (origin's copy, else the local one) when tracks are configured, else
|
|
29
|
+
* origin's HEAD branch, else the first of origin/main, origin/master, main,
|
|
30
|
+
* master, origin/develop, develop that exists.
|
|
31
|
+
*/
|
|
32
|
+
export function defaultBase(cwd: string): string | null {
|
|
33
|
+
const trunk = trackTrunk(cwd);
|
|
34
|
+
if (trunk)
|
|
35
|
+
for (const candidate of [`origin/${trunk}`, trunk])
|
|
36
|
+
if (resolveRef(cwd, candidate)) return candidate;
|
|
37
|
+
const originHead = originHeadRef(cwd);
|
|
38
|
+
if (originHead && resolveRef(cwd, originHead)) return originHead;
|
|
39
|
+
for (const candidate of [
|
|
40
|
+
"origin/main",
|
|
41
|
+
"origin/master",
|
|
42
|
+
"main",
|
|
43
|
+
"master",
|
|
44
|
+
"origin/develop",
|
|
45
|
+
"develop",
|
|
46
|
+
])
|
|
47
|
+
if (resolveRef(cwd, candidate)) return candidate;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
package/src/git/rev.ts
CHANGED
|
@@ -515,6 +515,117 @@ export function resolveRef(cwd: string, ref: string): string | null {
|
|
|
515
515
|
return line(git(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]));
|
|
516
516
|
}
|
|
517
517
|
|
|
518
|
+
/** The branch origin's HEAD names (`origin/main`), or null. */
|
|
519
|
+
export function originHeadRef(cwd: string): string | null {
|
|
520
|
+
return line(git(cwd, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The top of the working tree, or null outside one. */
|
|
524
|
+
export function worktreeTop(cwd: string): string | null {
|
|
525
|
+
return line(git(cwd, ["rev-parse", "--show-toplevel"]));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Whether git tracks `file` (repo-relative) in the index of the checkout at `top`. */
|
|
529
|
+
export function isTracked(top: string, file: string): boolean {
|
|
530
|
+
return git(top, ["ls-files", "--error-unmatch", "--", file]).ok;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The files whose own change differs between two versions of a branch: the
|
|
535
|
+
* judged `from` (forked from `fromFork`) and the current `to` (forked from
|
|
536
|
+
* `toFork`), compared by exact blob and mode, never by diff text (#334). A
|
|
537
|
+
* file the branch touches on one side only differs. A file it touches on
|
|
538
|
+
* both sides is unchanged only when the base left it alone between the forks
|
|
539
|
+
* and the branch's resulting blob is identical, so a hunk moved within a
|
|
540
|
+
* file, a re-indent or a base edit in a file the branch touches always
|
|
541
|
+
* counts. Null when git cannot read either side: the caller fails closed.
|
|
542
|
+
*/
|
|
543
|
+
export function ownChangeDrift(
|
|
544
|
+
cwd: string,
|
|
545
|
+
range: { from: string; to: string; fromFork: string; toFork: string },
|
|
546
|
+
): string[] | null {
|
|
547
|
+
const before = diffRaw(cwd, range.fromFork, range.from);
|
|
548
|
+
const after = diffRaw(cwd, range.toFork, range.to);
|
|
549
|
+
if (!before || !after) return null;
|
|
550
|
+
const judged = new Map(before.map((entry) => [entry.path, entry]));
|
|
551
|
+
const current = new Map(after.map((entry) => [entry.path, entry]));
|
|
552
|
+
const same = (a: RawEntry | undefined, b: RawEntry | undefined): boolean =>
|
|
553
|
+
a !== undefined &&
|
|
554
|
+
b !== undefined &&
|
|
555
|
+
a.oldMode === b.oldMode &&
|
|
556
|
+
a.oldBlob === b.oldBlob &&
|
|
557
|
+
a.newMode === b.newMode &&
|
|
558
|
+
a.newBlob === b.newBlob;
|
|
559
|
+
return [...new Set([...judged.keys(), ...current.keys()])]
|
|
560
|
+
.filter((file) => !same(judged.get(file), current.get(file)))
|
|
561
|
+
.toSorted();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Whether the branch's own files are identical at `from` (on `fromBase`) and
|
|
566
|
+
* `to` (on `toBase`): ownChangeDrift from each head's fork is empty. The
|
|
567
|
+
* exactness behind every patch-equal carry; false when git cannot tell.
|
|
568
|
+
*/
|
|
569
|
+
export function sameOwnChange(
|
|
570
|
+
cwd: string,
|
|
571
|
+
range: { from: string; to: string; fromBase: string; toBase: string },
|
|
572
|
+
): boolean {
|
|
573
|
+
const fromFork = mergeBase(cwd, range.fromBase, range.from);
|
|
574
|
+
const toFork = mergeBase(cwd, range.toBase, range.to);
|
|
575
|
+
if (!fromFork || !toFork) return false;
|
|
576
|
+
return ownChangeDrift(cwd, { from: range.from, to: range.to, fromFork, toFork })?.length === 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* The verbatim patch-ids of the non-merge commits of `fork..head` (empty
|
|
581
|
+
* commits have none) and how many merge commits the range holds: unlike
|
|
582
|
+
* `--stable`, whitespace counts, so a re-indent is another patch. Diff
|
|
583
|
+
* options are pinned as in patchId. Null when git fails, or has no
|
|
584
|
+
* `patch-id --verbatim`: the caller fails closed.
|
|
585
|
+
*/
|
|
586
|
+
export function commitPatchIds(
|
|
587
|
+
cwd: string,
|
|
588
|
+
fork: string,
|
|
589
|
+
head: string,
|
|
590
|
+
): { patchIds: string[]; merges: number } | null {
|
|
591
|
+
if (!safeArg(fork) || !safeArg(head)) return null;
|
|
592
|
+
const range = `${fork}..${head}`;
|
|
593
|
+
const merges = line(git(cwd, ["rev-list", "--count", "--merges", range]));
|
|
594
|
+
const log = git(
|
|
595
|
+
cwd,
|
|
596
|
+
[
|
|
597
|
+
"log",
|
|
598
|
+
"--no-merges",
|
|
599
|
+
"-p",
|
|
600
|
+
"-U3",
|
|
601
|
+
"--diff-algorithm=myers",
|
|
602
|
+
"--indent-heuristic",
|
|
603
|
+
"--no-color",
|
|
604
|
+
"--no-ext-diff",
|
|
605
|
+
"--no-textconv",
|
|
606
|
+
"--no-renames",
|
|
607
|
+
"--full-index",
|
|
608
|
+
"--src-prefix=a/",
|
|
609
|
+
"--dst-prefix=b/",
|
|
610
|
+
"--format=commit %H",
|
|
611
|
+
range,
|
|
612
|
+
"--",
|
|
613
|
+
],
|
|
614
|
+
{ timeoutMs: GIT_TIMEOUTS.worktree },
|
|
615
|
+
);
|
|
616
|
+
if (merges === null || !log.ok) return null;
|
|
617
|
+
if (!log.stdout.trim()) return { patchIds: [], merges: Number(merges) };
|
|
618
|
+
const ids = git(cwd, ["patch-id", "--verbatim"], { input: log.stdout });
|
|
619
|
+
if (!ids.ok) return null;
|
|
620
|
+
return {
|
|
621
|
+
patchIds: ids.stdout
|
|
622
|
+
.split("\n")
|
|
623
|
+
.map((entry) => entry.trim().split(/\s+/u)[0])
|
|
624
|
+
.filter(Boolean),
|
|
625
|
+
merges: Number(merges),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
518
629
|
/** Pinned diff options for the review-lane reads: user config never changes them. */
|
|
519
630
|
const LANE_DIFF = [
|
|
520
631
|
"-c",
|
|
@@ -1,16 +1,32 @@
|
|
|
1
|
-
// `cd <dir> && workit <args>` → `workit --cwd <dir> <
|
|
2
|
-
// On Claude Code the compound does not match a `Bash(workit
|
|
1
|
+
// `cd <dir> && workit <args>` → `workit <verb> --cwd <dir> <rest>` (issue #388).
|
|
2
|
+
// On Claude Code the compound does not match a `Bash(workit …)` allow rule
|
|
3
3
|
// (every subcommand must match, and a `cd` outside the working dirs breaks
|
|
4
4
|
// it), so the auto-mode classifier judged it. The rewrite is exact and
|
|
5
5
|
// conservative: one literal directory, one workit call, and only read-only
|
|
6
6
|
// filters after it. Anything else is left alone. Pure string work.
|
|
7
|
-
|
|
7
|
+
//
|
|
8
|
+
// The verb goes first (issue #297): Workit's enumerated host rules, such as
|
|
9
|
+
// `Bash(workit git *)`, cannot match a variable directory before the verb.
|
|
10
|
+
// `--cwd` and `--json` mean the same anywhere before `--` (main.ts
|
|
11
|
+
// parseGlobals), so moving them never changes what runs.
|
|
12
|
+
import { WORKIT_ALLOWED_VERBS } from "./delivery-verbs";
|
|
13
|
+
import { segmentsOf, type Segment } from "./shell-words";
|
|
8
14
|
|
|
9
15
|
// A directory spelled as one literal word: bare, '…' or "…" with no expansion.
|
|
10
|
-
const
|
|
11
|
-
|
|
16
|
+
const DIR = String.raw`[A-Za-z0-9_./@%+:,=-]+|'[^'$${"`"}\\\n]*'|"[^"$${"`"}\\!\n]*"`;
|
|
17
|
+
const COMPOUND = new RegExp(String.raw`^\s*cd\s+(${DIR})\s*&&\s*(workit(?:\s[\s\S]*)?)$`, "u");
|
|
18
|
+
// `workit` then `--json` / `--cwd <dir>` globals, then the verb.
|
|
19
|
+
const GLOBALS_FIRST = new RegExp(
|
|
20
|
+
String.raw`^(\s*workit)((?:\s+(?:--json|--cwd(?:=|\s+)(?:${DIR})))+)\s+([a-z][a-z-]*)(?=\s|$)`,
|
|
21
|
+
"u",
|
|
22
|
+
);
|
|
23
|
+
const VERB_FIRST = /^((?:--json\s+)*)([a-z][a-z-]*)(?=\s|$)/u;
|
|
24
|
+
const VERBS = new Set<string>(WORKIT_ALLOWED_VERBS);
|
|
12
25
|
const FILTERS = new Set(["tail", "head", "grep"]);
|
|
13
26
|
|
|
27
|
+
const clean = (s: Segment): boolean =>
|
|
28
|
+
!s.unanalyzable && !s.group && !s.subs?.length && !s.stdin?.length && !s.redirects.length;
|
|
29
|
+
|
|
14
30
|
// A filter that reads only stdin, so moving the cwd cannot change what it
|
|
15
31
|
// reads: flags and counts for tail/head, one pattern for grep (no -r/-R/-f).
|
|
16
32
|
const stdinOnly = (words: readonly string[]): boolean => {
|
|
@@ -22,8 +38,9 @@ const stdinOnly = (words: readonly string[]): boolean => {
|
|
|
22
38
|
};
|
|
23
39
|
|
|
24
40
|
/**
|
|
25
|
-
* The command rewritten to `workit --cwd <dir>
|
|
26
|
-
*
|
|
41
|
+
* The command rewritten to `workit <verb> --cwd <dir> …` (`workit --cwd <dir>
|
|
42
|
+
* …` for a verb no host rule lists), or null when it is not exactly
|
|
43
|
+
* `cd <literal dir> && workit … [2>&1] [| tail|head|grep …]*`.
|
|
27
44
|
*/
|
|
28
45
|
export const cwdRewrite = (command: string): string | null => {
|
|
29
46
|
const match = COMPOUND.exec(command);
|
|
@@ -36,10 +53,27 @@ export const cwdRewrite = (command: string): string | null => {
|
|
|
36
53
|
const [head, ...filters] = segments;
|
|
37
54
|
if (!head || head.words[0] !== "workit") return null;
|
|
38
55
|
if (head.words.some((w) => w === "--cwd" || w.startsWith("--cwd="))) return null;
|
|
39
|
-
const clean = (s: (typeof segments)[number]) =>
|
|
40
|
-
!s.unanalyzable && !s.group && !s.subs?.length && !s.stdin?.length && !s.redirects.length;
|
|
41
56
|
if (!segments.every(clean)) return null;
|
|
42
57
|
if (!segments.slice(0, -1).every((s) => s.pipe)) return null;
|
|
43
58
|
if (!filters.every((s) => FILTERS.has(s.words[0] ?? "") && stdinOnly(s.words))) return null;
|
|
44
|
-
|
|
59
|
+
const args = rest.slice("workit".length).trimStart();
|
|
60
|
+
const verb = VERB_FIRST.exec(args);
|
|
61
|
+
if (!verb?.[2] || !VERBS.has(verb[2])) return `workit --cwd ${dir} ${args}`.trimEnd();
|
|
62
|
+
const json = verb[1] ? " --json" : "";
|
|
63
|
+
return `workit ${verb[2]} --cwd ${dir}${json}${args.slice(verb[0].length)}`.trimEnd();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `workit --cwd <dir> <verb> …` (with any `--json` there) rewritten with the
|
|
68
|
+
* verb first, for a verb the host rules list; null otherwise. Only the words
|
|
69
|
+
* before the first verb move, and only in a command that is that one call
|
|
70
|
+
* (no `;`, `&&`, `|`, newline, substitution or redirect).
|
|
71
|
+
*/
|
|
72
|
+
export const verbFirst = (command: string): string | null => {
|
|
73
|
+
const match = GLOBALS_FIRST.exec(command);
|
|
74
|
+
const [whole, lead, globals, verb] = match ?? [];
|
|
75
|
+
if (!whole || !verb || !VERBS.has(verb) || !globals?.includes("--cwd")) return null;
|
|
76
|
+
const segments = segmentsOf(command);
|
|
77
|
+
if (segments.length !== 1 || !segments.every(clean)) return null;
|
|
78
|
+
return `${lead} ${verb}${globals}${command.slice(whole.length)}`;
|
|
45
79
|
};
|