@brainervirus/workit-core 6.0.1 → 7.1.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-deslop/SKILL.md +1 -1
- package/skills/workit-implement/SKILL.md +9 -4
- package/skills/workit-review/SKILL.md +2 -0
- package/skills/workit-shape/SKILL.md +1 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/autonomy.ts +24 -0
- package/src/core/branch.ts +63 -6
- package/src/core/config.ts +9 -3
- package/src/core/methods.ts +7 -5
- package/src/core/operation-input.ts +506 -0
- package/src/core/policy/derive.ts +338 -0
- package/src/core/policy/judgment.ts +192 -0
- package/src/core/policy-resolver.ts +32 -427
- package/src/core/pr-create.ts +8 -3
- package/src/core/release-tracks.ts +779 -0
- package/src/core/task-contract.ts +40 -229
- package/src/core/task-engine.ts +51 -58
- package/src/core/task-evaluation.ts +35 -1
- package/src/core/vcs-config.ts +65 -16
- package/src/core/workspaces.ts +82 -16
- package/src/core.ts +8 -6
- package/src/forge/pr-ops.ts +31 -0
- package/src/git/ops.ts +61 -13
- package/src/hooks/handle.ts +19 -4
- package/src/hooks/hosts/claude-code.ts +20 -1
- package/src/hooks/hosts/codex.ts +3 -0
- package/src/hooks/hosts/cursor.ts +11 -2
- package/src/hooks/hosts/fields.ts +35 -0
- package/src/hooks/hosts/opencode.ts +1 -0
- package/src/hooks/hosts/pi.ts +1 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/protocol.ts +3 -0
- package/src/hooks/write-gate.ts +303 -0
- package/src/ledger.ts +20 -0
package/src/core/vcs-config.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { configDir,
|
|
5
|
-
import { resolveRuntimeWorkspaceVcs } from "./workspaces";
|
|
6
|
-
import {
|
|
4
|
+
import { configDir, vcsConfigPath } from "./config";
|
|
5
|
+
import { resolveRuntimeWorkspaceVcs, workspaceDefaultTarget } from "./workspaces";
|
|
6
|
+
import { resolveTrackFor, type RuntimeReleaseTrack, type TrackResolution } from "./release-tracks";
|
|
7
7
|
// VCS resolution and CLI-backed identity/style reads.
|
|
8
8
|
|
|
9
|
-
export const vcsConfigPath = (): string =>
|
|
10
|
-
process.env.WORKFLOW_VCS_CONFIG ?? path.join(configDir(), "vcs.json");
|
|
11
|
-
|
|
12
9
|
const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
|
|
13
10
|
|
|
14
11
|
const vcsCwd = (cwd?: string): string =>
|
|
@@ -146,8 +143,45 @@ export const readVcsConfig = (): VcsConfigResult => {
|
|
|
146
143
|
return { status: "malformed", path: file, config: {}, error: `${file} is not a JSON object` };
|
|
147
144
|
};
|
|
148
145
|
|
|
146
|
+
/** The release track a vcsConfig call resolved (null: the workspace has no tracks). */
|
|
147
|
+
export type ResolvedReleaseTrack = {
|
|
148
|
+
name: string | null;
|
|
149
|
+
source: string;
|
|
150
|
+
detail: string;
|
|
151
|
+
warnings: string[];
|
|
152
|
+
track: RuntimeReleaseTrack | null;
|
|
153
|
+
/** Every configured track, for displays and the branch policy. */
|
|
154
|
+
tracks: RuntimeReleaseTrack[];
|
|
155
|
+
/** The line is undetermined (or WORKFLOW_RELEASE_TRACK is wrong): mutating verbs refuse. */
|
|
156
|
+
blocking: string | null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export type VcsConfigOptions = {
|
|
160
|
+
/** An explicit release track (`--track`); WORKFLOW_RELEASE_TRACK is read below it. */
|
|
161
|
+
track?: string | null;
|
|
162
|
+
/** Resolve the track for this branch instead of the checked-out one. */
|
|
163
|
+
branch?: string | null;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const trackSummary = (resolution: TrackResolution): ResolvedReleaseTrack | null =>
|
|
167
|
+
resolution.status === "resolved"
|
|
168
|
+
? {
|
|
169
|
+
name: resolution.track?.name ?? null,
|
|
170
|
+
source: resolution.source,
|
|
171
|
+
detail: resolution.detail,
|
|
172
|
+
warnings: resolution.warnings,
|
|
173
|
+
track: resolution.track,
|
|
174
|
+
tracks: resolution.tracks,
|
|
175
|
+
blocking: resolution.blocking,
|
|
176
|
+
}
|
|
177
|
+
: null;
|
|
178
|
+
|
|
149
179
|
/** Port of scripts/vcs/config.sh — mode: load | summary | resolve. */
|
|
150
|
-
export function vcsConfig(
|
|
180
|
+
export function vcsConfig(
|
|
181
|
+
mode: "load" | "summary" | "resolve",
|
|
182
|
+
cwd?: string,
|
|
183
|
+
options: VcsConfigOptions = {},
|
|
184
|
+
): Record<string, any> {
|
|
151
185
|
const ws = resolveRuntimeWorkspaceVcs(vcsCwd(cwd));
|
|
152
186
|
const wsVcs = (ws?.vcs ?? {}) as Record<string, any>;
|
|
153
187
|
const wsYt = (ws?.youtrack ?? {}) as Record<string, any>;
|
|
@@ -183,14 +217,9 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
183
217
|
// main). Explicit workspace vcs.defaultTargetBranch stays authoritative; a
|
|
184
218
|
// workspace without a branchPolicy still falls back to the global vcs.json
|
|
185
219
|
// default, and unmatched repos keep it too.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
const defaultTarget = String(
|
|
190
|
-
wsVcs.defaultTargetBranch ??
|
|
191
|
-
(hasWorkspacePolicy ? policyDefault : (cfg.defaultTargetBranch ?? policyDefault)) ??
|
|
192
|
-
"develop",
|
|
193
|
-
);
|
|
220
|
+
// CA-05/CA-02 (workspaces.ts workspaceDefaultTarget): shared with doctor
|
|
221
|
+
// and grant show so their display never disagrees with the commands.
|
|
222
|
+
const workspaceDefault = workspaceDefaultTarget(root, cfg.defaultTargetBranch);
|
|
194
223
|
const linkIssues = typeof wsYt.link_issues === "boolean" ? wsYt.link_issues : null;
|
|
195
224
|
const youtrackBaseUrl = typeof wsYt.baseUrl === "string" ? wsYt.baseUrl : null;
|
|
196
225
|
// github issues path only when BOTH providers are github (mirrors WorkspaceConfig.issues).
|
|
@@ -210,11 +239,30 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
210
239
|
// instead of silently resolving defaults; a missing file is still a
|
|
211
240
|
// legitimate unconfigured state that falls back to defaults.
|
|
212
241
|
if (cfgStatus === "malformed") return { ok: false, error: cfgError, configPath: cfgPath };
|
|
242
|
+
// Release tracks (core/release-tracks.ts): the one place a branch's line is
|
|
243
|
+
// decided, in resolve mode only (load serves identity, push and tracker
|
|
244
|
+
// paths that never need it). With tracks, the PR target and the base for
|
|
245
|
+
// new branches come from the resolved track; without, the workspace
|
|
246
|
+
// default stays as is.
|
|
247
|
+
const trackResolution = resolveTrackFor(root, {
|
|
248
|
+
track: options.track,
|
|
249
|
+
...(options.branch !== undefined ? { branch: options.branch } : {}),
|
|
250
|
+
defaultBranch: workspaceDefault,
|
|
251
|
+
});
|
|
252
|
+
if (trackResolution.status === "invalid")
|
|
253
|
+
return { ok: false, error: trackResolution.error, configPath: workspacesPath() };
|
|
254
|
+
const releaseTrack = trackSummary(trackResolution);
|
|
255
|
+
const defaultTarget = releaseTrack?.track?.pullRequestTarget ?? workspaceDefault;
|
|
256
|
+
const baseBranch = releaseTrack?.track?.baseBranch ?? defaultTarget;
|
|
213
257
|
return {
|
|
214
258
|
ok: true,
|
|
215
259
|
workspace_name: ws?.name ?? null,
|
|
216
260
|
provider,
|
|
217
261
|
defaultTargetBranch: defaultTarget,
|
|
262
|
+
/** The target before release tracks (vcs.defaultTargetBranch or the policy default). */
|
|
263
|
+
workspaceDefaultTargetBranch: workspaceDefault,
|
|
264
|
+
baseBranch,
|
|
265
|
+
releaseTrack,
|
|
218
266
|
link_issues: linkIssues,
|
|
219
267
|
youtrack_base_url: youtrackBaseUrl,
|
|
220
268
|
issues_provider: issuesProvider,
|
|
@@ -241,7 +289,8 @@ export function vcsConfig(mode: "load" | "summary" | "resolve", cwd?: string): R
|
|
|
241
289
|
ok: true,
|
|
242
290
|
configPath: path.resolve(cfgPath),
|
|
243
291
|
provider,
|
|
244
|
-
|
|
292
|
+
// Load mode does not resolve release tracks: the workspace default.
|
|
293
|
+
defaultTargetBranch: workspaceDefault,
|
|
245
294
|
pr: cfg.pr ?? {},
|
|
246
295
|
workspace_name: ws?.name ?? null,
|
|
247
296
|
link_issues: linkIssues,
|
package/src/core/workspaces.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
PRESETS,
|
|
8
8
|
configDir,
|
|
9
9
|
isConfigObject,
|
|
10
|
+
readConfig,
|
|
10
11
|
resolveBranchPolicy as resolveConfiguredBranchPolicy,
|
|
11
12
|
resolveCommitPolicy as resolveConfiguredCommitPolicy,
|
|
12
13
|
type BranchPreset,
|
|
@@ -68,31 +69,43 @@ const versionSourceSchema = z.discriminatedUnion("kind", [
|
|
|
68
69
|
z.object({ kind: z.literal("git-tag") }).strict(),
|
|
69
70
|
z.object({ kind: z.literal("manual") }).strict(),
|
|
70
71
|
]);
|
|
72
|
+
// Only the production and integration branches are required: runtime
|
|
73
|
+
// (core/release-tracks.ts) defaults the rest. Unknown keys pass through (D17:
|
|
74
|
+
// a newer Workit's field must not invalidate the file for an older one); a
|
|
75
|
+
// track lists the ones an older reader must not ignore in `critical`.
|
|
71
76
|
const releaseTrackSchema = z
|
|
72
77
|
.object({
|
|
73
|
-
strategy: branchPreset,
|
|
78
|
+
strategy: branchPreset.optional(),
|
|
74
79
|
productionBranch: nonBlank,
|
|
75
80
|
integrationBranch: nonBlank,
|
|
76
|
-
naming: z
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
naming: z
|
|
82
|
+
.object({
|
|
83
|
+
feature: nonBlank.optional(),
|
|
84
|
+
release: nonBlank.optional(),
|
|
85
|
+
hotfix: nonBlank.optional(),
|
|
86
|
+
})
|
|
87
|
+
.passthrough()
|
|
88
|
+
.optional(),
|
|
89
|
+
baseBranch: nonBlank.optional(),
|
|
90
|
+
mergeBackBranches: z.array(nonBlank).optional(),
|
|
91
|
+
pullRequestTarget: nonBlank.optional(),
|
|
92
|
+
tagNamespace: z.string().optional(),
|
|
93
|
+
versionSource: versionSourceSchema.optional(),
|
|
94
|
+
requiredChecks: z.array(nonBlank).optional(),
|
|
95
|
+
critical: z.array(nonBlank).optional(),
|
|
83
96
|
})
|
|
84
|
-
.
|
|
97
|
+
.passthrough()
|
|
85
98
|
.superRefine((track, context) => {
|
|
86
99
|
for (const [field, values] of [
|
|
87
|
-
["mergeBackBranches", track.mergeBackBranches],
|
|
88
|
-
["requiredChecks", track.requiredChecks],
|
|
100
|
+
["mergeBackBranches", track.mergeBackBranches ?? []],
|
|
101
|
+
["requiredChecks", track.requiredChecks ?? []],
|
|
89
102
|
] as const) {
|
|
90
103
|
if (new Set(values).size !== values.length) {
|
|
91
104
|
context.addIssue({ code: "custom", path: [field], message: "entries must be unique" });
|
|
92
105
|
}
|
|
93
106
|
}
|
|
94
107
|
if (
|
|
95
|
-
track.versionSource
|
|
108
|
+
track.versionSource?.kind === "package-json" &&
|
|
96
109
|
(path.posix.isAbsolute(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
97
110
|
/^[A-Za-z]:\//u.test(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
98
111
|
track.versionSource.path.replaceAll("\\", "/").split("/").includes(".."))
|
|
@@ -121,6 +134,8 @@ const workspaceConfigSchema = z
|
|
|
121
134
|
autoApprove: z.union([z.boolean(), z.array(nonBlank)]).optional(),
|
|
122
135
|
/** Where an unnamed request stops (skills read it); same rules as grants. */
|
|
123
136
|
defaultEndpoint: z.enum(["commit", "pr"]).optional(),
|
|
137
|
+
/** How a normal-risk behavior change is verified (S17); same rules as grants. */
|
|
138
|
+
verification: z.enum(["self", "independent"]).optional(),
|
|
124
139
|
/** Autonomy grants (D15): only ever set here, by the user. */
|
|
125
140
|
autonomy: z
|
|
126
141
|
.object(
|
|
@@ -555,10 +570,10 @@ const runtimeWorkspaceIndexSchema = z
|
|
|
555
570
|
})
|
|
556
571
|
.passthrough();
|
|
557
572
|
|
|
558
|
-
|
|
573
|
+
/** The workspaces.json entry matching cwd, checked only for name/glob (D17). */
|
|
574
|
+
const matchRuntimeWorkspace = (
|
|
559
575
|
cwd: string,
|
|
560
|
-
|
|
561
|
-
): RuntimeWorkspaceCandidate | null => {
|
|
576
|
+
): { file: string; selected: Record<string, unknown> & { name: string } } | null => {
|
|
562
577
|
const file = path.join(configDir(), "workspaces.json");
|
|
563
578
|
let raw: string;
|
|
564
579
|
try {
|
|
@@ -599,7 +614,30 @@ const resolveRuntimeWorkspaceCandidate = (
|
|
|
599
614
|
});
|
|
600
615
|
const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
|
|
601
616
|
const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
|
|
602
|
-
|
|
617
|
+
return selected ? { file, selected } : null;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The raw `releaseTracks` of the workspace matching cwd, unvalidated: the
|
|
622
|
+
* runtime reads them leniently (core/release-tracks.ts) so one malformed track
|
|
623
|
+
* is reported instead of disabling every command.
|
|
624
|
+
*/
|
|
625
|
+
export const resolveRuntimeReleaseTracks = (
|
|
626
|
+
cwd: string,
|
|
627
|
+
): { workspace: string; releaseTracks: unknown } | null => {
|
|
628
|
+
const matched = matchRuntimeWorkspace(cwd);
|
|
629
|
+
return matched
|
|
630
|
+
? { workspace: matched.selected.name, releaseTracks: matched.selected.releaseTracks }
|
|
631
|
+
: null;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const resolveRuntimeWorkspaceCandidate = (
|
|
635
|
+
cwd: string,
|
|
636
|
+
kind: RuntimeWorkspacePolicy,
|
|
637
|
+
): RuntimeWorkspaceCandidate | null => {
|
|
638
|
+
const matched = matchRuntimeWorkspace(cwd);
|
|
639
|
+
if (!matched) return null;
|
|
640
|
+
const { file, selected } = matched;
|
|
603
641
|
const policy = runtimeWorkspacePolicySchema(kind).safeParse(selected);
|
|
604
642
|
if (!policy.success) {
|
|
605
643
|
const detail = policy.error.issues
|
|
@@ -728,3 +766,31 @@ export const selectReleaseTrack = (
|
|
|
728
766
|
}
|
|
729
767
|
return { status: "selected", name: requestedTrack, track: tracks[requestedTrack] };
|
|
730
768
|
};
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* The workspace default target before release tracks: the one computation
|
|
772
|
+
* vcsConfig, `workit doctor` and `workit grant show` share (CA-05/CA-02).
|
|
773
|
+
* Explicit workspace vcs.defaultTargetBranch wins; a workspace with its own
|
|
774
|
+
* branchPolicy uses its preset's default (gitflow -> develop, github-flow ->
|
|
775
|
+
* main, trunk-based -> master); otherwise the global vcs.json
|
|
776
|
+
* `defaultTargetBranch` (`globalDefault`), else the effective preset's.
|
|
777
|
+
*/
|
|
778
|
+
export function workspaceDefaultTarget(cwd: string, globalDefault: unknown): string {
|
|
779
|
+
const ws = resolveRuntimeWorkspaceVcs(cwd);
|
|
780
|
+
const wp = (ws?.branchPolicy ?? {}) as Record<string, unknown>;
|
|
781
|
+
const hasWorkspacePolicy = typeof wp.preset === "string" && Object.hasOwn(PRESETS, wp.preset);
|
|
782
|
+
const selected = resolveRuntimeWorkspacePolicy(
|
|
783
|
+
cwd,
|
|
784
|
+
"branch",
|
|
785
|
+
process.env.WORKFLOW_PROFILE?.trim() || undefined,
|
|
786
|
+
);
|
|
787
|
+
const policyDefault = resolveConfiguredBranchPolicy(
|
|
788
|
+
readConfig(),
|
|
789
|
+
selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
|
|
790
|
+
).defaultTargetBranch;
|
|
791
|
+
return String(
|
|
792
|
+
(ws?.vcs as { defaultTargetBranch?: unknown } | undefined)?.defaultTargetBranch ??
|
|
793
|
+
(hasWorkspacePolicy ? policyDefault : (globalDefault ?? policyDefault)) ??
|
|
794
|
+
"develop",
|
|
795
|
+
);
|
|
796
|
+
}
|
package/src/core.ts
CHANGED
|
@@ -8,11 +8,6 @@ export {
|
|
|
8
8
|
OPERATION_FAMILIES,
|
|
9
9
|
operationSchemas,
|
|
10
10
|
advertisedOperationSchemas,
|
|
11
|
-
operationJsonSchema,
|
|
12
|
-
boundedOperationJsonSchema,
|
|
13
|
-
OPERATION_SCHEMA_DEPTH,
|
|
14
|
-
OPERATION_SCHEMA_MAX_DEPTH,
|
|
15
|
-
canonicalFieldsDescription,
|
|
16
11
|
parseOperation,
|
|
17
12
|
success,
|
|
18
13
|
failure,
|
|
@@ -25,6 +20,13 @@ export {
|
|
|
25
20
|
requirementId,
|
|
26
21
|
rewriteRecordRefs,
|
|
27
22
|
} from "./core/task-contract";
|
|
23
|
+
export {
|
|
24
|
+
flatOperationJsonSchema,
|
|
25
|
+
normalizeOperationInput,
|
|
26
|
+
operationDescription,
|
|
27
|
+
operationJsonSchema,
|
|
28
|
+
parseAdvertisedOperation,
|
|
29
|
+
} from "./core/operation-input";
|
|
28
30
|
export type {
|
|
29
31
|
OperationFamily,
|
|
30
32
|
OperationRequest,
|
|
@@ -32,7 +34,7 @@ export type {
|
|
|
32
34
|
Caller,
|
|
33
35
|
Scope,
|
|
34
36
|
Ref,
|
|
35
|
-
|
|
37
|
+
Judgment,
|
|
36
38
|
Dimension,
|
|
37
39
|
Requirement,
|
|
38
40
|
Constraint,
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { requireGrant, type AutonomySource } from "../autonomy";
|
|
19
19
|
import { isProtectedTarget } from "../core/branch";
|
|
20
20
|
import { vcsConfig } from "../core/vcs-config";
|
|
21
|
+
import { workspaceReleaseTracks } from "../core/release-tracks";
|
|
21
22
|
import { deleteRemoteBranch } from "../git/ops";
|
|
22
23
|
import { spawnSync } from "node:child_process";
|
|
23
24
|
import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from "../git/rev";
|
|
@@ -260,6 +261,33 @@ export type MergeOutcome = {
|
|
|
260
261
|
grant: { source: AutonomySource };
|
|
261
262
|
deletedBranch: boolean | { error: string };
|
|
262
263
|
recorded: { id: string } | { error: string };
|
|
264
|
+
/**
|
|
265
|
+
* Release tracks: when the PR landed on a track's production branch, the
|
|
266
|
+
* branches it must flow back into (mergeBackBranches); empty otherwise.
|
|
267
|
+
* Workit reports them; it does not open the merge-back PRs itself.
|
|
268
|
+
*/
|
|
269
|
+
mergeBack: string[];
|
|
270
|
+
mergeBackTrack: string | null;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The merge-back a merge into `base` owes: the track whose production branch
|
|
275
|
+
* `base` is, by ownership alone (no --track, no WORKFLOW_RELEASE_TRACK, no
|
|
276
|
+
* history). Best effort: a config problem never stops the pr.merged record.
|
|
277
|
+
*/
|
|
278
|
+
const mergeBackFor = (cwd: string, base: string): { track: string | null; branches: string[] } => {
|
|
279
|
+
try {
|
|
280
|
+
const owners = workspaceReleaseTracks(cwd).tracks.filter(
|
|
281
|
+
(track) => track.productionBranch === base,
|
|
282
|
+
);
|
|
283
|
+
if (owners.length !== 1) return { track: null, branches: [] };
|
|
284
|
+
return {
|
|
285
|
+
track: owners[0].name,
|
|
286
|
+
branches: owners[0].mergeBackBranches.filter((branch) => branch !== base),
|
|
287
|
+
};
|
|
288
|
+
} catch {
|
|
289
|
+
return { track: null, branches: [] };
|
|
290
|
+
}
|
|
263
291
|
};
|
|
264
292
|
|
|
265
293
|
/** Why `pr merge` refused, for the envelope `data` (agents branch on `reason`). */
|
|
@@ -431,6 +459,7 @@ export async function mergePullRequest(
|
|
|
431
459
|
deletedBranch =
|
|
432
460
|
deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
|
|
433
461
|
}
|
|
462
|
+
const mergeBack = mergeBackFor(cwd, doc.base);
|
|
434
463
|
const row = appendObserved(cwd, {
|
|
435
464
|
type: "pr.merged",
|
|
436
465
|
actor: input.actor,
|
|
@@ -457,6 +486,8 @@ export async function mergePullRequest(
|
|
|
457
486
|
grant: { source: grant.source },
|
|
458
487
|
deletedBranch,
|
|
459
488
|
recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
|
|
489
|
+
mergeBack: mergeBack.branches,
|
|
490
|
+
mergeBackTrack: mergeBack.track,
|
|
460
491
|
},
|
|
461
492
|
};
|
|
462
493
|
}
|
package/src/git/ops.ts
CHANGED
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
} from "../core/branch";
|
|
28
28
|
import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
|
|
29
29
|
import { pushTargetIsStable } from "../core/pr-create";
|
|
30
|
-
import {
|
|
30
|
+
import { recordBranchBase, trackBranchName } from "../core/release-tracks";
|
|
31
|
+
import { vcsConfig, type ResolvedReleaseTrack } from "../core/vcs-config";
|
|
31
32
|
import { redactText } from "../forge/redact";
|
|
32
33
|
import { failure, success, type ForgeResult } from "../forge/types";
|
|
33
34
|
import { appendObserved, readLedger, type LedgerActor } from "../ledger";
|
|
@@ -119,15 +120,51 @@ export type BranchOutcome = {
|
|
|
119
120
|
carried: boolean;
|
|
120
121
|
/** Fallbacks taken (fetch failed, base used from the local copy). */
|
|
121
122
|
notes: string[];
|
|
123
|
+
/** The release track the base and name came from; null without tracks. */
|
|
124
|
+
releaseTrack: ResolvedReleaseTrack | null;
|
|
122
125
|
};
|
|
123
126
|
|
|
124
|
-
export
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
export type BranchInput = {
|
|
128
|
+
/** An explicit branch name; else `kind` + `slug` through the track's naming. */
|
|
129
|
+
name?: string;
|
|
130
|
+
kind?: "feature" | "bugfix" | "hotfix";
|
|
131
|
+
slug?: string;
|
|
132
|
+
base?: string | null;
|
|
133
|
+
carry?: boolean;
|
|
134
|
+
/** An explicit release track (`--track`). */
|
|
135
|
+
track?: string | null;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOutcome> {
|
|
129
139
|
if (!headSha(cwd) && currentBranch(cwd) === null)
|
|
130
140
|
return failure("invalid_input", "not inside a git repository with a commit");
|
|
141
|
+
// One resolution: the default target, the base for new branches and the
|
|
142
|
+
// release track (core/release-tracks.ts) all come from vcsConfig. With
|
|
143
|
+
// --base the track is the base's line (it decides the --kind naming);
|
|
144
|
+
// otherwise the checkout's.
|
|
145
|
+
const explicitBase = input.base?.trim() || null;
|
|
146
|
+
const resolved = vcsConfig("resolve", cwd, {
|
|
147
|
+
track: input.track ?? null,
|
|
148
|
+
...(explicitBase ? { branch: explicitBase } : {}),
|
|
149
|
+
});
|
|
150
|
+
const releaseTrack: ResolvedReleaseTrack | null =
|
|
151
|
+
resolved.ok === false ? null : (resolved.releaseTrack ?? null);
|
|
152
|
+
// An undetermined line blocks whenever the track would decide something:
|
|
153
|
+
// the base (no --base) or the --kind/--slug name.
|
|
154
|
+
if (releaseTrack?.blocking && (!explicitBase || input.name === undefined))
|
|
155
|
+
return failure(
|
|
156
|
+
"blocked",
|
|
157
|
+
String(releaseTrack.blocking),
|
|
158
|
+
"workit git branch … --track <name> # or --base <branch>",
|
|
159
|
+
);
|
|
160
|
+
const name = (
|
|
161
|
+
input.name ??
|
|
162
|
+
(input.kind && input.slug
|
|
163
|
+
? (trackBranchName(releaseTrack?.track ?? null, input.kind, input.slug) ??
|
|
164
|
+
`${input.kind}/${input.slug}`)
|
|
165
|
+
: "")
|
|
166
|
+
).trim();
|
|
167
|
+
if (!name) return failure("invalid_input", "missing branch name");
|
|
131
168
|
if (!validRefName(cwd, name))
|
|
132
169
|
return failure("invalid_input", `invalid branch name ${JSON.stringify(name)}`);
|
|
133
170
|
const policy = validateBranchNameFor(cwd, name);
|
|
@@ -135,28 +172,36 @@ export function gitBranch(
|
|
|
135
172
|
if (resolveRef(cwd, `refs/heads/${name}`))
|
|
136
173
|
return failure("failed", `branch_exists: ${name} already exists`, `git switch ${name}`);
|
|
137
174
|
|
|
138
|
-
let base =
|
|
175
|
+
let base = explicitBase;
|
|
139
176
|
let defaultTarget: string | null = null;
|
|
140
|
-
|
|
141
|
-
|
|
177
|
+
let defaultBase: string | null = null;
|
|
178
|
+
// An explicit --track that does not resolve is never ignored, even with --base.
|
|
179
|
+
if (resolved.ok === false && (!base || input.track))
|
|
142
180
|
return failure(
|
|
143
181
|
"blocked",
|
|
144
182
|
String(resolved.error),
|
|
145
183
|
"fix ~/.config/workit/vcs.json or pass --base <branch>",
|
|
146
184
|
);
|
|
147
|
-
if (resolved.ok !== false)
|
|
148
|
-
|
|
185
|
+
if (resolved.ok !== false) {
|
|
186
|
+
defaultTarget = String(resolved.defaultTargetBranch ?? "") || null;
|
|
187
|
+
defaultBase = String(resolved.baseBranch ?? "") || defaultTarget;
|
|
188
|
+
}
|
|
189
|
+
base ??= defaultBase;
|
|
149
190
|
if (!base)
|
|
150
191
|
return failure("invalid_input", "no default target branch is configured; pass --base <branch>");
|
|
151
192
|
if (base.startsWith("-")) return failure("invalid_input", "--base must not start with -");
|
|
152
193
|
|
|
153
|
-
const notes: string[] = [];
|
|
194
|
+
const notes: string[] = [...(releaseTrack?.warnings ?? [])];
|
|
154
195
|
const remotes = remoteNames(cwd);
|
|
155
196
|
const remote = remotes.includes("origin") ? "origin" : pushRemoteName(cwd);
|
|
156
197
|
const localTip = resolveRef(cwd, `refs/heads/${base}`);
|
|
157
198
|
// A local, unprotected branch that is not the default target is a stack
|
|
158
199
|
// parent: its local tip is the truth (it may not be pushed yet).
|
|
159
|
-
const stackParent =
|
|
200
|
+
const stackParent =
|
|
201
|
+
localTip !== null &&
|
|
202
|
+
base !== defaultTarget &&
|
|
203
|
+
base !== defaultBase &&
|
|
204
|
+
!isProtectedTarget(cwd, base);
|
|
160
205
|
let baseRef: string | null = null;
|
|
161
206
|
if (stackParent) baseRef = `refs/heads/${base}`;
|
|
162
207
|
else if (remote && validRefName(cwd, base)) {
|
|
@@ -194,6 +239,8 @@ export function gitBranch(
|
|
|
194
239
|
const previous = currentBranch(cwd);
|
|
195
240
|
const switched = git(cwd, ["switch", "--no-track", "-c", name, baseSha]);
|
|
196
241
|
if (!switched.ok) return failure("failed", `git switch -c ${name} failed: ${gitError(switched)}`);
|
|
242
|
+
// The cheapest track signal later (release-tracks.ts): where this branch began.
|
|
243
|
+
recordBranchBase(cwd, name, base);
|
|
197
244
|
return success({
|
|
198
245
|
branch: name,
|
|
199
246
|
previous,
|
|
@@ -202,6 +249,7 @@ export function gitBranch(
|
|
|
202
249
|
baseSha,
|
|
203
250
|
carried: dirt !== "clean",
|
|
204
251
|
notes,
|
|
252
|
+
releaseTrack,
|
|
205
253
|
});
|
|
206
254
|
}
|
|
207
255
|
|
package/src/hooks/handle.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { sessionContextText, turnContextText } from "./context";
|
|
3
3
|
import type { HostDescriptor, Support } from "./descriptor";
|
|
4
4
|
import { shellPolicy } from "./policy";
|
|
5
|
+
import { shellWrites, writeGate } from "./write-gate";
|
|
5
6
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
6
7
|
|
|
7
8
|
export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
|
|
@@ -58,9 +59,13 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
58
59
|
const key = input.session.id ? `${input.host}\0${input.session.id}` : null;
|
|
59
60
|
const offer = event.source === "startup" && (key === null || !offered.has(key));
|
|
60
61
|
if (offer && key !== null) offered.add(key);
|
|
62
|
+
const advisory = usable(descriptor.events["write.pre"].support)
|
|
63
|
+
? null
|
|
64
|
+
: `Workit cannot gate file writes on ${descriptor.label} (no pre-write hook): before-write requirements (an open product choice, a missing plan) are advisory here, so settle them before editing.`;
|
|
65
|
+
const addendum = [deps.addendum, advisory].filter(Boolean).join("\n") || null;
|
|
61
66
|
return {
|
|
62
67
|
kind: "context",
|
|
63
|
-
text: sessionContextText(input, descriptor, { offer, addendum
|
|
68
|
+
text: sessionContextText(input, descriptor, { offer, addendum }),
|
|
64
69
|
};
|
|
65
70
|
}
|
|
66
71
|
case "context.turn": {
|
|
@@ -68,8 +73,18 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
68
73
|
const text = turnContextText(input, descriptor);
|
|
69
74
|
return text ? { kind: "context", text } : NONE;
|
|
70
75
|
}
|
|
71
|
-
case "shell.pre":
|
|
72
|
-
|
|
76
|
+
case "shell.pre": {
|
|
77
|
+
const policy = usable(descriptor.shellPolicy.deny)
|
|
78
|
+
? shellPolicy(input.cwd, event.command)
|
|
79
|
+
: NONE;
|
|
80
|
+
if (policy.kind !== "none" || !usable(descriptor.events["write.pre"].support)) return policy;
|
|
81
|
+
const writes = shellWrites(event.command);
|
|
82
|
+
return writes.writes ? writeGate(input.cwd, writes.targets) : NONE;
|
|
83
|
+
}
|
|
84
|
+
case "write.pre":
|
|
85
|
+
return usable(descriptor.events["write.pre"].support)
|
|
86
|
+
? writeGate(input.cwd, event.paths)
|
|
87
|
+
: NONE;
|
|
73
88
|
case "subagent.start":
|
|
74
89
|
return {
|
|
75
90
|
kind: "context",
|
|
@@ -102,7 +117,7 @@ export const failureDecision = (
|
|
|
102
117
|
event: HookEventKind | null,
|
|
103
118
|
error: string,
|
|
104
119
|
): HookDecision => {
|
|
105
|
-
if (event === "shell.pre" || event === "tool.pre")
|
|
120
|
+
if (event === "shell.pre" || event === "tool.pre" || event === "write.pre")
|
|
106
121
|
return descriptor.shellPolicy.failClosed
|
|
107
122
|
? { kind: "deny", reason: error, unblock: null }
|
|
108
123
|
: NONE;
|
|
@@ -8,7 +8,15 @@ import type {
|
|
|
8
8
|
HostAdapter,
|
|
9
9
|
SessionSource,
|
|
10
10
|
} from "../protocol";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
commandText,
|
|
13
|
+
existingDirectory,
|
|
14
|
+
isRecord,
|
|
15
|
+
isWriteTool,
|
|
16
|
+
nonEmpty,
|
|
17
|
+
optionalText,
|
|
18
|
+
writePaths,
|
|
19
|
+
} from "./fields";
|
|
12
20
|
|
|
13
21
|
type ClaudeHookEvent =
|
|
14
22
|
| "SessionStart"
|
|
@@ -31,6 +39,7 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
|
|
|
31
39
|
"context.turn": { support: "native", native: "UserPromptSubmit" },
|
|
32
40
|
"shell.pre": { support: "native", native: "PreToolUse" },
|
|
33
41
|
"tool.pre": { support: "native", native: "PreToolUse" },
|
|
42
|
+
"write.pre": { support: "native", native: "PreToolUse" },
|
|
34
43
|
"shell.post": { support: "native", native: "PostToolUse" },
|
|
35
44
|
// SubagentStart output is additionalContext only: it cannot block or bind.
|
|
36
45
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
@@ -140,6 +149,16 @@ const eventOf = (name: ClaudeHookEvent, value: Record<string, unknown>): Parsed
|
|
|
140
149
|
return { ok: true, event: { kind: "context.turn" } };
|
|
141
150
|
case "PreToolUse": {
|
|
142
151
|
if (!nonEmpty(value.tool_name)) return { ok: false, error: "tool_name is required" };
|
|
152
|
+
if (isWriteTool(value.tool_name))
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
event: {
|
|
156
|
+
kind: "write.pre",
|
|
157
|
+
tool: value.tool_name,
|
|
158
|
+
paths: writePaths(value.tool_input),
|
|
159
|
+
toolUseId,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
143
162
|
if (!SHELL_TOOLS.has(value.tool_name))
|
|
144
163
|
return { ok: true, event: { kind: "tool.pre", tool: value.tool_name, toolUseId } };
|
|
145
164
|
const command = toolCommand(value);
|
package/src/hooks/hosts/codex.ts
CHANGED
|
@@ -50,6 +50,9 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
|
|
|
50
50
|
"context.turn": undocumented,
|
|
51
51
|
"shell.pre": { support: "native", native: "PreToolUse" },
|
|
52
52
|
"tool.pre": { support: "native", native: "PreToolUse" },
|
|
53
|
+
// Codex PreToolUse intercepts shell calls, not apply_patch edits: the
|
|
54
|
+
// before-write gate stays advisory rather than half-enforced.
|
|
55
|
+
"write.pre": { support: "undocumented", native: null },
|
|
53
56
|
"shell.post": undocumented,
|
|
54
57
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
55
58
|
"subagent.stop": { support: "native", native: "SubagentStop" },
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { HostDescriptor } from "../descriptor";
|
|
4
4
|
import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
|
|
5
|
-
import { existingDirectory, isRecord, nonEmpty } from "./fields";
|
|
5
|
+
import { existingDirectory, isRecord, isWriteTool, nonEmpty, writePaths } from "./fields";
|
|
6
6
|
|
|
7
7
|
export type CursorHookEvent =
|
|
8
8
|
| "sessionStart"
|
|
@@ -44,6 +44,8 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
44
44
|
"context.turn": none,
|
|
45
45
|
"shell.pre": { support: "native", native: "beforeShellExecution" },
|
|
46
46
|
"tool.pre": { support: "native", native: "preToolUse" },
|
|
47
|
+
// preToolUse write tools; the file path is read from tool_input when present.
|
|
48
|
+
"write.pre": { support: "partial", native: "preToolUse" },
|
|
47
49
|
"shell.post": undocumented,
|
|
48
50
|
"subagent.start": { support: "native", native: "subagentStart" },
|
|
49
51
|
// subagentStop carries no stable child identity.
|
|
@@ -212,7 +214,14 @@ const protocolEvent = (input: CursorHookInput): HookEvent => {
|
|
|
212
214
|
// which branch name it targets.
|
|
213
215
|
return { kind: "shell.pre", command: input.command ?? "", toolUseId: null };
|
|
214
216
|
case "preToolUse":
|
|
215
|
-
return
|
|
217
|
+
return isWriteTool(input.tool_name)
|
|
218
|
+
? {
|
|
219
|
+
kind: "write.pre",
|
|
220
|
+
tool: input.tool_name ?? "",
|
|
221
|
+
paths: writePaths(input.tool_input),
|
|
222
|
+
toolUseId: null,
|
|
223
|
+
}
|
|
224
|
+
: { kind: "tool.pre", tool: input.tool_name ?? "", toolUseId: null };
|
|
216
225
|
case "subagentStart":
|
|
217
226
|
return {
|
|
218
227
|
kind: "subagent.start",
|