@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.
Files changed (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -6
  3. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  4. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
  5. package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
  6. package/dist/flows.mjs +9 -1
  7. package/dist/gtd.bundle.mjs +1114 -865
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1135 -218
  10. package/dist/workflow-kit.mjs +9 -7
  11. package/dist/workflows/feature.mjs +152 -35
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +1 -1
  14. package/dist/workflows.mjs +223 -54
  15. package/package.json +1 -1
  16. package/src/cli/Cli.test.ts +57 -0
  17. package/src/cli/Cli.ts +89 -9
  18. package/src/cli/History.test.ts +225 -0
  19. package/src/cli/History.ts +143 -0
  20. package/src/cli/Output.ts +68 -0
  21. package/src/cli/Program.test.ts +10 -9
  22. package/src/cli/Program.ts +24 -72
  23. package/src/edge/Edge.test.ts +261 -3
  24. package/src/edge/Edge.ts +218 -41
  25. package/src/edge/index.ts +1 -0
  26. package/src/emit/GitScript.test.ts +27 -0
  27. package/src/emit/GitScript.ts +18 -2
  28. package/src/emit/index.ts +2 -0
  29. package/src/flows/scripts.ts +6 -0
  30. package/src/platform/Git.test.ts +6 -9
  31. package/src/platform/Git.ts +48 -10
  32. package/src/platform/RetainedHistory.test.ts +14 -0
  33. package/src/platform/RetainedHistory.ts +5 -0
  34. package/src/platform/index.ts +7 -1
  35. package/src/replay/Trailers.test.ts +32 -0
  36. package/src/replay/Trailers.ts +17 -0
  37. package/src/steering/FORMATS.md +1 -1
  38. package/src/steering/SteeringFormat.test.ts +9 -1
  39. package/src/steering/SteeringFormat.ts +7 -0
  40. package/src/steering/contract.test.ts +3 -0
  41. package/src/steering/index.ts +1 -0
  42. package/src/steering/review.test.ts +30 -0
  43. package/src/steering/review.ts +8 -2
  44. package/src/step/LandStep.ts +3 -1
  45. package/src/step/RepoSnapshot.ts +7 -1
  46. package/src/step/planStart.test.ts +6 -3
  47. package/src/step/planStep.test.ts +29 -1
  48. package/src/step/planStep.ts +8 -0
  49. package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
  50. package/src/testkit/EmittedScriptRecognizer.ts +149 -52
  51. package/src/testkit/EmittedScripts.test.ts +166 -0
  52. package/src/testkit/FakeGitOperations.ts +6 -2
  53. package/src/testkit/FlowScripts.test.ts +242 -0
  54. package/src/testkit/FlowScripts.ts +326 -0
  55. package/src/testkit/GitTiers.ts +269 -47
  56. package/src/testkit/InMemRepo.ts +61 -8
  57. package/src/testkit/Layers.ts +10 -2
  58. package/src/testkit/TestRepo.ts +4 -1
  59. package/src/testkit/index.ts +1 -1
  60. package/src/workflow/discovery.test.ts +147 -0
  61. package/src/workflow/discovery.ts +5 -0
  62. package/src/workflow/fingerprint.test.ts +20 -4
  63. package/src/workflow/fingerprint.ts +8 -9
  64. package/src/workflow/index.ts +1 -6
  65. package/src/workflow/load.test.ts +5 -19
  66. package/src/workflow/load.ts +94 -10
  67. package/src/workflow-kit/review.test.ts +84 -0
  68. package/src/workflow-kit/review.ts +9 -6
  69. package/src/workflows/feature/review.test.ts +238 -12
  70. package/src/workflows/feature/review.ts +172 -31
  71. package/src/workflows/feature/steps.test.ts +12 -0
  72. package/src/workflows/feature/steps.ts +8 -1
  73. package/src/workflows/shared/access.test.ts +4 -2
  74. package/src/workflows/shared/access.ts +1 -0
  75. package/src/workflows/shared/options.test.ts +8 -0
  76. package/src/workflows/shared/options.ts +1 -0
  77. package/src/workflows/shared/steps.ts +2 -0
  78. package/src/workflows/shared/tail.ts +3 -11
  79. package/src/workflows/shared/text.test.ts +106 -0
  80. package/src/workflows/shared/text.ts +57 -1
  81. 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 only) output",
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
- : `${row.token} <${row.arity.name}>`
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 row = first === undefined ? undefined : commandByToken(first)
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 ? positionals : positionals.slice(judgeAnswer || judgeRun ? 2 : 1)
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 ? undefined : arityError(first!, restPositionals, row!.arity)
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
@@ -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 { formatFinding, needsOf, runCommand, SelectorUsageError } from "./Program.js"
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
- "gtd(human): working\n\nGtd-Workflow: hotfix\nGtd-Var: greeting=hello\nGtd-Format: 1",
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 thirteen non-standalone kinds — a canary for the table-driven cases below", () => {
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
  })