@pmelab/gtd 24.2.0 → 24.4.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 -6
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +1114 -865
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1135 -218
- package/dist/workflow-kit.mjs +9 -7
- package/dist/workflows/feature.mjs +152 -35
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +223 -54
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +57 -0
- package/src/cli/Cli.ts +89 -9
- package/src/cli/History.test.ts +225 -0
- package/src/cli/History.ts +143 -0
- package/src/cli/Output.ts +68 -0
- package/src/cli/Program.test.ts +10 -9
- package/src/cli/Program.ts +24 -72
- package/src/edge/Edge.test.ts +261 -3
- package/src/edge/Edge.ts +218 -41
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +27 -0
- package/src/emit/GitScript.ts +18 -2
- package/src/emit/index.ts +2 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/platform/Git.ts +48 -10
- package/src/platform/RetainedHistory.test.ts +14 -0
- package/src/platform/RetainedHistory.ts +5 -0
- package/src/platform/index.ts +7 -1
- package/src/replay/Trailers.test.ts +32 -0
- package/src/replay/Trailers.ts +17 -0
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/step/LandStep.ts +3 -1
- package/src/step/RepoSnapshot.ts +7 -1
- package/src/step/planStart.test.ts +6 -3
- package/src/step/planStep.test.ts +29 -1
- package/src/step/planStep.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +149 -52
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +269 -47
- package/src/testkit/InMemRepo.ts +61 -8
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflows/feature/review.test.ts +238 -12
- package/src/workflows/feature/review.ts +172 -31
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/tail.ts +3 -11
- package/src/workflows/shared/text.test.ts +106 -0
- package/src/workflows/shared/text.ts +57 -1
- package/src/workflows/shared/vars.ts +1 -0
package/src/cli/Cli.ts
CHANGED
|
@@ -92,15 +92,17 @@ const FLAGS: readonly FlagRow[] = [
|
|
|
92
92
|
kind === "land" ||
|
|
93
93
|
kind === "judge" ||
|
|
94
94
|
kind === "judgeAnswer" ||
|
|
95
|
-
kind === "doors"
|
|
95
|
+
kind === "doors" ||
|
|
96
|
+
kind === "historyList" ||
|
|
97
|
+
kind === "historyShow",
|
|
96
98
|
decode: ([raw]) => Either.right(raw ?? ""),
|
|
97
99
|
scopeError:
|
|
98
|
-
"gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors` — " +
|
|
100
|
+
"gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show` — " +
|
|
99
101
|
"every other command prints plain text; see `gtd install` for the driver protocol briefing",
|
|
100
102
|
valueHint: "<path>",
|
|
101
103
|
help: [
|
|
102
|
-
"(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors
|
|
103
|
-
"structured JSON. Bare --json prints the whole document;",
|
|
104
|
+
"(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
|
|
105
|
+
"list/gtd history show only) output structured JSON. Bare --json prints the whole document;",
|
|
104
106
|
"--json=<path> (a dotted key path into that document, e.g.",
|
|
105
107
|
"kind, content, session.id) prints just that value: a",
|
|
106
108
|
"scalar raw and unquoted, a boolean as true/false, a list",
|
|
@@ -366,6 +368,8 @@ type Arity =
|
|
|
366
368
|
| { readonly names: readonly [string, string] }
|
|
367
369
|
// One required leading positional, then any number the command itself judges.
|
|
368
370
|
| { readonly name: string; readonly variadic: string }
|
|
371
|
+
// At most one trailing positional.
|
|
372
|
+
| { readonly name: string; readonly optional: true }
|
|
369
373
|
|
|
370
374
|
interface CommandRow {
|
|
371
375
|
readonly token: string
|
|
@@ -661,6 +665,41 @@ const COMMAND_ROWS: readonly CommandRow[] = [
|
|
|
661
665
|
"every question, with nothing on stdout",
|
|
662
666
|
],
|
|
663
667
|
},
|
|
668
|
+
{
|
|
669
|
+
token: "history list",
|
|
670
|
+
kind: "historyList",
|
|
671
|
+
arity: "none",
|
|
672
|
+
details: [
|
|
673
|
+
"List the finished processes kept behind refs/gtd/history/*,",
|
|
674
|
+
"newest first: entry, branch, tip, date and subject per line.",
|
|
675
|
+
"--json prints one array of { ref, entry, branch, tip, date,",
|
|
676
|
+
"subject }. Writes nothing and never replays; with no refs it",
|
|
677
|
+
"hints at `gtd history fetch | sh` and still exits 0",
|
|
678
|
+
],
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
token: "history show",
|
|
682
|
+
kind: "historyShow",
|
|
683
|
+
arity: { name: "ref" },
|
|
684
|
+
details: [
|
|
685
|
+
"Replay one kept process under today's config and print its",
|
|
686
|
+
"steps and judge verdicts. <ref> is a full ref, an entry hash",
|
|
687
|
+
"(or unique prefix) or a branch name. Refuses (exit 1) when a",
|
|
688
|
+
"config file differs from its content at the ref's tip. A",
|
|
689
|
+
"version mismatch only warns on stderr. --json prints the",
|
|
690
|
+
"trail as one document; writes nothing",
|
|
691
|
+
],
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
token: "history fetch",
|
|
695
|
+
kind: "historyFetch",
|
|
696
|
+
arity: { name: "remote", optional: true },
|
|
697
|
+
details: [
|
|
698
|
+
"Print a script that fetches every refs/gtd/history/* from",
|
|
699
|
+
"<remote> (default origin) and reports how many arrived — run",
|
|
700
|
+
"it with `gtd history fetch | sh`. Never runs git itself",
|
|
701
|
+
],
|
|
702
|
+
},
|
|
664
703
|
]
|
|
665
704
|
|
|
666
705
|
const commandByToken = (token: string): CommandRow | undefined =>
|
|
@@ -708,7 +747,9 @@ export const renderHelp = (): string => {
|
|
|
708
747
|
? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>`
|
|
709
748
|
: "variadic" in row.arity
|
|
710
749
|
? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]`
|
|
711
|
-
:
|
|
750
|
+
: "optional" in row.arity
|
|
751
|
+
? `${row.token} [${row.arity.name}]`
|
|
752
|
+
: `${row.token} <${row.arity.name}>`
|
|
712
753
|
return renderBlock(header, row.details)
|
|
713
754
|
})
|
|
714
755
|
const commands = [
|
|
@@ -883,6 +924,11 @@ const arityError = (cmd: string, rest: readonly string[], arity: Arity): string
|
|
|
883
924
|
: undefined
|
|
884
925
|
}
|
|
885
926
|
if ("names" in arity) return pairArityError(cmd, rest, arity.names)
|
|
927
|
+
if ("optional" in arity) {
|
|
928
|
+
return rest.length > 1
|
|
929
|
+
? `gtd ${cmd}: too many arguments — expected at most one ${arity.name}, got: ${rest.join(", ")}`
|
|
930
|
+
: undefined
|
|
931
|
+
}
|
|
886
932
|
if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`
|
|
887
933
|
if (!("variadic" in arity) && rest.length > 1) {
|
|
888
934
|
return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`
|
|
@@ -950,7 +996,18 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
950
996
|
if (first === "version") return { kind: "output", stdout: `${GTD_VERSION}\n` }
|
|
951
997
|
if (first === "help") return { kind: "output", stdout: renderHelp() }
|
|
952
998
|
|
|
953
|
-
const
|
|
999
|
+
const isHistory = first === "history"
|
|
1000
|
+
const row =
|
|
1001
|
+
first === undefined
|
|
1002
|
+
? undefined
|
|
1003
|
+
: commandByToken(isHistory ? `history ${positionals[1] ?? ""}` : first)
|
|
1004
|
+
|
|
1005
|
+
if (isHistory && row === undefined) {
|
|
1006
|
+
return usagePlan(
|
|
1007
|
+
"gtd history: expected a subcommand — list, show <ref> or fetch [remote]",
|
|
1008
|
+
jsonSeen,
|
|
1009
|
+
)
|
|
1010
|
+
}
|
|
954
1011
|
|
|
955
1012
|
// `gtd judge answer` — the CLI's one two-level verb. The NORMAL path is
|
|
956
1013
|
// "answer" arriving as `positionals[1]`, a second token after "judge" —
|
|
@@ -1005,7 +1062,9 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1005
1062
|
// From here, `kind` is a genuine `Command["kind"]`. `judgeAnswer` consumes
|
|
1006
1063
|
// TWO leading positionals ("judge" and "answer"), everything else one.
|
|
1007
1064
|
const restPositionals =
|
|
1008
|
-
first === undefined
|
|
1065
|
+
first === undefined
|
|
1066
|
+
? positionals
|
|
1067
|
+
: positionals.slice(judgeAnswer || judgeRun || isHistory ? 2 : 1)
|
|
1009
1068
|
|
|
1010
1069
|
if (kind === "start" && first === undefined) {
|
|
1011
1070
|
if (restPositionals.length > 0) {
|
|
@@ -1019,7 +1078,9 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1019
1078
|
// `first === undefined`, which the `if` branch above already handled.
|
|
1020
1079
|
const bareCheck =
|
|
1021
1080
|
kind === "check" && restPositionals.length === 0 && present.has("--open-threads")
|
|
1022
|
-
const arityMsg = bareCheck
|
|
1081
|
+
const arityMsg = bareCheck
|
|
1082
|
+
? undefined
|
|
1083
|
+
: arityError(isHistory ? row!.token : first!, restPositionals, row!.arity)
|
|
1023
1084
|
if (arityMsg !== undefined) return usagePlan(arityMsg, jsonSeen)
|
|
1024
1085
|
}
|
|
1025
1086
|
|
|
@@ -1147,6 +1208,24 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1147
1208
|
}
|
|
1148
1209
|
}
|
|
1149
1210
|
|
|
1211
|
+
if (kind === "historyShow") {
|
|
1212
|
+
return {
|
|
1213
|
+
kind: "command",
|
|
1214
|
+
command: { kind: "historyShow", ref: restPositionals[0]! },
|
|
1215
|
+
json,
|
|
1216
|
+
verbose,
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (kind === "historyFetch") {
|
|
1221
|
+
return {
|
|
1222
|
+
kind: "command",
|
|
1223
|
+
command: { kind: "historyFetch", remote: restPositionals[0] ?? "origin" },
|
|
1224
|
+
json,
|
|
1225
|
+
verbose,
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1150
1229
|
if (kind === "uncheck") {
|
|
1151
1230
|
return {
|
|
1152
1231
|
kind: "command",
|
|
@@ -1170,7 +1249,8 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1170
1249
|
| "summary"
|
|
1171
1250
|
| "base"
|
|
1172
1251
|
| "judge"
|
|
1173
|
-
| "judgeAnswer"
|
|
1252
|
+
| "judgeAnswer"
|
|
1253
|
+
| "historyList",
|
|
1174
1254
|
}
|
|
1175
1255
|
return { kind: "command", command, json, verbose }
|
|
1176
1256
|
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Effect, Exit } 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 { historyEntries, resolveHistoryRef, type HistoryEntry } from "./History.js"
|
|
6
|
+
|
|
7
|
+
const CONFIG = `import { agent, human } from "@pmelab/gtd/flows"
|
|
8
|
+
|
|
9
|
+
export default async () => {
|
|
10
|
+
await human("idle")
|
|
11
|
+
await agent("building", "build-prompt")
|
|
12
|
+
}
|
|
13
|
+
`
|
|
14
|
+
|
|
15
|
+
const land = async (repo: InMemRepo, files: Record<string, string>): Promise<string> => {
|
|
16
|
+
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
17
|
+
const { io, result } = makeCapturingCliIo(repo)
|
|
18
|
+
await Effect.runPromise(runCli(["node", "gtd.js", "land", "--json"], io))
|
|
19
|
+
const { stdout, exitCode, stderr } = result()
|
|
20
|
+
if (exitCode !== 0) throw new Error(`land exited ${exitCode}: ${stderr}`)
|
|
21
|
+
const outcome = applyEmittedScript(
|
|
22
|
+
repo,
|
|
23
|
+
new Map(),
|
|
24
|
+
(JSON.parse(stdout) as { script: string }).script,
|
|
25
|
+
)
|
|
26
|
+
if (!outcome.ok) throw new Error(outcome.error)
|
|
27
|
+
return repo.resolveRef("HEAD")!
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const finishedProcess = async (repo: InMemRepo, file: string) => {
|
|
31
|
+
const entry = await land(repo, { [file]: `${file}\n` })
|
|
32
|
+
const tip = await land(repo, { [`${file}.out`]: "out\n" })
|
|
33
|
+
return { entry, tip }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const repoWithConfig = (): InMemRepo => {
|
|
37
|
+
const repo = new InMemRepo()
|
|
38
|
+
repo.writeFile("gtd.config.ts", CONFIG)
|
|
39
|
+
repo.commitAllWithPrefix("chore: add workflow")
|
|
40
|
+
return repo
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const entries = (repo: InMemRepo): Promise<readonly HistoryEntry[]> =>
|
|
44
|
+
Effect.runPromise(historyEntries.pipe(Effect.provide(testLayers(repo, {}))))
|
|
45
|
+
|
|
46
|
+
const entry = (over: Partial<HistoryEntry> & { entry: string }): HistoryEntry => ({
|
|
47
|
+
ref: `refs/gtd/history/${over.entry}`,
|
|
48
|
+
branch: null,
|
|
49
|
+
tip: "t".repeat(40),
|
|
50
|
+
date: "1",
|
|
51
|
+
subject: "s",
|
|
52
|
+
...over,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const resolve = (
|
|
56
|
+
arg: string,
|
|
57
|
+
list: readonly HistoryEntry[],
|
|
58
|
+
): Promise<Exit.Exit<HistoryEntry, Error>> => Effect.runPromiseExit(resolveHistoryRef(arg, list))
|
|
59
|
+
|
|
60
|
+
const failureText = (exit: Exit.Exit<unknown, Error>): string =>
|
|
61
|
+
Exit.isFailure(exit) ? String(exit.cause) : ""
|
|
62
|
+
|
|
63
|
+
describe("historyEntries", () => {
|
|
64
|
+
it("is empty without refs", async () => {
|
|
65
|
+
expect(await entries(repoWithConfig())).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("lists finished processes newest tip first, with branch and entry subject", async () => {
|
|
69
|
+
const repo = repoWithConfig()
|
|
70
|
+
const first = await finishedProcess(repo, "a.txt")
|
|
71
|
+
const second = await finishedProcess(repo, "b.txt")
|
|
72
|
+
const list = await entries(repo)
|
|
73
|
+
expect(list.map((e) => e.entry)).toEqual([second.entry, first.entry])
|
|
74
|
+
expect(list[0]).toMatchObject({
|
|
75
|
+
ref: `refs/gtd/history/${second.entry}`,
|
|
76
|
+
branch: repo.branchName(),
|
|
77
|
+
tip: second.tip,
|
|
78
|
+
subject: "gtd(human): idle → building",
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("skips a ref whose name suffix is not an entry hash", async () => {
|
|
83
|
+
const repo = repoWithConfig()
|
|
84
|
+
const { entry: hash } = await finishedProcess(repo, "a.txt")
|
|
85
|
+
repo.updateRef("refs/gtd/history/--output=x", "HEAD")
|
|
86
|
+
expect((await entries(repo)).map((e) => e.entry)).toEqual([hash])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("has a null branch when the tip records none", async () => {
|
|
90
|
+
const repo = repoWithConfig()
|
|
91
|
+
const { entry: hash } = await finishedProcess(repo, "a.txt")
|
|
92
|
+
repo.writeFile("x.txt", "x\n")
|
|
93
|
+
repo.commitAllWithPrefix("chore: bare tip")
|
|
94
|
+
repo.updateRef(`refs/gtd/history/${hash}`, "HEAD")
|
|
95
|
+
expect((await entries(repo))[0]?.branch).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe("resolveHistoryRef", () => {
|
|
100
|
+
const a = entry({ entry: "abc1230000", branch: "feat/x", date: "1" })
|
|
101
|
+
const b = entry({ entry: "abc9990000", branch: "feat/x", date: "2" })
|
|
102
|
+
const c = entry({ entry: "def4560000", branch: "other", date: "3" })
|
|
103
|
+
const list = [c, b, a]
|
|
104
|
+
|
|
105
|
+
it("accepts the full ref", async () => {
|
|
106
|
+
expect(await resolve(a.ref, list)).toEqual(Exit.succeed(a))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("accepts the entry hash or a unique hex prefix", async () => {
|
|
110
|
+
expect(await resolve("abc1230000", list)).toEqual(Exit.succeed(a))
|
|
111
|
+
expect(await resolve("def", list)).toEqual(Exit.succeed(c))
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("refuses an ambiguous prefix, listing every match", async () => {
|
|
115
|
+
const exit = await resolve("abc", list)
|
|
116
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
117
|
+
expect(failureText(exit)).toContain(a.entry)
|
|
118
|
+
expect(failureText(exit)).toContain(b.entry)
|
|
119
|
+
expect(failureText(exit)).not.toContain(c.entry)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("resolves a branch name to its newest ref by tip date", async () => {
|
|
123
|
+
expect(await resolve("feat/x", list)).toEqual(Exit.succeed(b))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("resolves a hex-looking branch name matching no entry as a branch", async () => {
|
|
127
|
+
const dead = entry({ entry: "1111110000", branch: "beef", date: "9" })
|
|
128
|
+
expect(await resolve("beef", [dead, ...list])).toEqual(Exit.succeed(dead))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("fails when nothing matches", async () => {
|
|
132
|
+
const exit = await resolve("nope", list)
|
|
133
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
134
|
+
expect(failureText(exit)).toContain("nope")
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const gtd = async (repo: InMemRepo, ...args: string[]) => {
|
|
139
|
+
const { io, result } = makeCapturingCliIo(repo)
|
|
140
|
+
await Effect.runPromise(runCli(["node", "gtd.js", ...args], io))
|
|
141
|
+
return result()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("gtd history list", () => {
|
|
145
|
+
it("prints the fetch hint and exits 0 without refs", async () => {
|
|
146
|
+
const out = await gtd(repoWithConfig(), "history", "list")
|
|
147
|
+
expect(out.exitCode).toBe(0)
|
|
148
|
+
expect(out.stdout).toBe("no history refs; run gtd history fetch | sh to pull them\n")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("prints [] under --json without refs", async () => {
|
|
152
|
+
const out = await gtd(repoWithConfig(), "history", "list", "--json")
|
|
153
|
+
expect(out.exitCode).toBe(0)
|
|
154
|
+
expect(JSON.parse(out.stdout)).toEqual([])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("prints one text line per ref and a full-hash JSON array", async () => {
|
|
158
|
+
const repo = repoWithConfig()
|
|
159
|
+
const { entry: hash, tip } = await finishedProcess(repo, "a.txt")
|
|
160
|
+
const text = await gtd(repo, "history", "list")
|
|
161
|
+
const line = text.stdout.trimEnd().split("\n")
|
|
162
|
+
expect(line).toHaveLength(1)
|
|
163
|
+
expect(
|
|
164
|
+
line[0]!.startsWith(`${hash.slice(0, 7)} ${repo.branchName()} ${tip.slice(0, 7)} `),
|
|
165
|
+
).toBe(true)
|
|
166
|
+
expect(line[0]!.endsWith(" gtd(human): idle → building")).toBe(true)
|
|
167
|
+
const json = await gtd(repo, "history", "list", "--json")
|
|
168
|
+
expect(JSON.parse(json.stdout)).toEqual([
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
ref: `refs/gtd/history/${hash}`,
|
|
171
|
+
entry: hash,
|
|
172
|
+
branch: repo.branchName(),
|
|
173
|
+
tip,
|
|
174
|
+
subject: "gtd(human): idle → building",
|
|
175
|
+
}),
|
|
176
|
+
])
|
|
177
|
+
expect((await gtd(repo, "history", "list", "--json=0.entry")).stdout).toBe(`${hash}\n`)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe("gtd history show", () => {
|
|
182
|
+
it("prints header, steps and the ending", async () => {
|
|
183
|
+
const repo = repoWithConfig()
|
|
184
|
+
const { entry: hash, tip } = await finishedProcess(repo, "a.txt")
|
|
185
|
+
const out = await gtd(repo, "history", "show", hash.slice(0, 7))
|
|
186
|
+
expect(out.exitCode).toBe(0)
|
|
187
|
+
const lines = out.stdout.trimEnd().split("\n")
|
|
188
|
+
expect(lines[0]).toBe(
|
|
189
|
+
`refs/gtd/history/${hash} (branch ${repo.branchName()}, tip ${tip.slice(0, 7)})`,
|
|
190
|
+
)
|
|
191
|
+
expect(out.stdout).toContain("gtd(human): idle → building")
|
|
192
|
+
expect(lines[lines.length - 1]).toBe("ended (return)")
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("--json carries the trail and the entry fields", async () => {
|
|
196
|
+
const repo = repoWithConfig()
|
|
197
|
+
const { entry: hash, tip } = await finishedProcess(repo, "a.txt")
|
|
198
|
+
const out = await gtd(repo, "history", "show", hash, "--json")
|
|
199
|
+
const doc = JSON.parse(out.stdout)
|
|
200
|
+
expect(doc).toMatchObject({ entry: hash, tip, ended: "return", branch: repo.branchName() })
|
|
201
|
+
expect(doc.steps.length).toBeGreaterThan(0)
|
|
202
|
+
expect(doc.version).toHaveProperty("running")
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it("exits 1 for an unknown ref", async () => {
|
|
206
|
+
const out = await gtd(repoWithConfig(), "history", "show", "nope")
|
|
207
|
+
expect(out.exitCode).toBe(1)
|
|
208
|
+
expect(out.stdout).toBe("")
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe("gtd history fetch", () => {
|
|
213
|
+
it("prints a script fetching from origin and writes no git", async () => {
|
|
214
|
+
const repo = repoWithConfig()
|
|
215
|
+
const out = await gtd(repo, "history", "fetch")
|
|
216
|
+
expect(out.exitCode).toBe(0)
|
|
217
|
+
expect(out.stdout).toContain("git fetch 'origin' 'refs/gtd/history/*:refs/gtd/history/*'")
|
|
218
|
+
expect(repo.resolveRef("refs/gtd/history/x")).toBeNull()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("takes the remote as an argument", async () => {
|
|
222
|
+
const out = await gtd(repoWithConfig(), "history", "fetch", "upstream")
|
|
223
|
+
expect(out.stdout).toContain("git fetch 'upstream'")
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { historyTrail } from "../edge/index.js"
|
|
3
|
+
import { combinedScript, fetchHistoryRefs } from "../emit/index.js"
|
|
4
|
+
import { GitService, Narrator, PROCESS_HISTORY_PREFIX } from "../platform/index.js"
|
|
5
|
+
import { parseCommitMessage } from "../replay/index.js"
|
|
6
|
+
import {
|
|
7
|
+
writeSelection,
|
|
8
|
+
type ArtifactOut,
|
|
9
|
+
type CommandRequirements,
|
|
10
|
+
type JsonMode,
|
|
11
|
+
} from "./Output.js"
|
|
12
|
+
|
|
13
|
+
export interface HistoryEntry {
|
|
14
|
+
readonly ref: string
|
|
15
|
+
readonly entry: string
|
|
16
|
+
/** From the tip's `Gtd-Branch`; null when the finishing commit recorded none. */
|
|
17
|
+
readonly branch: string | null
|
|
18
|
+
readonly tip: string
|
|
19
|
+
readonly date: string
|
|
20
|
+
readonly subject: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Newest tip first. */
|
|
24
|
+
export const historyEntries: Effect.Effect<readonly HistoryEntry[], Error, GitService> = Effect.gen(
|
|
25
|
+
function* () {
|
|
26
|
+
const git = yield* GitService
|
|
27
|
+
// A fetched ref name is attacker-controlled: only hash suffixes are entries.
|
|
28
|
+
const refs = (yield* git.listRefs(PROCESS_HISTORY_PREFIX)).filter(({ ref }) =>
|
|
29
|
+
ENTRY_HASH.test(ref.slice(PROCESS_HISTORY_PREFIX.length)),
|
|
30
|
+
)
|
|
31
|
+
return yield* Effect.forEach(refs, ({ ref, hash, date }) =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const entry = ref.slice(PROCESS_HISTORY_PREFIX.length)
|
|
34
|
+
const message = parseCommitMessage(yield* git.lastCommitMessage(hash))
|
|
35
|
+
return {
|
|
36
|
+
ref,
|
|
37
|
+
entry,
|
|
38
|
+
branch: message.branch ?? null,
|
|
39
|
+
tip: hash,
|
|
40
|
+
date,
|
|
41
|
+
subject: yield* git.lastCommitSubject(entry),
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const ENTRY_HASH = /^[0-9a-f]{40}([0-9a-f]{24})?$/
|
|
49
|
+
|
|
50
|
+
const HEX = /^[0-9a-f]+$/i
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lookup order: full ref; then entry hash or hex prefix (unique → it, several
|
|
54
|
+
* → fail listing them); then branch name (newest tip first, as `entries` is
|
|
55
|
+
* ordered). A hex-looking name that matches no entry still resolves as a
|
|
56
|
+
* branch. No match → fail.
|
|
57
|
+
*/
|
|
58
|
+
export const resolveHistoryRef = (
|
|
59
|
+
arg: string,
|
|
60
|
+
entries: readonly HistoryEntry[],
|
|
61
|
+
): Effect.Effect<HistoryEntry, Error> => {
|
|
62
|
+
const byRef = entries.find((e) => e.ref === arg)
|
|
63
|
+
if (byRef !== undefined) return Effect.succeed(byRef)
|
|
64
|
+
if (HEX.test(arg)) {
|
|
65
|
+
const lower = arg.toLowerCase()
|
|
66
|
+
const matches = entries.filter((e) => e.entry.toLowerCase().startsWith(lower))
|
|
67
|
+
if (matches.length === 1) return Effect.succeed(matches[0]!)
|
|
68
|
+
if (matches.length > 1) {
|
|
69
|
+
return Effect.fail(
|
|
70
|
+
new Error(
|
|
71
|
+
`gtd history: "${arg}" is ambiguous — it matches:\n${matches.map((e) => ` ${e.entry} ${e.branch ?? "-"}`).join("\n")}`,
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const byBranch = entries.find((e) => e.branch === arg)
|
|
77
|
+
if (byBranch !== undefined) return Effect.succeed(byBranch)
|
|
78
|
+
return Effect.fail(new Error(`gtd history: no history ref matches "${arg}"`))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const seven = (hash: string): string => hash.slice(0, 7)
|
|
82
|
+
|
|
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
|
+
export const runHistoryListCommand = (
|
|
97
|
+
json: JsonMode,
|
|
98
|
+
out: ArtifactOut,
|
|
99
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const entries = yield* historyEntries
|
|
102
|
+
if (yield* writeJson(json, out, entries)) return
|
|
103
|
+
if (entries.length === 0) {
|
|
104
|
+
out.write("no history refs; run gtd history fetch | sh to pull them\n")
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
out.write(
|
|
108
|
+
entries
|
|
109
|
+
.map((e) => `${seven(e.entry)} ${e.branch ?? "-"} ${seven(e.tip)} ${e.date} ${e.subject}\n`)
|
|
110
|
+
.join(""),
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
export const runHistoryShowCommand = (
|
|
115
|
+
ref: string,
|
|
116
|
+
json: JsonMode,
|
|
117
|
+
out: ArtifactOut,
|
|
118
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const entry = yield* resolveHistoryRef(ref, yield* historyEntries)
|
|
121
|
+
const trail = yield* historyTrail(entry.tip)
|
|
122
|
+
const document = { ref: entry.ref, entry: entry.entry, branch: entry.branch, ...trail }
|
|
123
|
+
if (yield* writeJson(json, out, document)) return
|
|
124
|
+
if (trail.versionWarning !== undefined) {
|
|
125
|
+
yield* (yield* Narrator).warn(trail.versionWarning)
|
|
126
|
+
}
|
|
127
|
+
const lines = [`${entry.ref} (branch ${entry.branch ?? "-"}, tip ${seven(entry.tip)})`]
|
|
128
|
+
for (const step of trail.steps) {
|
|
129
|
+
lines.push(`${seven(step.hash)} ${step.subject}`)
|
|
130
|
+
for (const v of step.verdicts) lines.push(` judge ${v.id} = ${v.answer} (p=${v.p})`)
|
|
131
|
+
}
|
|
132
|
+
lines.push(`ended (${trail.ended})`)
|
|
133
|
+
out.write(`${lines.join("\n")}\n`)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
/** Prints the script and never runs git: gtd's promise that it only decides and prints. */
|
|
137
|
+
export const runHistoryFetchCommand = (
|
|
138
|
+
remote: string,
|
|
139
|
+
out: ArtifactOut,
|
|
140
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
141
|
+
Effect.sync(() => {
|
|
142
|
+
out.write(`${combinedScript(fetchHistoryRefs(remote), "")}\n`)
|
|
143
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { CommandRunner, GitService, Host, Narrator, Workspace } from "../platform/index.js"
|
|
3
|
+
import { selectPath } from "../wire/index.js"
|
|
4
|
+
import { ConfigDiscovery, ConfigService } from "../workflow/index.js"
|
|
5
|
+
import type { ToolCommands } from "./ToolCommands.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A command-level failure that should exit like a CLI usage error (2), not a
|
|
9
|
+
* generic runtime failure (1): an unknown `--json=<path>` selector, or a
|
|
10
|
+
* `gtd judge answer` verdict that fails to decode — malformed JSON on stdin,
|
|
11
|
+
* or one naming a question id the pending judgment never asked. Both are
|
|
12
|
+
* caller-input errors per `docs/cli.md`'s exit-code table, not a refusal
|
|
13
|
+
* about the resolved rest's own state.
|
|
14
|
+
*/
|
|
15
|
+
export class SelectorUsageError extends Error {}
|
|
16
|
+
|
|
17
|
+
/** The `--json=<path>` select branch, shared by the emitting commands so none drift on the unknown-selector message. `absent` writes nothing and lets the caller's success path continue. */
|
|
18
|
+
export const writeSelection = (
|
|
19
|
+
out: ArtifactOut,
|
|
20
|
+
fields: unknown,
|
|
21
|
+
path: string,
|
|
22
|
+
): Effect.Effect<void, Error> =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
const selection = selectPath(fields, path)
|
|
25
|
+
if (selection.kind === "value") {
|
|
26
|
+
out.write(`${selection.text}\n`)
|
|
27
|
+
} else if (selection.kind === "unknown") {
|
|
28
|
+
return yield* Effect.fail(
|
|
29
|
+
new SelectorUsageError(
|
|
30
|
+
`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``,
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `--json`'s three shapes: absent, bare (the whole document), or `--json=<path>`
|
|
38
|
+
* (one reduced value). See `src/wire/Select.ts` for how `select` is later resolved.
|
|
39
|
+
*/
|
|
40
|
+
export type JsonMode =
|
|
41
|
+
| { readonly kind: "off" }
|
|
42
|
+
| { readonly kind: "document" }
|
|
43
|
+
| { readonly kind: "select"; readonly path: string }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The all-or-nothing stdout buffer every `run*Command` handler writes
|
|
47
|
+
* through instead of `io.stdout` directly. `flush()` is called exactly once,
|
|
48
|
+
* by `runCli`, after the command's Effect succeeds; on any failure `flush()`
|
|
49
|
+
* is never reached, so the buffer is simply discarded and nothing reaches
|
|
50
|
+
* `io.stdout`. `ui` is the one handler that calls `flush()` itself,
|
|
51
|
+
* ahead of blocking on `Effect.never` — since it blocks forever, `runCli`'s
|
|
52
|
+
* own flush-on-success would otherwise never fire and its output would never
|
|
53
|
+
* reach stdout.
|
|
54
|
+
*/
|
|
55
|
+
export interface ArtifactOut {
|
|
56
|
+
readonly write: (chunk: string) => void
|
|
57
|
+
readonly flush: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type CommandRequirements =
|
|
61
|
+
| GitService
|
|
62
|
+
| ConfigService
|
|
63
|
+
| ConfigDiscovery
|
|
64
|
+
| Workspace
|
|
65
|
+
| Host
|
|
66
|
+
| Narrator
|
|
67
|
+
| CommandRunner
|
|
68
|
+
| ToolCommands
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ vi.mock("node:os", async (importOriginal) => ({
|
|
|
17
17
|
|
|
18
18
|
import { runCli, type Command } from "./index.js"
|
|
19
19
|
import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
|
|
20
|
-
import {
|
|
20
|
+
import { needsOf, runCommand, SelectorUsageError } from "./Program.js"
|
|
21
21
|
import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../testkit/index.js"
|
|
22
22
|
import {
|
|
23
23
|
commitAll,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "../emit/index.js"
|
|
29
29
|
import { HISTORY_REF } from "../platform/index.js"
|
|
30
30
|
import { CONFIG_SCAFFOLD } from "../install/index.js"
|
|
31
|
+
import { contentHash } from "../workflow/index.js"
|
|
31
32
|
|
|
32
33
|
const run = async (
|
|
33
34
|
repo: InMemRepo,
|
|
@@ -102,7 +103,7 @@ export const defaults = { greeting: "hello" }
|
|
|
102
103
|
expect(exitCode).toBe(0)
|
|
103
104
|
expect(stdout).toContain(
|
|
104
105
|
commitAll(
|
|
105
|
-
|
|
106
|
+
`gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Config: ${contentHash(WORKFLOW)} gtd.config.ts\nGtd-Format: 1`,
|
|
106
107
|
),
|
|
107
108
|
)
|
|
108
109
|
expect(repo.commitHistory()).toHaveLength(before)
|
|
@@ -958,12 +959,6 @@ describe("gtd check <mode> <file>", () => {
|
|
|
958
959
|
)
|
|
959
960
|
})
|
|
960
961
|
|
|
961
|
-
it("a line-carrying finding with no range prints '<file>:<line>: <message>' — the flat-shape fallback no built-in format reaches", async () => {
|
|
962
|
-
expect(formatFinding("FAKE.md", { message: "positioned but rangeless", line: 4 })).toBe(
|
|
963
|
-
"FAKE.md:5: positioned but rangeless",
|
|
964
|
-
)
|
|
965
|
-
})
|
|
966
|
-
|
|
967
962
|
it("an absent file exits 0 with no output", async () => {
|
|
968
963
|
const repo = bareRepo()
|
|
969
964
|
const { stdout, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md")
|
|
@@ -1697,6 +1692,9 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1697
1692
|
judge: { kind: "judge" },
|
|
1698
1693
|
judgeAnswer: { kind: "judgeAnswer" },
|
|
1699
1694
|
judgeRun: { kind: "judgeRun", provider: "fixed" },
|
|
1695
|
+
historyList: { kind: "historyList" },
|
|
1696
|
+
historyShow: { kind: "historyShow", ref: "x" },
|
|
1697
|
+
historyFetch: { kind: "historyFetch", remote: "origin" },
|
|
1700
1698
|
}
|
|
1701
1699
|
|
|
1702
1700
|
const stateKinds = (Object.keys(commandFor) as Command["kind"][]).filter(
|
|
@@ -1706,7 +1704,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1706
1704
|
const NO_COMMITS_MESSAGE =
|
|
1707
1705
|
"gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
|
|
1708
1706
|
|
|
1709
|
-
it("derives exactly the
|
|
1707
|
+
it("derives exactly the sixteen non-standalone kinds — a canary for the table-driven cases below", () => {
|
|
1710
1708
|
expect(stateKinds.sort()).toEqual(
|
|
1711
1709
|
[
|
|
1712
1710
|
"abandon",
|
|
@@ -1722,6 +1720,9 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1722
1720
|
"ui",
|
|
1723
1721
|
"judge",
|
|
1724
1722
|
"judgeAnswer",
|
|
1723
|
+
"historyList",
|
|
1724
|
+
"historyShow",
|
|
1725
|
+
"historyFetch",
|
|
1725
1726
|
].sort(),
|
|
1726
1727
|
)
|
|
1727
1728
|
})
|