@pmelab/gtd 27.8.0 → 27.9.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 +9 -4
- package/dist/chunks/{wire-C12dahrH.mjs → wire-D3Rc5r22.mjs} +14 -1
- package/dist/gtd.bundle.mjs +131 -33
- package/dist/testkit.mjs +115 -34
- package/dist/wire.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +39 -3
- package/src/cli/Cli.ts +23 -2
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +22 -0
- package/src/edge/Edge.test.ts +209 -54
- package/src/edge/Edge.ts +121 -42
- package/src/edge/index.ts +1 -0
- package/src/replay/Replay.test.ts +32 -0
- package/src/replay/Replay.ts +3 -1
- package/src/replay/Trailers.test.ts +11 -0
- package/src/replay/Trailers.ts +3 -1
- package/src/replay/index.ts +1 -1
- package/src/wire/Slices.ts +25 -0
- package/src/wire/WireSchema.test.ts +18 -0
- package/src/wire/golden/1/slices.json +11 -0
- package/src/wire/index.ts +1 -0
package/src/cli/Cli.test.ts
CHANGED
|
@@ -112,6 +112,7 @@ describe("parseArgv — scope", () => {
|
|
|
112
112
|
["judge", "--json"],
|
|
113
113
|
["judge", "answer", "--json"],
|
|
114
114
|
["doors", "--json"],
|
|
115
|
+
["slices", "--json"],
|
|
115
116
|
]) {
|
|
116
117
|
const ok = parseArgv(["node", "gtd.js", ...args])
|
|
117
118
|
expect(ok.kind).toBe("command")
|
|
@@ -927,6 +928,40 @@ describe("parseArgv — doors", () => {
|
|
|
927
928
|
})
|
|
928
929
|
})
|
|
929
930
|
|
|
931
|
+
describe("parseArgv — slices", () => {
|
|
932
|
+
const parse = (...args: string[]): CliPlan => parseArgv(["node", "gtd.js", ...args])
|
|
933
|
+
|
|
934
|
+
it("`gtd slices` parses with every --json shape", () => {
|
|
935
|
+
expect(parse("slices")).toMatchObject({
|
|
936
|
+
kind: "command",
|
|
937
|
+
command: { kind: "slices" },
|
|
938
|
+
json: { kind: "off" },
|
|
939
|
+
})
|
|
940
|
+
expect(parse("slices", "--json")).toMatchObject({ json: { kind: "document" } })
|
|
941
|
+
expect(parse("slices", "--json=slices.0.name")).toMatchObject({
|
|
942
|
+
json: { kind: "select", path: "slices.0.name" },
|
|
943
|
+
})
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
it("`gtd slices` takes no argument", () => {
|
|
947
|
+
const plan = parse("slices", "extra")
|
|
948
|
+
expect(plan.kind).toBe("usage")
|
|
949
|
+
if (plan.kind === "usage") expect(plan.message).toContain("too many arguments")
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
it("help lists the command, and --json names it", () => {
|
|
953
|
+
const help = renderHelp()
|
|
954
|
+
expect(help).toMatch(/^ {2}slices\b/m)
|
|
955
|
+
expect(help).toContain("gtd slices")
|
|
956
|
+
})
|
|
957
|
+
|
|
958
|
+
it("--json's scope error names gtd slices", () => {
|
|
959
|
+
const plan = parse("lsp", "--json")
|
|
960
|
+
expect(plan.kind).toBe("usage")
|
|
961
|
+
if (plan.kind === "usage") expect(plan.message).toContain("`gtd slices`")
|
|
962
|
+
})
|
|
963
|
+
})
|
|
964
|
+
|
|
930
965
|
describe("parseArgv — gtd history", () => {
|
|
931
966
|
const parse = (...args: string[]) => parseArgv(["node", "gtd.js", ...args])
|
|
932
967
|
const usage = (plan: CliPlan): string => (plan.kind === "usage" ? plan.message : "")
|
|
@@ -1057,6 +1092,7 @@ describe("standaloneKinds / needsOf", () => {
|
|
|
1057
1092
|
"judgeAnswer",
|
|
1058
1093
|
"door",
|
|
1059
1094
|
"doors",
|
|
1095
|
+
"slices",
|
|
1060
1096
|
"historyList",
|
|
1061
1097
|
"historyShow",
|
|
1062
1098
|
"historyFetch",
|
|
@@ -1136,9 +1172,9 @@ describe("renderHelp", () => {
|
|
|
1136
1172
|
expect(match, file).not.toBeNull()
|
|
1137
1173
|
return new Set([...match![1]!.matchAll(/`([a-z]+(?: [a-z]+)?)`/g)].map(([, name]) => name!))
|
|
1138
1174
|
}
|
|
1139
|
-
expect(
|
|
1140
|
-
commands,
|
|
1141
|
-
)
|
|
1175
|
+
expect(
|
|
1176
|
+
listed("../../README.md", /The `gtd` bin's commands — ([\s\S]*?) — are\s+listed/),
|
|
1177
|
+
).toEqual(commands)
|
|
1142
1178
|
expect(listed("../../COMPATIBILITY.md", /every command \(([\s\S]*?)\), option/)).toEqual(
|
|
1143
1179
|
commands,
|
|
1144
1180
|
)
|
package/src/cli/Cli.ts
CHANGED
|
@@ -101,16 +101,17 @@ const FLAGS: readonly FlagRow[] = [
|
|
|
101
101
|
kind === "judge" ||
|
|
102
102
|
kind === "judgeAnswer" ||
|
|
103
103
|
kind === "doors" ||
|
|
104
|
+
kind === "slices" ||
|
|
104
105
|
kind === "historyList" ||
|
|
105
106
|
kind === "historyShow" ||
|
|
106
107
|
kind === "strays",
|
|
107
108
|
decode: ([raw]) => Either.right(raw ?? ""),
|
|
108
109
|
scopeError:
|
|
109
|
-
"gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd history list`/`gtd history show`/`gtd strays` — " +
|
|
110
|
+
"gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors`/`gtd slices`/`gtd history list`/`gtd history show`/`gtd strays` — " +
|
|
110
111
|
"every other command prints plain text; see `gtd install` for the driver protocol briefing",
|
|
111
112
|
valueHint: "<path>",
|
|
112
113
|
help: [
|
|
113
|
-
"(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd history",
|
|
114
|
+
"(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors/gtd slices/gtd history",
|
|
114
115
|
"list/gtd history show/gtd strays only) output structured JSON. Bare --json prints the whole document;",
|
|
115
116
|
"--json=<path> (a dotted key path into that document, e.g.",
|
|
116
117
|
"kind, content, session.id) prints just that value: a",
|
|
@@ -636,6 +637,25 @@ const COMMAND_ROWS: readonly CommandRow[] = [
|
|
|
636
637
|
"value off it. Writes nothing",
|
|
637
638
|
],
|
|
638
639
|
},
|
|
640
|
+
{
|
|
641
|
+
token: "slices",
|
|
642
|
+
kind: "slices",
|
|
643
|
+
arity: "none",
|
|
644
|
+
details: [
|
|
645
|
+
"List the slices of the process HEAD closes or sits inside,",
|
|
646
|
+
"first slice first: one `name base..tip` line each (full",
|
|
647
|
+
"hashes; plain output carries no promise). --json prints",
|
|
648
|
+
"{ schema: 1, slices: [{ name, base, tip, closed }] };",
|
|
649
|
+
"--json=<path> reads one value off it. Writes nothing: no",
|
|
650
|
+
"git, no state transition, no session identity. When HEAD",
|
|
651
|
+
"closes a process, every slice is listed as closed; a gtd",
|
|
652
|
+
"version mismatch only warns on stderr. Refuses (exit 1)",
|
|
653
|
+
"when HEAD neither closes nor sits inside a process, or when",
|
|
654
|
+
"today's config disagrees with history about the process:",
|
|
655
|
+
"HEAD closes it and its config files differ from the working",
|
|
656
|
+
"tree, or the replay diverged",
|
|
657
|
+
],
|
|
658
|
+
},
|
|
639
659
|
{
|
|
640
660
|
token: "judge",
|
|
641
661
|
kind: "judge",
|
|
@@ -1304,6 +1324,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1304
1324
|
| "install"
|
|
1305
1325
|
| "loop"
|
|
1306
1326
|
| "doors"
|
|
1327
|
+
| "slices"
|
|
1307
1328
|
| "summary"
|
|
1308
1329
|
| "base"
|
|
1309
1330
|
| "judge"
|
package/src/cli/Program.test.ts
CHANGED
|
@@ -1677,6 +1677,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1677
1677
|
start: { kind: "start", actor: "human", workflow: "feature", vars: {}, label: "" },
|
|
1678
1678
|
door: { kind: "door", name: "fix", args: [] },
|
|
1679
1679
|
doors: { kind: "doors" },
|
|
1680
|
+
slices: { kind: "slices" },
|
|
1680
1681
|
abandon: { kind: "abandon" },
|
|
1681
1682
|
restore: { kind: "restore" },
|
|
1682
1683
|
next: { kind: "next" },
|
|
@@ -1703,13 +1704,14 @@ describe("runCommand — refuses in a repository with no commits", () => {
|
|
|
1703
1704
|
const NO_COMMITS_MESSAGE =
|
|
1704
1705
|
"gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
|
|
1705
1706
|
|
|
1706
|
-
it("derives exactly the
|
|
1707
|
+
it("derives exactly the seventeen non-standalone kinds — a canary for the table-driven cases below", () => {
|
|
1707
1708
|
expect(stateKinds.sort()).toEqual(
|
|
1708
1709
|
[
|
|
1709
1710
|
"abandon",
|
|
1710
1711
|
"base",
|
|
1711
1712
|
"door",
|
|
1712
1713
|
"doors",
|
|
1714
|
+
"slices",
|
|
1713
1715
|
"start",
|
|
1714
1716
|
"land",
|
|
1715
1717
|
"next",
|
package/src/cli/Program.ts
CHANGED
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
type StatusChange,
|
|
44
44
|
selectPath,
|
|
45
45
|
EXIT_OK,
|
|
46
|
+
renderSlicesJson,
|
|
47
|
+
slicesDocument,
|
|
46
48
|
} from "../wire/index.js"
|
|
47
49
|
import {
|
|
48
50
|
currentRest,
|
|
@@ -51,6 +53,7 @@ import {
|
|
|
51
53
|
renderRest,
|
|
52
54
|
restAt,
|
|
53
55
|
previewLanding,
|
|
56
|
+
processSlices,
|
|
54
57
|
noProcessUnderway,
|
|
55
58
|
restIsIdle,
|
|
56
59
|
reviewBaseFor,
|
|
@@ -143,6 +146,7 @@ export type Command =
|
|
|
143
146
|
}
|
|
144
147
|
| { readonly kind: "door"; readonly name: string; readonly args: readonly string[] }
|
|
145
148
|
| { readonly kind: "doors" }
|
|
149
|
+
| { readonly kind: "slices" }
|
|
146
150
|
| { readonly kind: "abandon" }
|
|
147
151
|
| { readonly kind: "restore" }
|
|
148
152
|
| { readonly kind: "next" }
|
|
@@ -659,6 +663,22 @@ const runDoorsCommand = (
|
|
|
659
663
|
else yield* writeSelection(out, document, json.path)
|
|
660
664
|
})
|
|
661
665
|
|
|
666
|
+
const runSlicesCommand = (
|
|
667
|
+
json: JsonMode,
|
|
668
|
+
out: ArtifactOut,
|
|
669
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
670
|
+
Effect.gen(function* () {
|
|
671
|
+
const { slices, versionWarning } = yield* processSlices
|
|
672
|
+
if (versionWarning !== undefined) yield* (yield* Narrator).warn(versionWarning)
|
|
673
|
+
if (json.kind === "off") {
|
|
674
|
+
out.write(slices.map((s) => `${s.name} ${s.base}..${s.tip}\n`).join(""))
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
const document = slicesDocument(slices)
|
|
678
|
+
if (json.kind === "document") out.write(renderSlicesJson(document))
|
|
679
|
+
else yield* writeSelection(out, document, json.path)
|
|
680
|
+
})
|
|
681
|
+
|
|
662
682
|
/**
|
|
663
683
|
* Ends the process underway without completing it. Nothing is discarded: a
|
|
664
684
|
* `git reset --mixed` to the process's start commit leaves every turn
|
|
@@ -1223,6 +1243,8 @@ const dispatchVoidCommand = (
|
|
|
1223
1243
|
return runDoorCommand(command.name, command.args, out)
|
|
1224
1244
|
case "doors":
|
|
1225
1245
|
return runDoorsCommand(json, out)
|
|
1246
|
+
case "slices":
|
|
1247
|
+
return runSlicesCommand(json, out)
|
|
1226
1248
|
case "abandon":
|
|
1227
1249
|
return runAbandonCommand(out)
|
|
1228
1250
|
case "restore":
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -3,10 +3,10 @@ import { describe, expect, it } from "vitest"
|
|
|
3
3
|
import { runCli } from "../cli/index.js"
|
|
4
4
|
import {
|
|
5
5
|
currentRest,
|
|
6
|
-
closedRunAt,
|
|
7
6
|
currentRun,
|
|
8
7
|
historyTrail,
|
|
9
8
|
noProcessUnderway,
|
|
9
|
+
processSlices,
|
|
10
10
|
renderRest,
|
|
11
11
|
restAt,
|
|
12
12
|
restIsIdle,
|
|
@@ -596,31 +596,168 @@ export const summary = (it) => it.marks.map((m) => m.name + "=" + m.value).join(
|
|
|
596
596
|
})
|
|
597
597
|
})
|
|
598
598
|
|
|
599
|
-
|
|
600
|
-
const
|
|
599
|
+
const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
|
|
600
|
+
const real = fakeGitOperations(repo)
|
|
601
|
+
const head = repo.resolveRef("HEAD")
|
|
602
|
+
return {
|
|
603
|
+
...real,
|
|
604
|
+
commitHistory: (base, tip) =>
|
|
605
|
+
Effect.map(real.commitHistory(base, tip), (commits) =>
|
|
606
|
+
commits.map((c) => (c.hash === head ? { ...c, message: edit(c.message) } : c)),
|
|
607
|
+
),
|
|
608
|
+
}
|
|
609
|
+
}
|
|
601
610
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
611
|
+
describe("processSlices", () => {
|
|
612
|
+
const failure = async (repo: InMemRepo): Promise<string> => {
|
|
613
|
+
const exit = await provideExit(processSlices, repo)
|
|
614
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
615
|
+
return Exit.isFailure(exit) ? String(exit.cause) : ""
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
it("never reports an open slice for a closed run whose replay no longer ends", async () => {
|
|
619
|
+
const repo = repoWith(LINEAR)
|
|
620
|
+
await land(repo, { "a.txt": "a\n" })
|
|
621
|
+
await land(repo, { "b.txt": "b\n" })
|
|
622
|
+
await land(repo, { "c.txt": "c\n" })
|
|
623
|
+
repo.writeFile(
|
|
624
|
+
"gtd.config.ts",
|
|
625
|
+
LINEAR.replace(
|
|
626
|
+
'await agent("checking", "check-prompt")',
|
|
627
|
+
'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
// Stands for a flow change with no in-repo config edit (an upgraded
|
|
631
|
+
// out-of-repo import): every ref reads as the working tree, so the
|
|
632
|
+
// config-differs refusal stays quiet and only replay drift remains.
|
|
633
|
+
const git: GitOperations = {
|
|
634
|
+
...fakeGitOperations(repo),
|
|
635
|
+
readFileAtRef: (_ref, path) => Effect.succeed(repo.readFile(path) ?? ""),
|
|
636
|
+
}
|
|
637
|
+
const exit = await Effect.runPromiseExit(
|
|
638
|
+
processSlices.pipe(
|
|
639
|
+
Effect.provideService(GitService, git),
|
|
640
|
+
Effect.provide(testLayers(repo, {})),
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
644
|
+
const message = Exit.isFailure(exit) ? String(exit.cause) : ""
|
|
645
|
+
expect(message).toContain(
|
|
646
|
+
`gtd slices: refused — ${repo.resolveRef("HEAD")!.slice(0, 7)} closes a process that does not end under today's config`,
|
|
647
|
+
)
|
|
606
648
|
})
|
|
607
|
-
|
|
608
|
-
|
|
649
|
+
|
|
650
|
+
it("never reports a closed slice for an underway run whose replay now ends", async () => {
|
|
651
|
+
const repo = repoWith(LINEAR)
|
|
652
|
+
await land(repo, { "a.txt": "a\n" })
|
|
653
|
+
await land(repo, { "b.txt": "b\n" })
|
|
654
|
+
repo.writeFile(
|
|
655
|
+
"gtd.config.ts",
|
|
656
|
+
LINEAR.replace(' await agent("checking", "check-prompt")\n', ""),
|
|
657
|
+
)
|
|
658
|
+
const message = await failure(repo)
|
|
659
|
+
expect(message).toContain("gtd slices: refused")
|
|
660
|
+
expect(message).toContain("the workflow changed under this process")
|
|
661
|
+
expect(message).toContain("gtd abandon")
|
|
662
|
+
expect(message).not.toContain("gtd slices: gtd:")
|
|
609
663
|
})
|
|
610
|
-
}
|
|
611
|
-
`
|
|
612
664
|
|
|
613
|
-
it("
|
|
614
|
-
const repo = repoWith(
|
|
615
|
-
|
|
665
|
+
it("refuses with no process at HEAD, naming that HEAD neither closes nor sits inside one", async () => {
|
|
666
|
+
const repo = repoWith(LINEAR)
|
|
667
|
+
expect(await failure(repo)).toContain(
|
|
668
|
+
"gtd slices: refused — HEAD neither closes nor sits inside a process",
|
|
669
|
+
)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it("refuses a renamed step with replay's message, prefixed and without a gtd: lead", async () => {
|
|
673
|
+
const repo = repoWith(LINEAR)
|
|
616
674
|
await land(repo, { "a.txt": "a\n" })
|
|
617
|
-
|
|
618
|
-
const
|
|
619
|
-
expect(
|
|
620
|
-
expect(
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
675
|
+
repo.writeFile("gtd.config.ts", LINEAR.replace('agent("building"', 'agent("constructing"'))
|
|
676
|
+
const message = await failure(repo)
|
|
677
|
+
expect(message).toContain("gtd slices: refused — ")
|
|
678
|
+
expect(message).not.toContain("HEAD neither closes nor sits inside a process")
|
|
679
|
+
expect(message).not.toContain("refused — gtd: ")
|
|
680
|
+
expect(message).not.toContain("gtd slices: gtd:")
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it("refuses a finished process whose config file was edited since", async () => {
|
|
684
|
+
const repo = repoWith(LINEAR)
|
|
685
|
+
await land(repo, { "a.txt": "a\n" })
|
|
686
|
+
await land(repo, { "b.txt": "b\n" })
|
|
687
|
+
await land(repo, { "c.txt": "c\n" })
|
|
688
|
+
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
689
|
+
const message = await failure(repo)
|
|
690
|
+
expect(message).toContain(
|
|
691
|
+
`gtd slices: refused — config differs at ${repo.resolveRef("HEAD")!.slice(0, 7)}: gtd.config.ts. HEAD's process replays under today's config; revert or stash the edit to list its slices`,
|
|
692
|
+
)
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
const finishedProcess = async () => {
|
|
696
|
+
const repo = repoWith(LINEAR)
|
|
697
|
+
await land(repo, { "a.txt": "a\n" })
|
|
698
|
+
await land(repo, { "b.txt": "b\n" })
|
|
699
|
+
await land(repo, { "c.txt": "c\n" })
|
|
700
|
+
return repo
|
|
701
|
+
}
|
|
702
|
+
const slicesWith = (repo: InMemRepo, git: GitOperations) =>
|
|
703
|
+
Effect.runPromise(
|
|
704
|
+
processSlices.pipe(
|
|
705
|
+
Effect.provideService(GitService, git),
|
|
706
|
+
Effect.provide(testLayers(repo, {})),
|
|
707
|
+
),
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
it("warns about a closed run recorded by another version, slices unchanged", async () => {
|
|
711
|
+
const repo = await finishedProcess()
|
|
712
|
+
const plain = await provide(processSlices, repo)
|
|
713
|
+
const git = withTipMessage(repo, (m) =>
|
|
714
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
|
|
715
|
+
)
|
|
716
|
+
const result = await slicesWith(repo, git)
|
|
717
|
+
expect(result.versionWarning).toContain("0.0.1")
|
|
718
|
+
expect(result.versionWarning).toContain(GTD_VERSION)
|
|
719
|
+
expect(result.slices).toEqual(plain.slices)
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it("warns about an unknown version when the closing commit records none", async () => {
|
|
723
|
+
const repo = await finishedProcess()
|
|
724
|
+
const git = withTipMessage(repo, (m) => m.replace(/Gtd-Version: .*\n?/, ""))
|
|
725
|
+
expect((await slicesWith(repo, git)).versionWarning).toContain("an unknown gtd version")
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it("does not warn for a closed run landed at the running version", async () => {
|
|
729
|
+
const repo = await finishedProcess()
|
|
730
|
+
expect((await provide(processSlices, repo)).versionWarning).toBeUndefined()
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it("does not warn for an underway process", async () => {
|
|
734
|
+
const repo = repoWith(LINEAR)
|
|
735
|
+
await land(repo, { "a.txt": "a\n" })
|
|
736
|
+
const git = withTipMessage(repo, (m) =>
|
|
737
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
|
|
738
|
+
)
|
|
739
|
+
expect((await slicesWith(repo, git)).versionWarning).toBeUndefined()
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it("refuses on config drift before replay-ends drift", async () => {
|
|
743
|
+
const repo = await finishedProcess()
|
|
744
|
+
repo.writeFile(
|
|
745
|
+
"gtd.config.ts",
|
|
746
|
+
LINEAR.replace(
|
|
747
|
+
'await agent("checking", "check-prompt")',
|
|
748
|
+
'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
|
|
749
|
+
),
|
|
750
|
+
)
|
|
751
|
+
const message = await failure(repo)
|
|
752
|
+
expect(message).toContain("config differs at")
|
|
753
|
+
expect(message).not.toContain("does not end")
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it("still lists an underway process's slices after the same config edit", async () => {
|
|
757
|
+
const repo = repoWith(LINEAR)
|
|
758
|
+
await land(repo, { "a.txt": "a\n" })
|
|
759
|
+
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
760
|
+
expect((await provide(processSlices, repo)).slices).toHaveLength(1)
|
|
624
761
|
})
|
|
625
762
|
})
|
|
626
763
|
|
|
@@ -1154,30 +1291,6 @@ describe("the pending working tree is read at most once per restAt, and never st
|
|
|
1154
1291
|
})
|
|
1155
1292
|
})
|
|
1156
1293
|
|
|
1157
|
-
describe("closedRunAt", () => {
|
|
1158
|
-
it("is the run summaryRun reads when HEAD is the tip", async () => {
|
|
1159
|
-
const repo = repoWith(LINEAR)
|
|
1160
|
-
await land(repo, { "a.txt": "a\n" })
|
|
1161
|
-
await land(repo, { "b.txt": "b\n" })
|
|
1162
|
-
const tip = await land(repo, { "c.txt": "c\n" })
|
|
1163
|
-
expect(await provide(closedRunAt(tip), repo)).toEqual(await provide(summaryRun, repo))
|
|
1164
|
-
})
|
|
1165
|
-
|
|
1166
|
-
it("resolves the closed run with HEAD elsewhere", async () => {
|
|
1167
|
-
const repo = repoWith(LINEAR)
|
|
1168
|
-
const building = await land(repo, { "a.txt": "a\n" })
|
|
1169
|
-
await land(repo, { "b.txt": "b\n" })
|
|
1170
|
-
const tip = await land(repo, { "c.txt": "c\n" })
|
|
1171
|
-
const closed = await provide(closedRunAt(tip), repo)
|
|
1172
|
-
repo.writeFile("d.txt", "d\n")
|
|
1173
|
-
repo.seedCommit("chore: unrelated")
|
|
1174
|
-
const later = await provide(closedRunAt(tip), repo)
|
|
1175
|
-
expect(later).toEqual(closed)
|
|
1176
|
-
expect(later.closingHash).toBe(tip)
|
|
1177
|
-
expect(later.startHash).toBe(building)
|
|
1178
|
-
})
|
|
1179
|
-
})
|
|
1180
|
-
|
|
1181
1294
|
describe("finishing landings keep history", () => {
|
|
1182
1295
|
const landingOf = async (repo: InMemRepo, git?: GitOperations) => {
|
|
1183
1296
|
const rest = await provide(currentRest, repo)
|
|
@@ -1467,14 +1580,6 @@ export default async () => {
|
|
|
1467
1580
|
expect(trail.versionWarning).toBeUndefined()
|
|
1468
1581
|
})
|
|
1469
1582
|
|
|
1470
|
-
const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
|
|
1471
|
-
const real = fakeGitOperations(repo)
|
|
1472
|
-
return {
|
|
1473
|
-
...real,
|
|
1474
|
-
lastCommitMessage: (ref) => Effect.map(real.lastCommitMessage(ref), edit),
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
1583
|
it("warns, never refuses, when the recorded version differs", async () => {
|
|
1479
1584
|
const { repo, tip } = await finished()
|
|
1480
1585
|
const git = withTipMessage(repo, (m) =>
|
|
@@ -1504,13 +1609,63 @@ export default async () => {
|
|
|
1504
1609
|
expect(trail.versionWarning).toContain(GTD_VERSION)
|
|
1505
1610
|
})
|
|
1506
1611
|
|
|
1612
|
+
it("reads a recorded version carrying control bytes as unknown", async () => {
|
|
1613
|
+
const { repo, tip } = await finished()
|
|
1614
|
+
const raw = "1.0\u001b]0;x\u0007"
|
|
1615
|
+
const git = withTipMessage(repo, (m) =>
|
|
1616
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, `Gtd-Version: ${raw}`),
|
|
1617
|
+
)
|
|
1618
|
+
const trail = await Effect.runPromise(
|
|
1619
|
+
historyTrail(tip).pipe(
|
|
1620
|
+
Effect.provideService(GitService, git),
|
|
1621
|
+
Effect.provide(testLayers(repo, {})),
|
|
1622
|
+
),
|
|
1623
|
+
)
|
|
1624
|
+
expect(trail.version.recorded).toBeUndefined()
|
|
1625
|
+
expect(trail.versionWarning).toContain("an unknown gtd version")
|
|
1626
|
+
// eslint-disable-next-line no-control-regex
|
|
1627
|
+
expect(trail.versionWarning).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/)
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1507
1630
|
it("refuses naming the config file that differs at the tip", async () => {
|
|
1508
1631
|
const { repo, tip } = await finished()
|
|
1509
1632
|
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
1510
1633
|
repo.seedCommit("chore: edit config")
|
|
1511
1634
|
const exit = await provideExit(historyTrail(tip), repo)
|
|
1512
1635
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
1513
|
-
expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
|
|
1636
|
+
expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
|
|
1637
|
+
`gtd history: refused — config differs at ${tip.slice(0, 7)}: gtd.config.ts. A history ref replays under today's config, so it is unreadable after the config changes`,
|
|
1638
|
+
)
|
|
1639
|
+
})
|
|
1640
|
+
|
|
1641
|
+
it("refuses a resting tip with the exact message", async () => {
|
|
1642
|
+
const repo = repoWith(LINEAR)
|
|
1643
|
+
const tip = await land(repo, { "a.txt": "a\n" })
|
|
1644
|
+
const exit = await provideExit(historyTrail(tip), repo)
|
|
1645
|
+
expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
|
|
1646
|
+
`gtd history: ${tip.slice(0, 7)} does not close a process under today's config`,
|
|
1647
|
+
)
|
|
1648
|
+
})
|
|
1649
|
+
|
|
1650
|
+
it("reads the episode's trees exactly once", async () => {
|
|
1651
|
+
const { repo, tip } = await finished()
|
|
1652
|
+
let calls = 0
|
|
1653
|
+
const trail = await Effect.runPromise(
|
|
1654
|
+
Effect.gen(function* () {
|
|
1655
|
+
const real = yield* Workspace
|
|
1656
|
+
return yield* historyTrail(tip).pipe(
|
|
1657
|
+
Effect.provideService(Workspace, {
|
|
1658
|
+
...real,
|
|
1659
|
+
episodeTrees: (...args: Parameters<typeof real.episodeTrees>) => {
|
|
1660
|
+
calls++
|
|
1661
|
+
return real.episodeTrees(...args)
|
|
1662
|
+
},
|
|
1663
|
+
}),
|
|
1664
|
+
)
|
|
1665
|
+
}).pipe(Effect.provide(testLayers(repo, {}))),
|
|
1666
|
+
)
|
|
1667
|
+
expect(trail.steps).toHaveLength(3)
|
|
1668
|
+
expect(calls).toBe(1)
|
|
1514
1669
|
})
|
|
1515
1670
|
|
|
1516
1671
|
it("refuses naming a config file missing from the tip", async () => {
|