@pmelab/gtd 38.1.0 → 38.2.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.
@@ -0,0 +1,310 @@
1
+ import { Effect, Option } from "effect"
2
+ import { GitService } from "../platform/index.js"
3
+ import { parseCommitMessage } from "../replay/index.js"
4
+ import { UNATTRIBUTED_MODEL } from "../wire/index.js"
5
+ import { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
6
+ import { writeJson, type ArtifactOut, type CommandRequirements, type JsonMode } from "./Output.js"
7
+
8
+ // Read from commit subjects and trailers alone, never by replay: a metric over
9
+ // old histories must not refuse because today's config has moved on. The step
10
+ // names below are the bundled feature workflow's public ones.
11
+
12
+ const QUALITY = ".gtd/QUALITY.md"
13
+
14
+ export interface MetricCommit {
15
+ readonly message: string
16
+ /** Findings this commit added to the quality lap's file; only a reviewing turn's count is read. */
17
+ readonly findings: number
18
+ }
19
+
20
+ export interface Metrics {
21
+ readonly slices: {
22
+ readonly decided: number
23
+ readonly landed: number
24
+ readonly rebuilt: number
25
+ readonly refactored: number
26
+ }
27
+ /** `rebuilt / decided`, to three places; null without a decision. */
28
+ readonly refactorFirstRate: number | null
29
+ /** Continuation turns per phase, keyed by the step without its `continue` segment. */
30
+ readonly continuations: Readonly<Record<string, number>>
31
+ readonly cost: {
32
+ readonly total: number
33
+ readonly byModel: Readonly<Record<string, number>>
34
+ readonly byPhase: {
35
+ readonly naive: number
36
+ readonly refactor: number
37
+ readonly implement: number
38
+ readonly other: number
39
+ }
40
+ /** A rebuilt slice's naive pass and the implement turns that rebuilt it. */
41
+ readonly doubleWork: {
42
+ readonly naive: number
43
+ readonly rebuild: number
44
+ readonly total: number
45
+ }
46
+ }
47
+ readonly findings: { readonly total: number; readonly byLens: Readonly<Record<string, number>> }
48
+ }
49
+
50
+ type Phase = keyof Metrics["cost"]["byPhase"]
51
+
52
+ // Keyed by the lap's path without `.reviewing`, so the refactor lap's lenses stay apart from the feature's.
53
+ const LENS = /^((?:.*\.)?quality\.[^.]+)\.reviewing$/
54
+
55
+ const CONTINUE = /(^|\.)continue\./
56
+
57
+ const inSeam = (step: string): boolean => step.startsWith("seam.")
58
+
59
+ // A naive retry after a continuation runs under `seam.fresh.naive`: any seam step named `naive` is the pass.
60
+ const phaseOf = (step: string): Phase => {
61
+ const scope = step.split(".")[0]
62
+ if (inSeam(step) && step.endsWith(".naive")) return "naive"
63
+ if (step === "seam.note" || scope === "refactor") return "refactor"
64
+ if (scope === "implement") return "implement"
65
+ return "other"
66
+ }
67
+
68
+ const round = (n: number, places = 6): number => {
69
+ const f = 10 ** places
70
+ return Math.round(n * f) / f
71
+ }
72
+
73
+ // Keys are model, step and lens names from history: a prototype-free record keeps `constructor` a plain key.
74
+ const record = (): Record<string, number> => Object.create(null) as Record<string, number>
75
+
76
+ const add = (into: Record<string, number>, key: string, n: number): void => {
77
+ into[key] = (into[key] ?? 0) + n
78
+ }
79
+
80
+ const roundAll = (record: Record<string, number>): Record<string, number> =>
81
+ Object.fromEntries(Object.entries(record).map(([k, v]) => [k, round(v)]))
82
+
83
+ interface Tally {
84
+ readonly slices: { decided: number; landed: number; rebuilt: number; refactored: number }
85
+ readonly continuations: Record<string, number>
86
+ readonly byModel: Record<string, number>
87
+ readonly byPhase: Record<Phase, number>
88
+ readonly doubleWork: { naive: number; rebuild: number }
89
+ readonly byLens: Record<string, number>
90
+ }
91
+
92
+ const emptyTally = (): Tally => ({
93
+ slices: { decided: 0, landed: 0, rebuilt: 0, refactored: 0 },
94
+ continuations: record(),
95
+ byModel: record(),
96
+ byPhase: { naive: 0, refactor: 0, implement: 0, other: 0 },
97
+ doubleWork: { naive: 0, rebuild: 0 },
98
+ byLens: record(),
99
+ })
100
+
101
+ const sum = (record: Readonly<Record<string, number>>): number =>
102
+ Object.values(record).reduce((a, b) => a + b, 0)
103
+
104
+ const finish = (t: Tally): Metrics => ({
105
+ slices: t.slices,
106
+ refactorFirstRate: t.slices.decided === 0 ? null : round(t.slices.rebuilt / t.slices.decided, 3),
107
+ continuations: t.continuations,
108
+ cost: {
109
+ total: round(sum(t.byModel)),
110
+ byModel: roundAll(t.byModel),
111
+ byPhase: roundAll(t.byPhase) as Metrics["cost"]["byPhase"],
112
+ doubleWork: {
113
+ naive: round(t.doubleWork.naive),
114
+ rebuild: round(t.doubleWork.rebuild),
115
+ total: round(t.doubleWork.naive + t.doubleWork.rebuild),
116
+ },
117
+ },
118
+ findings: { total: sum(t.byLens), byLens: t.byLens },
119
+ })
120
+
121
+ /** The open slice: its naive cost so far, and whether its implement turns rebuild it. */
122
+ interface Slice {
123
+ naive: number
124
+ rebuilding: boolean
125
+ /** Landed, not yet applied, with its naive cost: an apply that fails turns it into a rebuild. */
126
+ applying: number | undefined
127
+ }
128
+
129
+ const tallyCost = (
130
+ t: Tally,
131
+ open: Slice,
132
+ costs: ReturnType<typeof parseCommitMessage>["cost"],
133
+ phase: Phase,
134
+ ): void => {
135
+ for (const { cost, model } of costs) {
136
+ add(t.byModel, model ?? UNATTRIBUTED_MODEL, cost)
137
+ t.byPhase[phase] += cost
138
+ if (phase === "naive") open.naive += cost
139
+ if (phase === "implement" && open.rebuilding) t.doubleWork.rebuild += cost
140
+ }
141
+ }
142
+
143
+ /** A slice is decided where its turns leave the seam: for `implement.applying` the naive pass landed — unless it fails to apply and is built afresh — anywhere else it is rebuilt. */
144
+ const tallyDecision = (t: Tally, open: Slice, at: string, to: string): void => {
145
+ // The slice's closing ends its rebuild: later implement turns (an amend after review) are new work.
146
+ if (at === "implement.closing") open.rebuilding = false
147
+ if (open.applying !== undefined && at === "implement.applying") {
148
+ const naive = open.applying
149
+ open.applying = undefined
150
+ if (to !== "implement.building") return
151
+ t.slices.landed--
152
+ rebuild(t, open, naive)
153
+ return
154
+ }
155
+ if (!inSeam(at) || inSeam(to)) return
156
+ t.slices.decided++
157
+ const naive = open.naive
158
+ open.naive = 0
159
+ if (to === "implement.applying") {
160
+ open.applying = naive
161
+ open.rebuilding = false
162
+ t.slices.landed++
163
+ return
164
+ }
165
+ rebuild(t, open, naive)
166
+ if (at === "seam.note") t.slices.refactored++
167
+ }
168
+
169
+ const rebuild = (t: Tally, open: Slice, naive: number): void => {
170
+ open.rebuilding = true
171
+ t.slices.rebuilt++
172
+ t.doubleWork.naive += naive
173
+ }
174
+
175
+ /** Oldest first; a commit gtd did not write is skipped. */
176
+ export const processMetrics = (commits: readonly MetricCommit[]): Metrics => {
177
+ const t = emptyTally()
178
+ const open: Slice = { naive: 0, rebuilding: false, applying: undefined }
179
+ for (const commit of commits) {
180
+ const message = parseCommitMessage(commit.message)
181
+ const subject = message.parsed
182
+ if (subject === undefined) continue
183
+ const at = subject.from ?? subject.to
184
+ tallyCost(t, open, message.cost, phaseOf(at))
185
+ if (subject.actor === "agent" && CONTINUE.test(at))
186
+ add(t.continuations, at.replace(CONTINUE, "$1"), 1)
187
+ const lens = LENS.exec(at)?.[1]
188
+ if (lens !== undefined && commit.findings > 0) add(t.byLens, lens, commit.findings)
189
+ tallyDecision(t, open, at, subject.to)
190
+ }
191
+ return finish(t)
192
+ }
193
+
194
+ export const totalMetrics = (all: readonly Metrics[]): Metrics & { readonly processes: number } => {
195
+ const t = emptyTally()
196
+ for (const m of all) {
197
+ for (const key of Object.keys(t.slices) as (keyof Tally["slices"])[])
198
+ t.slices[key] += m.slices[key]
199
+ for (const [k, v] of Object.entries(m.continuations)) add(t.continuations, k, v)
200
+ for (const [k, v] of Object.entries(m.cost.byModel)) add(t.byModel, k, v)
201
+ for (const key of Object.keys(t.byPhase) as Phase[]) t.byPhase[key] += m.cost.byPhase[key]
202
+ t.doubleWork.naive += m.cost.doubleWork.naive
203
+ t.doubleWork.rebuild += m.cost.doubleWork.rebuild
204
+ for (const [k, v] of Object.entries(m.findings.byLens)) add(t.byLens, k, v)
205
+ }
206
+ return { processes: all.length, ...finish(t) }
207
+ }
208
+
209
+ const chunks = (content: string | undefined): number =>
210
+ content === undefined ? 0 : (content.match(/^## /gm) ?? []).length
211
+
212
+ export interface ProcessMetrics extends Metrics {
213
+ /** The opening commit's `Gtd-Workflow`; null for the default workflow. */
214
+ readonly workflow: string | null
215
+ }
216
+
217
+ export const readProcessMetrics = (
218
+ entry: Pick<HistoryEntry, "entry" | "tip">,
219
+ ): Effect.Effect<ProcessMetrics, Error, GitService> =>
220
+ Effect.gen(function* () {
221
+ const git = yield* GitService
222
+ const base = Option.getOrUndefined(yield* git.readRefOption(`${entry.entry}^`))
223
+ const commits = yield* git.commitHistory(base, entry.tip)
224
+ const quality = (hash: string) =>
225
+ git.readFileAtRef(hash, QUALITY).pipe(
226
+ Effect.map((content): string | undefined => content),
227
+ Effect.orElseSucceed(() => undefined),
228
+ )
229
+ // Only a commit that touched the file changes it: carry the last content forward.
230
+ let count = chunks(base === undefined ? undefined : yield* quality(base))
231
+ const read: MetricCommit[] = []
232
+ for (const { hash, message, touched } of commits) {
233
+ const before = count
234
+ if (touched.includes(QUALITY)) count = chunks(yield* quality(hash))
235
+ read.push({ message, findings: Math.max(0, count - before) })
236
+ }
237
+ const workflow =
238
+ commits[0] === undefined ? undefined : parseCommitMessage(commits[0].message).workflow
239
+ return { workflow: workflow ?? null, ...processMetrics(read) }
240
+ })
241
+
242
+ // Keys come from fetched history: never let them put control bytes on a terminal.
243
+ // eslint-disable-next-line no-control-regex -- the control range IS the point
244
+ const printable = (s: string): string => s.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, "?")
245
+
246
+ const listed = (record: Readonly<Record<string, number>>): string =>
247
+ Object.entries(record)
248
+ .map(([k, v]) => `${printable(k)} ${v}`)
249
+ .join(", ")
250
+
251
+ const line = (label: string, m: Metrics): string => {
252
+ const { slices, cost } = m
253
+ const percent = m.refactorFirstRate === null ? "-" : `${Math.round(m.refactorFirstRate * 100)}%`
254
+ const continued = sum(m.continuations)
255
+ return [
256
+ `${label}: ${slices.decided} slices, ${slices.rebuilt} rebuilt (refactor-first ${percent}), ${slices.refactored} refactored`,
257
+ `${continued} continuations${continued > 0 ? ` (${listed(m.continuations)})` : ""}`,
258
+ `cost ${cost.total} (${listed(cost.byPhase)}; by model ${listed(cost.byModel) || "-"}), double work ${cost.doubleWork.total}`,
259
+ `${m.findings.total} findings${m.findings.total > 0 ? ` (${listed(m.findings.byLens)})` : ""}`,
260
+ ].join("; ")
261
+ }
262
+
263
+ export const runHistoryMetricsCommand = (
264
+ refs: readonly string[],
265
+ json: JsonMode,
266
+ out: ArtifactOut,
267
+ ): Effect.Effect<void, Error, CommandRequirements> =>
268
+ Effect.gen(function* () {
269
+ const entries = yield* historyEntries
270
+ const chosen =
271
+ refs.length === 0
272
+ ? entries
273
+ : (yield* Effect.forEach(refs, (ref) =>
274
+ // A branch name means every process kept from it, not just its newest.
275
+ resolveHistoryRef(ref, entries).pipe(
276
+ Effect.map((hit) =>
277
+ hit.branch === ref ? entries.filter((e) => e.branch === ref) : [hit],
278
+ ),
279
+ ),
280
+ )).flat()
281
+ const unique = chosen.filter((e, i) => chosen.findIndex((o) => o.ref === e.ref) === i)
282
+ const processes = yield* Effect.forEach(
283
+ unique,
284
+ (e) =>
285
+ readProcessMetrics(e).pipe(
286
+ Effect.map((metrics) => ({
287
+ ref: e.ref,
288
+ entry: e.entry,
289
+ branch: e.branch,
290
+ tip: e.tip,
291
+ ...metrics,
292
+ })),
293
+ ),
294
+ { concurrency: 4 },
295
+ )
296
+ const document = { processes, total: totalMetrics(processes) }
297
+ if (yield* writeJson(json, out, document)) return
298
+ if (processes.length === 0) {
299
+ out.write("no history refs; run gtd history fetch | sh to pull them\n")
300
+ return
301
+ }
302
+ const lines = processes.map((p) =>
303
+ line(
304
+ `${p.entry.slice(0, 7)} ${printable(p.branch ?? "-")}${p.workflow === null ? "" : ` (${printable(p.workflow)})`}`,
305
+ p,
306
+ ),
307
+ )
308
+ lines.push(line(`total (${processes.length} processes)`, document.total))
309
+ out.write(`${lines.join("\n")}\n`)
310
+ })
package/src/cli/Output.ts CHANGED
@@ -68,3 +68,16 @@ export type CommandRequirements =
68
68
  | ToolCommands
69
69
 
70
70
  export type SuccessExit = typeof EXIT_OK | typeof EXIT_STRAYS_FOUND
71
+
72
+ /** Writes `document` per `json` (bare → whole, `=path` → one value); returns false for `off`. */
73
+ export const writeJson = (
74
+ json: JsonMode,
75
+ out: ArtifactOut,
76
+ document: unknown,
77
+ ): Effect.Effect<boolean, Error> =>
78
+ Effect.gen(function* () {
79
+ if (json.kind === "off") return false
80
+ if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`)
81
+ else yield* writeSelection(out, document, json.path)
82
+ return true
83
+ })
@@ -1694,6 +1694,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1694
1694
  judgeRun: { kind: "judgeRun", provider: "fixed" },
1695
1695
  historyList: { kind: "historyList" },
1696
1696
  historyShow: { kind: "historyShow", ref: "x" },
1697
+ historyMetrics: { kind: "historyMetrics", refs: [] },
1697
1698
  historyFetch: { kind: "historyFetch", remote: "origin" },
1698
1699
  strays: { kind: "strays", base: "HEAD", tip: "HEAD", globs: [] },
1699
1700
  }
@@ -1705,7 +1706,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1705
1706
  const NO_COMMITS_MESSAGE =
1706
1707
  "gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
1707
1708
 
1708
- it("derives exactly the seventeen non-standalone kinds — a canary for the table-driven cases below", () => {
1709
+ it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
1709
1710
  expect(stateKinds.sort()).toEqual(
1710
1711
  [
1711
1712
  "abandon",
@@ -1724,6 +1725,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1724
1725
  "judgeAnswer",
1725
1726
  "historyList",
1726
1727
  "historyShow",
1728
+ "historyMetrics",
1727
1729
  "historyFetch",
1728
1730
  ].sort(),
1729
1731
  )
@@ -109,6 +109,7 @@ export {
109
109
  } from "./Output.js"
110
110
  import { runStraysCommand } from "./Strays.js"
111
111
  import { runHistoryFetchCommand, runHistoryListCommand, runHistoryShowCommand } from "./History.js"
112
+ import { runHistoryMetricsCommand } from "./Metrics.js"
112
113
 
113
114
  /**
114
115
  * `Edge.ts`'s `Rest` is module-private (`.gtd/packages/05-step-core.md`
@@ -174,6 +175,7 @@ export type Command =
174
175
  | { readonly kind: "judgeAnswer" }
175
176
  | { readonly kind: "historyList" }
176
177
  | { readonly kind: "historyShow"; readonly ref: string }
178
+ | { readonly kind: "historyMetrics"; readonly refs: readonly string[] }
177
179
  | { readonly kind: "historyFetch"; readonly remote: string }
178
180
  | {
179
181
  readonly kind: "strays"
@@ -1320,6 +1322,8 @@ const dispatchVoidCommand = (
1320
1322
  return runHistoryListCommand(json, out)
1321
1323
  case "historyShow":
1322
1324
  return runHistoryShowCommand(command.ref, json, out)
1325
+ case "historyMetrics":
1326
+ return runHistoryMetricsCommand(command.refs, json, out)
1323
1327
  case "historyFetch":
1324
1328
  return runHistoryFetchCommand(command.remote, out)
1325
1329
  }