@pmelab/gtd 42.0.0 → 44.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.
Files changed (62) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +96 -48
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-DJk_6-ju.mjs} +75 -46
  5. package/dist/chunks/review-X7Jfz0dY.mjs +249 -0
  6. package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
  7. package/dist/chunks/{tail-D2o2h5cc.mjs → tail-5w5mxGTq.mjs} +3 -3
  8. package/dist/gtd.bundle.mjs +488 -30
  9. package/dist/steering.mjs +2 -2
  10. package/dist/testkit.mjs +447 -27
  11. package/dist/web/main.js +42 -13
  12. package/dist/workflow-kit.mjs +61 -25
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +7 -290
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +27 -7
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +287 -161
  19. package/package.json +1 -1
  20. package/src/cli/Cli.test.ts +33 -1
  21. package/src/cli/Cli.ts +70 -6
  22. package/src/cli/History.ts +15 -0
  23. package/src/cli/Judges.test.ts +373 -0
  24. package/src/cli/Judges.ts +624 -0
  25. package/src/cli/Metrics.ts +3 -16
  26. package/src/cli/Program.test.ts +3 -1
  27. package/src/cli/Program.ts +9 -0
  28. package/src/steering/FORMATS.md +4 -4
  29. package/src/steering/index.ts +12 -1
  30. package/src/steering/review.test.ts +107 -0
  31. package/src/steering/review.ts +159 -9
  32. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  33. package/src/testkit/steps.ts +4 -0
  34. package/src/workflow-kit/review.test.ts +170 -26
  35. package/src/workflow-kit/review.ts +95 -19
  36. package/src/workflows/feature/coverage.ts +1 -1
  37. package/src/workflows/feature/feature.test.ts +8 -0
  38. package/src/workflows/feature/feature.ts +2 -2
  39. package/src/workflows/feature/index.ts +1 -1
  40. package/src/workflows/feature/lens-review.feature +16 -4
  41. package/src/workflows/feature/refactor.ts +1 -1
  42. package/src/workflows/feature/review-answers.feature +179 -0
  43. package/src/workflows/feature/seam.ts +1 -1
  44. package/src/workflows/feature/steps.test.ts +2 -76
  45. package/src/workflows/feature/steps.ts +5 -48
  46. package/src/workflows/fix/fix.feature +20 -21
  47. package/src/workflows/index.ts +1 -1
  48. package/src/workflows/review/review-door.feature +149 -0
  49. package/src/workflows/review/review.test.ts +1 -73
  50. package/src/workflows/review/review.ts +34 -6
  51. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  52. package/src/workflows/shared/access.test.ts +2 -11
  53. package/src/workflows/shared/access.ts +0 -2
  54. package/src/workflows/{feature → shared}/review.test.ts +9 -61
  55. package/src/workflows/{feature → shared}/review.ts +40 -48
  56. package/src/workflows/shared/skills.test.ts +2 -0
  57. package/src/workflows/shared/skills.ts +2 -0
  58. package/src/workflows/shared/steps.test.ts +64 -2
  59. package/src/workflows/shared/steps.ts +40 -1
  60. package/src/workflows/shared/tail.ts +2 -2
  61. package/src/workflows/shared/text.test.ts +40 -89
  62. package/src/workflows/shared/text.ts +63 -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
+ })
@@ -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 { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
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
- unique,
278
+ yield* selectHistoryEntries(refs),
292
279
  (e) =>
293
280
  readProcessMetrics(e).pipe(
294
281
  Effect.map((metrics) => ({
@@ -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 eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
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
  )