@pmelab/gtd 41.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.
Files changed (73) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +107 -53
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-Ch4LLrEj.mjs → reserved-COEMFEpy.mjs} +84 -51
  5. package/dist/chunks/{steering-DVILanVf.mjs → steering-D4LUQ-av.mjs} +95 -12
  6. package/dist/chunks/tail-C_xn8U7U.mjs +246 -0
  7. package/dist/flows.mjs +11 -1
  8. package/dist/gtd.bundle.mjs +498 -30
  9. package/dist/steering.mjs +2 -2
  10. package/dist/testkit.mjs +460 -28
  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 +66 -57
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +2 -2
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +446 -141
  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/flows/scripts.ts +15 -0
  29. package/src/steering/index.ts +12 -1
  30. package/src/steering/review.test.ts +109 -20
  31. package/src/steering/review.ts +160 -9
  32. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  33. package/src/testkit/FlowScripts.test.ts +17 -0
  34. package/src/testkit/FlowScripts.ts +15 -0
  35. package/src/testkit/steps.ts +31 -0
  36. package/src/workflow-kit/review.test.ts +170 -26
  37. package/src/workflow-kit/review.ts +95 -19
  38. package/src/workflows/feature/coverage.feature +3 -3
  39. package/src/workflows/feature/fast-path.feature +2 -2
  40. package/src/workflows/feature/feature.test.ts +2 -37
  41. package/src/workflows/feature/feature.ts +12 -14
  42. package/src/workflows/feature/flag-mode.feature +2 -2
  43. package/src/workflows/feature/lens-review.feature +246 -0
  44. package/src/workflows/feature/one-step-land.feature +2 -2
  45. package/src/workflows/feature/quality-unresolved.feature +47 -32
  46. package/src/workflows/feature/refactor-absorbs.feature +3 -3
  47. package/src/workflows/feature/refactor-first.feature +5 -5
  48. package/src/workflows/feature/refactor-rebuild.feature +7 -7
  49. package/src/workflows/feature/refactor-review.feature +19 -100
  50. package/src/workflows/feature/refactor.test.ts +12 -27
  51. package/src/workflows/feature/refactor.ts +0 -1
  52. package/src/workflows/feature/review-answers.feature +179 -0
  53. package/src/workflows/feature/review-gate.feature +6 -6
  54. package/src/workflows/feature/review.test.ts +33 -77
  55. package/src/workflows/feature/review.ts +79 -47
  56. package/src/workflows/feature/seam-rejection.feature +2 -2
  57. package/src/workflows/feature/seam.feature +2 -2
  58. package/src/workflows/feature/seam.test.ts +1 -1
  59. package/src/workflows/feature/slicing.feature +6 -6
  60. package/src/workflows/feature/steps.test.ts +0 -12
  61. package/src/workflows/feature/steps.ts +3 -10
  62. package/src/workflows/index.ts +1 -0
  63. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  64. package/src/workflows/shared/access.test.ts +2 -11
  65. package/src/workflows/shared/access.ts +0 -2
  66. package/src/workflows/shared/lenses.test.ts +39 -0
  67. package/src/workflows/shared/lenses.ts +176 -0
  68. package/src/workflows/shared/options.ts +1 -0
  69. package/src/workflows/shared/tail.ts +19 -4
  70. package/src/workflows/shared/text.test.ts +106 -88
  71. package/src/workflows/shared/text.ts +164 -53
  72. package/src/workflows/shared/vars.ts +1 -0
  73. package/dist/chunks/tail-lY1UmJrU.mjs +0 -112
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "41.0.0",
3
+ "version": "43.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -171,7 +171,8 @@ describe("parseArgv — scope", () => {
171
171
  it("--var without --workflow is rejected", () => {
172
172
  const plan = parseArgv(["node", "gtd.js", "land", "--var", "a=1"])
173
173
  expect(plan.kind).toBe("usage")
174
- if (plan.kind === "usage") expect(plan.message).toBe("gtd: --var requires --workflow")
174
+ if (plan.kind === "usage")
175
+ expect(plan.message).toBe("gtd: --var requires --workflow (or `gtd history judges`)")
175
176
  })
176
177
 
177
178
  it("--cost with --workflow is rejected (landing and starting are different verbs)", () => {
@@ -1027,6 +1028,34 @@ describe("parseArgv — gtd history", () => {
1027
1028
  expect(parse("history", "metrics", "a", "feat/b")).toMatchObject({
1028
1029
  command: { kind: "historyMetrics", refs: ["a", "feat/b"] },
1029
1030
  })
1031
+ expect(parse("history", "judges", "a", "--var", "qualityTriageMinP=0.9")).toMatchObject({
1032
+ command: {
1033
+ kind: "historyJudges",
1034
+ refs: ["a"],
1035
+ vars: { qualityTriageMinP: "0.9" },
1036
+ jsonl: false,
1037
+ },
1038
+ })
1039
+ expect(parse("history", "judges", "--jsonl")).toMatchObject({
1040
+ command: { kind: "historyJudges", refs: [], vars: {}, jsonl: true },
1041
+ })
1042
+ })
1043
+
1044
+ it("judges takes only judge thresholds as --var, and --jsonl alone", () => {
1045
+ expect(usage(parse("history", "judges", "--var", "reviewBase=main"))).toContain(
1046
+ "no judge threshold",
1047
+ )
1048
+ expect(usage(parse("history", "judges", "--var", "toString=0.9"))).toContain(
1049
+ "no judge threshold",
1050
+ )
1051
+ expect(usage(parse("history", "judges", "--var", "qualityTriageMinP=0,9"))).toContain(
1052
+ "must be a number",
1053
+ )
1054
+ expect(usage(parse("history", "judges", "--jsonl", "--json"))).toContain("mutually exclusive")
1055
+ expect(usage(parse("history", "judges", "--jsonl", "--var", "qualityTriageMinP=1"))).toContain(
1056
+ "mutually exclusive",
1057
+ )
1058
+ expect(usage(parse("history", "metrics", "--jsonl"))).toContain("only valid for")
1030
1059
  })
1031
1060
 
1032
1061
  it("bare history and an unknown subcommand are usage errors naming list, show, metrics, fetch", () => {
@@ -1036,6 +1065,7 @@ describe("parseArgv — gtd history", () => {
1036
1065
  expect(message).toContain("show")
1037
1066
  expect(message).toContain("fetch")
1038
1067
  expect(message).toContain("metrics")
1068
+ expect(message).toContain("judges")
1039
1069
  }
1040
1070
  })
1041
1071
 
@@ -1063,6 +1093,7 @@ describe("parseArgv — gtd history", () => {
1063
1093
  expect(help).toContain("history show <ref>")
1064
1094
  expect(help).toContain("history fetch [remote]")
1065
1095
  expect(help).toContain("history metrics [ref...]")
1096
+ expect(help).toContain("history judges [ref...]")
1066
1097
  })
1067
1098
  })
1068
1099
 
@@ -1148,6 +1179,7 @@ describe("standaloneKinds / needsOf", () => {
1148
1179
  "historyList",
1149
1180
  "historyShow",
1150
1181
  "historyMetrics",
1182
+ "historyJudges",
1151
1183
  "historyFetch",
1152
1184
  ] as const) {
1153
1185
  expect(needsOf(kind)).toBe("state")
package/src/cli/Cli.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  type JsonMode,
36
36
  } from "./Program.js"
37
37
  import type { ToolCommands } from "./ToolCommands.js"
38
+ import { THRESHOLDS } from "./Judges.js"
38
39
  import { EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR, parseContextWindow } from "../wire/index.js"
39
40
 
40
41
  export type { Command, CommandRequirements } from "./Program.js"
@@ -107,6 +108,7 @@ const JSON_KINDS: ReadonlySet<Command["kind"] | undefined> = new Set<Command["ki
107
108
  "historyList",
108
109
  "historyShow",
109
110
  "historyMetrics",
111
+ "historyJudges",
110
112
  "strays",
111
113
  ])
112
114
 
@@ -118,12 +120,12 @@ const FLAGS: readonly FlagRow[] = [
118
120
  scope: (kind) => JSON_KINDS.has(kind),
119
121
  decode: ([raw]) => Either.right(raw ?? ""),
120
122
  scopeError:
121
- "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd slices`/`gtd history list`/`gtd history show`/`gtd history metrics`/`gtd strays` — " +
123
+ "gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd slices`/`gtd history list`/`gtd history show`/`gtd history metrics`/`gtd history judges`/`gtd strays` — " +
122
124
  "every other command prints plain text; see `gtd install` for the driver protocol briefing",
123
125
  valueHint: "<path>",
124
126
  help: [
125
127
  "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd slices/gtd history",
126
- "list/gtd history show/gtd history metrics/gtd strays only) output structured JSON. Bare --json prints the whole document;",
128
+ "list/gtd history show/gtd history metrics/gtd history judges/gtd strays only) output structured JSON. Bare --json prints the whole document;",
127
129
  "--json=<path> (a dotted key path into that document, e.g.",
128
130
  "kind, content, session.id) prints just that value: a",
129
131
  "scalar raw and unquoted, a boolean as true/false, a list",
@@ -325,7 +327,7 @@ const FLAGS: readonly FlagRow[] = [
325
327
  name: "--var",
326
328
  arity: 1,
327
329
  repeatable: true,
328
- scope: (kind) => kind === "start",
330
+ scope: (kind) => kind === "start" || kind === "historyJudges",
329
331
  decode: (raws) => {
330
332
  const vars: Record<string, string> = {}
331
333
  const seen = new Set<string>()
@@ -362,15 +364,28 @@ const FLAGS: readonly FlagRow[] = [
362
364
  }
363
365
  return Either.right(vars)
364
366
  },
365
- scopeError: "gtd: --var requires --workflow",
367
+ scopeError: "gtd: --var requires --workflow (or `gtd history judges`)",
366
368
  valueHint: "<name>=<value>",
367
369
  help: [
368
370
  "(with --workflow; repeatable) pin a process setting for the",
369
371
  "new process; the name must already be declared by the",
370
372
  "workflow's defaults (environment settings are not",
371
- "pinnable)",
373
+ "pinnable). (gtd history judges) re-decide past verdicts",
374
+ "under this threshold: judgeIdenticalMinP or",
375
+ "qualityTriageMinP",
372
376
  ],
373
377
  },
378
+ {
379
+ name: "--jsonl",
380
+ arity: 0,
381
+ repeatable: false,
382
+ scope: (kind) => kind === "historyJudges",
383
+ decode: () => Either.right(true),
384
+ scopeError: "gtd: --jsonl is only valid for `gtd history judges`",
385
+ valueHint: "",
386
+ help: ["(gtd history judges only) print one JSON line per verdict", "instead of the report"],
387
+ conflicts: ["--json", "--var"],
388
+ },
374
389
  {
375
390
  name: "--open-questions",
376
391
  arity: 0,
@@ -847,6 +862,23 @@ const COMMAND_ROWS: readonly CommandRow[] = [
847
862
  "} as one document; writes nothing",
848
863
  ],
849
864
  },
865
+ {
866
+ token: "history judges",
867
+ kind: "historyJudges",
868
+ arity: { name: "ref", many: true },
869
+ details: [
870
+ "Calibrate the judges of the kept processes from their",
871
+ "recorded Gtd-Judge verdicts: per judge (retry, triage, clear)",
872
+ "and p bucket, how many verdicts later events overturned.",
873
+ "--var <name>=<value> re-decides every past verdict under",
874
+ "another threshold and prints what would have changed;",
875
+ "--jsonl prints one { process, judge, commit, step, question,",
876
+ "state, answer, p, outcome } line per verdict instead. <ref>",
877
+ "is read as by history metrics. Never replays and asks no",
878
+ "model. --json prints { processes, total, whatIf }; writes",
879
+ "nothing",
880
+ ],
881
+ },
850
882
  {
851
883
  token: "history fetch",
852
884
  kind: "historyFetch",
@@ -1163,7 +1195,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1163
1195
 
1164
1196
  if (isHistory && row === undefined) {
1165
1197
  return usagePlan(
1166
- "gtd history: expected a subcommand — list, show <ref>, metrics [ref...] or fetch [remote]",
1198
+ "gtd history: expected a subcommand — list, show <ref>, metrics [ref...], judges [ref...] or fetch [remote]",
1167
1199
  jsonSeen,
1168
1200
  )
1169
1201
  }
@@ -1268,6 +1300,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1268
1300
  readonly "--soft"?: number
1269
1301
  readonly "--hard"?: number
1270
1302
  readonly "--window"?: number
1303
+ readonly "--jsonl"?: boolean
1271
1304
  }
1272
1305
 
1273
1306
  // `present.has("--json")` alone can't distinguish bare `--json` (no value
@@ -1415,6 +1448,37 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1415
1448
  }
1416
1449
  }
1417
1450
 
1451
+ if (kind === "historyJudges") {
1452
+ const vars = bag["--var"] ?? {}
1453
+ const unknown = Object.keys(vars).find((name) => !Object.hasOwn(THRESHOLDS, name))
1454
+ if (unknown !== undefined) {
1455
+ return usagePlan(
1456
+ `gtd history judges: --var ${unknown} is no judge threshold — use ${Object.keys(THRESHOLDS).join(" or ")}`,
1457
+ jsonSeen,
1458
+ )
1459
+ }
1460
+ const nan = Object.entries(vars).find(
1461
+ ([, value]) => value.trim() === "" || !Number.isFinite(Number(value)),
1462
+ )
1463
+ if (nan !== undefined) {
1464
+ return usagePlan(
1465
+ `gtd history judges: --var ${nan[0]} must be a number — got "${nan[1]}"`,
1466
+ jsonSeen,
1467
+ )
1468
+ }
1469
+ return {
1470
+ kind: "command",
1471
+ command: {
1472
+ kind: "historyJudges",
1473
+ refs: restPositionals,
1474
+ vars,
1475
+ jsonl: bag["--jsonl"] ?? false,
1476
+ },
1477
+ json,
1478
+ verbose,
1479
+ }
1480
+ }
1481
+
1418
1482
  if (kind === "historyFetch") {
1419
1483
  return {
1420
1484
  kind: "command",
@@ -73,6 +73,21 @@ export const resolveHistoryRef = (
73
73
  return Effect.fail(new Error(`gtd history: no history ref matches "${arg}"`))
74
74
  }
75
75
 
76
+ /** Every kept process, or those `refs` name — a branch name meaning every process kept from it, not just its newest. */
77
+ export const selectHistoryEntries = (
78
+ refs: readonly string[],
79
+ ): Effect.Effect<readonly HistoryEntry[], Error, GitService> =>
80
+ Effect.gen(function* () {
81
+ const entries = yield* historyEntries
82
+ if (refs.length === 0) return entries
83
+ const chosen = (yield* Effect.forEach(refs, (ref) =>
84
+ resolveHistoryRef(ref, entries).pipe(
85
+ Effect.map((hit) => (hit.branch === ref ? entries.filter((e) => e.branch === ref) : [hit])),
86
+ ),
87
+ )).flat()
88
+ return chosen.filter((e, i) => chosen.findIndex((o) => o.ref === e.ref) === i)
89
+ })
90
+
76
91
  const seven = (hash: string): string => hash.slice(0, 7)
77
92
 
78
93
  export const runHistoryListCommand = (
@@ -0,0 +1,373 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ calibrate,
4
+ criteriaChanged,
5
+ findingsOf,
6
+ processJudgments,
7
+ whatIf,
8
+ type JudgeCommit,
9
+ type Judgment,
10
+ } from "./Judges.js"
11
+
12
+ let n = 0
13
+ const c = (
14
+ subject: string,
15
+ facts: Omit<JudgeCommit, "hash" | "message"> & { verdicts?: readonly object[] } = {},
16
+ ): JudgeCommit => {
17
+ const { verdicts = [], ...rest } = facts
18
+ const trailers = verdicts.map((v) => `Gtd-Judge: ${JSON.stringify(v)}`).join("\n")
19
+ return {
20
+ hash: `c${++n}`,
21
+ message: trailers === "" ? subject : `${subject}\n\n${trailers}`,
22
+ ...rest,
23
+ }
24
+ }
25
+
26
+ const identical = (p = 0.9) => ({ verdicts: [{ id: "verdict", answer: "identical", p }] })
27
+
28
+ describe("processJudgments — retry", () => {
29
+ const red = (subject: string, report: string) => c(subject, { feedback: report })
30
+
31
+ it("overturns identical when the next fix turn's check goes green", () => {
32
+ const [j] = processJudgments([
33
+ red("gtd(check): build.health.check → build.fix", "1 failing"),
34
+ c("gtd(agent): build.fix → build.health.check"),
35
+ red("gtd(check): build.health.check → build.health.judge", "1 failing again"),
36
+ c("gtd(judge): build.health.judge → build.health.recheck", identical()),
37
+ red("gtd(check): build.health.recheck → build.health.describe", "still"),
38
+ c("gtd(agent): build.health.describe → build.health.stop"),
39
+ c("gtd(human): build.health.stop → build.fix"),
40
+ c("gtd(agent): build.fix → build.health.check"),
41
+ c("gtd(check): build.health.check → build.quality"),
42
+ ])
43
+ expect(j).toMatchObject({
44
+ judge: "retry",
45
+ step: "build.health.judge",
46
+ question: "verdict",
47
+ outcome: "overturned",
48
+ state: { previous: "1 failing", current: "1 failing again" },
49
+ })
50
+ })
51
+
52
+ it("holds identical when that check stays red", () => {
53
+ const [j] = processJudgments([
54
+ red("gtd(check): health.check → fix", "x"),
55
+ red("gtd(check): health.check → health.judge", "x"),
56
+ c("gtd(judge): health.judge → health.recheck", identical()),
57
+ red("gtd(check): health.recheck → health.describe", "x"),
58
+ c("gtd(agent): fix → health.check"),
59
+ red("gtd(check): health.check → health.judge", "x"),
60
+ ])
61
+ expect(j?.outcome).toBe("held")
62
+ })
63
+
64
+ it("has no outcome when a person edited code before the deciding check", () => {
65
+ const [j] = processJudgments([
66
+ c("gtd(judge): build.health.judge → build.health.recheck", identical()),
67
+ red("gtd(check): build.health.recheck → build.health.describe", "x"),
68
+ c("gtd(human): build.health.stop → build.fix", { code: true }),
69
+ c("gtd(agent): build.fix → build.health.check"),
70
+ c("gtd(check): build.health.check → idle"),
71
+ ])
72
+ expect(j?.outcome).toBeNull()
73
+ })
74
+
75
+ it("hands an empty report on as the flow does, and scores none of it", () => {
76
+ const js = processJudgments([
77
+ red("gtd(check): health.check → fix", " "),
78
+ red("gtd(check): health.check → health.judge", "x"),
79
+ c("gtd(judge): health.judge → fix", identical()),
80
+ c("gtd(agent): fix → health.check"),
81
+ c("gtd(check): health.check → idle"),
82
+ ])
83
+ expect(js[0]).toMatchObject({ state: { tailsNotComparable: "true" }, outcome: null })
84
+ const w = whatIf(
85
+ [{ vars: { judgeIdenticalMinP: "0.99" }, judgments: js }],
86
+ "judgeIdenticalMinP",
87
+ "0.5",
88
+ )
89
+ expect(w.changes).toEqual([])
90
+ })
91
+
92
+ it("has no outcome when history shows fewer than two reports", () => {
93
+ const [j] = processJudgments([
94
+ c("gtd(judge): build.health.judge → build.fix", identical()),
95
+ c("gtd(agent): build.fix → build.health.check"),
96
+ c("gtd(check): build.health.check → idle"),
97
+ ])
98
+ expect(j?.outcome).toBeNull()
99
+ })
100
+
101
+ it("has no outcome when the re-check passes before any fix, or for another answer", () => {
102
+ const js = processJudgments([
103
+ c("gtd(judge): build.health.judge → build.health.recheck", identical()),
104
+ c("gtd(check): build.health.recheck → build.quality"),
105
+ c("gtd(judge): build.health.judge → build.fix", {
106
+ verdicts: [{ id: "verdict", answer: "progress", p: 0.9 }],
107
+ }),
108
+ c("gtd(agent): build.fix → build.health.check"),
109
+ c("gtd(check): build.health.check → idle"),
110
+ ])
111
+ expect(js.map((j) => j.outcome)).toEqual([null, null])
112
+ })
113
+ })
114
+
115
+ describe("processJudgments — triage", () => {
116
+ const findings = findingsOf("## a stray export\n\nexport it\n\n## a missing test\n\nadd one\n")
117
+ const composed = [
118
+ "# Review: abc",
119
+ "",
120
+ "## Fixed automatically",
121
+ "",
122
+ "- a stray export (abc1234)",
123
+ "",
124
+ "## Dropped as noise",
125
+ "",
126
+ "- a missing test (p=0.85)",
127
+ "",
128
+ ].join("\n")
129
+ const triage = c("gtd(judge): build.quality.triage → build.quality.sorting", {
130
+ findings,
131
+ verdicts: [
132
+ { id: "Q1", answer: "mechanical", p: 0.95 },
133
+ { id: "Q2", answer: "noise", p: 0.85 },
134
+ ],
135
+ })
136
+
137
+ it("overturns a dropped finding the human wrote back on, holds the untouched fix", () => {
138
+ const js = processJudgments([
139
+ triage,
140
+ c("gtd(check): build.review.composing → build.review.await", {
141
+ review: { before: "", after: composed },
142
+ }),
143
+ c("gtd(human): build.review.await → build.review.triage", {
144
+ review: {
145
+ before: composed,
146
+ after: composed.replace("(p=0.85)", "(p=0.85) — it is not noise"),
147
+ },
148
+ }),
149
+ ])
150
+ expect(js.map((j) => [j.question, j.outcome])).toEqual([
151
+ ["Q1", "held"],
152
+ ["Q2", "overturned"],
153
+ ])
154
+ expect(js[1]?.state).toEqual({ Q2: "## a missing test\n\nadd one" })
155
+ })
156
+
157
+ it("credits a written-back line to its own finding only", () => {
158
+ const listed = [
159
+ "# Review",
160
+ "",
161
+ "## Dropped as noise",
162
+ "",
163
+ "- a test (p=0.8)",
164
+ "- a test for x (p=0.8)",
165
+ "",
166
+ ].join("\n")
167
+ const js = processJudgments([
168
+ c("gtd(judge): build.quality.triage → build.quality.sorting", {
169
+ findings: findingsOf("## a test\n\n## a test for x\n"),
170
+ verdicts: [
171
+ { id: "Q1", answer: "noise", p: 0.8 },
172
+ { id: "Q2", answer: "noise", p: 0.8 },
173
+ ],
174
+ }),
175
+ c("gtd(human): build.review.await → build.review.triage", {
176
+ review: { before: listed, after: listed.replace("x (p=0.8)", "x (p=0.8) — needed") },
177
+ }),
178
+ ])
179
+ expect(js.map((j) => j.outcome)).toEqual(["held", "overturned"])
180
+ })
181
+
182
+ it("overturns a note written under the line, or a rewrite of it", () => {
183
+ const listed = "# R\n\n## Dropped as noise\n\n- a test (p=0.8)\n- b (p=0.8)\n"
184
+ const triaged = (after: string) =>
185
+ processJudgments([
186
+ c("gtd(judge): build.quality.triage → build.quality.sorting", {
187
+ findings: findingsOf("## a test\n\n## b\n"),
188
+ verdicts: [
189
+ { id: "Q1", answer: "noise", p: 0.8 },
190
+ { id: "Q2", answer: "noise", p: 0.8 },
191
+ ],
192
+ }),
193
+ c("gtd(human): build.review.await → build.review.triage", {
194
+ review: { before: listed, after },
195
+ }),
196
+ ]).map((j) => j.outcome)
197
+ expect(triaged(listed.replace("(p=0.8)\n", "(p=0.8)\n we need it\n"))).toEqual([
198
+ "overturned",
199
+ "held",
200
+ ])
201
+ expect(triaged(listed.replace("- a test (p=0.8)", "- a test: not noise"))).toEqual([
202
+ "overturned",
203
+ "held",
204
+ ])
205
+ })
206
+
207
+ it("never reads a longer title's edited line as a rewrite of a deleted shorter one", () => {
208
+ const listed = "# R\n\n## Dropped as noise\n\n- a test (p=0.8)\n- a test for x (p=0.8)\n"
209
+ const js = processJudgments([
210
+ c("gtd(judge): build.quality.triage → build.quality.sorting", {
211
+ findings: findingsOf("## a test\n\n## a test for x\n"),
212
+ verdicts: [
213
+ { id: "Q1", answer: "noise", p: 0.8 },
214
+ { id: "Q2", answer: "noise", p: 0.8 },
215
+ ],
216
+ }),
217
+ c("gtd(human): build.review.await → build.review.triage", {
218
+ review: {
219
+ before: listed,
220
+ after: "# R\n\n## Dropped as noise\n\n- a test for x (p=0.8) — needed\n",
221
+ },
222
+ }),
223
+ ])
224
+ expect(js.map((j) => j.outcome)).toEqual(["held", "overturned"])
225
+ })
226
+
227
+ it("scores only a finding the review listed as fixed or dropped", () => {
228
+ const js = processJudgments([
229
+ c("gtd(judge): build.quality.triage → build.quality.sorting", {
230
+ findings,
231
+ verdicts: [
232
+ { id: "Q1", answer: "mechanical", p: 0.95 },
233
+ { id: "Q2", answer: "design", p: 0.95 },
234
+ ],
235
+ }),
236
+ c("gtd(human): build.review.await → idle", {
237
+ review: { before: "# Review\n\n## a stray export\n\nleft\n", after: "# Review\n" },
238
+ }),
239
+ ])
240
+ expect(js.map((j) => j.outcome)).toEqual([null, null])
241
+ })
242
+ })
243
+
244
+ describe("processJudgments — clear", () => {
245
+ const clear = (answer: unknown) =>
246
+ c("gtd(judge): design.clear → design.normalise", {
247
+ sketch: "# TODO\n",
248
+ verdicts: [{ id: "clear", answer, p: 0.9 }],
249
+ })
250
+
251
+ it("overturns a skip a later design gate edited the criteria after", () => {
252
+ const [j] = processJudgments([
253
+ clear("yes"),
254
+ c("gtd(agent): design.triage → design.answer-product-questions", { criteriaChanged: true }),
255
+ c("gtd(human): design.answer-product-questions → design.triage", { criteriaChanged: true }),
256
+ ])
257
+ expect(j).toMatchObject({
258
+ judge: "clear",
259
+ outcome: "overturned",
260
+ state: { sketch: "# TODO\n" },
261
+ })
262
+ })
263
+
264
+ it("holds a skip no gate edited after, reading a boolean verdict as yes", () => {
265
+ const [j] = processJudgments([
266
+ clear(true),
267
+ c("gtd(agent): design.triage → design.answer-product-questions", { criteriaChanged: true }),
268
+ c("gtd(human): build.review.await → idle"),
269
+ ])
270
+ expect(j?.outcome).toBe("held")
271
+ })
272
+
273
+ it("has no outcome for a no, or a yes below the skip floor", () => {
274
+ expect(processJudgments([clear("no")])[0]?.outcome).toBeNull()
275
+ const low = c("gtd(judge): design.clear → design.triage", {
276
+ verdicts: [{ id: "clear", answer: "yes", p: 0.75 }],
277
+ })
278
+ expect(processJudgments([low])[0]?.outcome).toBeNull()
279
+ // A cut sketch: a confident yes the flow did not act on.
280
+ const cut = c("gtd(judge): design.clear → design.triage", {
281
+ verdicts: [{ id: "clear", answer: "yes", p: 0.9 }],
282
+ })
283
+ expect(processJudgments([cut])[0]?.outcome).toBeNull()
284
+ })
285
+ })
286
+
287
+ describe("findingsOf", () => {
288
+ it("numbers stray prose first, as the quality lap does", () => {
289
+ expect(findingsOf("just prose\n\n## one\n\nbody\n").map((f) => [f.id, f.title])).toEqual([
290
+ ["Q1", "Quality findings"],
291
+ ["Q2", "one"],
292
+ ])
293
+ })
294
+ })
295
+
296
+ describe("criteriaChanged", () => {
297
+ const doc = (items: string) => `# R\n\n## Acceptance Criteria\n\n${items}\n\n## Notes\n\nx\n`
298
+
299
+ it("ignores rewrapping and edits outside the criteria", () => {
300
+ expect(criteriaChanged(doc("- AC-1: a b"), doc("- AC-1: a\n b").replace("x", "y"))).toBe(false)
301
+ expect(criteriaChanged(doc("- AC-1: a"), doc("- AC-1: a\n- AC-2: b"))).toBe(true)
302
+ expect(criteriaChanged(undefined, doc("- AC-1: a"))).toBe(true)
303
+ expect(criteriaChanged(doc("- AC-1: a"), doc("Settled.\n\n- AC-1: a"))).toBe(false)
304
+ })
305
+ })
306
+
307
+ const judgment = (over: Partial<Judgment>): Judgment => ({
308
+ judge: "triage",
309
+ commit: "c",
310
+ step: "build.quality.triage",
311
+ question: "Q1",
312
+ state: {},
313
+ answer: "noise",
314
+ p: 0.8,
315
+ outcome: null,
316
+ ...over,
317
+ })
318
+
319
+ describe("calibrate", () => {
320
+ it("buckets scored verdicts by tenths of p, 1 in the top bucket", () => {
321
+ const { triage, retry } = calibrate([
322
+ judgment({ p: 0.85, outcome: "overturned" }),
323
+ judgment({ p: 0.8, outcome: "held" }),
324
+ judgment({ p: 1, outcome: "held" }),
325
+ judgment({ p: 0.5 }),
326
+ ])
327
+ expect(triage).toEqual({
328
+ verdicts: 4,
329
+ scored: 3,
330
+ overturned: 1,
331
+ buckets: [
332
+ { p: 0.8, scored: 2, overturned: 1, rate: 0.5 },
333
+ { p: 0.9, scored: 1, overturned: 0, rate: 0 },
334
+ ],
335
+ })
336
+ expect(retry).toEqual({ verdicts: 0, scored: 0, overturned: 0, buckets: [] })
337
+ })
338
+ })
339
+
340
+ describe("whatIf", () => {
341
+ it("re-decides each verdict under its process's recorded threshold and the new one", () => {
342
+ const w = whatIf(
343
+ [
344
+ {
345
+ vars: { qualityTriageMinP: "0.7" },
346
+ judgments: [
347
+ judgment({ p: 0.85, outcome: "overturned" }),
348
+ judgment({ p: 0.8 }),
349
+ judgment({ answer: "mechanical", p: 0.95 }),
350
+ judgment({ judge: "retry", answer: "identical", p: 0.8 }),
351
+ ],
352
+ },
353
+ ],
354
+ "qualityTriageMinP",
355
+ "0.9",
356
+ )
357
+ expect(w).toEqual({
358
+ var: "qualityTriageMinP",
359
+ value: "0.9",
360
+ judge: "triage",
361
+ changes: [{ from: "noise", to: "design", count: 2, overturned: 1 }],
362
+ })
363
+ })
364
+
365
+ it("leaves out a process that recorded no such threshold", () => {
366
+ const w = whatIf(
367
+ [{ vars: {}, judgments: [judgment({ judge: "retry", answer: "identical", p: 0.8 })] }],
368
+ "judgeIdenticalMinP",
369
+ "0.7",
370
+ )
371
+ expect(w.changes).toEqual([])
372
+ })
373
+ })