@brainervirus/workit-core 7.3.0 → 7.5.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 +27 -27
- package/skills/workit-fanout/references/brief.md +5 -1
- 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/duration.ts +27 -0
- package/src/fanout-check.ts +178 -27
- package/src/fanout-landed.ts +313 -0
- package/src/fanout-status.ts +321 -0
- package/src/fanout-worktree.ts +520 -0
- package/src/fanout.ts +131 -17
- package/src/knowledge.ts +368 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// `workit fanout status` (G3): one dashboard row per slice, derived from side
|
|
2
|
+
// effects only (git, the forge, the ledger), never from what a worker said.
|
|
3
|
+
//
|
|
4
|
+
// Per slice: whether its branch exists and its head, the age of its last
|
|
5
|
+
// commit, its PR and CI state (when the forge answers), the ledger verdict on
|
|
6
|
+
// that head, and whether it landed (fanout-landed.ts, squash merges
|
|
7
|
+
// included). A slice is STUCK when it started, has not landed, has no
|
|
8
|
+
// accepted verdict, and nothing moved for longer than the threshold: no new
|
|
9
|
+
// commit or branch update, no ledger row for its branch or slice. The
|
|
10
|
+
// threshold is --stuck-after, else the slice's TIMEBOX when it reads as a
|
|
11
|
+
// duration, else 30 minutes.
|
|
12
|
+
//
|
|
13
|
+
// Landable: not landed, an accepted verdict on the current head and, when the
|
|
14
|
+
// forge answered, an open PR whose checks pass. The suggested landing order
|
|
15
|
+
// walks the plan's order (dependencies first), skips landed slices and keeps
|
|
16
|
+
// a slice only once each dependency has landed or comes earlier in the order.
|
|
17
|
+
// Without the forge (offline, no gh/glab) the rest still works and says so.
|
|
18
|
+
import { parseDuration } from "./duration";
|
|
19
|
+
import { checksState, gatingChecks, missingRequired, type ChecksState } from "./forge/report";
|
|
20
|
+
import type { ResolvedForge } from "./forge/resolve";
|
|
21
|
+
import { checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
|
|
22
|
+
import { sliceLandings } from "./fanout-check";
|
|
23
|
+
import { forgeBreaker } from "./fanout-landed";
|
|
24
|
+
import {
|
|
25
|
+
branchRef,
|
|
26
|
+
fanoutFail,
|
|
27
|
+
gitRun,
|
|
28
|
+
landingOrder,
|
|
29
|
+
trunkRef,
|
|
30
|
+
type FanoutFile,
|
|
31
|
+
type FanoutResult,
|
|
32
|
+
type Slice,
|
|
33
|
+
} from "./fanout";
|
|
34
|
+
import type { LandedHow } from "./fanout-landed";
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_STUCK_MS = 30 * 60_000;
|
|
37
|
+
|
|
38
|
+
export type SliceState = "landed" | "ready" | "stuck" | "active" | "waiting" | "not_started";
|
|
39
|
+
|
|
40
|
+
export type SliceStatusRow = {
|
|
41
|
+
id: string;
|
|
42
|
+
branch: string;
|
|
43
|
+
base: string;
|
|
44
|
+
worktree: string;
|
|
45
|
+
dependsOn: string[];
|
|
46
|
+
branchExists: boolean;
|
|
47
|
+
head: string | null;
|
|
48
|
+
lastCommitAt: string | null;
|
|
49
|
+
/** Newest side effect: commit, branch update (reflog) or ledger row. */
|
|
50
|
+
lastActivityAt: string | null;
|
|
51
|
+
idleMinutes: number | null;
|
|
52
|
+
stuckAfterMinutes: number;
|
|
53
|
+
pr: { number: number; url: string; state: string } | null;
|
|
54
|
+
/** passing | failing | pending | none, or unknown without the forge or a PR. */
|
|
55
|
+
ci: ChecksState | "unknown";
|
|
56
|
+
verdict: { accepted: boolean; basis: VerdictBasis; review: string };
|
|
57
|
+
landed: { how: LandedHow; pr: number | null } | null;
|
|
58
|
+
stuck: boolean;
|
|
59
|
+
/** Dependencies that have not landed. */
|
|
60
|
+
waitsFor: string[];
|
|
61
|
+
state: SliceState;
|
|
62
|
+
/** Why it is not landable yet. */
|
|
63
|
+
reasons: string[];
|
|
64
|
+
notes: string[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type StatusOutcome = {
|
|
68
|
+
name: string;
|
|
69
|
+
trunk: string;
|
|
70
|
+
trunkRef: string | null;
|
|
71
|
+
forge: { available: boolean; error: string | null };
|
|
72
|
+
slices: SliceStatusRow[];
|
|
73
|
+
/** Landable slices in the order to land them. */
|
|
74
|
+
landingOrder: string[];
|
|
75
|
+
/** Not started; every dependency landed (or, stacked, its parent has an accepted verdict). */
|
|
76
|
+
spawnable: string[];
|
|
77
|
+
stuck: string[];
|
|
78
|
+
next: string;
|
|
79
|
+
notes: string[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type StatusOptions = {
|
|
83
|
+
forge: ResolvedForge | null;
|
|
84
|
+
/** Why the forge is not available (shown in notes). */
|
|
85
|
+
forgeError?: string | null;
|
|
86
|
+
stuckAfterMs?: number | null;
|
|
87
|
+
now?: Date;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** A TIMEBOX such as `45 minutes`, `2h` or `1h30m`, in ms; null without a unit or otherwise. */
|
|
91
|
+
export function timeboxMs(text: string | null): number | null {
|
|
92
|
+
if (!text || /^\s*\d+(?:\.\d+)?\s*$/u.test(text)) return null;
|
|
93
|
+
const ms = parseDuration(text);
|
|
94
|
+
return ms && ms > 0 ? ms : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isoOf = (seconds: string): string | null => {
|
|
98
|
+
const value = Number(seconds.trim());
|
|
99
|
+
return Number.isFinite(value) && value > 0 ? new Date(value * 1000).toISOString() : null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const newest = (...values: Array<string | null | undefined>): string | null =>
|
|
103
|
+
values
|
|
104
|
+
.filter((value): value is string => Boolean(value) && !Number.isNaN(Date.parse(value ?? "")))
|
|
105
|
+
.reduce<string | null>(
|
|
106
|
+
(best, value) => (best === null || Date.parse(value) > Date.parse(best) ? value : best),
|
|
107
|
+
null,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
function activity(cwd: string, plan: FanoutFile, slice: Slice, rows: readonly ReadRow[]) {
|
|
111
|
+
const head = branchRef(cwd, slice.branch);
|
|
112
|
+
const commit = head ? isoOf(gitRun(cwd, ["log", "-1", "--format=%ct", head, "--"]).stdout) : null;
|
|
113
|
+
// `refs/heads/<b>@{<unix time>}`: when the local branch last moved.
|
|
114
|
+
const moved = /@\{(\d+)\}/u.exec(
|
|
115
|
+
gitRun(cwd, [
|
|
116
|
+
"log",
|
|
117
|
+
"-g",
|
|
118
|
+
"-1",
|
|
119
|
+
"--date=unix",
|
|
120
|
+
"--format=%gd",
|
|
121
|
+
`refs/heads/${slice.branch}`,
|
|
122
|
+
"--",
|
|
123
|
+
]).stdout,
|
|
124
|
+
);
|
|
125
|
+
const reflog = moved ? isoOf(moved[1]) : null;
|
|
126
|
+
const ledger = rows
|
|
127
|
+
.filter(
|
|
128
|
+
(row) => row.branch === slice.branch || (row.fanout === plan.name && row.slice === slice.id),
|
|
129
|
+
)
|
|
130
|
+
.map((row) => row.at);
|
|
131
|
+
return {
|
|
132
|
+
head,
|
|
133
|
+
commit,
|
|
134
|
+
last: newest(commit, reflog, ...ledger),
|
|
135
|
+
worktreeCreated: rows.some(
|
|
136
|
+
(row) =>
|
|
137
|
+
row.type === "fanout.worktree.created" &&
|
|
138
|
+
row.fanout === plan.name &&
|
|
139
|
+
row.slice === slice.id,
|
|
140
|
+
),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function fanoutStatus(
|
|
145
|
+
cwd: string,
|
|
146
|
+
plan: FanoutFile,
|
|
147
|
+
options: StatusOptions,
|
|
148
|
+
): FanoutResult<StatusOutcome> {
|
|
149
|
+
const now = options.now ?? new Date();
|
|
150
|
+
const breaker = forgeBreaker(options.forge);
|
|
151
|
+
const forge = breaker.forge;
|
|
152
|
+
const trunk = trunkRef(cwd, plan.trunk);
|
|
153
|
+
if (!trunk)
|
|
154
|
+
return fanoutFail(
|
|
155
|
+
"unavailable",
|
|
156
|
+
`trunk ${plan.trunk} does not resolve here`,
|
|
157
|
+
`git fetch origin ${plan.trunk}`,
|
|
158
|
+
);
|
|
159
|
+
const ledger = readLedger(cwd);
|
|
160
|
+
const rows: readonly ReadRow[] = ledger.ok ? ledger.value.rows : [];
|
|
161
|
+
const notes: string[] = [];
|
|
162
|
+
if (!ledger.ok) notes.push(`ledger unreadable (${ledger.error}); verdicts read as missing`);
|
|
163
|
+
if (!forge)
|
|
164
|
+
notes.push(
|
|
165
|
+
`forge unavailable${options.forgeError ? ` (${options.forgeError})` : ""}: PR and CI not shown; landed detection uses git only, so a squash merge may be missed`,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const tips = new Map(plan.slices.map((slice) => [slice.id, branchRef(cwd, slice.branch)]));
|
|
169
|
+
const landings = sliceLandings(cwd, plan, trunk, tips, rows, forge);
|
|
170
|
+
const byId = new Map(plan.slices.map((slice) => [slice.id, slice]));
|
|
171
|
+
const order = landingOrder(plan.slices);
|
|
172
|
+
const out = new Map<string, SliceStatusRow>();
|
|
173
|
+
|
|
174
|
+
for (const id of order) {
|
|
175
|
+
const slice = byId.get(id) as Slice;
|
|
176
|
+
const landing = landings.get(id);
|
|
177
|
+
const seen = activity(cwd, plan, slice, rows);
|
|
178
|
+
const threshold = options.stuckAfterMs ?? timeboxMs(slice.timebox) ?? DEFAULT_STUCK_MS;
|
|
179
|
+
const idle = seen.last ? Math.max(0, now.getTime() - Date.parse(seen.last)) : null;
|
|
180
|
+
const verdict = seen.head ? checkVerdicts(cwd, slice.branch, rows) : null;
|
|
181
|
+
const row: SliceStatusRow = {
|
|
182
|
+
id,
|
|
183
|
+
branch: slice.branch,
|
|
184
|
+
base: slice.base,
|
|
185
|
+
worktree: slice.worktree,
|
|
186
|
+
dependsOn: slice.dependsOn,
|
|
187
|
+
branchExists: seen.head !== null,
|
|
188
|
+
head: seen.head,
|
|
189
|
+
lastCommitAt: seen.commit,
|
|
190
|
+
lastActivityAt: seen.last,
|
|
191
|
+
idleMinutes: idle === null ? null : Math.floor(idle / 60_000),
|
|
192
|
+
stuckAfterMinutes: Math.round(threshold / 60_000),
|
|
193
|
+
pr: landing?.pr
|
|
194
|
+
? { number: landing.pr.number, url: landing.pr.url, state: landing.pr.state }
|
|
195
|
+
: null,
|
|
196
|
+
ci: "unknown",
|
|
197
|
+
verdict: {
|
|
198
|
+
accepted: verdict?.accepted.accepted ?? false,
|
|
199
|
+
basis: verdict?.current.basis ?? "none",
|
|
200
|
+
review: verdict?.review ?? "unreviewed",
|
|
201
|
+
},
|
|
202
|
+
landed:
|
|
203
|
+
landing?.landed && landing.how
|
|
204
|
+
? { how: landing.how, pr: landing.pr?.number ?? null }
|
|
205
|
+
: null,
|
|
206
|
+
stuck: false,
|
|
207
|
+
waitsFor: [],
|
|
208
|
+
state: "not_started",
|
|
209
|
+
reasons: [],
|
|
210
|
+
notes: [
|
|
211
|
+
...(landing?.note ? [landing.note] : []),
|
|
212
|
+
...(landing?.forgeError ? [`forge: ${landing.forgeError}`] : []),
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
const answered = forge !== null && !landing?.forgeError;
|
|
216
|
+
if (answered && landing?.pr && landing.pr.state === "open" && !row.landed) {
|
|
217
|
+
const status = forge.forge.prStatus(landing.pr.number);
|
|
218
|
+
if (status.ok)
|
|
219
|
+
row.ci = checksState(gatingChecks(status.data).checks, missingRequired(status.data));
|
|
220
|
+
else row.notes.push(`forge: ${status.error}`);
|
|
221
|
+
}
|
|
222
|
+
row.waitsFor = slice.dependsOn.filter((dep) => !out.get(dep)?.landed);
|
|
223
|
+
out.set(id, row);
|
|
224
|
+
if (row.landed) {
|
|
225
|
+
row.state = "landed";
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const started = row.branchExists || seen.worktreeCreated;
|
|
229
|
+
if (!row.branchExists) row.reasons.push("no branch yet");
|
|
230
|
+
if (!row.verdict.accepted)
|
|
231
|
+
row.reasons.push(
|
|
232
|
+
`verdict ${row.verdict.basis === "none" ? "missing" : row.verdict.basis} on the current head`,
|
|
233
|
+
);
|
|
234
|
+
if (answered && row.branchExists) {
|
|
235
|
+
if (!row.pr) row.reasons.push("no PR");
|
|
236
|
+
else if (row.pr.state !== "open") row.reasons.push(`PR ${row.pr.state}`);
|
|
237
|
+
else if (row.ci === "failing" || row.ci === "pending") row.reasons.push(`CI ${row.ci}`);
|
|
238
|
+
}
|
|
239
|
+
row.stuck = started && !row.verdict.accepted && idle !== null && idle > threshold;
|
|
240
|
+
if (row.stuck) row.reasons.push(`no activity for ${row.idleMinutes} min`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Landing order: landable slices whose dependencies landed or land earlier.
|
|
244
|
+
const suggested: string[] = [];
|
|
245
|
+
for (const id of order) {
|
|
246
|
+
const row = out.get(id) as SliceStatusRow;
|
|
247
|
+
if (row.landed || row.reasons.length) continue;
|
|
248
|
+
if (row.waitsFor.every((dep) => suggested.includes(dep))) suggested.push(id);
|
|
249
|
+
}
|
|
250
|
+
const spawnable: string[] = [];
|
|
251
|
+
for (const id of order) {
|
|
252
|
+
const row = out.get(id) as SliceStatusRow;
|
|
253
|
+
if (row.landed) continue;
|
|
254
|
+
const parent = plan.slices.find((other) => other.branch === row.base);
|
|
255
|
+
if (suggested.includes(id)) row.state = "ready";
|
|
256
|
+
else if (row.stuck) row.state = "stuck";
|
|
257
|
+
// Work on a branch is in progress even while its dependency has not landed.
|
|
258
|
+
else if (row.branchExists) row.state = "active";
|
|
259
|
+
else if (
|
|
260
|
+
row.waitsFor.length === 0 ||
|
|
261
|
+
(parent &&
|
|
262
|
+
row.waitsFor.length === 1 &&
|
|
263
|
+
row.waitsFor[0] === parent.id &&
|
|
264
|
+
out.get(parent.id)?.verdict.accepted)
|
|
265
|
+
) {
|
|
266
|
+
const created = rows.some(
|
|
267
|
+
(candidate) =>
|
|
268
|
+
candidate.type === "fanout.worktree.created" &&
|
|
269
|
+
candidate.fanout === plan.name &&
|
|
270
|
+
candidate.slice === id,
|
|
271
|
+
);
|
|
272
|
+
row.state = created ? "active" : "not_started";
|
|
273
|
+
if (!created) spawnable.push(id);
|
|
274
|
+
} else row.state = "waiting";
|
|
275
|
+
if (row.waitsFor.length && !suggested.includes(id))
|
|
276
|
+
row.reasons.push(`waits for ${row.waitsFor.join(", ")}`);
|
|
277
|
+
}
|
|
278
|
+
const stuck = order.filter((id) => out.get(id)?.stuck);
|
|
279
|
+
const down = breaker.down();
|
|
280
|
+
if (down)
|
|
281
|
+
notes.push(
|
|
282
|
+
`forge went down during this run (${down}): later slices show no PR or CI, and git alone decided whether they landed`,
|
|
283
|
+
);
|
|
284
|
+
const online = forge !== null && !down;
|
|
285
|
+
// Verified, but the forge has no PR for them yet.
|
|
286
|
+
const noPr = order.filter((id) => {
|
|
287
|
+
const row = out.get(id) as SliceStatusRow;
|
|
288
|
+
return !row.landed && row.reasons.length === 1 && row.reasons[0] === "no PR";
|
|
289
|
+
});
|
|
290
|
+
const next = stuck.length
|
|
291
|
+
? `replace ${stuck.join(", ")}: stop the worker, then workit fanout worktree release ${stuck[0]} (records git status first) and respawn it in MODE: resume`
|
|
292
|
+
: suggested.length
|
|
293
|
+
? online
|
|
294
|
+
? `land in this order: ${suggested.join(", ")} (workit fanout check first; workit pr merge each)`
|
|
295
|
+
: `land in this order: ${suggested.join(", ")} once each has a PR (offline: PRs unchecked; open a missing one with workit git push && workit pr create --fill, then workit fanout status online)`
|
|
296
|
+
: noPr.length
|
|
297
|
+
? `open PRs for ${noPr.join(", ")}: workit git push && workit pr create --fill on each branch`
|
|
298
|
+
: spawnable.length
|
|
299
|
+
? `spawn ${spawnable.join(", ")} (workit fanout worktree create <slice> on hosts without native worktrees)`
|
|
300
|
+
: order.every((id) => out.get(id)?.landed)
|
|
301
|
+
? "every slice has landed"
|
|
302
|
+
: "wait for workers, then workit fanout status again";
|
|
303
|
+
return {
|
|
304
|
+
ok: true,
|
|
305
|
+
data: {
|
|
306
|
+
name: plan.name,
|
|
307
|
+
trunk: plan.trunk,
|
|
308
|
+
trunkRef: trunk,
|
|
309
|
+
forge: {
|
|
310
|
+
available: online,
|
|
311
|
+
error: online ? null : (down ?? options.forgeError ?? null),
|
|
312
|
+
},
|
|
313
|
+
slices: order.map((id) => out.get(id) as SliceStatusRow),
|
|
314
|
+
landingOrder: suggested,
|
|
315
|
+
spawnable,
|
|
316
|
+
stuck,
|
|
317
|
+
next,
|
|
318
|
+
notes,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|