@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +37 -10
- package/claude/hooks/access.ts +25 -0
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-DJwU5C4z.mjs} +1 -1
- package/dist/gtd.bundle.mjs +337 -55
- package/dist/testkit.mjs +477 -65
- package/dist/workflows/feature.mjs +1 -1
- package/dist/workflows/fix.mjs +1 -1
- package/dist/workflows/review.mjs +1 -1
- 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 +7 -1
- package/src/cli/Program.ts +23 -64
- package/src/edge/Edge.test.ts +256 -1
- package/src/edge/Edge.ts +217 -39
- 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 +1 -0
- 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/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.ts +1 -1
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/GitTiers.ts +133 -2
- 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-
|
|
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), {
|
package/dist/workflows/fix.mjs
CHANGED
|
@@ -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-
|
|
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-
|
|
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
package/src/cli/Cli.test.ts
CHANGED
|
@@ -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
|
|
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
|
+
})
|