@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/.claude-plugin/plugin.json +1 -1
- package/README.md +30 -3
- package/dist/chunks/{Schema-DO5ttJaI.mjs → Schema-C6fLlQzx.mjs} +1 -1
- package/dist/chunks/{reserved-CfQ8eurM.mjs → reserved-C3PgP7XB.mjs} +1 -1
- package/dist/chunks/{tail-C7yaWvaS.mjs → tail-Bkxki2nK.mjs} +1 -1
- package/dist/gtd.bundle.mjs +291 -52
- package/dist/testkit.mjs +256 -20
- 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/package.json +1 -1
- package/src/cli/Cli.test.ts +15 -3
- package/src/cli/Cli.ts +61 -28
- package/src/cli/History.ts +1 -19
- package/src/cli/Metrics.test.ts +224 -0
- package/src/cli/Metrics.ts +310 -0
- package/src/cli/Output.ts +13 -0
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +4 -0
|
@@ -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
|
+
})
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -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
|
|
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
|
)
|
package/src/cli/Program.ts
CHANGED
|
@@ -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
|
}
|