@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +41 -7
- package/dist/chunks/{Schema-DO5ttJaI.mjs → Schema-C6fLlQzx.mjs} +1 -1
- package/dist/chunks/{reserved-CfQ8eurM.mjs → reserved-B6tTYjeb.mjs} +23 -6
- package/dist/chunks/{tail-C7yaWvaS.mjs → tail-_CZTP7Hu.mjs} +1 -1
- package/dist/gtd.bundle.mjs +300 -52
- package/dist/testkit.mjs +265 -20
- package/dist/workflow-kit.mjs +12 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +2 -2
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +34 -6
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +15 -3
- package/src/cli/Cli.ts +62 -28
- package/src/cli/History.ts +1 -19
- package/src/cli/Metrics.test.ts +255 -0
- package/src/cli/Metrics.ts +318 -0
- package/src/cli/Output.ts +13 -0
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +4 -0
- package/src/workflow-kit/health.ts +15 -1
- package/src/workflows/cleanup/cleanup.test.ts +2 -0
- package/src/workflows/feature/refactor.test.ts +4 -0
- package/src/workflows/rollout/rollout.feature +16 -0
- package/src/workflows/rollout/rollout.test.ts +2 -0
- package/src/workflows/shared/health.test.ts +2 -0
- package/src/workflows/shared/health.ts +34 -5
- package/src/workflows/shared/recheck.feature +60 -0
package/src/cli/Cli.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
:
|
|
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 ("
|
|
1062
|
-
|
|
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
|
-
|
|
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
|
|
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",
|
package/src/cli/History.ts
CHANGED
|
@@ -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
|
+
})
|