@brainervirus/workit-core 7.2.0 → 7.4.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 +29 -20
- package/skills/workit-fanout/references/brief.md +45 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +3 -2
- package/skills/workit-shape/references/knowledge.md +3 -0
- package/skills/workit-ship/SKILL.md +2 -1
- package/src/core/methods.ts +9 -10
- package/src/core/skill-manifests.ts +3 -0
- package/src/fanout-check.ts +294 -0
- package/src/fanout.ts +916 -0
- package/src/knowledge.ts +368 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// `workit fanout check` (G2): the fan-in gate over a recorded fanout plan.
|
|
2
|
+
//
|
|
3
|
+
// For each slice branch it reads what git says, never what a worker reported:
|
|
4
|
+
// - scope: `git diff --name-only <base>...<branch>` against the slice's
|
|
5
|
+
// manifest (its scope and owns, minus files another slice owns); any other
|
|
6
|
+
// file is an out-of-scope edit;
|
|
7
|
+
// - trunk: `git merge-tree --write-tree` of the branch with the trunk;
|
|
8
|
+
// - siblings: the same merge between every pair of slice branches, so two
|
|
9
|
+
// PRs that would conflict once the first lands are caught before either
|
|
10
|
+
// does. A pairwise conflict is charged to the slice that lands later; the
|
|
11
|
+
// earlier one stays landable.
|
|
12
|
+
// The landing order puts dependencies first, then the plan's order. A slice
|
|
13
|
+
// is ready when none of the above fired and every slice it depends on is
|
|
14
|
+
// ready. Verdicts are reported (`workit ledger check`) but gated by
|
|
15
|
+
// `workit pr merge`, not here. Refs are read as they are locally: fetch first.
|
|
16
|
+
import { checkVerdicts, readLedger, type ReadRow } from "./ledger";
|
|
17
|
+
import {
|
|
18
|
+
FANOUT_VERSION,
|
|
19
|
+
branchRef,
|
|
20
|
+
fanoutFail,
|
|
21
|
+
gitRun,
|
|
22
|
+
inScope,
|
|
23
|
+
landingOrder,
|
|
24
|
+
nulList,
|
|
25
|
+
trunkRef,
|
|
26
|
+
type FanoutFile,
|
|
27
|
+
type FanoutResult,
|
|
28
|
+
type Slice,
|
|
29
|
+
} from "./fanout";
|
|
30
|
+
|
|
31
|
+
export type SliceStatus = "ready" | "blocked";
|
|
32
|
+
|
|
33
|
+
export type SliceCheck = {
|
|
34
|
+
id: string;
|
|
35
|
+
branch: string;
|
|
36
|
+
head: string | null;
|
|
37
|
+
base: string;
|
|
38
|
+
status: SliceStatus;
|
|
39
|
+
changed: number;
|
|
40
|
+
outOfScope: string[];
|
|
41
|
+
trunkConflicts: string[];
|
|
42
|
+
siblingConflicts: Array<{ with: string; paths: string[] }>;
|
|
43
|
+
waitsFor: string[];
|
|
44
|
+
verdict: { accepted: boolean; basis: string };
|
|
45
|
+
reasons: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type SiblingConflict = {
|
|
49
|
+
/** Lands first in the landing order. */
|
|
50
|
+
first: string;
|
|
51
|
+
/** Lands second; it carries the conflict. */
|
|
52
|
+
second: string;
|
|
53
|
+
paths: string[];
|
|
54
|
+
/** The two branches name one file with different case (one file on macOS and Windows). */
|
|
55
|
+
caseOnly?: true;
|
|
56
|
+
resolution: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type CheckOutcome = {
|
|
60
|
+
name: string;
|
|
61
|
+
trunk: string;
|
|
62
|
+
trunkRef: string;
|
|
63
|
+
ready: boolean;
|
|
64
|
+
slices: SliceCheck[];
|
|
65
|
+
conflicts: SiblingConflict[];
|
|
66
|
+
landingOrder: string[];
|
|
67
|
+
next: string;
|
|
68
|
+
notes: string[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const SAFE = (value: string | null): value is string => Boolean(value) && !value?.startsWith("-");
|
|
72
|
+
|
|
73
|
+
/** Paths both sides change incompatibly, [] when clean, or an error. */
|
|
74
|
+
function mergeConflicts(cwd: string, a: string, b: string): string[] | { error: string } {
|
|
75
|
+
const run = gitRun(cwd, [
|
|
76
|
+
"merge-tree",
|
|
77
|
+
"--write-tree",
|
|
78
|
+
"--name-only",
|
|
79
|
+
"--no-messages",
|
|
80
|
+
"-z",
|
|
81
|
+
a,
|
|
82
|
+
b,
|
|
83
|
+
]);
|
|
84
|
+
if (run.status === 0) return [];
|
|
85
|
+
if (run.status === 1) return [...new Set(nulList(run.stdout).slice(1))];
|
|
86
|
+
const first = run.stderr.trim().split("\n")[0] || `git merge-tree exited ${run.status}`;
|
|
87
|
+
return {
|
|
88
|
+
error: /write-tree|usage: git merge-tree/u.test(run.stderr)
|
|
89
|
+
? `${first} (needs git >= 2.38 for merge-tree --write-tree)`
|
|
90
|
+
: first,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const isAncestor = (cwd: string, a: string, b: string): boolean =>
|
|
95
|
+
gitRun(cwd, ["merge-base", "--is-ancestor", a, b]).ok;
|
|
96
|
+
|
|
97
|
+
export function checkFanout(
|
|
98
|
+
cwd: string,
|
|
99
|
+
plan: FanoutFile,
|
|
100
|
+
options: { only?: readonly string[]; trunkRef?: string | null } = {},
|
|
101
|
+
): FanoutResult<CheckOutcome> {
|
|
102
|
+
const unknown = (options.only ?? []).filter((id) => !plan.slices.some((s) => s.id === id));
|
|
103
|
+
if (unknown.length)
|
|
104
|
+
return fanoutFail(
|
|
105
|
+
"invalid_input",
|
|
106
|
+
`${unknown.join(", ")}: not a slice of fanout ${plan.name}`,
|
|
107
|
+
`slices: ${plan.slices.map((slice) => slice.id).join(", ")}`,
|
|
108
|
+
);
|
|
109
|
+
const trunk = options.trunkRef ?? trunkRef(cwd, plan.trunk);
|
|
110
|
+
if (!SAFE(trunk) || !resolves(cwd, trunk))
|
|
111
|
+
return fanoutFail(
|
|
112
|
+
"unavailable",
|
|
113
|
+
`trunk ${options.trunkRef ?? plan.trunk} does not resolve here`,
|
|
114
|
+
`git fetch, or pass --base <ref>`,
|
|
115
|
+
);
|
|
116
|
+
const order = landingOrder(plan.slices);
|
|
117
|
+
const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
|
|
118
|
+
const tips = new Map(plan.slices.map((slice) => [slice.id, branchRef(cwd, slice.branch)]));
|
|
119
|
+
const ledger = readLedger(cwd);
|
|
120
|
+
const rows: readonly ReadRow[] = ledger.ok ? ledger.value.rows : [];
|
|
121
|
+
|
|
122
|
+
const checks = new Map<string, SliceCheck>();
|
|
123
|
+
const changedBy = new Map<string, string[]>();
|
|
124
|
+
for (const slice of plan.slices)
|
|
125
|
+
checks.set(slice.id, inspect(cwd, plan, slice, trunk, tips, rows, changedBy));
|
|
126
|
+
|
|
127
|
+
// Pairwise siblings: skip a pair where one contains the other (a stacked child).
|
|
128
|
+
const conflicts: SiblingConflict[] = [];
|
|
129
|
+
for (let i = 0; i < order.length; i += 1)
|
|
130
|
+
for (let j = i + 1; j < order.length; j += 1) {
|
|
131
|
+
const first = order[i];
|
|
132
|
+
const second = order[j];
|
|
133
|
+
const a = tips.get(first);
|
|
134
|
+
const b = tips.get(second);
|
|
135
|
+
if (!a || !b || isAncestor(cwd, a, b) || isAncestor(cwd, b, a)) continue;
|
|
136
|
+
const check = checks.get(second) as SliceCheck;
|
|
137
|
+
const theirs = new Map(
|
|
138
|
+
(changedBy.get(first) ?? []).map((file) => [file.toLowerCase(), file]),
|
|
139
|
+
);
|
|
140
|
+
const cased = (changedBy.get(second) ?? []).flatMap((file) => {
|
|
141
|
+
const other = theirs.get(file.toLowerCase());
|
|
142
|
+
return other !== undefined && other !== file ? [`${other} ~ ${file}`] : [];
|
|
143
|
+
});
|
|
144
|
+
if (cased.length) {
|
|
145
|
+
conflicts.push({
|
|
146
|
+
first,
|
|
147
|
+
second,
|
|
148
|
+
paths: cased,
|
|
149
|
+
caseOnly: true,
|
|
150
|
+
resolution: `use ${first}'s spelling on ${second} (git mv), then workit fanout check`,
|
|
151
|
+
});
|
|
152
|
+
check.siblingConflicts.push({ with: first, paths: cased });
|
|
153
|
+
check.reasons.push(`differs only in case from ${first}: ${cased.join(", ")}`);
|
|
154
|
+
}
|
|
155
|
+
const result = mergeConflicts(cwd, a, b);
|
|
156
|
+
if (!Array.isArray(result)) {
|
|
157
|
+
check.reasons.push(`cannot merge with ${first}: ${result.error}`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (result.length === 0) continue;
|
|
161
|
+
const related = byId.get(second)?.dependsOn.includes(first);
|
|
162
|
+
conflicts.push({
|
|
163
|
+
first,
|
|
164
|
+
second,
|
|
165
|
+
paths: result,
|
|
166
|
+
resolution: related
|
|
167
|
+
? `rebase ${second} onto ${byId.get(first)?.branch} (its dependency), then workit fanout check`
|
|
168
|
+
: `land ${first} first, then rebase ${second} onto ${plan.trunk} and run workit fanout check; or make ${second} depend on ${first}`,
|
|
169
|
+
});
|
|
170
|
+
check.siblingConflicts.push({ with: first, paths: result });
|
|
171
|
+
check.reasons.push(`conflicts with ${first} on ${result.join(", ")}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const id of order) {
|
|
175
|
+
const check = checks.get(id) as SliceCheck;
|
|
176
|
+
check.waitsFor = (byId.get(id)?.dependsOn ?? []).filter(
|
|
177
|
+
(dep) => checks.get(dep)?.status !== "ready",
|
|
178
|
+
);
|
|
179
|
+
if (check.waitsFor.length) check.reasons.push(`waits for ${check.waitsFor.join(", ")}`);
|
|
180
|
+
check.status = check.reasons.length ? "blocked" : "ready";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const selected = options.only?.length ? new Set(options.only) : null;
|
|
184
|
+
const slices = order
|
|
185
|
+
.filter((id) => !selected || selected.has(id))
|
|
186
|
+
.map((id) => checks.get(id) as SliceCheck);
|
|
187
|
+
const ready = slices.every((check) => check.status === "ready");
|
|
188
|
+
const blocked = slices.find((check) => check.status === "blocked");
|
|
189
|
+
const notes =
|
|
190
|
+
plan.v === FANOUT_VERSION
|
|
191
|
+
? []
|
|
192
|
+
: [
|
|
193
|
+
`plan file version ${plan.v} is not ${FANOUT_VERSION}; read leniently (re-plan to rewrite it)`,
|
|
194
|
+
];
|
|
195
|
+
const next = blocked
|
|
196
|
+
? unblockFor(blocked, plan)
|
|
197
|
+
: `land in this order: ${slices.map((check) => check.id).join(", ")} (workit pr merge each once verified; stacked slices with workit stack land)`;
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
data: {
|
|
201
|
+
name: plan.name,
|
|
202
|
+
trunk: plan.trunk,
|
|
203
|
+
trunkRef: trunk,
|
|
204
|
+
ready,
|
|
205
|
+
slices,
|
|
206
|
+
conflicts: conflicts.filter(
|
|
207
|
+
(conflict) => !selected || selected.has(conflict.first) || selected.has(conflict.second),
|
|
208
|
+
),
|
|
209
|
+
landingOrder: order,
|
|
210
|
+
notes,
|
|
211
|
+
next,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const resolves = (cwd: string, ref: string): boolean =>
|
|
217
|
+
gitRun(cwd, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]).ok;
|
|
218
|
+
|
|
219
|
+
function inspect(
|
|
220
|
+
cwd: string,
|
|
221
|
+
plan: FanoutFile,
|
|
222
|
+
slice: Slice,
|
|
223
|
+
trunk: string,
|
|
224
|
+
tips: ReadonlyMap<string, string | null>,
|
|
225
|
+
rows: readonly ReadRow[],
|
|
226
|
+
changedBy: Map<string, string[]>,
|
|
227
|
+
): SliceCheck {
|
|
228
|
+
const head = tips.get(slice.id) ?? null;
|
|
229
|
+
const check: SliceCheck = {
|
|
230
|
+
id: slice.id,
|
|
231
|
+
branch: slice.branch,
|
|
232
|
+
head,
|
|
233
|
+
base: slice.base,
|
|
234
|
+
status: "blocked",
|
|
235
|
+
changed: 0,
|
|
236
|
+
outOfScope: [],
|
|
237
|
+
trunkConflicts: [],
|
|
238
|
+
siblingConflicts: [],
|
|
239
|
+
waitsFor: [],
|
|
240
|
+
verdict: { accepted: false, basis: "missing" },
|
|
241
|
+
reasons: [],
|
|
242
|
+
};
|
|
243
|
+
if (!head) {
|
|
244
|
+
check.reasons.push(`branch ${slice.branch} not found (not started, landed, or not fetched)`);
|
|
245
|
+
return check;
|
|
246
|
+
}
|
|
247
|
+
const parent = plan.slices.find((other) => other.branch === slice.base);
|
|
248
|
+
const baseRef =
|
|
249
|
+
slice.base === plan.trunk
|
|
250
|
+
? trunk
|
|
251
|
+
: parent
|
|
252
|
+
? (tips.get(parent.id) ?? null)
|
|
253
|
+
: branchRef(cwd, slice.base);
|
|
254
|
+
if (!SAFE(baseRef)) {
|
|
255
|
+
check.reasons.push(`base ${slice.base} not found`);
|
|
256
|
+
return check;
|
|
257
|
+
}
|
|
258
|
+
const diff = gitRun(cwd, ["diff", "--name-only", "--no-renames", "-z", `${baseRef}...${head}`]);
|
|
259
|
+
if (!diff.ok) {
|
|
260
|
+
check.reasons.push(`cannot diff ${slice.base}...${slice.branch}: ${diff.stderr.trim()}`);
|
|
261
|
+
return check;
|
|
262
|
+
}
|
|
263
|
+
const changed = nulList(diff.stdout);
|
|
264
|
+
changedBy.set(slice.id, changed);
|
|
265
|
+
check.changed = changed.length;
|
|
266
|
+
if (changed.length === 0) check.reasons.push(`no changes beyond ${slice.base}`);
|
|
267
|
+
check.outOfScope = changed.filter((file) => !inScope(plan, slice, file)).toSorted();
|
|
268
|
+
if (check.outOfScope.length)
|
|
269
|
+
check.reasons.push(`edits outside its scope: ${check.outOfScope.join(", ")}`);
|
|
270
|
+
const trunkResult = mergeConflicts(cwd, trunk, head);
|
|
271
|
+
if (Array.isArray(trunkResult)) {
|
|
272
|
+
check.trunkConflicts = trunkResult;
|
|
273
|
+
if (trunkResult.length)
|
|
274
|
+
check.reasons.push(`conflicts with ${plan.trunk} on ${trunkResult.join(", ")}`);
|
|
275
|
+
} else check.reasons.push(`cannot merge with ${plan.trunk}: ${trunkResult.error}`);
|
|
276
|
+
const verdict = checkVerdicts(cwd, slice.branch, rows);
|
|
277
|
+
check.verdict = { accepted: verdict.accepted.accepted, basis: verdict.current.basis };
|
|
278
|
+
return check;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function unblockFor(check: SliceCheck, plan: FanoutFile): string {
|
|
282
|
+
if (!check.head)
|
|
283
|
+
return `start or fetch ${check.branch}, then workit fanout check; if it landed, re-plan without slice ${check.id}`;
|
|
284
|
+
if (check.outOfScope.length)
|
|
285
|
+
return `move ${check.outOfScope.join(", ")} off ${check.branch} (a follow-up slice), or widen slice ${check.id}'s scope with workit fanout plan, then workit fanout check`;
|
|
286
|
+
if (check.trunkConflicts.length)
|
|
287
|
+
return `rebase ${check.branch} onto ${plan.trunk} and resolve ${check.trunkConflicts.join(", ")}, then workit fanout check`;
|
|
288
|
+
if (check.siblingConflicts.length) {
|
|
289
|
+
const first = check.siblingConflicts[0].with;
|
|
290
|
+
return `land ${first} first, then rebase ${check.branch} onto ${plan.trunk} and run workit fanout check`;
|
|
291
|
+
}
|
|
292
|
+
if (check.waitsFor.length) return `unblock ${check.waitsFor.join(", ")} first`;
|
|
293
|
+
return check.reasons[0] ?? "workit fanout check";
|
|
294
|
+
}
|