@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.
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,22 @@ 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, and quality-lap findings per lens — per process and",
842
+ "in total. Each <ref> is read as by history show, but a branch",
843
+ "name means every process kept from it; none means every ref.",
844
+ "Reads commit subjects and trailers, never replays, so a",
845
+ "changed config is no refusal. --json prints { processes, total",
846
+ "} as one document; writes nothing",
847
+ ],
848
+ },
827
849
  {
828
850
  token: "history fetch",
829
851
  kind: "historyFetch",
@@ -883,7 +905,9 @@ export const renderHelp = (): string => {
883
905
  ? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]`
884
906
  : "optional" in row.arity
885
907
  ? `${row.token} [${row.arity.name}]`
886
- : `${row.token} <${row.arity.name}>`
908
+ : "many" in row.arity
909
+ ? `${row.token} [${row.arity.name}...]`
910
+ : `${row.token} <${row.arity.name}>`
887
911
  return renderBlock(header, row.details)
888
912
  })
889
913
  const commands = [
@@ -1051,23 +1075,23 @@ const conflictViolation = (present: ReadonlySet<string>): string | undefined =>
1051
1075
  return undefined
1052
1076
  }
1053
1077
 
1078
+ const tooMany = (
1079
+ cmd: string,
1080
+ rest: readonly string[],
1081
+ expected: string,
1082
+ max: number,
1083
+ ): string | undefined =>
1084
+ rest.length > max
1085
+ ? `gtd ${cmd}: too many arguments — expected ${expected}, got: ${rest.join(", ")}`
1086
+ : undefined
1087
+
1054
1088
  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
- }
1089
+ if (arity === "none") return tooMany(cmd, rest, "none", 0)
1060
1090
  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
- }
1091
+ if ("many" in arity) return undefined
1092
+ if ("optional" in arity) return tooMany(cmd, rest, `at most one ${arity.name}`, 1)
1066
1093
  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
1094
+ return "variadic" in arity ? undefined : tooMany(cmd, rest, `one ${arity.name}`, 1)
1071
1095
  }
1072
1096
 
1073
1097
  const pairArityError = (
@@ -1138,7 +1162,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1138
1162
 
1139
1163
  if (isHistory && row === undefined) {
1140
1164
  return usagePlan(
1141
- "gtd history: expected a subcommand — list, show <ref> or fetch [remote]",
1165
+ "gtd history: expected a subcommand — list, show <ref>, metrics [ref...] or fetch [remote]",
1142
1166
  jsonSeen,
1143
1167
  )
1144
1168
  }
@@ -1381,6 +1405,15 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1381
1405
  }
1382
1406
  }
1383
1407
 
1408
+ if (kind === "historyMetrics") {
1409
+ return {
1410
+ kind: "command",
1411
+ command: { kind: "historyMetrics", refs: restPositionals },
1412
+ json,
1413
+ verbose,
1414
+ }
1415
+ }
1416
+
1384
1417
  if (kind === "historyFetch") {
1385
1418
  return {
1386
1419
  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,224 @@
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("totalMetrics", () => {
167
+ it("adds processes and recomputes the rate over every decision", () => {
168
+ const a = processMetrics([
169
+ turn("gtd(agent): seam.naive → seam.check", { cost: "1 a" }),
170
+ turn("gtd(check): seam.check → implement.applying"),
171
+ ])
172
+ const b = processMetrics([
173
+ turn("gtd(agent): seam.continue.naive → seam.note", { cost: "2 b" }),
174
+ turn("gtd(agent): seam.note → refactor.deriving"),
175
+ turn("gtd(agent): seam.naive → seam.note"),
176
+ turn("gtd(agent): seam.note → refactor.deriving"),
177
+ ])
178
+ const total = totalMetrics([a, b])
179
+ expect(total.processes).toBe(2)
180
+ expect(total.slices).toEqual({ decided: 3, landed: 1, rebuilt: 2, refactored: 2 })
181
+ expect(total.refactorFirstRate).toBe(0.667)
182
+ expect(total.continuations).toEqual({ "seam.naive": 1 })
183
+ expect(total.cost.byModel).toEqual({ a: 1, b: 2 })
184
+ })
185
+ })
186
+
187
+ const CONFIG = `import { agent, human, scope } from "@pmelab/gtd/flows"
188
+
189
+ export default async () => {
190
+ await human("idle")
191
+ await scope("build", () => scope("quality", () => scope("correctness", () => agent("reviewing", "review"))))
192
+ }
193
+ `
194
+
195
+ const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
196
+ for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
197
+ const { io, result } = makeCapturingCliIo(repo)
198
+ await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
199
+ const { stdout, exitCode, stderr } = result()
200
+ if (exitCode !== 0) throw new Error(`land exited ${exitCode}: ${stderr}`)
201
+ const outcome = applyEmittedScript(
202
+ repo,
203
+ new Map(),
204
+ (JSON.parse(stdout) as { script: string }).script,
205
+ )
206
+ if (!outcome.ok) throw new Error(outcome.error)
207
+ return repo.resolveRef("HEAD")!
208
+ }
209
+
210
+ describe("readProcessMetrics", () => {
211
+ it("counts the findings a reviewing turn added to an existing QUALITY.md", async () => {
212
+ const repo = new InMemRepo()
213
+ repo.writeFile("gtd.config.ts", CONFIG)
214
+ repo.writeFile(".gtd/QUALITY.md", "## old finding\n")
215
+ repo.seedCommit("chore: add workflow")
216
+ const entry = await land(repo, { "NOTE.md": "go\n" })
217
+ const tip = await land(repo, { ".gtd/QUALITY.md": "## old finding\n\n## one\n\n## two\n" })
218
+ const metrics = await Effect.runPromise(
219
+ readProcessMetrics({ entry, tip }).pipe(Effect.provide(testLayers(repo, {}))),
220
+ )
221
+ expect(metrics.findings).toEqual({ total: 2, byLens: { "build.quality.correctness": 2 } })
222
+ expect(metrics.workflow).toBeNull()
223
+ })
224
+ })