@pmelab/gtd 38.1.0 → 39.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1021,14 +1021,21 @@ describe("parseArgv — gtd history", () => {
1021
1021
  expect(parse("history", "fetch", "upstream")).toMatchObject({
1022
1022
  command: { kind: "historyFetch", remote: "upstream" },
1023
1023
  })
1024
+ expect(parse("history", "metrics")).toMatchObject({
1025
+ command: { kind: "historyMetrics", refs: [] },
1026
+ })
1027
+ expect(parse("history", "metrics", "a", "feat/b")).toMatchObject({
1028
+ command: { kind: "historyMetrics", refs: ["a", "feat/b"] },
1029
+ })
1024
1030
  })
1025
1031
 
1026
- it("bare history and an unknown subcommand are usage errors naming list, show, fetch", () => {
1032
+ it("bare history and an unknown subcommand are usage errors naming list, show, metrics, fetch", () => {
1027
1033
  for (const args of [["history"], ["history", "nope"]]) {
1028
1034
  const message = usage(parse(...args))
1029
1035
  expect(message).toContain("list")
1030
1036
  expect(message).toContain("show")
1031
1037
  expect(message).toContain("fetch")
1038
+ expect(message).toContain("metrics")
1032
1039
  }
1033
1040
  })
1034
1041
 
@@ -1039,19 +1046,23 @@ describe("parseArgv — gtd history", () => {
1039
1046
  expect(usage(parse("history", "fetch", "a", "b"))).toContain("too many arguments")
1040
1047
  })
1041
1048
 
1042
- it("--json is accepted for list and show, rejected for fetch", () => {
1049
+ it("--json is accepted for list, show and metrics, rejected for fetch", () => {
1043
1050
  expect(parse("history", "list", "--json")).toMatchObject({ json: { kind: "document" } })
1044
1051
  expect(parse("history", "show", "x", "--json=ended")).toMatchObject({
1045
1052
  json: { kind: "select", path: "ended" },
1046
1053
  })
1054
+ expect(parse("history", "metrics", "--json=total.refactorFirstRate")).toMatchObject({
1055
+ json: { kind: "select", path: "total.refactorFirstRate" },
1056
+ })
1047
1057
  expect(parse("history", "fetch", "--json").kind).toBe("usage")
1048
1058
  })
1049
1059
 
1050
- it("help lists the three history rows", () => {
1060
+ it("help lists the four history rows", () => {
1051
1061
  const help = renderHelp()
1052
1062
  expect(help).toMatch(/^ {2}history list\b/m)
1053
1063
  expect(help).toContain("history show <ref>")
1054
1064
  expect(help).toContain("history fetch [remote]")
1065
+ expect(help).toContain("history metrics [ref...]")
1055
1066
  })
1056
1067
  })
1057
1068
 
@@ -1136,6 +1147,7 @@ describe("standaloneKinds / needsOf", () => {
1136
1147
  "slices",
1137
1148
  "historyList",
1138
1149
  "historyShow",
1150
+ "historyMetrics",
1139
1151
  "historyFetch",
1140
1152
  ] as const) {
1141
1153
  expect(needsOf(kind)).toBe("state")
package/src/cli/Cli.ts CHANGED
@@ -97,29 +97,33 @@ const share = (raw: string, flag: string): Either.Either<number, string> => {
97
97
  : Either.left(`gtd: ${flag} must be a share of the context window in (0, 1] — got "${raw}"`)
98
98
  }
99
99
 
100
+ const JSON_KINDS: ReadonlySet<Command["kind"] | undefined> = new Set<Command["kind"]>([
101
+ "next",
102
+ "land",
103
+ "judge",
104
+ "judgeAnswer",
105
+ "doors",
106
+ "slices",
107
+ "historyList",
108
+ "historyShow",
109
+ "historyMetrics",
110
+ "strays",
111
+ ])
112
+
100
113
  const FLAGS: readonly FlagRow[] = [
101
114
  {
102
115
  name: "--json",
103
116
  arity: "optional",
104
117
  repeatable: false,
105
- scope: (kind) =>
106
- kind === "next" ||
107
- kind === "land" ||
108
- kind === "judge" ||
109
- kind === "judgeAnswer" ||
110
- kind === "doors" ||
111
- kind === "slices" ||
112
- kind === "historyList" ||
113
- kind === "historyShow" ||
114
- kind === "strays",
118
+ scope: (kind) => JSON_KINDS.has(kind),
115
119
  decode: ([raw]) => Either.right(raw ?? ""),
116
120
  scopeError:
117
- "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 strays` — " +
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` — " +
118
122
  "every other command prints plain text; see `gtd install` for the driver protocol briefing",
119
123
  valueHint: "<path>",
120
124
  help: [
121
125
  "(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd slices/gtd history",
122
- "list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
126
+ "list/gtd history show/gtd history metrics/gtd strays only) output structured JSON. Bare --json prints the whole document;",
123
127
  "--json=<path> (a dotted key path into that document, e.g.",
124
128
  "kind, content, session.id) prints just that value: a",
125
129
  "scalar raw and unquoted, a boolean as true/false, a list",
@@ -445,6 +449,8 @@ type Arity =
445
449
  | { readonly name: string; readonly variadic: string }
446
450
  // At most one trailing positional.
447
451
  | { readonly name: string; readonly optional: true }
452
+ // Any number of positionals, none included.
453
+ | { readonly name: string; readonly many: true }
448
454
 
449
455
  interface CommandRow {
450
456
  readonly token: string
@@ -824,6 +830,23 @@ const COMMAND_ROWS: readonly CommandRow[] = [
824
830
  "prints the trail as one document; writes nothing",
825
831
  ],
826
832
  },
833
+ {
834
+ token: "history metrics",
835
+ kind: "historyMetrics",
836
+ arity: { name: "ref", many: true },
837
+ details: [
838
+ "Report how the kept processes went: slices whose naive pass",
839
+ "landed or was rebuilt (the refactor-first rate), continuations",
840
+ "per phase, Gtd-Cost by model and phase with the double work of",
841
+ "a rebuild, quality-lap findings per lens, and red checks that",
842
+ "passed on their re-check — per process and in total. Each <ref>",
843
+ "is read as by history show, but a branch name means every",
844
+ "process kept from it; none means every ref.",
845
+ "Reads commit subjects and trailers, never replays, so a",
846
+ "changed config is no refusal. --json prints { processes, total",
847
+ "} as one document; writes nothing",
848
+ ],
849
+ },
827
850
  {
828
851
  token: "history fetch",
829
852
  kind: "historyFetch",
@@ -883,7 +906,9 @@ export const renderHelp = (): string => {
883
906
  ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]`
884
907
  : "optional" in row.arity
885
908
  ? `${row.token} [${row.arity.name}]`
886
- : `${row.token} <${row.arity.name}>`
909
+ : "many" in row.arity
910
+ ? `${row.token} [${row.arity.name}...]`
911
+ : `${row.token} <${row.arity.name}>`
887
912
  return renderBlock(header, row.details)
888
913
  })
889
914
  const commands = [
@@ -1051,23 +1076,23 @@ const conflictViolation = (present: ReadonlySet<string>): string | undefined =>
1051
1076
  return undefined
1052
1077
  }
1053
1078
 
1079
+ const tooMany = (
1080
+ cmd: string,
1081
+ rest: readonly string[],
1082
+ expected: string,
1083
+ max: number,
1084
+ ): string | undefined =>
1085
+ rest.length > max
1086
+ ? `gtd ${cmd}: too many arguments — expected ${expected}, got: ${rest.join(", ")}`
1087
+ : undefined
1088
+
1054
1089
  const arityError = (cmd: string, rest: readonly string[], arity: Arity): string | undefined => {
1055
- if (arity === "none") {
1056
- return rest.length > 0
1057
- ? `gtd ${cmd}: too many arguments — expected none, got: ${rest.join(", ")}`
1058
- : undefined
1059
- }
1090
+ if (arity === "none") return tooMany(cmd, rest, "none", 0)
1060
1091
  if ("names" in arity) return pairArityError(cmd, rest, arity.names)
1061
- if ("optional" in arity) {
1062
- return rest.length > 1
1063
- ? `gtd ${cmd}: too many arguments — expected at most one ${arity.name}, got: ${rest.join(", ")}`
1064
- : undefined
1065
- }
1092
+ if ("many" in arity) return undefined
1093
+ if ("optional" in arity) return tooMany(cmd, rest, `at most one ${arity.name}`, 1)
1066
1094
  if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`
1067
- if (!("variadic" in arity) && rest.length > 1) {
1068
- return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`
1069
- }
1070
- return undefined
1095
+ return "variadic" in arity ? undefined : tooMany(cmd, rest, `one ${arity.name}`, 1)
1071
1096
  }
1072
1097
 
1073
1098
  const pairArityError = (
@@ -1138,7 +1163,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1138
1163
 
1139
1164
  if (isHistory && row === undefined) {
1140
1165
  return usagePlan(
1141
- "gtd history: expected a subcommand — list, show <ref> or fetch [remote]",
1166
+ "gtd history: expected a subcommand — list, show <ref>, metrics [ref...] or fetch [remote]",
1142
1167
  jsonSeen,
1143
1168
  )
1144
1169
  }
@@ -1381,6 +1406,15 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1381
1406
  }
1382
1407
  }
1383
1408
 
1409
+ if (kind === "historyMetrics") {
1410
+ return {
1411
+ kind: "command",
1412
+ command: { kind: "historyMetrics", refs: restPositionals },
1413
+ json,
1414
+ verbose,
1415
+ }
1416
+ }
1417
+
1384
1418
  if (kind === "historyFetch") {
1385
1419
  return {
1386
1420
  kind: "command",
@@ -3,12 +3,7 @@ import { historyTrail } from "../edge/index.js"
3
3
  import { combinedScript, fetchHistoryRefs } from "../emit/index.js"
4
4
  import { GitService, Narrator, PROCESS_HISTORY_PREFIX } from "../platform/index.js"
5
5
  import { parseCommitMessage } from "../replay/index.js"
6
- import {
7
- writeSelection,
8
- type ArtifactOut,
9
- type CommandRequirements,
10
- type JsonMode,
11
- } from "./Output.js"
6
+ import { writeJson, type ArtifactOut, type CommandRequirements, type JsonMode } from "./Output.js"
12
7
 
13
8
  export interface HistoryEntry {
14
9
  readonly ref: string
@@ -80,19 +75,6 @@ export const resolveHistoryRef = (
80
75
 
81
76
  const seven = (hash: string): string => hash.slice(0, 7)
82
77
 
83
- /** Writes `document` per `json` (bare → whole, `=path` → one value); returns false for `off`. */
84
- const writeJson = (
85
- json: JsonMode,
86
- out: ArtifactOut,
87
- document: unknown,
88
- ): Effect.Effect<boolean, Error> =>
89
- Effect.gen(function* () {
90
- if (json.kind === "off") return false
91
- if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`)
92
- else yield* writeSelection(out, document, json.path)
93
- return true
94
- })
95
-
96
78
  export const runHistoryListCommand = (
97
79
  json: JsonMode,
98
80
  out: ArtifactOut,
@@ -0,0 +1,255 @@
1
+ import { Effect } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { applyEmittedScript, InMemRepo, makeCapturingCliIo, testLayers } from "../testkit/index.js"
4
+ import { runCli } from "./index.js"
5
+ import { processMetrics, readProcessMetrics, totalMetrics, type MetricCommit } from "./Metrics.js"
6
+
7
+ const turn = (subject: string, opts: { cost?: string; findings?: number } = {}): MetricCommit => ({
8
+ message: opts.cost === undefined ? subject : `${subject}\n\nGtd-Cost: ${opts.cost}`,
9
+ findings: opts.findings ?? 0,
10
+ })
11
+
12
+ describe("processMetrics", () => {
13
+ it("counts a naive pass applied as it is as a landed slice", () => {
14
+ const m = processMetrics([
15
+ turn("gtd(human): idle → seam.naive"),
16
+ turn("gtd(agent): seam.naive → seam.check", { cost: "0.4 coder" }),
17
+ turn("gtd(check): seam.check → implement.applying"),
18
+ turn("gtd(check): implement.applying → idle"),
19
+ ])
20
+ expect(m.slices).toEqual({ decided: 1, landed: 1, rebuilt: 0, refactored: 0 })
21
+ expect(m.refactorFirstRate).toBe(0)
22
+ expect(m.cost.byPhase).toEqual({ naive: 0.4, refactor: 0, implement: 0, other: 0 })
23
+ expect(m.cost.doubleWork).toEqual({ naive: 0, rebuild: 0, total: 0 })
24
+ })
25
+
26
+ it("charges a rebuilt slice's naive pass and rebuild as double work, refactor apart", () => {
27
+ const m = processMetrics([
28
+ turn("gtd(human): idle → seam.naive"),
29
+ turn("gtd(agent): seam.naive → seam.continue.naive", { cost: "0.4 coder" }),
30
+ turn("gtd(agent): seam.continue.naive → seam.judge", { cost: "0.2 coder" }),
31
+ turn("gtd(judge): seam.judge → seam.note", { cost: "0.01 judge" }),
32
+ turn("gtd(agent): seam.note → refactor.deriving", { cost: "0.05 coder" }),
33
+ turn("gtd(check): refactor.deriving → refactor.building"),
34
+ turn("gtd(agent): refactor.building → refactor.closing", { cost: "0.3 coder" }),
35
+ turn("gtd(check): refactor.closing → implement.building"),
36
+ turn("gtd(agent): implement.building → implement.continue.building", { cost: "0.5 coder" }),
37
+ turn("gtd(agent): implement.continue.building → idle", { cost: "0.25 coder" }),
38
+ ])
39
+ expect(m.slices).toEqual({ decided: 1, landed: 0, rebuilt: 1, refactored: 1 })
40
+ expect(m.refactorFirstRate).toBe(1)
41
+ expect(m.continuations).toEqual({ "seam.naive": 1, "implement.building": 1 })
42
+ expect(m.cost.total).toBe(1.71)
43
+ expect(m.cost.byModel).toEqual({ coder: 1.7, judge: 0.01 })
44
+ expect(m.cost.byPhase).toEqual({ naive: 0.6, refactor: 0.35, implement: 0.75, other: 0.01 })
45
+ expect(m.cost.doubleWork).toEqual({ naive: 0.6, rebuild: 0.75, total: 1.35 })
46
+ })
47
+
48
+ it("rebuilds without a refactor when the slice leaves the seam for a build turn", () => {
49
+ const m = processMetrics([
50
+ turn("gtd(judge): seam.judge → implement.building"),
51
+ turn("gtd(agent): implement.building → idle"),
52
+ ])
53
+ expect(m.slices).toEqual({ decided: 1, landed: 0, rebuilt: 1, refactored: 0 })
54
+ })
55
+
56
+ it("keeps each slice's decision apart", () => {
57
+ const m = processMetrics([
58
+ turn("gtd(agent): seam.naive → seam.check", { cost: "1" }),
59
+ turn("gtd(check): seam.check → implement.applying"),
60
+ turn("gtd(agent): implement.building → implement.health.check", { cost: "1" }),
61
+ turn("gtd(check): implement.closing → seam.naive"),
62
+ turn("gtd(agent): seam.naive → seam.note", { cost: "2" }),
63
+ turn("gtd(agent): seam.note → refactor.deriving"),
64
+ turn("gtd(agent): refactor.continue.building → refactor.closing", { cost: "1" }),
65
+ turn("gtd(check): refactor.closing → implement.building"),
66
+ turn("gtd(agent): implement.building → idle", { cost: "3" }),
67
+ ])
68
+ expect(m.slices).toEqual({ decided: 2, landed: 1, rebuilt: 1, refactored: 1 })
69
+ expect(m.refactorFirstRate).toBe(0.5)
70
+ expect(m.continuations).toEqual({ "refactor.building": 1 })
71
+ expect(m.cost.byModel).toEqual({ unspecified: 8 })
72
+ expect(m.cost.doubleWork).toEqual({ naive: 2, rebuild: 3, total: 5 })
73
+ })
74
+
75
+ it("counts a naive retry in a fresh conversation as naive cost", () => {
76
+ const m = processMetrics([
77
+ turn("gtd(agent): seam.continue.naive → seam.judge", { cost: "1" }),
78
+ turn("gtd(judge): seam.judge → seam.fresh.naive"),
79
+ turn("gtd(agent): seam.fresh.naive → seam.judge", { cost: "2" }),
80
+ turn("gtd(judge): seam.judge → implement.building"),
81
+ ])
82
+ expect(m.cost.byPhase.naive).toBe(3)
83
+ expect(m.cost.doubleWork.naive).toBe(3)
84
+ })
85
+
86
+ it("turns a landed slice whose naive pass fails to apply into a rebuild", () => {
87
+ const m = processMetrics([
88
+ turn("gtd(agent): seam.naive → seam.check", { cost: "1" }),
89
+ turn("gtd(check): seam.check → implement.applying"),
90
+ turn("gtd(check): implement.applying → implement.building"),
91
+ turn("gtd(agent): implement.building → implement.health.check", { cost: "2" }),
92
+ ])
93
+ expect(m.slices).toEqual({ decided: 1, landed: 0, rebuilt: 1, refactored: 0 })
94
+ expect(m.cost.doubleWork).toEqual({ naive: 1, rebuild: 2, total: 3 })
95
+ })
96
+
97
+ it("keeps the refactor lap's lens findings apart from the feature lap's", () => {
98
+ const m = processMetrics([
99
+ turn("gtd(agent): refactor.build.quality.correctness.reviewing → refactor.closing", {
100
+ findings: 3,
101
+ }),
102
+ turn("gtd(agent): build.quality.correctness.reviewing → idle", { findings: 2 }),
103
+ ])
104
+ expect(m.findings.byLens).toEqual({
105
+ "refactor.build.quality.correctness": 3,
106
+ "build.quality.correctness": 2,
107
+ })
108
+ })
109
+
110
+ it("ends a rebuild's double work at the slice's closing", () => {
111
+ const m = processMetrics([
112
+ turn("gtd(judge): seam.judge → implement.building"),
113
+ turn("gtd(agent): implement.building → implement.closing", { cost: "1" }),
114
+ turn("gtd(check): implement.closing → build.quality.correctness.reviewing"),
115
+ turn("gtd(agent): implement.building → idle", { cost: "2" }),
116
+ ])
117
+ expect(m.cost.doubleWork.rebuild).toBe(1)
118
+ expect(m.cost.byPhase.implement).toBe(3)
119
+ })
120
+
121
+ it("keeps a model named like an Object member a plain key", () => {
122
+ const m = processMetrics([
123
+ turn("gtd(agent): seam.naive → seam.check", { cost: "0.4 constructor" }),
124
+ turn("gtd(agent): seam.naive → seam.check", { cost: "0.1 __proto__" }),
125
+ ])
126
+ expect(m.cost.total).toBe(0.5)
127
+ expect(Object.entries(m.cost.byModel)).toEqual([
128
+ ["constructor", 0.4],
129
+ ["__proto__", 0.1],
130
+ ])
131
+ })
132
+
133
+ it("has no refactor-first rate without a seam decision", () => {
134
+ expect(processMetrics([turn("gtd(human): idle → building")]).refactorFirstRate).toBeNull()
135
+ })
136
+
137
+ it("counts continuations only for agent turns, the bare form included", () => {
138
+ const m = processMetrics([
139
+ turn("gtd(agent): seam.continue.naive"),
140
+ turn("gtd(human): seam.continue.naive → seam.oversized"),
141
+ ])
142
+ expect(m.continuations).toEqual({ "seam.naive": 1 })
143
+ })
144
+
145
+ it("sums findings per quality lens and skips commits gtd did not write", () => {
146
+ const m = processMetrics([
147
+ turn(
148
+ "gtd(agent): build.quality.correctness.reviewing → build.quality.conventions.reviewing",
149
+ {
150
+ findings: 2,
151
+ },
152
+ ),
153
+ turn("gtd(agent): build.quality.conventions.reviewing → build.fix.quality.fixing", {
154
+ findings: 1,
155
+ }),
156
+ turn("fix: a hand-made commit", { cost: "5" }),
157
+ ])
158
+ expect(m.findings).toEqual({
159
+ total: 3,
160
+ byLens: { "build.quality.correctness": 2, "build.quality.conventions": 1 },
161
+ })
162
+ expect(m.cost.total).toBe(0)
163
+ })
164
+ })
165
+
166
+ describe("flaky re-checks", () => {
167
+ it("counts each flaky re-check under its own full step name", () => {
168
+ const m = processMetrics([
169
+ {
170
+ message:
171
+ "gtd(check): start-gate.recheck → design.triage\n\nGtd-Mark: flaky=start-gate.check",
172
+ findings: 0,
173
+ },
174
+ {
175
+ message: "gtd(check): build.health.recheck → idle\n\nGtd-Mark: flaky=health.check",
176
+ findings: 0,
177
+ },
178
+ {
179
+ message:
180
+ "gtd(check): implement.health.recheck → implement.closing\n\nGtd-Mark: flaky=health.check",
181
+ findings: 0,
182
+ },
183
+ { message: "gtd(agent): seam.naive → seam.check\n\nGtd-Mark: covers=AC-1", findings: 0 },
184
+ { message: "fix: hand-made\n\nGtd-Mark: flaky=x.check", findings: 0 },
185
+ ])
186
+ expect(m.flaky).toEqual({
187
+ total: 3,
188
+ byStep: { "start-gate.recheck": 1, "build.health.recheck": 1, "implement.health.recheck": 1 },
189
+ })
190
+ expect(totalMetrics([m, m]).flaky).toEqual({
191
+ total: 6,
192
+ byStep: { "start-gate.recheck": 2, "build.health.recheck": 2, "implement.health.recheck": 2 },
193
+ })
194
+ })
195
+ })
196
+
197
+ describe("totalMetrics", () => {
198
+ it("adds processes and recomputes the rate over every decision", () => {
199
+ const a = processMetrics([
200
+ turn("gtd(agent): seam.naive → seam.check", { cost: "1 a" }),
201
+ turn("gtd(check): seam.check → implement.applying"),
202
+ ])
203
+ const b = processMetrics([
204
+ turn("gtd(agent): seam.continue.naive → seam.note", { cost: "2 b" }),
205
+ turn("gtd(agent): seam.note → refactor.deriving"),
206
+ turn("gtd(agent): seam.naive → seam.note"),
207
+ turn("gtd(agent): seam.note → refactor.deriving"),
208
+ ])
209
+ const total = totalMetrics([a, b])
210
+ expect(total.processes).toBe(2)
211
+ expect(total.slices).toEqual({ decided: 3, landed: 1, rebuilt: 2, refactored: 2 })
212
+ expect(total.refactorFirstRate).toBe(0.667)
213
+ expect(total.continuations).toEqual({ "seam.naive": 1 })
214
+ expect(total.cost.byModel).toEqual({ a: 1, b: 2 })
215
+ })
216
+ })
217
+
218
+ const CONFIG = `import { agent, human, scope } from "@pmelab/gtd/flows"
219
+
220
+ export default async () => {
221
+ await human("idle")
222
+ await scope("build", () => scope("quality", () => scope("correctness", () => agent("reviewing", "review"))))
223
+ }
224
+ `
225
+
226
+ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
227
+ for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
228
+ const { io, result } = makeCapturingCliIo(repo)
229
+ await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
230
+ const { stdout, exitCode, stderr } = result()
231
+ if (exitCode !== 0) throw new Error(`land exited ${exitCode}: ${stderr}`)
232
+ const outcome = applyEmittedScript(
233
+ repo,
234
+ new Map(),
235
+ (JSON.parse(stdout) as { script: string }).script,
236
+ )
237
+ if (!outcome.ok) throw new Error(outcome.error)
238
+ return repo.resolveRef("HEAD")!
239
+ }
240
+
241
+ describe("readProcessMetrics", () => {
242
+ it("counts the findings a reviewing turn added to an existing QUALITY.md", async () => {
243
+ const repo = new InMemRepo()
244
+ repo.writeFile("gtd.config.ts", CONFIG)
245
+ repo.writeFile(".gtd/QUALITY.md", "## old finding\n")
246
+ repo.seedCommit("chore: add workflow")
247
+ const entry = await land(repo, { "NOTE.md": "go\n" })
248
+ const tip = await land(repo, { ".gtd/QUALITY.md": "## old finding\n\n## one\n\n## two\n" })
249
+ const metrics = await Effect.runPromise(
250
+ readProcessMetrics({ entry, tip }).pipe(Effect.provide(testLayers(repo, {}))),
251
+ )
252
+ expect(metrics.findings).toEqual({ total: 2, byLens: { "build.quality.correctness": 2 } })
253
+ expect(metrics.workflow).toBeNull()
254
+ })
255
+ })