@pmelab/gtd 24.1.0 → 24.3.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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -10
  3. package/claude/hooks/access.ts +25 -0
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
  12. package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
  13. package/dist/gtd.bundle.mjs +337 -55
  14. package/dist/testkit.mjs +477 -65
  15. package/dist/workflows/feature.mjs +1 -1
  16. package/dist/workflows/fix.mjs +1 -1
  17. package/dist/workflows/review.mjs +1 -1
  18. package/package.json +1 -1
  19. package/src/cli/Cli.test.ts +57 -0
  20. package/src/cli/Cli.ts +89 -9
  21. package/src/cli/History.test.ts +225 -0
  22. package/src/cli/History.ts +143 -0
  23. package/src/cli/Output.ts +68 -0
  24. package/src/cli/Program.test.ts +7 -1
  25. package/src/cli/Program.ts +23 -64
  26. package/src/edge/Edge.test.ts +256 -1
  27. package/src/edge/Edge.ts +217 -39
  28. package/src/edge/index.ts +1 -0
  29. package/src/emit/GitScript.test.ts +27 -0
  30. package/src/emit/GitScript.ts +18 -2
  31. package/src/emit/index.ts +1 -0
  32. package/src/platform/Git.ts +48 -10
  33. package/src/platform/RetainedHistory.test.ts +14 -0
  34. package/src/platform/RetainedHistory.ts +5 -0
  35. package/src/platform/index.ts +7 -1
  36. package/src/replay/Trailers.test.ts +32 -0
  37. package/src/replay/Trailers.ts +17 -0
  38. package/src/step/LandStep.ts +3 -1
  39. package/src/step/RepoSnapshot.ts +7 -1
  40. package/src/step/planStart.test.ts +6 -3
  41. package/src/step/planStep.test.ts +29 -1
  42. package/src/step/planStep.ts +8 -0
  43. package/src/testkit/EmittedScriptRecognizer.ts +1 -1
  44. package/src/testkit/FakeGitOperations.ts +6 -2
  45. package/src/testkit/GitTiers.ts +133 -2
  46. package/src/testkit/InMemRepo.ts +26 -6
@@ -1,6 +1,6 @@
1
1
  import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, numeric, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, start, vars } from "../flows.mjs";
2
2
  import { freezeScenarios, review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as unwindFailure, A as architectureAuthorPrompt, B as designTriagePrompt, C as fixCheck, D as reviewing, E as reviewMissing, F as buildReviewFixNitsPrompt, G as packagesItemFixSuitePrompt, I as buildReviewTriageMessage, K as reviewEditNotesCapture, L as builderSystem, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewCollectingPrompt, Q as unwindFailedMessage, R as designGateAnswerMessage, S as coder, T as planner, U as idleMessage, W as packagesItemBuildingPrompt, X as reviewerSystem, Y as reviewNotesCapture, Z as startGateBlockedMessage, _ as ARCHITECTURE, b as REVIEW, c as refusePendingHandOff, d as shipped, f as defaults, g as healthy, h as gate, i as skills, j as architectureDecomposePrompt, k as architectSystem, l as tail, n as summary, o as closeReview, p as envDefaults, q as reviewEditsCapture, r as access, t as steering, u as guarded, v as FEEDBACK, w as fixRisks, x as awaitReview, y as REQUIREMENTS, z as designSystem } from "../chunks/reserved-ejn-LVYO.mjs";
3
+ import { $ as unwindFailure, A as architectureAuthorPrompt, B as designTriagePrompt, C as fixCheck, D as reviewing, E as reviewMissing, F as buildReviewFixNitsPrompt, G as packagesItemFixSuitePrompt, I as buildReviewTriageMessage, K as reviewEditNotesCapture, L as builderSystem, M as architectureGateAnswerMessage, N as buildReviewAnswerQuestionsPrompt, O as agentWithSkills, P as buildReviewCollectingPrompt, Q as unwindFailedMessage, R as designGateAnswerMessage, S as coder, T as planner, U as idleMessage, W as packagesItemBuildingPrompt, X as reviewerSystem, Y as reviewNotesCapture, Z as startGateBlockedMessage, _ as ARCHITECTURE, b as REVIEW, c as refusePendingHandOff, d as shipped, f as defaults, g as healthy, h as gate, i as skills, j as architectureDecomposePrompt, k as architectSystem, l as tail, n as summary, o as closeReview, p as envDefaults, q as reviewEditsCapture, r as access, t as steering, u as guarded, v as FEEDBACK, w as fixRisks, x as awaitReview, y as REQUIREMENTS, z as designSystem } from "../chunks/reserved-DJwU5C4z.mjs";
4
4
  //#region src/workflows/feature/steps.ts
5
5
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
6
6
  const triage = (base) => agentWithSkills("triage", designTriagePrompt(base), {
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
2
  import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-D-PvRC5W.mjs";
3
- import { H as fixReviewNotesPrompt, O as agentWithSkills, S as coder, V as finisherSystem, a as REVIEW_ORIG, b as REVIEW, d as shipped, f as defaults, i as skills, m as baseline, n as summary, p as envDefaults, r as access, s as handOffTail, t as steering } from "../chunks/reserved-ejn-LVYO.mjs";
3
+ import { H as fixReviewNotesPrompt, O as agentWithSkills, S as coder, V as finisherSystem, a as REVIEW_ORIG, b as REVIEW, d as shipped, f as defaults, i as skills, m as baseline, n as summary, p as envDefaults, r as access, s as handOffTail, t as steering } from "../chunks/reserved-DJwU5C4z.mjs";
4
4
  //#region src/workflows/fix/fix.ts
5
5
  /**
6
6
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,5 +1,5 @@
1
1
  import { start } from "../flows.mjs";
2
- import { J as reviewGateBlockedMessage, c as refusePendingHandOff, d as shipped, f as defaults, h as gate, i as skills, n as summary, p as envDefaults, r as access, s as handOffTail, t as steering } from "../chunks/reserved-ejn-LVYO.mjs";
2
+ import { J as reviewGateBlockedMessage, c as refusePendingHandOff, d as shipped, f as defaults, h as gate, i as skills, n as summary, p as envDefaults, r as access, s as handOffTail, t as steering } from "../chunks/reserved-DJwU5C4z.mjs";
3
3
  //#region src/workflows/review/review.ts
4
4
  /**
5
5
  * Pure review of everything since `reviewBase`: a green gate, the quality
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "24.1.0",
3
+ "version": "24.3.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -926,6 +926,60 @@ describe("parseArgv — doors", () => {
926
926
  })
927
927
  })
928
928
 
929
+ describe("parseArgv — gtd history", () => {
930
+ const parse = (...args: string[]) => parseArgv(["node", "gtd.js", ...args])
931
+ const usage = (plan: CliPlan): string => (plan.kind === "usage" ? plan.message : "")
932
+
933
+ it("history list / show <ref> / fetch parse to their commands", () => {
934
+ expect(parse("history", "list")).toMatchObject({
935
+ kind: "command",
936
+ command: { kind: "historyList" },
937
+ })
938
+ expect(parse("history", "show", "feat/x")).toMatchObject({
939
+ kind: "command",
940
+ command: { kind: "historyShow", ref: "feat/x" },
941
+ })
942
+ expect(parse("history", "fetch")).toMatchObject({
943
+ kind: "command",
944
+ command: { kind: "historyFetch", remote: "origin" },
945
+ })
946
+ expect(parse("history", "fetch", "upstream")).toMatchObject({
947
+ command: { kind: "historyFetch", remote: "upstream" },
948
+ })
949
+ })
950
+
951
+ it("bare history and an unknown subcommand are usage errors naming list, show, fetch", () => {
952
+ for (const args of [["history"], ["history", "nope"]]) {
953
+ const message = usage(parse(...args))
954
+ expect(message).toContain("list")
955
+ expect(message).toContain("show")
956
+ expect(message).toContain("fetch")
957
+ }
958
+ })
959
+
960
+ it("show without a ref, and extra arguments, are usage errors", () => {
961
+ expect(usage(parse("history", "show"))).toContain("missing ref")
962
+ expect(usage(parse("history", "show", "a", "b"))).toContain("too many arguments")
963
+ expect(usage(parse("history", "list", "x"))).toContain("too many arguments")
964
+ expect(usage(parse("history", "fetch", "a", "b"))).toContain("too many arguments")
965
+ })
966
+
967
+ it("--json is accepted for list and show, rejected for fetch", () => {
968
+ expect(parse("history", "list", "--json")).toMatchObject({ json: { kind: "document" } })
969
+ expect(parse("history", "show", "x", "--json=ended")).toMatchObject({
970
+ json: { kind: "select", path: "ended" },
971
+ })
972
+ expect(parse("history", "fetch", "--json").kind).toBe("usage")
973
+ })
974
+
975
+ it("help lists the three history rows", () => {
976
+ const help = renderHelp()
977
+ expect(help).toMatch(/^ {2}history list\b/m)
978
+ expect(help).toContain("history show <ref>")
979
+ expect(help).toContain("history fetch [remote]")
980
+ })
981
+ })
982
+
929
983
  describe("standaloneKinds / needsOf", () => {
930
984
  it("pins the standalone kinds", () => {
931
985
  expect(standaloneKinds()).toEqual(["lsp", "check", "uncheck", "install", "loop", "judgeRun"])
@@ -950,6 +1004,9 @@ describe("standaloneKinds / needsOf", () => {
950
1004
  "judgeAnswer",
951
1005
  "door",
952
1006
  "doors",
1007
+ "historyList",
1008
+ "historyShow",
1009
+ "historyFetch",
953
1010
  ] as const) {
954
1011
  expect(needsOf(kind)).toBe("state")
955
1012
  }
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
+ })