@pmelab/gtd 42.0.0 → 43.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 +79 -35
- package/claude/hooks/sketch.ts +1 -1
- package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-COEMFEpy.mjs} +35 -46
- package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
- package/dist/chunks/{tail-D2o2h5cc.mjs → tail-C_xn8U7U.mjs} +1 -1
- package/dist/gtd.bundle.mjs +488 -30
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +447 -27
- package/dist/web/main.js +42 -13
- package/dist/workflow-kit.mjs +61 -25
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +31 -42
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +217 -121
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +33 -1
- package/src/cli/Cli.ts +70 -6
- package/src/cli/History.ts +15 -0
- package/src/cli/Judges.test.ts +373 -0
- package/src/cli/Judges.ts +624 -0
- package/src/cli/Metrics.ts +3 -16
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +9 -0
- package/src/steering/index.ts +12 -1
- package/src/steering/review.test.ts +107 -0
- package/src/steering/review.ts +159 -9
- package/src/testkit/EmittedScriptRecognizer.ts +0 -14
- package/src/testkit/steps.ts +4 -0
- package/src/workflow-kit/review.test.ts +170 -26
- package/src/workflow-kit/review.ts +95 -19
- package/src/workflows/feature/lens-review.feature +16 -4
- package/src/workflows/feature/review-answers.feature +179 -0
- package/src/workflows/feature/review.test.ts +5 -53
- package/src/workflows/feature/review.ts +31 -42
- package/src/workflows/feature/steps.test.ts +0 -12
- package/src/workflows/feature/steps.ts +3 -10
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
- package/src/workflows/shared/access.test.ts +2 -11
- package/src/workflows/shared/access.ts +0 -2
- package/src/workflows/shared/text.test.ts +40 -89
- package/src/workflows/shared/text.ts +52 -54
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { Effect, Option } from "effect"
|
|
2
|
+
import { answered, numeric } from "../flows/index.js"
|
|
3
|
+
import { GitService } from "../platform/index.js"
|
|
4
|
+
import { parseCommitMessage, type JudgeVerdict } from "../replay/index.js"
|
|
5
|
+
import { headingSectionBodies } from "../steering/index.js"
|
|
6
|
+
import { selectHistoryEntries, type HistoryEntry } from "./History.js"
|
|
7
|
+
import { printable } from "./Metrics.js"
|
|
8
|
+
import { writeJson, type ArtifactOut, type CommandRequirements, type JsonMode } from "./Output.js"
|
|
9
|
+
|
|
10
|
+
// Judge calibration reads recorded `Gtd-Judge:` verdicts and the trees around
|
|
11
|
+
// them, never replays and never asks a model: like the metrics, it must
|
|
12
|
+
// survive a config that has moved on. The step names, files and thresholds
|
|
13
|
+
// below are the bundled feature workflow's public ones.
|
|
14
|
+
|
|
15
|
+
const FEEDBACK = ".gtd/FEEDBACK.md"
|
|
16
|
+
const QUALITY = ".gtd/QUALITY.md"
|
|
17
|
+
const REVIEW = ".gtd/REVIEW.md"
|
|
18
|
+
const REQUIREMENTS = ".gtd/REQUIREMENTS.md"
|
|
19
|
+
|
|
20
|
+
export type JudgeName = "retry" | "triage" | "clear"
|
|
21
|
+
|
|
22
|
+
const JUDGE_STEPS: readonly (readonly [JudgeName, RegExp])[] = [
|
|
23
|
+
["retry", /(^|\.)health\.judge$/],
|
|
24
|
+
["triage", /(^|\.)quality\.triage$/],
|
|
25
|
+
["clear", /(^|\.)design\.clear$/],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** One `## ` finding of `.gtd/QUALITY.md`, numbered as the quality lap numbers it. */
|
|
29
|
+
export interface Finding {
|
|
30
|
+
readonly id: string
|
|
31
|
+
readonly title: string
|
|
32
|
+
readonly body: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What one commit of a kept process holds that a verdict's outcome or state reads. */
|
|
36
|
+
export interface JudgeCommit {
|
|
37
|
+
readonly hash: string
|
|
38
|
+
readonly message: string
|
|
39
|
+
/** The red report a health check wrote, stamp stripped; absent when the check was green. */
|
|
40
|
+
readonly feedback?: string
|
|
41
|
+
/** `.gtd/QUALITY.md`'s findings just before a triage verdict. */
|
|
42
|
+
readonly findings?: readonly Finding[]
|
|
43
|
+
/** `.gtd/REVIEW.md` around a commit that changed it. */
|
|
44
|
+
readonly review?: { readonly before: string; readonly after: string }
|
|
45
|
+
/** The commit changed the `## Acceptance Criteria` of `.gtd/REQUIREMENTS.md`. */
|
|
46
|
+
readonly criteriaChanged?: boolean
|
|
47
|
+
/** The commit changed a path outside `.gtd/`. */
|
|
48
|
+
readonly code?: true
|
|
49
|
+
/** The `TODO.md` the opening commit's sketch wrote. */
|
|
50
|
+
readonly sketch?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type Outcome = "overturned" | "held"
|
|
54
|
+
|
|
55
|
+
export interface Judgment {
|
|
56
|
+
readonly judge: JudgeName
|
|
57
|
+
readonly commit: string
|
|
58
|
+
readonly step: string
|
|
59
|
+
readonly question: string
|
|
60
|
+
/** The evidence the judge saw, as far as the tree still shows it. */
|
|
61
|
+
readonly state: Readonly<Record<string, string>>
|
|
62
|
+
readonly answer: JudgeVerdict["answer"]
|
|
63
|
+
readonly p: number
|
|
64
|
+
/** Null when nothing later in the process says whether the verdict held. */
|
|
65
|
+
readonly outcome: Outcome | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A threshold `--var` may re-decide past verdicts under. */
|
|
69
|
+
interface Threshold {
|
|
70
|
+
readonly judge: JudgeName
|
|
71
|
+
readonly decide: (answer: JudgeVerdict["answer"], p: number, minP: number) => string
|
|
72
|
+
/** Each decision as `[one, many, with an article]`. */
|
|
73
|
+
readonly labels: Readonly<Record<string, readonly [string, string, string]>>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Replay reads a boolean verdict as yes/no; so does every decision here.
|
|
77
|
+
const answerOf = (answer: JudgeVerdict["answer"]): string =>
|
|
78
|
+
typeof answer === "boolean" ? (answer ? "yes" : "no") : String(answer)
|
|
79
|
+
|
|
80
|
+
const verdictOf = (answer: JudgeVerdict["answer"], p: number) => ({ answer: answerOf(answer), p })
|
|
81
|
+
|
|
82
|
+
// Mirrors the flows: an answer counts only at or above the process's threshold.
|
|
83
|
+
export const THRESHOLDS: Readonly<Record<string, Threshold>> = {
|
|
84
|
+
judgeIdenticalMinP: {
|
|
85
|
+
judge: "retry",
|
|
86
|
+
decide: (answer, p, minP) =>
|
|
87
|
+
// Progress only changes anything at the fix cap, which history does not say was reached.
|
|
88
|
+
answered(verdictOf(answer, p), "identical", minP) ? "identical" : "retry",
|
|
89
|
+
labels: {
|
|
90
|
+
identical: ["escalation", "escalations", "an escalation"],
|
|
91
|
+
retry: ["plain retry", "plain retries", "a plain retry"],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
qualityTriageMinP: {
|
|
95
|
+
judge: "triage",
|
|
96
|
+
decide: (answer, p, minP) =>
|
|
97
|
+
(["mechanical", "noise"] as const).find((k) => answered(verdictOf(answer, p), k, minP)) ??
|
|
98
|
+
"design",
|
|
99
|
+
labels: {
|
|
100
|
+
mechanical: ["auto-fix", "auto-fixes", "an auto-fix"],
|
|
101
|
+
noise: ["noise drop", "noise drops", "a noise drop"],
|
|
102
|
+
design: ["note", "notes", "a note"],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface Read {
|
|
108
|
+
readonly commit: JudgeCommit
|
|
109
|
+
readonly actor: string | undefined
|
|
110
|
+
readonly at: string
|
|
111
|
+
readonly to: string
|
|
112
|
+
readonly judge: readonly JudgeVerdict[]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const UNTITLED = "Quality findings"
|
|
116
|
+
|
|
117
|
+
/** The quality lap's own numbering: prose outside every `## ` section is finding Q1. */
|
|
118
|
+
export const findingsOf = (text: string): readonly Finding[] => {
|
|
119
|
+
const sections = headingSectionBodies(text)
|
|
120
|
+
const stray = sections
|
|
121
|
+
.reduce((rest, s) => rest.replace(s.body, "\n"), text)
|
|
122
|
+
.split("\n")
|
|
123
|
+
.filter((line) => !/^#\s/.test(line))
|
|
124
|
+
.join("\n")
|
|
125
|
+
.trim()
|
|
126
|
+
return [
|
|
127
|
+
...(stray === "" ? [] : [{ title: UNTITLED, body: stray }]),
|
|
128
|
+
...sections.map((s) => ({
|
|
129
|
+
title: s.title,
|
|
130
|
+
body: s.body.split("\n").slice(1).join("\n").trim(),
|
|
131
|
+
})),
|
|
132
|
+
].map((f, i) => ({ id: `Q${i + 1}`, ...f }))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The criteria list, one item per line, wrapping undone: prose around it is no criterion. */
|
|
136
|
+
const criteriaOf = (text: string | undefined): string | undefined => {
|
|
137
|
+
if (text === undefined) return undefined
|
|
138
|
+
const section = headingSectionBodies(text).find((s) =>
|
|
139
|
+
/^acceptance criteria$/i.test(s.title.trim()),
|
|
140
|
+
)
|
|
141
|
+
if (section === undefined) return undefined
|
|
142
|
+
const items: string[] = []
|
|
143
|
+
let open = false
|
|
144
|
+
for (const line of section.body.split("\n").slice(1)) {
|
|
145
|
+
const item = /^\s*[-*+]\s+(.*)$/.exec(line)
|
|
146
|
+
if (item !== null) items.push(item[1]!.trim())
|
|
147
|
+
else if (open && line.trim() !== "") items.push(`${items.pop()!} ${line.trim()}`)
|
|
148
|
+
open = item !== null || (open && line.trim() !== "")
|
|
149
|
+
}
|
|
150
|
+
return items.map((i) => i.replace(/\s+/g, " ")).join("\n")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const criteriaChanged = (before: string | undefined, after: string | undefined): boolean =>
|
|
154
|
+
criteriaOf(before) !== criteriaOf(after)
|
|
155
|
+
|
|
156
|
+
const chunkLines = (doc: string, title: string): readonly string[] =>
|
|
157
|
+
headingSectionBodies(doc)
|
|
158
|
+
.find((s) => s.title === title)
|
|
159
|
+
?.body.split("\n")
|
|
160
|
+
.slice(1)
|
|
161
|
+
.map((l) => l.trim())
|
|
162
|
+
.filter((l) => l !== "") ?? []
|
|
163
|
+
|
|
164
|
+
const retryOutcome = (reads: readonly Read[], i: number, scope: string): Outcome | null => {
|
|
165
|
+
const isCheck = (r: Read) => r.at === `${scope}health.check` || r.at === `${scope}health.recheck`
|
|
166
|
+
const after = reads.slice(i + 1)
|
|
167
|
+
const fix = after.findIndex(
|
|
168
|
+
// A re-check that passed before any fix ends the loop: no fix turn to judge by.
|
|
169
|
+
(r) =>
|
|
170
|
+
(isCheck(r) && r.commit.feedback === undefined) ||
|
|
171
|
+
(r.actor === "agent" && !r.at.startsWith(`${scope}health.`)),
|
|
172
|
+
)
|
|
173
|
+
if (fix === -1 || after[fix]!.actor !== "agent") return null
|
|
174
|
+
const check = after.findIndex((r, k) => k > fix && r.at === `${scope}health.check`)
|
|
175
|
+
if (check === -1) return null
|
|
176
|
+
// A person's own code edit (narrowing an escalation) would earn the green, not the fix turn.
|
|
177
|
+
if (after.slice(0, check).some((r) => r.actor === "human" && r.commit.code === true)) return null
|
|
178
|
+
return after[check]!.commit.feedback === undefined ? "overturned" : "held"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const RESHOWN: Readonly<Record<string, string>> = {
|
|
182
|
+
mechanical: "Fixed automatically",
|
|
183
|
+
noise: "Dropped as noise",
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const isItem = (line: string): boolean => line.startsWith("- ")
|
|
187
|
+
|
|
188
|
+
/** The line at `at` and the note lines under it, up to the next list item. */
|
|
189
|
+
const block = (lines: readonly string[], at: number): string => {
|
|
190
|
+
const next = lines.findIndex((l, k) => k > at && isItem(l))
|
|
191
|
+
return lines.slice(at, next === -1 ? undefined : next).join("\n")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Whether the review edited `title`'s line in `chunk`, or wrote under it; null when the review did not list it there. */
|
|
195
|
+
const reviewedLine = (
|
|
196
|
+
review: NonNullable<JudgeCommit["review"]>,
|
|
197
|
+
chunk: string,
|
|
198
|
+
title: string,
|
|
199
|
+
): Outcome | null => {
|
|
200
|
+
const before = chunkLines(review.before, chunk)
|
|
201
|
+
// The ` (` after the title keeps "a test" apart from "a test for x".
|
|
202
|
+
const at = before.findIndex((l) => l.startsWith(`- ${title} (`))
|
|
203
|
+
// Only a finding the review listed as fixed or dropped was acted on: one the fix cap left is a note.
|
|
204
|
+
if (at === -1) return null
|
|
205
|
+
const after = chunkLines(review.after, chunk)
|
|
206
|
+
const kept = after.indexOf(before[at]!)
|
|
207
|
+
const edited =
|
|
208
|
+
kept === -1
|
|
209
|
+
? after.some(
|
|
210
|
+
(l) =>
|
|
211
|
+
!before.includes(l) &&
|
|
212
|
+
l.startsWith(`- ${title}`) &&
|
|
213
|
+
// A longer title's own line is no rewrite of this one.
|
|
214
|
+
!before.some(
|
|
215
|
+
(o, k) => k !== at && o.startsWith(`- ${title}`) && l.startsWith(o.split(" (")[0]!),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
: block(after, kept) !== block(before, at)
|
|
219
|
+
return edited ? "overturned" : "held"
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const triageOutcome = (
|
|
223
|
+
reads: readonly Read[],
|
|
224
|
+
i: number,
|
|
225
|
+
answer: string,
|
|
226
|
+
finding: Finding | undefined,
|
|
227
|
+
): Outcome | null => {
|
|
228
|
+
const chunk = RESHOWN[answer]
|
|
229
|
+
if (chunk === undefined || finding === undefined) return null
|
|
230
|
+
const next = reads
|
|
231
|
+
.slice(i + 1)
|
|
232
|
+
.find(
|
|
233
|
+
(r) => judgeOf(r.at) === "triage" || (r.actor === "human" && r.commit.review !== undefined),
|
|
234
|
+
)
|
|
235
|
+
const review = next?.actor === "human" ? next.commit.review : undefined
|
|
236
|
+
return review === undefined ? null : reviewedLine(review, chunk, finding.title)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The bundled criteria judge's own floor: below it the flow ran design anyway.
|
|
240
|
+
const CLEAR_MIN_P = 0.8
|
|
241
|
+
|
|
242
|
+
const clearOutcome = (reads: readonly Read[], i: number, v: JudgeVerdict): Outcome | null => {
|
|
243
|
+
if (!answered(verdictOf(v.answer, v.p), "yes", CLEAR_MIN_P)) return null
|
|
244
|
+
// A cut sketch runs design whatever the verdict: only a landing on `normalise` skipped it.
|
|
245
|
+
if (!reads[i]!.to.endsWith("design.normalise")) return null
|
|
246
|
+
const edited = reads
|
|
247
|
+
.slice(i + 1)
|
|
248
|
+
.some(
|
|
249
|
+
(r) =>
|
|
250
|
+
r.actor === "human" && /(^|\.)design\./.test(r.at) && r.commit.criteriaChanged === true,
|
|
251
|
+
)
|
|
252
|
+
return edited ? "overturned" : "held"
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const judgeOf = (step: string): JudgeName | undefined =>
|
|
256
|
+
JUDGE_STEPS.find(([, pattern]) => pattern.test(step))?.[0]
|
|
257
|
+
|
|
258
|
+
const reportsBefore = (reads: readonly Read[], i: number, scope: string): readonly string[] =>
|
|
259
|
+
reads
|
|
260
|
+
.slice(0, i)
|
|
261
|
+
.filter(
|
|
262
|
+
(r) =>
|
|
263
|
+
(r.at === `${scope}health.check` || r.at === `${scope}health.recheck`) &&
|
|
264
|
+
r.commit.feedback !== undefined,
|
|
265
|
+
)
|
|
266
|
+
.map((r) => r.commit.feedback!)
|
|
267
|
+
|
|
268
|
+
/** Oldest first: every verdict of a calibrated judge, with its outcome. */
|
|
269
|
+
export const processJudgments = (commits: readonly JudgeCommit[]): readonly Judgment[] => {
|
|
270
|
+
const reads: Read[] = commits.map((commit) => {
|
|
271
|
+
const message = parseCommitMessage(commit.message)
|
|
272
|
+
const subject = message.parsed
|
|
273
|
+
return {
|
|
274
|
+
commit,
|
|
275
|
+
actor: subject?.actor,
|
|
276
|
+
at: subject === undefined ? "" : (subject.from ?? subject.to),
|
|
277
|
+
to: subject?.to ?? "",
|
|
278
|
+
judge: message.judge,
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
const sketch = commits[0]?.sketch
|
|
282
|
+
return reads.flatMap((r, i) => {
|
|
283
|
+
const judge = judgeOf(r.at)
|
|
284
|
+
if (judge === undefined) return []
|
|
285
|
+
const scope = r.at.slice(0, r.at.length - r.at.split(".").slice(-2).join(".").length)
|
|
286
|
+
const at: Site = { reads, i, scope, sketch }
|
|
287
|
+
return r.judge.map(
|
|
288
|
+
(v): Judgment => ({
|
|
289
|
+
judge,
|
|
290
|
+
commit: r.commit.hash,
|
|
291
|
+
step: r.at,
|
|
292
|
+
question: v.id,
|
|
293
|
+
...JUDGES[judge](at, v),
|
|
294
|
+
answer: v.answer,
|
|
295
|
+
p: v.p,
|
|
296
|
+
}),
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Where a verdict was recorded: its commit among the process's, and the scope its step sits in. */
|
|
302
|
+
interface Site {
|
|
303
|
+
readonly reads: readonly Read[]
|
|
304
|
+
readonly i: number
|
|
305
|
+
readonly scope: string
|
|
306
|
+
readonly sketch: string | undefined
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const NOT_COMPARABLE = { tailsNotComparable: "true" }
|
|
310
|
+
|
|
311
|
+
const field = (key: string, value: string | undefined): Record<string, string> =>
|
|
312
|
+
value === undefined ? {} : { [key]: value }
|
|
313
|
+
|
|
314
|
+
type Verdict = (at: Site, v: JudgeVerdict) => Pick<Judgment, "state" | "outcome">
|
|
315
|
+
|
|
316
|
+
const JUDGES: Readonly<Record<JudgeName, Verdict>> = {
|
|
317
|
+
retry: ({ reads, i, scope }, v) => {
|
|
318
|
+
const reports = reportsBefore(reads, i, scope)
|
|
319
|
+
const previous = reports.at(-2)
|
|
320
|
+
const current = reports.at(-1)
|
|
321
|
+
// The flow hands the judge no reports when either is empty, and drops its verdict.
|
|
322
|
+
const comparable = previous?.trim() !== "" && current?.trim() !== ""
|
|
323
|
+
// Reports history no longer shows leave nothing to score against.
|
|
324
|
+
const known = previous !== undefined && current !== undefined
|
|
325
|
+
return {
|
|
326
|
+
state: comparable
|
|
327
|
+
? { ...field("previous", previous), ...field("current", current) }
|
|
328
|
+
: NOT_COMPARABLE,
|
|
329
|
+
outcome:
|
|
330
|
+
known && comparable && answerOf(v.answer) === "identical"
|
|
331
|
+
? retryOutcome(reads, i, scope)
|
|
332
|
+
: null,
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
triage: ({ reads, i }, v) => {
|
|
336
|
+
const finding = reads[i]!.commit.findings?.find((f) => f.id === v.id)
|
|
337
|
+
return {
|
|
338
|
+
state: field(v.id, finding && `## ${finding.title}\n\n${finding.body}`),
|
|
339
|
+
outcome: triageOutcome(reads, i, answerOf(v.answer), finding),
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
clear: ({ reads, i, sketch }, v) => ({
|
|
343
|
+
state: field("sketch", sketch),
|
|
344
|
+
outcome: clearOutcome(reads, i, v),
|
|
345
|
+
}),
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface Bucket {
|
|
349
|
+
/** The bucket's lower bound; it spans a tenth of p, the last one including 1. */
|
|
350
|
+
readonly p: number
|
|
351
|
+
readonly scored: number
|
|
352
|
+
readonly overturned: number
|
|
353
|
+
readonly rate: number
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface Calibration {
|
|
357
|
+
readonly verdicts: number
|
|
358
|
+
readonly scored: number
|
|
359
|
+
readonly overturned: number
|
|
360
|
+
/** Non-empty buckets only, lowest p first. */
|
|
361
|
+
readonly buckets: readonly Bucket[]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const rate = (overturned: number, scored: number): number =>
|
|
365
|
+
Math.round((overturned / scored) * 1000) / 1000
|
|
366
|
+
|
|
367
|
+
const bucketOf = (p: number): number => Math.min(9, Math.max(0, Math.floor(p * 10))) / 10
|
|
368
|
+
|
|
369
|
+
export const calibrate = (judgments: readonly Judgment[]): Record<JudgeName, Calibration> => {
|
|
370
|
+
const of = (judge: JudgeName): Calibration => {
|
|
371
|
+
const all = judgments.filter((j) => j.judge === judge)
|
|
372
|
+
const scored = all.filter((j) => j.outcome !== null)
|
|
373
|
+
const buckets = new Map<number, { scored: number; overturned: number }>()
|
|
374
|
+
for (const j of scored) {
|
|
375
|
+
const b = buckets.get(bucketOf(j.p)) ?? { scored: 0, overturned: 0 }
|
|
376
|
+
b.scored++
|
|
377
|
+
if (j.outcome === "overturned") b.overturned++
|
|
378
|
+
buckets.set(bucketOf(j.p), b)
|
|
379
|
+
}
|
|
380
|
+
const overturned = scored.filter((j) => j.outcome === "overturned").length
|
|
381
|
+
return {
|
|
382
|
+
verdicts: all.length,
|
|
383
|
+
scored: scored.length,
|
|
384
|
+
overturned,
|
|
385
|
+
buckets: [...buckets.entries()]
|
|
386
|
+
.sort(([a], [b]) => a - b)
|
|
387
|
+
.map(([p, b]) => ({ p, ...b, rate: rate(b.overturned, b.scored) })),
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { retry: of("retry"), triage: of("triage"), clear: of("clear") }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export interface Change {
|
|
394
|
+
readonly from: string
|
|
395
|
+
readonly to: string
|
|
396
|
+
readonly count: number
|
|
397
|
+
readonly overturned: number
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface WhatIf {
|
|
401
|
+
readonly var: string
|
|
402
|
+
readonly value: string
|
|
403
|
+
readonly judge: JudgeName
|
|
404
|
+
readonly changes: readonly Change[]
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export interface ProcessJudgments {
|
|
408
|
+
readonly vars: Readonly<Record<string, string>>
|
|
409
|
+
readonly judgments: readonly Judgment[]
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Every verdict of `threshold`'s judge as `[decided then, decided under value]`. */
|
|
413
|
+
const redecided = (
|
|
414
|
+
processes: readonly ProcessJudgments[],
|
|
415
|
+
name: string,
|
|
416
|
+
value: string,
|
|
417
|
+
): (readonly [string, string, Judgment])[] => {
|
|
418
|
+
const threshold = THRESHOLDS[name]!
|
|
419
|
+
const now = numeric(value, Infinity)
|
|
420
|
+
return (
|
|
421
|
+
processes
|
|
422
|
+
// A process that never recorded the setting decided under no threshold to compare against.
|
|
423
|
+
.filter(({ vars }) => Object.hasOwn(vars, name))
|
|
424
|
+
.flatMap(({ vars, judgments }) =>
|
|
425
|
+
judgments
|
|
426
|
+
.filter((j) => j.judge === threshold.judge && j.state !== NOT_COMPARABLE)
|
|
427
|
+
.map((j) => {
|
|
428
|
+
const was = numeric(vars[name], Infinity)
|
|
429
|
+
return [
|
|
430
|
+
threshold.decide(j.answer, j.p, was),
|
|
431
|
+
threshold.decide(j.answer, j.p, now),
|
|
432
|
+
j,
|
|
433
|
+
] as const
|
|
434
|
+
}),
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export const whatIf = (
|
|
440
|
+
processes: readonly ProcessJudgments[],
|
|
441
|
+
name: string,
|
|
442
|
+
value: string,
|
|
443
|
+
): WhatIf => {
|
|
444
|
+
const changes = new Map<string, Change>()
|
|
445
|
+
for (const [from, to, j] of redecided(processes, name, value)) {
|
|
446
|
+
if (from === to) continue
|
|
447
|
+
const c = changes.get(`${from} ${to}`) ?? { from, to, count: 0, overturned: 0 }
|
|
448
|
+
changes.set(`${from} ${to}`, {
|
|
449
|
+
...c,
|
|
450
|
+
count: c.count + 1,
|
|
451
|
+
overturned: c.overturned + (j.outcome === "overturned" ? 1 : 0),
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
return { var: name, value, judge: THRESHOLDS[name]!.judge, changes: [...changes.values()] }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const changeLine = (labels: Threshold["labels"], c: Change): string => {
|
|
458
|
+
const [one, many] = labels[c.from]!
|
|
459
|
+
const [, toMany, toOne] = labels[c.to]!
|
|
460
|
+
const what = c.count === 1 ? `1 ${one} becomes ${toOne}` : `${c.count} ${many} become ${toMany}`
|
|
461
|
+
return c.overturned > 0 ? `${what} (${c.overturned} overturned)` : what
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
type FileAt = (
|
|
465
|
+
ref: string | undefined,
|
|
466
|
+
path: string,
|
|
467
|
+
) => Effect.Effect<string | undefined, never, never>
|
|
468
|
+
|
|
469
|
+
interface Touched {
|
|
470
|
+
readonly hash: string
|
|
471
|
+
readonly message: string
|
|
472
|
+
readonly touched: readonly string[]
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const reportFact = (at: FileAt, c: Touched, from: string) =>
|
|
476
|
+
/(^|\.)health\.(re)?check$/.test(from) && c.touched.includes(FEEDBACK)
|
|
477
|
+
? at(c.hash, FEEDBACK).pipe(
|
|
478
|
+
Effect.map((r) => (r === undefined ? {} : { feedback: stripStamp(r) })),
|
|
479
|
+
)
|
|
480
|
+
: Effect.succeed({})
|
|
481
|
+
|
|
482
|
+
const findingsFact = (at: FileAt, parent: string | undefined, from: string) =>
|
|
483
|
+
judgeOf(from) === "triage"
|
|
484
|
+
? at(parent, QUALITY).pipe(Effect.map((q) => ({ findings: findingsOf(q ?? "") })))
|
|
485
|
+
: Effect.succeed({})
|
|
486
|
+
|
|
487
|
+
// Only a person's edit can overturn a verdict: no other actor's file is read.
|
|
488
|
+
const reviewFact = (at: FileAt, c: Touched, parent: string | undefined, human: boolean) =>
|
|
489
|
+
human && c.touched.includes(REVIEW)
|
|
490
|
+
? Effect.all([at(parent, REVIEW), at(c.hash, REVIEW)]).pipe(
|
|
491
|
+
Effect.map(([before, after]) => ({ review: { before: before ?? "", after: after ?? "" } })),
|
|
492
|
+
)
|
|
493
|
+
: Effect.succeed({})
|
|
494
|
+
|
|
495
|
+
const criteriaFact = (at: FileAt, c: Touched, parent: string | undefined, human: boolean) =>
|
|
496
|
+
human && c.touched.includes(REQUIREMENTS)
|
|
497
|
+
? Effect.all([at(parent, REQUIREMENTS), at(c.hash, REQUIREMENTS)]).pipe(
|
|
498
|
+
Effect.map(([before, after]) => ({ criteriaChanged: criteriaChanged(before, after) })),
|
|
499
|
+
)
|
|
500
|
+
: Effect.succeed({})
|
|
501
|
+
|
|
502
|
+
const sketchFact = (at: FileAt, c: Touched, opening: boolean) => {
|
|
503
|
+
const todo = c.touched.filter((p) => /(^|\/)TODO\.md$/.test(p))
|
|
504
|
+
return opening && todo.length === 1
|
|
505
|
+
? at(c.hash, todo[0]!).pipe(Effect.map((s) => (s === undefined ? {} : { sketch: s })))
|
|
506
|
+
: Effect.succeed({})
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Only the files a verdict's outcome or state reads, and only at the commits that can matter. */
|
|
510
|
+
const commitFacts = (
|
|
511
|
+
at: FileAt,
|
|
512
|
+
c: Touched,
|
|
513
|
+
parent: string | undefined,
|
|
514
|
+
opening: boolean,
|
|
515
|
+
): Effect.Effect<JudgeCommit> => {
|
|
516
|
+
const step = parseCommitMessage(c.message).parsed
|
|
517
|
+
const from = step === undefined ? "" : (step.from ?? step.to)
|
|
518
|
+
return Effect.all([
|
|
519
|
+
reportFact(at, c, from),
|
|
520
|
+
findingsFact(at, parent, from),
|
|
521
|
+
reviewFact(at, c, parent, step?.actor === "human"),
|
|
522
|
+
criteriaFact(at, c, parent, step?.actor === "human"),
|
|
523
|
+
sketchFact(at, c, opening),
|
|
524
|
+
]).pipe(
|
|
525
|
+
Effect.map((facts) =>
|
|
526
|
+
Object.assign(
|
|
527
|
+
{ hash: c.hash, message: c.message },
|
|
528
|
+
c.touched.some((p) => p !== ".gtd" && !p.startsWith(".gtd/")) ? { code: true } : {},
|
|
529
|
+
...facts,
|
|
530
|
+
),
|
|
531
|
+
),
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const readProcessJudgments = (
|
|
536
|
+
entry: Pick<HistoryEntry, "entry" | "tip">,
|
|
537
|
+
): Effect.Effect<ProcessJudgments, Error, GitService> =>
|
|
538
|
+
Effect.gen(function* () {
|
|
539
|
+
const git = yield* GitService
|
|
540
|
+
const base = Option.getOrUndefined(yield* git.readRefOption(`${entry.entry}^`))
|
|
541
|
+
const commits = yield* git.commitHistory(base, entry.tip)
|
|
542
|
+
const vars = commits[0] === undefined ? {} : parseCommitMessage(commits[0].message).vars
|
|
543
|
+
const judged = commits.some((c) => {
|
|
544
|
+
const m = parseCommitMessage(c.message)
|
|
545
|
+
return m.judge.length > 0 && judgeOf(m.parsed?.from ?? m.parsed?.to ?? "") !== undefined
|
|
546
|
+
})
|
|
547
|
+
if (!judged) return { vars, judgments: [] }
|
|
548
|
+
const at: FileAt = (ref, path) =>
|
|
549
|
+
ref === undefined
|
|
550
|
+
? Effect.succeed(undefined)
|
|
551
|
+
: git.readFileAtRef(ref, path).pipe(
|
|
552
|
+
Effect.map((content): string | undefined => content),
|
|
553
|
+
Effect.orElseSucceed(() => undefined),
|
|
554
|
+
)
|
|
555
|
+
const read = yield* Effect.forEach(commits, (commit, i) =>
|
|
556
|
+
commitFacts(at, commit, i === 0 ? base : commits[i - 1]!.hash, i === 0),
|
|
557
|
+
)
|
|
558
|
+
return { vars, judgments: processJudgments(read) }
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
// check() stamps the report with the commit it ran at; the judge compared it without.
|
|
562
|
+
const stripStamp = (text: string): string =>
|
|
563
|
+
text.replace(/\n<!-- gtd check [0-9a-f]+ -->\n?$/, "\n")
|
|
564
|
+
|
|
565
|
+
const NAMES: readonly JudgeName[] = ["retry", "triage", "clear"]
|
|
566
|
+
|
|
567
|
+
const calibrationLine = (name: JudgeName, c: Calibration): string => {
|
|
568
|
+
const buckets = c.buckets
|
|
569
|
+
.map((b) => `${b.p.toFixed(1)}-${(b.p + 0.1).toFixed(1)}: ${b.overturned}/${b.scored}`)
|
|
570
|
+
.join(", ")
|
|
571
|
+
return `${name}: ${c.verdicts} verdicts, ${c.scored} scored, ${c.overturned} overturned${buckets === "" ? "" : ` (${buckets})`}`
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export const runHistoryJudgesCommand = (
|
|
575
|
+
refs: readonly string[],
|
|
576
|
+
vars: Readonly<Record<string, string>>,
|
|
577
|
+
jsonl: boolean,
|
|
578
|
+
json: JsonMode,
|
|
579
|
+
out: ArtifactOut,
|
|
580
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
581
|
+
Effect.gen(function* () {
|
|
582
|
+
const entries = yield* selectHistoryEntries(refs)
|
|
583
|
+
const processes = yield* Effect.forEach(
|
|
584
|
+
entries,
|
|
585
|
+
(e) =>
|
|
586
|
+
readProcessJudgments(e).pipe(
|
|
587
|
+
Effect.map((p) => ({ ...p, entry: e.entry, ref: e.ref, branch: e.branch })),
|
|
588
|
+
),
|
|
589
|
+
{ concurrency: 4 },
|
|
590
|
+
)
|
|
591
|
+
if (jsonl) {
|
|
592
|
+
for (const p of processes)
|
|
593
|
+
for (const { judge, commit, step, question, state, answer, p: pr, outcome } of p.judgments)
|
|
594
|
+
out.write(
|
|
595
|
+
`${JSON.stringify({ process: p.entry, judge, commit, step, question, state, answer, p: pr, outcome })}\n`,
|
|
596
|
+
)
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
const judges = calibrate(processes.flatMap((p) => p.judgments))
|
|
600
|
+
const whatIfs = Object.entries(vars).map(([name, value]) => whatIf(processes, name, value))
|
|
601
|
+
// Shaped like `history metrics --json`: per process, then the total.
|
|
602
|
+
const document = {
|
|
603
|
+
processes: processes.map((p) => ({
|
|
604
|
+
ref: p.ref,
|
|
605
|
+
entry: p.entry,
|
|
606
|
+
branch: p.branch,
|
|
607
|
+
judges: calibrate(p.judgments),
|
|
608
|
+
})),
|
|
609
|
+
total: { processes: processes.length, judges },
|
|
610
|
+
whatIf: whatIfs,
|
|
611
|
+
}
|
|
612
|
+
if (yield* writeJson(json, out, document)) return
|
|
613
|
+
if (processes.length === 0) {
|
|
614
|
+
out.write("no history refs; run gtd history fetch | sh to pull them\n")
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
const lines = NAMES.map((name) => calibrationLine(name, judges[name]))
|
|
618
|
+
for (const w of whatIfs) {
|
|
619
|
+
const labels = THRESHOLDS[w.var]!.labels
|
|
620
|
+
const changes = w.changes.map((c) => changeLine(labels, c)).join(", ")
|
|
621
|
+
lines.push(`what-if ${w.var}=${printable(w.value)}: ${changes || "no decision changes"}`)
|
|
622
|
+
}
|
|
623
|
+
out.write(`${lines.join("\n")}\n`)
|
|
624
|
+
})
|
package/src/cli/Metrics.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Effect, Option } from "effect"
|
|
|
2
2
|
import { GitService } from "../platform/index.js"
|
|
3
3
|
import { parseCommitMessage } from "../replay/index.js"
|
|
4
4
|
import { UNATTRIBUTED_MODEL } from "../wire/index.js"
|
|
5
|
-
import {
|
|
5
|
+
import { selectHistoryEntries, type HistoryEntry } from "./History.js"
|
|
6
6
|
import { writeJson, type ArtifactOut, type CommandRequirements, type JsonMode } from "./Output.js"
|
|
7
7
|
|
|
8
8
|
// Read from commit subjects and trailers alone, never by replay: a metric over
|
|
@@ -248,7 +248,7 @@ export const readProcessMetrics = (
|
|
|
248
248
|
|
|
249
249
|
// Keys come from fetched history: never let them put control bytes on a terminal.
|
|
250
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, "?")
|
|
251
|
+
export const printable = (s: string): string => s.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, "?")
|
|
252
252
|
|
|
253
253
|
const listed = (record: Readonly<Record<string, number>>): string =>
|
|
254
254
|
Object.entries(record)
|
|
@@ -274,21 +274,8 @@ export const runHistoryMetricsCommand = (
|
|
|
274
274
|
out: ArtifactOut,
|
|
275
275
|
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
276
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
277
|
const processes = yield* Effect.forEach(
|
|
291
|
-
|
|
278
|
+
yield* selectHistoryEntries(refs),
|
|
292
279
|
(e) =>
|
|
293
280
|
readProcessMetrics(e).pipe(
|
|
294
281
|
Effect.map((metrics) => ({
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -1695,6 +1695,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1695
1695
|
historyList: { kind: "historyList" },
|
|
1696
1696
|
historyShow: { kind: "historyShow", ref: "x" },
|
|
1697
1697
|
historyMetrics: { kind: "historyMetrics", refs: [] },
|
|
1698
|
+
historyJudges: { kind: "historyJudges", refs: [], vars: {}, jsonl: false },
|
|
1698
1699
|
historyFetch: { kind: "historyFetch", remote: "origin" },
|
|
1699
1700
|
strays: { kind: "strays", base: "HEAD", tip: "HEAD", globs: [] },
|
|
1700
1701
|
}
|
|
@@ -1706,7 +1707,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1706
1707
|
const NO_COMMITS_MESSAGE =
|
|
1707
1708
|
"gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
|
|
1708
1709
|
|
|
1709
|
-
it("derives exactly the
|
|
1710
|
+
it("derives exactly the nineteen non-standalone kinds — a canary for the table-driven cases below", () => {
|
|
1710
1711
|
expect(stateKinds.sort()).toEqual(
|
|
1711
1712
|
[
|
|
1712
1713
|
"abandon",
|
|
@@ -1726,6 +1727,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1726
1727
|
"historyList",
|
|
1727
1728
|
"historyShow",
|
|
1728
1729
|
"historyMetrics",
|
|
1730
|
+
"historyJudges",
|
|
1729
1731
|
"historyFetch",
|
|
1730
1732
|
].sort(),
|
|
1731
1733
|
)
|