@pmelab/gtd 38.1.0 → 39.0.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/.claude-plugin/plugin.json +1 -1
- package/README.md +41 -7
- package/dist/chunks/{Schema-DO5ttJaI.mjs → Schema-C6fLlQzx.mjs} +1 -1
- package/dist/chunks/{reserved-CfQ8eurM.mjs → reserved-B6tTYjeb.mjs} +23 -6
- package/dist/chunks/{tail-C7yaWvaS.mjs → tail-_CZTP7Hu.mjs} +1 -1
- package/dist/gtd.bundle.mjs +300 -52
- package/dist/testkit.mjs +265 -20
- package/dist/workflow-kit.mjs +12 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +2 -2
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +34 -6
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +15 -3
- package/src/cli/Cli.ts +62 -28
- package/src/cli/History.ts +1 -19
- package/src/cli/Metrics.test.ts +255 -0
- package/src/cli/Metrics.ts +318 -0
- package/src/cli/Output.ts +13 -0
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +4 -0
- package/src/workflow-kit/health.ts +15 -1
- package/src/workflows/cleanup/cleanup.test.ts +2 -0
- package/src/workflows/feature/refactor.test.ts +4 -0
- package/src/workflows/rollout/rollout.feature +16 -0
- package/src/workflows/rollout/rollout.test.ts +2 -0
- package/src/workflows/shared/health.test.ts +2 -0
- package/src/workflows/shared/health.ts +34 -5
- package/src/workflows/shared/recheck.feature +60 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { Effect, Option } from "effect"
|
|
2
|
+
import { GitService } from "../platform/index.js"
|
|
3
|
+
import { parseCommitMessage } from "../replay/index.js"
|
|
4
|
+
import { UNATTRIBUTED_MODEL } from "../wire/index.js"
|
|
5
|
+
import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
|
|
6
|
+
import { writeJson, type ArtifactOut, type CommandRequirements, type JsonMode } from "./Output.js"
|
|
7
|
+
|
|
8
|
+
// Read from commit subjects and trailers alone, never by replay: a metric over
|
|
9
|
+
// old histories must not refuse because today's config has moved on. The step
|
|
10
|
+
// names below are the bundled feature workflow's public ones.
|
|
11
|
+
|
|
12
|
+
const QUALITY = ".gtd/QUALITY.md"
|
|
13
|
+
|
|
14
|
+
export interface MetricCommit {
|
|
15
|
+
readonly message: string
|
|
16
|
+
/** Findings this commit added to the quality lap's file; only a reviewing turn's count is read. */
|
|
17
|
+
readonly findings: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Metrics {
|
|
21
|
+
readonly slices: {
|
|
22
|
+
readonly decided: number
|
|
23
|
+
readonly landed: number
|
|
24
|
+
readonly rebuilt: number
|
|
25
|
+
readonly refactored: number
|
|
26
|
+
}
|
|
27
|
+
/** `rebuilt / decided`, to three places; null without a decision. */
|
|
28
|
+
readonly refactorFirstRate: number | null
|
|
29
|
+
/** Continuation turns per phase, keyed by the step without its `continue` segment. */
|
|
30
|
+
readonly continuations: Readonly<Record<string, number>>
|
|
31
|
+
readonly cost: {
|
|
32
|
+
readonly total: number
|
|
33
|
+
readonly byModel: Readonly<Record<string, number>>
|
|
34
|
+
readonly byPhase: {
|
|
35
|
+
readonly naive: number
|
|
36
|
+
readonly refactor: number
|
|
37
|
+
readonly implement: number
|
|
38
|
+
readonly other: number
|
|
39
|
+
}
|
|
40
|
+
/** A rebuilt slice's naive pass and the implement turns that rebuilt it. */
|
|
41
|
+
readonly doubleWork: {
|
|
42
|
+
readonly naive: number
|
|
43
|
+
readonly rebuild: number
|
|
44
|
+
readonly total: number
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
readonly findings: { readonly total: number; readonly byLens: Readonly<Record<string, number>> }
|
|
48
|
+
/** Keyed by the re-check's full step name, from its subject: the mark's value is scope-relative. */
|
|
49
|
+
readonly flaky: { readonly total: number; readonly byStep: Readonly<Record<string, number>> }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Phase = keyof Metrics["cost"]["byPhase"]
|
|
53
|
+
|
|
54
|
+
// Keyed by the lap's path without `.reviewing`, so the refactor lap's lenses stay apart from the feature's.
|
|
55
|
+
const LENS = /^((?:.*\.)?quality\.[^.]+)\.reviewing$/
|
|
56
|
+
|
|
57
|
+
const CONTINUE = /(^|\.)continue\./
|
|
58
|
+
|
|
59
|
+
const inSeam = (step: string): boolean => step.startsWith("seam.")
|
|
60
|
+
|
|
61
|
+
// A naive retry after a continuation runs under `seam.fresh.naive`: any seam step named `naive` is the pass.
|
|
62
|
+
const phaseOf = (step: string): Phase => {
|
|
63
|
+
const scope = step.split(".")[0]
|
|
64
|
+
if (inSeam(step) && step.endsWith(".naive")) return "naive"
|
|
65
|
+
if (step === "seam.note" || scope === "refactor") return "refactor"
|
|
66
|
+
if (scope === "implement") return "implement"
|
|
67
|
+
return "other"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const round = (n: number, places = 6): number => {
|
|
71
|
+
const f = 10 ** places
|
|
72
|
+
return Math.round(n * f) / f
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Keys are model, step and lens names from history: a prototype-free record keeps `constructor` a plain key.
|
|
76
|
+
const record = (): Record<string, number> => Object.create(null) as Record<string, number>
|
|
77
|
+
|
|
78
|
+
const add = (into: Record<string, number>, key: string, n: number): void => {
|
|
79
|
+
into[key] = (into[key] ?? 0) + n
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const roundAll = (record: Record<string, number>): Record<string, number> =>
|
|
83
|
+
Object.fromEntries(Object.entries(record).map(([k, v]) => [k, round(v)]))
|
|
84
|
+
|
|
85
|
+
interface Tally {
|
|
86
|
+
readonly slices: { decided: number; landed: number; rebuilt: number; refactored: number }
|
|
87
|
+
readonly continuations: Record<string, number>
|
|
88
|
+
readonly byModel: Record<string, number>
|
|
89
|
+
readonly byPhase: Record<Phase, number>
|
|
90
|
+
readonly doubleWork: { naive: number; rebuild: number }
|
|
91
|
+
readonly byLens: Record<string, number>
|
|
92
|
+
readonly flaky: Record<string, number>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const emptyTally = (): Tally => ({
|
|
96
|
+
slices: { decided: 0, landed: 0, rebuilt: 0, refactored: 0 },
|
|
97
|
+
continuations: record(),
|
|
98
|
+
byModel: record(),
|
|
99
|
+
byPhase: { naive: 0, refactor: 0, implement: 0, other: 0 },
|
|
100
|
+
doubleWork: { naive: 0, rebuild: 0 },
|
|
101
|
+
byLens: record(),
|
|
102
|
+
flaky: record(),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const sum = (record: Readonly<Record<string, number>>): number =>
|
|
106
|
+
Object.values(record).reduce((a, b) => a + b, 0)
|
|
107
|
+
|
|
108
|
+
const finish = (t: Tally): Metrics => ({
|
|
109
|
+
slices: t.slices,
|
|
110
|
+
refactorFirstRate: t.slices.decided === 0 ? null : round(t.slices.rebuilt / t.slices.decided, 3),
|
|
111
|
+
continuations: t.continuations,
|
|
112
|
+
cost: {
|
|
113
|
+
total: round(sum(t.byModel)),
|
|
114
|
+
byModel: roundAll(t.byModel),
|
|
115
|
+
byPhase: roundAll(t.byPhase) as Metrics["cost"]["byPhase"],
|
|
116
|
+
doubleWork: {
|
|
117
|
+
naive: round(t.doubleWork.naive),
|
|
118
|
+
rebuild: round(t.doubleWork.rebuild),
|
|
119
|
+
total: round(t.doubleWork.naive + t.doubleWork.rebuild),
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
findings: { total: sum(t.byLens), byLens: t.byLens },
|
|
123
|
+
flaky: { total: sum(t.flaky), byStep: t.flaky },
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
/** The open slice: its naive cost so far, and whether its implement turns rebuild it. */
|
|
127
|
+
interface Slice {
|
|
128
|
+
naive: number
|
|
129
|
+
rebuilding: boolean
|
|
130
|
+
/** Landed, not yet applied, with its naive cost: an apply that fails turns it into a rebuild. */
|
|
131
|
+
applying: number | undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tallyCost = (
|
|
135
|
+
t: Tally,
|
|
136
|
+
open: Slice,
|
|
137
|
+
costs: ReturnType<typeof parseCommitMessage>["cost"],
|
|
138
|
+
phase: Phase,
|
|
139
|
+
): void => {
|
|
140
|
+
for (const { cost, model } of costs) {
|
|
141
|
+
add(t.byModel, model ?? UNATTRIBUTED_MODEL, cost)
|
|
142
|
+
t.byPhase[phase] += cost
|
|
143
|
+
if (phase === "naive") open.naive += cost
|
|
144
|
+
if (phase === "implement" && open.rebuilding) t.doubleWork.rebuild += cost
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A slice is decided where its turns leave the seam: for `implement.applying` the naive pass landed — unless it fails to apply and is built afresh — anywhere else it is rebuilt. */
|
|
149
|
+
const tallyDecision = (t: Tally, open: Slice, at: string, to: string): void => {
|
|
150
|
+
// The slice's closing ends its rebuild: later implement turns (an amend after review) are new work.
|
|
151
|
+
if (at === "implement.closing") open.rebuilding = false
|
|
152
|
+
if (open.applying !== undefined && at === "implement.applying") {
|
|
153
|
+
const naive = open.applying
|
|
154
|
+
open.applying = undefined
|
|
155
|
+
if (to !== "implement.building") return
|
|
156
|
+
t.slices.landed--
|
|
157
|
+
rebuild(t, open, naive)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (!inSeam(at) || inSeam(to)) return
|
|
161
|
+
t.slices.decided++
|
|
162
|
+
const naive = open.naive
|
|
163
|
+
open.naive = 0
|
|
164
|
+
if (to === "implement.applying") {
|
|
165
|
+
open.applying = naive
|
|
166
|
+
open.rebuilding = false
|
|
167
|
+
t.slices.landed++
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
rebuild(t, open, naive)
|
|
171
|
+
if (at === "seam.note") t.slices.refactored++
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const rebuild = (t: Tally, open: Slice, naive: number): void => {
|
|
175
|
+
open.rebuilding = true
|
|
176
|
+
t.slices.rebuilt++
|
|
177
|
+
t.doubleWork.naive += naive
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Oldest first; a commit gtd did not write is skipped. */
|
|
181
|
+
export const processMetrics = (commits: readonly MetricCommit[]): Metrics => {
|
|
182
|
+
const t = emptyTally()
|
|
183
|
+
const open: Slice = { naive: 0, rebuilding: false, applying: undefined }
|
|
184
|
+
for (const commit of commits) {
|
|
185
|
+
const message = parseCommitMessage(commit.message)
|
|
186
|
+
const subject = message.parsed
|
|
187
|
+
if (subject === undefined) continue
|
|
188
|
+
const at = subject.from ?? subject.to
|
|
189
|
+
tallyCost(t, open, message.cost, phaseOf(at))
|
|
190
|
+
if (subject.actor === "agent" && CONTINUE.test(at))
|
|
191
|
+
add(t.continuations, at.replace(CONTINUE, "$1"), 1)
|
|
192
|
+
const lens = LENS.exec(at)?.[1]
|
|
193
|
+
if (lens !== undefined && commit.findings > 0) add(t.byLens, lens, commit.findings)
|
|
194
|
+
if (message.marks.some((m) => m.name === "flaky")) add(t.flaky, at, 1)
|
|
195
|
+
tallyDecision(t, open, at, subject.to)
|
|
196
|
+
}
|
|
197
|
+
return finish(t)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const totalMetrics = (all: readonly Metrics[]): Metrics & { readonly processes: number } => {
|
|
201
|
+
const t = emptyTally()
|
|
202
|
+
for (const m of all) {
|
|
203
|
+
for (const key of Object.keys(t.slices) as (keyof Tally["slices"])[])
|
|
204
|
+
t.slices[key] += m.slices[key]
|
|
205
|
+
for (const [k, v] of Object.entries(m.continuations)) add(t.continuations, k, v)
|
|
206
|
+
for (const [k, v] of Object.entries(m.cost.byModel)) add(t.byModel, k, v)
|
|
207
|
+
for (const key of Object.keys(t.byPhase) as Phase[]) t.byPhase[key] += m.cost.byPhase[key]
|
|
208
|
+
t.doubleWork.naive += m.cost.doubleWork.naive
|
|
209
|
+
t.doubleWork.rebuild += m.cost.doubleWork.rebuild
|
|
210
|
+
for (const [k, v] of Object.entries(m.findings.byLens)) add(t.byLens, k, v)
|
|
211
|
+
for (const [k, v] of Object.entries(m.flaky.byStep)) add(t.flaky, k, v)
|
|
212
|
+
}
|
|
213
|
+
return { processes: all.length, ...finish(t) }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const chunks = (content: string | undefined): number =>
|
|
217
|
+
content === undefined ? 0 : (content.match(/^## /gm) ?? []).length
|
|
218
|
+
|
|
219
|
+
export interface ProcessMetrics extends Metrics {
|
|
220
|
+
/** The opening commit's `Gtd-Workflow`; null for the default workflow. */
|
|
221
|
+
readonly workflow: string | null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const readProcessMetrics = (
|
|
225
|
+
entry: Pick<HistoryEntry, "entry" | "tip">,
|
|
226
|
+
): Effect.Effect<ProcessMetrics, Error, GitService> =>
|
|
227
|
+
Effect.gen(function* () {
|
|
228
|
+
const git = yield* GitService
|
|
229
|
+
const base = Option.getOrUndefined(yield* git.readRefOption(`${entry.entry}^`))
|
|
230
|
+
const commits = yield* git.commitHistory(base, entry.tip)
|
|
231
|
+
const quality = (hash: string) =>
|
|
232
|
+
git.readFileAtRef(hash, QUALITY).pipe(
|
|
233
|
+
Effect.map((content): string | undefined => content),
|
|
234
|
+
Effect.orElseSucceed(() => undefined),
|
|
235
|
+
)
|
|
236
|
+
// Only a commit that touched the file changes it: carry the last content forward.
|
|
237
|
+
let count = chunks(base === undefined ? undefined : yield* quality(base))
|
|
238
|
+
const read: MetricCommit[] = []
|
|
239
|
+
for (const { hash, message, touched } of commits) {
|
|
240
|
+
const before = count
|
|
241
|
+
if (touched.includes(QUALITY)) count = chunks(yield* quality(hash))
|
|
242
|
+
read.push({ message, findings: Math.max(0, count - before) })
|
|
243
|
+
}
|
|
244
|
+
const workflow =
|
|
245
|
+
commits[0] === undefined ? undefined : parseCommitMessage(commits[0].message).workflow
|
|
246
|
+
return { workflow: workflow ?? null, ...processMetrics(read) }
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// Keys come from fetched history: never let them put control bytes on a terminal.
|
|
250
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
251
|
+
const printable = (s: string): string => s.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, "?")
|
|
252
|
+
|
|
253
|
+
const listed = (record: Readonly<Record<string, number>>): string =>
|
|
254
|
+
Object.entries(record)
|
|
255
|
+
.map(([k, v]) => `${printable(k)} ${v}`)
|
|
256
|
+
.join(", ")
|
|
257
|
+
|
|
258
|
+
const line = (label: string, m: Metrics): string => {
|
|
259
|
+
const { slices, cost } = m
|
|
260
|
+
const percent = m.refactorFirstRate === null ? "-" : `${Math.round(m.refactorFirstRate * 100)}%`
|
|
261
|
+
const continued = sum(m.continuations)
|
|
262
|
+
return [
|
|
263
|
+
`${label}: ${slices.decided} slices, ${slices.rebuilt} rebuilt (refactor-first ${percent}), ${slices.refactored} refactored`,
|
|
264
|
+
`${continued} continuations${continued > 0 ? ` (${listed(m.continuations)})` : ""}`,
|
|
265
|
+
`cost ${cost.total} (${listed(cost.byPhase)}; by model ${listed(cost.byModel) || "-"}), double work ${cost.doubleWork.total}`,
|
|
266
|
+
`${m.findings.total} findings${m.findings.total > 0 ? ` (${listed(m.findings.byLens)})` : ""}`,
|
|
267
|
+
`${m.flaky.total} flaky${m.flaky.total > 0 ? ` (${listed(m.flaky.byStep)})` : ""}`,
|
|
268
|
+
].join("; ")
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const runHistoryMetricsCommand = (
|
|
272
|
+
refs: readonly string[],
|
|
273
|
+
json: JsonMode,
|
|
274
|
+
out: ArtifactOut,
|
|
275
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
276
|
+
Effect.gen(function* () {
|
|
277
|
+
const entries = yield* historyEntries
|
|
278
|
+
const chosen =
|
|
279
|
+
refs.length === 0
|
|
280
|
+
? entries
|
|
281
|
+
: (yield* Effect.forEach(refs, (ref) =>
|
|
282
|
+
// A branch name means every process kept from it, not just its newest.
|
|
283
|
+
resolveHistoryRef(ref, entries).pipe(
|
|
284
|
+
Effect.map((hit) =>
|
|
285
|
+
hit.branch === ref ? entries.filter((e) => e.branch === ref) : [hit],
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
)).flat()
|
|
289
|
+
const unique = chosen.filter((e, i) => chosen.findIndex((o) => o.ref === e.ref) === i)
|
|
290
|
+
const processes = yield* Effect.forEach(
|
|
291
|
+
unique,
|
|
292
|
+
(e) =>
|
|
293
|
+
readProcessMetrics(e).pipe(
|
|
294
|
+
Effect.map((metrics) => ({
|
|
295
|
+
ref: e.ref,
|
|
296
|
+
entry: e.entry,
|
|
297
|
+
branch: e.branch,
|
|
298
|
+
tip: e.tip,
|
|
299
|
+
...metrics,
|
|
300
|
+
})),
|
|
301
|
+
),
|
|
302
|
+
{ concurrency: 4 },
|
|
303
|
+
)
|
|
304
|
+
const document = { processes, total: totalMetrics(processes) }
|
|
305
|
+
if (yield* writeJson(json, out, document)) return
|
|
306
|
+
if (processes.length === 0) {
|
|
307
|
+
out.write("no history refs; run gtd history fetch | sh to pull them\n")
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
const lines = processes.map((p) =>
|
|
311
|
+
line(
|
|
312
|
+
`${p.entry.slice(0, 7)} ${printable(p.branch ?? "-")}${p.workflow === null ? "" : ` (${printable(p.workflow)})`}`,
|
|
313
|
+
p,
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
lines.push(line(`total (${processes.length} processes)`, document.total))
|
|
317
|
+
out.write(`${lines.join("\n")}\n`)
|
|
318
|
+
})
|
package/src/cli/Output.ts
CHANGED
|
@@ -68,3 +68,16 @@ export type CommandRequirements =
|
|
|
68
68
|
| ToolCommands
|
|
69
69
|
|
|
70
70
|
export type SuccessExit = typeof EXIT_OK | typeof EXIT_STRAYS_FOUND
|
|
71
|
+
|
|
72
|
+
/** Writes `document` per `json` (bare → whole, `=path` → one value); returns false for `off`. */
|
|
73
|
+
export const writeJson = (
|
|
74
|
+
json: JsonMode,
|
|
75
|
+
out: ArtifactOut,
|
|
76
|
+
document: unknown,
|
|
77
|
+
): Effect.Effect<boolean, Error> =>
|
|
78
|
+
Effect.gen(function* () {
|
|
79
|
+
if (json.kind === "off") return false
|
|
80
|
+
if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`)
|
|
81
|
+
else yield* writeSelection(out, document, json.path)
|
|
82
|
+
return true
|
|
83
|
+
})
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -1694,6 +1694,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1694
1694
|
judgeRun: { kind: "judgeRun", provider: "fixed" },
|
|
1695
1695
|
historyList: { kind: "historyList" },
|
|
1696
1696
|
historyShow: { kind: "historyShow", ref: "x" },
|
|
1697
|
+
historyMetrics: { kind: "historyMetrics", refs: [] },
|
|
1697
1698
|
historyFetch: { kind: "historyFetch", remote: "origin" },
|
|
1698
1699
|
strays: { kind: "strays", base: "HEAD", tip: "HEAD", globs: [] },
|
|
1699
1700
|
}
|
|
@@ -1705,7 +1706,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1705
1706
|
const NO_COMMITS_MESSAGE =
|
|
1706
1707
|
"gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
|
|
1707
1708
|
|
|
1708
|
-
it("derives exactly the
|
|
1709
|
+
it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
|
|
1709
1710
|
expect(stateKinds.sort()).toEqual(
|
|
1710
1711
|
[
|
|
1711
1712
|
"abandon",
|
|
@@ -1724,6 +1725,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1724
1725
|
"judgeAnswer",
|
|
1725
1726
|
"historyList",
|
|
1726
1727
|
"historyShow",
|
|
1728
|
+
"historyMetrics",
|
|
1727
1729
|
"historyFetch",
|
|
1728
1730
|
].sort(),
|
|
1729
1731
|
)
|
package/src/cli/Program.ts
CHANGED
|
@@ -109,6 +109,7 @@ export {
|
|
|
109
109
|
} from "./Output.js"
|
|
110
110
|
import { runStraysCommand } from "./Strays.js"
|
|
111
111
|
import { runHistoryFetchCommand, runHistoryListCommand, runHistoryShowCommand } from "./History.js"
|
|
112
|
+
import { runHistoryMetricsCommand } from "./Metrics.js"
|
|
112
113
|
|
|
113
114
|
/**
|
|
114
115
|
* `Edge.ts`'s `Rest` is module-private (`.gtd/packages/05-step-core.md`
|
|
@@ -174,6 +175,7 @@ export type Command =
|
|
|
174
175
|
| { readonly kind: "judgeAnswer" }
|
|
175
176
|
| { readonly kind: "historyList" }
|
|
176
177
|
| { readonly kind: "historyShow"; readonly ref: string }
|
|
178
|
+
| { readonly kind: "historyMetrics"; readonly refs: readonly string[] }
|
|
177
179
|
| { readonly kind: "historyFetch"; readonly remote: string }
|
|
178
180
|
| {
|
|
179
181
|
readonly kind: "strays"
|
|
@@ -1320,6 +1322,8 @@ const dispatchVoidCommand = (
|
|
|
1320
1322
|
return runHistoryListCommand(json, out)
|
|
1321
1323
|
case "historyShow":
|
|
1322
1324
|
return runHistoryShowCommand(command.ref, json, out)
|
|
1325
|
+
case "historyMetrics":
|
|
1326
|
+
return runHistoryMetricsCommand(command.refs, json, out)
|
|
1323
1327
|
case "historyFetch":
|
|
1324
1328
|
return runHistoryFetchCommand(command.remote, out)
|
|
1325
1329
|
}
|
|
@@ -44,16 +44,24 @@ export interface Health {
|
|
|
44
44
|
/** Shared with other callers in the same run of red checks. */
|
|
45
45
|
readonly escalations: EscalationCount
|
|
46
46
|
readonly escalation: EscalationSteps
|
|
47
|
+
/** Runs the suite once more before a person is asked; `true` ends the loop as if `check` had passed. */
|
|
48
|
+
readonly recheck?: () => Promise<boolean>
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
// check() stamps the report with the commit it ran at, so equal failures differ there.
|
|
50
52
|
const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/
|
|
51
53
|
const stripStamp = (text: string): string => text.replace(STAMP, "\n")
|
|
52
54
|
|
|
55
|
+
/** `true` when the re-check passed, else the red re-check's report — the one the escalation describes. */
|
|
56
|
+
const recheckReport = async (health: Health): Promise<true | string | undefined> => {
|
|
57
|
+
if (health.recheck === undefined) return undefined
|
|
58
|
+
return (await health.recheck()) || stripStamp(read(health.report) ?? "")
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
/**
|
|
54
62
|
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
55
63
|
* first once `fixCap` fixes are spent, or `sameFailure` calls the failure
|
|
56
|
-
* identical to the previous round's.
|
|
64
|
+
* identical to the previous round's, unless `recheck` passes.
|
|
57
65
|
*/
|
|
58
66
|
export const healthy = async (fix: () => Promise<void>, health: Health): Promise<void> => {
|
|
59
67
|
let fixes = health.fixesSoFar ?? 0
|
|
@@ -67,6 +75,12 @@ export const healthy = async (fix: () => Promise<void>, health: Health): Promise
|
|
|
67
75
|
const stuck = previous !== undefined && (await health.sameFailure(previous, current))
|
|
68
76
|
previous = current
|
|
69
77
|
if (stuck || fixes >= health.fixCap) {
|
|
78
|
+
const rechecked = await recheckReport(health)
|
|
79
|
+
if (rechecked === true) {
|
|
80
|
+
health.escalations.rounds = 0
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
previous = rechecked ?? previous
|
|
70
84
|
await escalation(health.escalations, health.escalation)
|
|
71
85
|
fixes = 0
|
|
72
86
|
}
|
|
@@ -160,6 +160,7 @@ describe("the cleanup guard over every later agent turn", () => {
|
|
|
160
160
|
"build.health.check": (_, r) => {
|
|
161
161
|
if (!stopped) r.write(FEEDBACK, `failing ${runs++}\n`)
|
|
162
162
|
},
|
|
163
|
+
"build.health.recheck": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
|
|
163
164
|
"build.health.describe": () => undefined,
|
|
164
165
|
"build.health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
|
|
165
166
|
"build.fix": (_, r) => r.write("src/other.ts", `export const n = ${runs}\n`),
|
|
@@ -176,6 +177,7 @@ describe("the cleanup guard over every later agent turn", () => {
|
|
|
176
177
|
await expect(
|
|
177
178
|
driven(guarded("", NO_ACCESS), {
|
|
178
179
|
"build.health.check": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
|
|
180
|
+
"build.health.recheck": (_, r) => r.write(FEEDBACK, `failing ${runs++}\n`),
|
|
179
181
|
"build.health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
|
|
180
182
|
"build.fix": (_, r) => r.write("src/other.ts", `export const n = ${runs}\n`),
|
|
181
183
|
"build.health.describe": (_, r) => r.write("src/b.ts", NAMED),
|
|
@@ -436,6 +436,8 @@ const drive = async (
|
|
|
436
436
|
checks++
|
|
437
437
|
phase = "check"
|
|
438
438
|
}
|
|
439
|
+
// Re-runs the red check it follows, so it reads red as well.
|
|
440
|
+
if (request.kind === "run" && name === "health.recheck") phase = "check"
|
|
439
441
|
return Promise.resolve(
|
|
440
442
|
request.kind === "judge" ? { answers: {}, truncated: [] } : undefined,
|
|
441
443
|
)
|
|
@@ -806,6 +808,8 @@ const driveReviewed = async (
|
|
|
806
808
|
// Red checks apply to the tail's own runs, never the refactor lap's.
|
|
807
809
|
checking = request.name === "health.check" && stack.includes("build")
|
|
808
810
|
if (checking && reds > 0) reds--
|
|
811
|
+
// Re-runs the red check it follows, so it reads red as well.
|
|
812
|
+
if (request.name === "health.recheck" && stack.includes("build")) checking = true
|
|
809
813
|
}
|
|
810
814
|
const lensEffects = ["correctness", "ponytail-review", "conventions"].map(
|
|
811
815
|
(lens): [string, () => void] => [
|
|
@@ -157,6 +157,10 @@ Feature: rollout turns one release flag default-on and touches nothing else
|
|
|
157
157
|
"""
|
|
158
158
|
1 test failing
|
|
159
159
|
"""
|
|
160
|
+
And step "rollout.suite.recheck" writes ".gtd/FEEDBACK.md" with:
|
|
161
|
+
"""
|
|
162
|
+
1 test still failing
|
|
163
|
+
"""
|
|
160
164
|
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
161
165
|
And the process is driven
|
|
162
166
|
Then the process rests at "rollout.suite.blocked"
|
|
@@ -176,6 +180,10 @@ Feature: rollout turns one release flag default-on and touches nothing else
|
|
|
176
180
|
"""
|
|
177
181
|
1 test failing
|
|
178
182
|
"""
|
|
183
|
+
And step "rollout.suite.recheck" writes ".gtd/FEEDBACK.md" with:
|
|
184
|
+
"""
|
|
185
|
+
1 test still failing
|
|
186
|
+
"""
|
|
179
187
|
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
180
188
|
And the process is driven
|
|
181
189
|
Then the process rests at "rollout.suite.blocked"
|
|
@@ -200,6 +208,10 @@ Feature: rollout turns one release flag default-on and touches nothing else
|
|
|
200
208
|
"""
|
|
201
209
|
1 test failing
|
|
202
210
|
"""
|
|
211
|
+
And step "rollout.suite.recheck" writes ".gtd/FEEDBACK.md" with:
|
|
212
|
+
"""
|
|
213
|
+
1 test still failing
|
|
214
|
+
"""
|
|
203
215
|
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
204
216
|
And the process is driven
|
|
205
217
|
Then the process rests at "rollout.suite.blocked"
|
|
@@ -229,6 +241,10 @@ Feature: rollout turns one release flag default-on and touches nothing else
|
|
|
229
241
|
"""
|
|
230
242
|
1 test failing
|
|
231
243
|
"""
|
|
244
|
+
And step "rollout.suite.recheck" writes ".gtd/FEEDBACK.md" with:
|
|
245
|
+
"""
|
|
246
|
+
1 test still failing
|
|
247
|
+
"""
|
|
232
248
|
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
233
249
|
And the process is driven
|
|
234
250
|
Then the process rests at "rollout.suite.blocked"
|
|
@@ -114,6 +114,7 @@ describe("the rollout workflow", () => {
|
|
|
114
114
|
const rest = await repo.drive({
|
|
115
115
|
"rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
|
|
116
116
|
"rollout.suite.check": (_, r) => r.write(".gtd/FEEDBACK.md", "1 failing\n"),
|
|
117
|
+
"rollout.suite.recheck": (_, r) => r.write(".gtd/FEEDBACK.md", "still 1 failing\n"),
|
|
117
118
|
})
|
|
118
119
|
expect(rest).toMatchObject({ state: "rollout.suite.blocked", actor: "human" })
|
|
119
120
|
})
|
|
@@ -125,6 +126,7 @@ describe("the rollout workflow", () => {
|
|
|
125
126
|
await repo.drive({
|
|
126
127
|
"rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
|
|
127
128
|
"rollout.suite.check": (_, r) => r.write(".gtd/FEEDBACK.md", "1 failing\n"),
|
|
129
|
+
"rollout.suite.recheck": (_, r) => r.write(".gtd/FEEDBACK.md", "still 1 failing\n"),
|
|
128
130
|
})
|
|
129
131
|
return repo
|
|
130
132
|
}
|
|
@@ -27,6 +27,7 @@ describe("gate guard", () => {
|
|
|
27
27
|
const red = {
|
|
28
28
|
first: (_: unknown, r: W) => r.write("f", "1\n"),
|
|
29
29
|
"suite.check": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", "red\n"),
|
|
30
|
+
"suite.recheck": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", "red again\n"),
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
it("runs the guard after a blocked landing and lets its refusal stop it", async () => {
|
|
@@ -66,6 +67,7 @@ describe("healthy describeGuard", () => {
|
|
|
66
67
|
let runs = 0
|
|
67
68
|
const handlers = (extra: Record<string, unknown>) => ({
|
|
68
69
|
"health.check": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", `red ${runs++}\n`),
|
|
70
|
+
"health.recheck": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", `red ${runs++}\n`),
|
|
69
71
|
"health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
|
|
70
72
|
fix: (_: unknown, r: W) => r.write("f", `${runs}\n`),
|
|
71
73
|
first: (_: unknown, r: W) => r.write("start", "1\n"),
|
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
env,
|
|
5
5
|
human,
|
|
6
6
|
judge,
|
|
7
|
+
mark,
|
|
7
8
|
numeric,
|
|
8
9
|
vars,
|
|
10
|
+
type CheckOptions,
|
|
9
11
|
type JudgeQuestion,
|
|
10
12
|
} from "../../flows/index.js"
|
|
11
13
|
import * as kit from "../../workflow-kit/index.js"
|
|
@@ -19,6 +21,24 @@ export const FIX_CAP = 3
|
|
|
19
21
|
export const baseline = (name: string, label = "Checking the baseline"): Promise<boolean> =>
|
|
20
22
|
check(name, env.testCommand ?? "", { report: FEEDBACK, label })
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Re-run a red check as step `name` before a person sees it. Green records
|
|
26
|
+
* `Gtd-Mark: flaky=<red>` on the re-check's landing and resolves `true`.
|
|
27
|
+
*/
|
|
28
|
+
export const recheck = async (
|
|
29
|
+
name: string,
|
|
30
|
+
red: string,
|
|
31
|
+
options: Pick<CheckOptions, "sweepOnGreen"> = {},
|
|
32
|
+
): Promise<boolean> => {
|
|
33
|
+
const green = await check(name, env.testCommand ?? "", {
|
|
34
|
+
...options,
|
|
35
|
+
report: FEEDBACK,
|
|
36
|
+
label: "Re-running checks",
|
|
37
|
+
})
|
|
38
|
+
if (green) mark("flaky", red)
|
|
39
|
+
return green
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
export type { EscalationCount } from "../../workflow-kit/index.js"
|
|
23
43
|
|
|
24
44
|
const escalationSteps = (rules?: string): kit.EscalationSteps => ({
|
|
@@ -117,18 +137,24 @@ export interface HealthOptions {
|
|
|
117
137
|
readonly rules?: string | undefined
|
|
118
138
|
}
|
|
119
139
|
|
|
140
|
+
// Swept only on green: an unresolved analysis survives every retry.
|
|
141
|
+
const healthSweep = (options: HealthOptions): string[] => [
|
|
142
|
+
ESCALATION,
|
|
143
|
+
...(options.sweepOnGreen ?? []),
|
|
144
|
+
]
|
|
145
|
+
|
|
120
146
|
const runSuite = (options: HealthOptions): Promise<boolean> =>
|
|
121
147
|
check("health.check", env.testCommand ?? "", {
|
|
122
148
|
report: FEEDBACK,
|
|
123
149
|
label: "Running checks",
|
|
124
|
-
|
|
125
|
-
sweepOnGreen: [ESCALATION, ...(options.sweepOnGreen ?? [])],
|
|
150
|
+
sweepOnGreen: healthSweep(options),
|
|
126
151
|
})
|
|
127
152
|
|
|
128
153
|
/**
|
|
129
154
|
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
130
155
|
* first once `FIX_CAP` fixes are spent, or a judge calls the failure
|
|
131
|
-
* identical to the previous round's.
|
|
156
|
+
* identical to the previous round's. An escalation re-runs the suite once
|
|
157
|
+
* first; a green re-run ends the loop.
|
|
132
158
|
*/
|
|
133
159
|
export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
|
|
134
160
|
kit.healthy(guarded(fix, options.guard), {
|
|
@@ -138,6 +164,8 @@ export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}):
|
|
|
138
164
|
fixesSoFar: options.fixesSoFar ?? 0,
|
|
139
165
|
sameFailure,
|
|
140
166
|
escalations: options.escalations ?? { rounds: 0 },
|
|
167
|
+
recheck: () =>
|
|
168
|
+
recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
|
|
141
169
|
escalation: guardedSteps(
|
|
142
170
|
options.guard,
|
|
143
171
|
options.describeGuard,
|
|
@@ -147,13 +175,14 @@ export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}):
|
|
|
147
175
|
})
|
|
148
176
|
|
|
149
177
|
/**
|
|
150
|
-
* Hold the process at `<name>.blocked` until the suite is green
|
|
178
|
+
* Hold the process at `<name>.blocked` until the suite is green, re-running a
|
|
179
|
+
* red check once as `<name>.recheck` before resting. `guard` runs
|
|
151
180
|
* after each `blocked` landing only — never after a check, whose refusal would
|
|
152
181
|
* re-run the same script forever.
|
|
153
182
|
*/
|
|
154
183
|
export const gate = (name: string, message: string, guard?: Guard): Promise<void> =>
|
|
155
184
|
kit.gate(
|
|
156
185
|
name,
|
|
157
|
-
() => baseline("check"),
|
|
186
|
+
async () => (await baseline("check")) || recheck("recheck", `${name}.check`),
|
|
158
187
|
guarded(() => human("blocked", { message, label: "Baseline is red", file: FEEDBACK }), guard),
|
|
159
188
|
)
|