@pmelab/gtd 28.0.0 → 28.2.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 +18 -14
- package/claude/hooks/assist.ts +1 -1
- package/claude/hooks/register.tsx +1 -1
- package/claude/hooks/ship.ts +963 -143
- package/dist/chunks/{reserved-BIsngMGL.mjs → reserved-DeBS4Ymh.mjs} +10 -5
- package/dist/chunks/{tail-DHEaOHyp.mjs → tail-B2SER2Ih.mjs} +1 -1
- package/dist/gtd.bundle.mjs +166 -57
- package/dist/testkit.mjs +166 -57
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +2 -2
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +10 -5
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +39 -0
- package/src/cli/Cli.ts +37 -5
- package/src/cli/Program.test.ts +31 -0
- package/src/cli/Program.ts +14 -7
- package/src/edge/Edge.test.ts +259 -3
- package/src/edge/Edge.ts +175 -58
- package/src/flows/runtime.ts +20 -1
- package/src/workflows/shared/text.test.ts +124 -0
- package/src/workflows/shared/text.ts +20 -6
package/src/cli/Program.ts
CHANGED
|
@@ -162,7 +162,7 @@ export type Command =
|
|
|
162
162
|
| { readonly kind: "uncheck"; readonly file: string }
|
|
163
163
|
| { readonly kind: "install" }
|
|
164
164
|
| { readonly kind: "loop" }
|
|
165
|
-
| { readonly kind: "summary" }
|
|
165
|
+
| { readonly kind: "summary"; readonly slice?: string }
|
|
166
166
|
| { readonly kind: "base" }
|
|
167
167
|
| { readonly kind: "judge" }
|
|
168
168
|
| { readonly kind: "judgeAnswer" }
|
|
@@ -326,13 +326,20 @@ const planLanding = (
|
|
|
326
326
|
* closes or sits inside its own closing message. Writes nothing — no git, no
|
|
327
327
|
* state transition, no file. Refuses (throws, mapped to the runtime-error
|
|
328
328
|
* exit code) when the workflow declares no `summary` prompt, or the resolved
|
|
329
|
-
* run has no commits to name.
|
|
329
|
+
* run has no commits to name. The edge never narrates: the CLI prints the
|
|
330
|
+
* version warning, before the unknown-slice refusal; a replay refusal carries
|
|
331
|
+
* it in its message.
|
|
330
332
|
*/
|
|
331
|
-
const runSummaryCommand = (
|
|
333
|
+
const runSummaryCommand = (
|
|
334
|
+
out: ArtifactOut,
|
|
335
|
+
sliceName: string | undefined,
|
|
336
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
332
337
|
Effect.gen(function* () {
|
|
333
338
|
const run = yield* summaryRun
|
|
334
|
-
const
|
|
335
|
-
if (
|
|
339
|
+
const { prompt, versionWarning, unknownSlice } = yield* summaryFor(run, sliceName)
|
|
340
|
+
if (versionWarning !== undefined) yield* (yield* Narrator).warn(versionWarning)
|
|
341
|
+
if (unknownSlice !== undefined) return yield* Effect.fail(new Error(unknownSlice))
|
|
342
|
+
if (prompt === undefined) {
|
|
336
343
|
return yield* Effect.fail(
|
|
337
344
|
new Error(
|
|
338
345
|
`gtd summary: refused — either this workflow declares no "summary:" template, or ` +
|
|
@@ -340,7 +347,7 @@ const runSummaryCommand = (out: ArtifactOut): Effect.Effect<void, Error, Command
|
|
|
340
347
|
),
|
|
341
348
|
)
|
|
342
349
|
}
|
|
343
|
-
out.write(
|
|
350
|
+
out.write(prompt.endsWith("\n") ? prompt : prompt + "\n")
|
|
344
351
|
})
|
|
345
352
|
|
|
346
353
|
/** Prints the review anchor hash bare, so an external tool can be pointed at the range under review. Nothing is written. Refuses when the run has no commits, where the hash would name a range with no review. */
|
|
@@ -1270,7 +1277,7 @@ const dispatchVoidCommand = (
|
|
|
1270
1277
|
case "loop":
|
|
1271
1278
|
return runLoopCommand()
|
|
1272
1279
|
case "summary":
|
|
1273
|
-
return runSummaryCommand(out)
|
|
1280
|
+
return runSummaryCommand(out, command.slice)
|
|
1274
1281
|
case "base":
|
|
1275
1282
|
return runBaseCommand(out)
|
|
1276
1283
|
case "judge":
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
reviewBaseFor,
|
|
14
14
|
snapshotFromRest,
|
|
15
15
|
stalledAt,
|
|
16
|
+
summaryFor,
|
|
16
17
|
summaryRun,
|
|
17
18
|
TRUNCATION_NOTICE,
|
|
18
19
|
UNATTRIBUTED_MODEL,
|
|
@@ -278,9 +279,9 @@ export const again = async () => {
|
|
|
278
279
|
)
|
|
279
280
|
const run = await provide(currentRun, repo)
|
|
280
281
|
expect(run.costEntries).toEqual([
|
|
281
|
-
{ cost: 120, model: "opus" },
|
|
282
|
-
{ cost: 50, model: UNATTRIBUTED_MODEL },
|
|
283
|
-
{ cost: 300, model: "haiku" },
|
|
282
|
+
{ cost: 120, model: "opus", hash: run.trace[0]!.hash },
|
|
283
|
+
{ cost: 50, model: UNATTRIBUTED_MODEL, hash: run.trace[1]!.hash },
|
|
284
|
+
{ cost: 300, model: "haiku", hash: run.trace[2]!.hash },
|
|
284
285
|
])
|
|
285
286
|
})
|
|
286
287
|
|
|
@@ -761,7 +762,262 @@ describe("processSlices", () => {
|
|
|
761
762
|
})
|
|
762
763
|
})
|
|
763
764
|
|
|
765
|
+
describe("summaryFor", () => {
|
|
766
|
+
const SLICED = `import { agent, human, slice, mark } from "@pmelab/gtd/flows"
|
|
767
|
+
|
|
768
|
+
export default async () => {
|
|
769
|
+
await human("idle", { message: "idle-message" })
|
|
770
|
+
mark("risk-tier", "high")
|
|
771
|
+
await slice("build", async () => {
|
|
772
|
+
await agent("building", "build-prompt")
|
|
773
|
+
})
|
|
774
|
+
await human("gate", { message: "gate-message" })
|
|
775
|
+
await slice("verify", async () => {
|
|
776
|
+
await agent("checking", "check-prompt")
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export const summary = (it) => JSON.stringify(it)
|
|
781
|
+
`
|
|
782
|
+
const landCost = async (repo: InMemRepo, files: Files, cost: number, model: string) => {
|
|
783
|
+
write(repo, files)
|
|
784
|
+
const { stdout, stderr, exitCode } = await cli(
|
|
785
|
+
repo,
|
|
786
|
+
"land",
|
|
787
|
+
"--json",
|
|
788
|
+
`--cost=${cost}`,
|
|
789
|
+
`--model=${model}`,
|
|
790
|
+
)
|
|
791
|
+
if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
|
|
792
|
+
return applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
|
|
793
|
+
}
|
|
794
|
+
type Rendered = {
|
|
795
|
+
entryCommit: string
|
|
796
|
+
processBase: string
|
|
797
|
+
processTip: string
|
|
798
|
+
humanCommits: { hash: string; state: string }[]
|
|
799
|
+
processCost: number
|
|
800
|
+
processCostByModel: { model: string; cost: number }[]
|
|
801
|
+
marks: Mark[]
|
|
802
|
+
slice?: unknown
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Entry (human, idle→building), build agent, human gate, verify agent closing. */
|
|
806
|
+
const finished = async () => {
|
|
807
|
+
const repo = repoWith(SLICED)
|
|
808
|
+
const boundary = headOf(repo)
|
|
809
|
+
const entry = await landCost(repo, { "NOTE.md": "n\n" }, 1, "opus")
|
|
810
|
+
const built = await landCost(repo, { "a.txt": "a\n" }, 10, "opus")
|
|
811
|
+
const gate = await landCost(repo, { "b.txt": "b\n" }, 100, "haiku")
|
|
812
|
+
const closing = await landCost(repo, { "c.txt": "c\n" }, 1000, "haiku")
|
|
813
|
+
return { repo, boundary, entry, built, gate, closing }
|
|
814
|
+
}
|
|
815
|
+
const render = async (repo: InMemRepo, sliceName?: string) => {
|
|
816
|
+
const run = await provide(summaryRun, repo)
|
|
817
|
+
const { prompt } = await provide(summaryFor(run, sliceName), repo)
|
|
818
|
+
return JSON.parse(prompt!) as Rendered
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
it("resolves each slice with its range, 1-based index and count", async () => {
|
|
822
|
+
const { repo, boundary, built, closing } = await finished()
|
|
823
|
+
expect((await render(repo, "build")).slice).toEqual({
|
|
824
|
+
name: "build",
|
|
825
|
+
base: boundary,
|
|
826
|
+
tip: built,
|
|
827
|
+
index: 1,
|
|
828
|
+
count: 2,
|
|
829
|
+
})
|
|
830
|
+
expect((await render(repo, "verify")).slice).toEqual({
|
|
831
|
+
name: "verify",
|
|
832
|
+
base: built,
|
|
833
|
+
tip: closing,
|
|
834
|
+
index: 2,
|
|
835
|
+
count: 2,
|
|
836
|
+
})
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
it("renders no slice key for the plain call", async () => {
|
|
840
|
+
const { repo } = await finished()
|
|
841
|
+
expect("slice" in (await render(repo))).toBe(false)
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it("narrows range and human commits to the slice's window, base exclusive and tip inclusive", async () => {
|
|
845
|
+
const { repo, boundary, built, gate, closing } = await finished()
|
|
846
|
+
const whole = await render(repo)
|
|
847
|
+
expect(whole.humanCommits.map((c) => c.hash)).toEqual([gate])
|
|
848
|
+
const build = await render(repo, "build")
|
|
849
|
+
const verify = await render(repo, "verify")
|
|
850
|
+
// the gate commit falls in the gap between the slices and folds into the later one
|
|
851
|
+
expect([build.processBase, build.processTip]).toEqual([boundary, built])
|
|
852
|
+
expect([verify.processBase, verify.processTip]).toEqual([built, closing])
|
|
853
|
+
expect(build.humanCommits).toEqual([])
|
|
854
|
+
expect(verify.humanCommits.map((c) => c.hash)).toEqual([gate])
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it("splits cost by slice so the parts sum to the whole, gap commits included", async () => {
|
|
858
|
+
const { repo } = await finished()
|
|
859
|
+
const whole = await render(repo)
|
|
860
|
+
const build = await render(repo, "build")
|
|
861
|
+
const verify = await render(repo, "verify")
|
|
862
|
+
expect(whole.processCost).toBe(1111)
|
|
863
|
+
expect(build.processCost + verify.processCost).toBe(whole.processCost)
|
|
864
|
+
expect(build.processCost).toBe(11)
|
|
865
|
+
expect(verify.processCost).toBe(1100)
|
|
866
|
+
const byModel = new Map<string, number>()
|
|
867
|
+
for (const part of [build, verify])
|
|
868
|
+
for (const m of part.processCostByModel)
|
|
869
|
+
byModel.set(m.model, (byModel.get(m.model) ?? 0) + m.cost)
|
|
870
|
+
expect([...byModel.entries()].sort()).toEqual(
|
|
871
|
+
whole.processCostByModel.map((m) => [m.model, m.cost] as const).sort(),
|
|
872
|
+
)
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
it("keeps entryCommit and marks whole-process under a slice", async () => {
|
|
876
|
+
const { repo, entry } = await finished()
|
|
877
|
+
const whole = await render(repo)
|
|
878
|
+
const verify = await render(repo, "verify")
|
|
879
|
+
expect(verify.entryCommit).toBe(entry)
|
|
880
|
+
expect(verify.entryCommit).toBe(whole.entryCommit)
|
|
881
|
+
expect(verify.marks).toEqual(whole.marks)
|
|
882
|
+
expect(verify.marks.length).toBeGreaterThan(0)
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it("refuses a named slice on an empty trace, while the plain call has nothing to render", async () => {
|
|
886
|
+
const repo = repoWith(SLICED)
|
|
887
|
+
const run = await provide(summaryRun, repo)
|
|
888
|
+
const exit = await provideExit(summaryFor(run, "build"), repo)
|
|
889
|
+
expect(Exit.isFailure(exit) ? String(exit.cause) : "").toContain(
|
|
890
|
+
"gtd summary: refused — HEAD neither closes nor sits inside a process",
|
|
891
|
+
)
|
|
892
|
+
expect(await provide(summaryFor(run), repo)).toEqual({ prompt: undefined })
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
it("refuses config drift at a closing HEAD with the summary prefix", async () => {
|
|
896
|
+
const { repo } = await finished()
|
|
897
|
+
const run = await provide(summaryRun, repo)
|
|
898
|
+
repo.writeFile("gtd.config.ts", `${SLICED}\n// edited\n`)
|
|
899
|
+
const exit = await provideExit(summaryFor(run, "build"), repo)
|
|
900
|
+
const message = Exit.isFailure(exit) ? String(exit.cause) : ""
|
|
901
|
+
expect(message).toContain("gtd summary: refused — config differs at")
|
|
902
|
+
expect(message).toContain("revert or stash")
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
it.each([
|
|
906
|
+
{ recorded: GTD_VERSION, warned: false },
|
|
907
|
+
{ recorded: "0.0.1", warned: true },
|
|
908
|
+
])(
|
|
909
|
+
"refuses a closed process that does not end under today's config (recorded $recorded)",
|
|
910
|
+
async ({ recorded, warned }) => {
|
|
911
|
+
const { repo } = await finished()
|
|
912
|
+
const tip = withTipMessage(repo, (m) =>
|
|
913
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, `Gtd-Version: ${recorded}`),
|
|
914
|
+
)
|
|
915
|
+
const run = await Effect.runPromise(
|
|
916
|
+
summaryRun.pipe(
|
|
917
|
+
Effect.provideService(GitService, tip),
|
|
918
|
+
Effect.provide(testLayers(repo, {})),
|
|
919
|
+
),
|
|
920
|
+
)
|
|
921
|
+
repo.writeFile(
|
|
922
|
+
"gtd.config.ts",
|
|
923
|
+
SLICED.replace(
|
|
924
|
+
'await agent("checking", "check-prompt")',
|
|
925
|
+
'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
|
|
926
|
+
),
|
|
927
|
+
)
|
|
928
|
+
// Every ref reads as the working tree: only replay divergence remains.
|
|
929
|
+
const git: GitOperations = {
|
|
930
|
+
...tip,
|
|
931
|
+
readFileAtRef: (_ref, path) => Effect.succeed(repo.readFile(path) ?? ""),
|
|
932
|
+
}
|
|
933
|
+
const exit = await Effect.runPromiseExit(
|
|
934
|
+
summaryFor(run, "build").pipe(
|
|
935
|
+
Effect.provideService(GitService, git),
|
|
936
|
+
Effect.provide(testLayers(repo, {})),
|
|
937
|
+
),
|
|
938
|
+
)
|
|
939
|
+
const message = Exit.isFailure(exit) ? String(exit.cause) : ""
|
|
940
|
+
expect(message).toContain(
|
|
941
|
+
`gtd summary: refused — ${repo.resolveRef("HEAD")!.slice(0, 7)} closes a process that does not end under today's config`,
|
|
942
|
+
)
|
|
943
|
+
if (warned) expect(message).toContain(`recorded by gtd ${recorded}`)
|
|
944
|
+
else expect(message).not.toContain("recorded by gtd")
|
|
945
|
+
},
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
it("returns no prompt, not a failure, when an edited config no longer exports a summary", async () => {
|
|
949
|
+
const { repo } = await finished()
|
|
950
|
+
const run = await provide(summaryRun, repo)
|
|
951
|
+
repo.writeFile("gtd.config.ts", SLICED.replace(/export const summary.*\n/, ""))
|
|
952
|
+
expect(await provide(summaryFor(run, "build"), repo)).toEqual({ prompt: undefined })
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
it("plain summaryFor still returns a prompt under a config edited since the close, never replaying", async () => {
|
|
956
|
+
const { repo } = await finished()
|
|
957
|
+
const run = await provide(summaryRun, repo)
|
|
958
|
+
repo.writeFile("gtd.config.ts", `${SLICED}\n// edited\n`)
|
|
959
|
+
expect((await provide(summaryFor(run), repo)).prompt).toBeDefined()
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
it("returns a version warning for a closing commit by another gtd version, not a failure", async () => {
|
|
963
|
+
const { repo } = await finished()
|
|
964
|
+
const git = withTipMessage(repo, (m) =>
|
|
965
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
|
|
966
|
+
)
|
|
967
|
+
const result = await Effect.runPromise(
|
|
968
|
+
Effect.flatMap(summaryRun, (run) => summaryFor(run, "build")).pipe(
|
|
969
|
+
Effect.provideService(GitService, git),
|
|
970
|
+
Effect.provide(testLayers(repo, {})),
|
|
971
|
+
),
|
|
972
|
+
)
|
|
973
|
+
expect(result.versionWarning).toContain("0.0.1")
|
|
974
|
+
expect(result.prompt).toBeDefined()
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
it("an unknown slice name returns unknownSlice naming every slice, and keeps the version warning", async () => {
|
|
978
|
+
const { repo } = await finished()
|
|
979
|
+
const git = withTipMessage(repo, (m) =>
|
|
980
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
|
|
981
|
+
)
|
|
982
|
+
const result = await Effect.runPromise(
|
|
983
|
+
Effect.flatMap(summaryRun, (run) => summaryFor(run, "nope")).pipe(
|
|
984
|
+
Effect.provideService(GitService, git),
|
|
985
|
+
Effect.provide(testLayers(repo, {})),
|
|
986
|
+
),
|
|
987
|
+
)
|
|
988
|
+
expect(result.prompt).toBeUndefined()
|
|
989
|
+
expect(result.unknownSlice).toContain('no slice named "nope"')
|
|
990
|
+
expect(result.unknownSlice).toContain("build, verify")
|
|
991
|
+
expect(result.versionWarning).toContain("0.0.1")
|
|
992
|
+
})
|
|
993
|
+
})
|
|
994
|
+
|
|
764
995
|
describe("restAt", () => {
|
|
996
|
+
it("totals recorded cost and breaks it down by model, highest first", async () => {
|
|
997
|
+
const repo = repoWith(LINEAR)
|
|
998
|
+
for (const [file, cost, model] of [
|
|
999
|
+
["a.txt", 1, "opus"],
|
|
1000
|
+
["b.txt", 10, "haiku"],
|
|
1001
|
+
] as const) {
|
|
1002
|
+
write(repo, { [file]: "x\n" })
|
|
1003
|
+
const { stdout, exitCode } = await cli(
|
|
1004
|
+
repo,
|
|
1005
|
+
"land",
|
|
1006
|
+
"--json",
|
|
1007
|
+
`--cost=${cost}`,
|
|
1008
|
+
`--model=${model}`,
|
|
1009
|
+
)
|
|
1010
|
+
expect(exitCode).toBe(0)
|
|
1011
|
+
await applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
|
|
1012
|
+
}
|
|
1013
|
+
const { cost } = await provide(currentRest, repo)
|
|
1014
|
+
expect(cost.total).toBe(11)
|
|
1015
|
+
expect(cost.byModel).toEqual([
|
|
1016
|
+
{ model: "haiku", cost: 10 },
|
|
1017
|
+
{ model: "opus", cost: 1 },
|
|
1018
|
+
])
|
|
1019
|
+
})
|
|
1020
|
+
|
|
765
1021
|
it("restAt(ref) resolves where the process rested at that commit", async () => {
|
|
766
1022
|
const repo = repoWith(LINEAR)
|
|
767
1023
|
const boundary = headOf(repo)
|
package/src/edge/Edge.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
type ReplayOutcome,
|
|
46
46
|
type TreeView,
|
|
47
47
|
} from "../replay/index.js"
|
|
48
|
-
import type { Mark, ScopeAccess } from "../flows/index.js"
|
|
48
|
+
import type { Mark, ScopeAccess, SummaryContext, SummarySlice } from "../flows/index.js"
|
|
49
49
|
import { steeringFormatFor } from "../steering/index.js"
|
|
50
50
|
import {
|
|
51
51
|
judgeJson,
|
|
@@ -126,6 +126,8 @@ const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocati
|
|
|
126
126
|
export interface CostEntry {
|
|
127
127
|
readonly cost: number
|
|
128
128
|
readonly model: string
|
|
129
|
+
/** The process commit whose `Gtd-Cost` trailer recorded it. */
|
|
130
|
+
readonly hash: string
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
export interface JudgeVerdictEntry {
|
|
@@ -203,8 +205,8 @@ const traceEntryOf = (
|
|
|
203
205
|
actor: message.parsed?.actor ?? "",
|
|
204
206
|
})
|
|
205
207
|
|
|
206
|
-
const costEntriesOf = (message: ReturnType<typeof parseCommitMessage>): CostEntry[] =>
|
|
207
|
-
message.cost.map((c) => ({ cost: c.cost, model: c.model ?? UNATTRIBUTED_MODEL }))
|
|
208
|
+
const costEntriesOf = (hash: string, message: ReturnType<typeof parseCommitMessage>): CostEntry[] =>
|
|
209
|
+
message.cost.map((c) => ({ cost: c.cost, model: c.model ?? UNATTRIBUTED_MODEL, hash }))
|
|
208
210
|
|
|
209
211
|
const hashAt = (history: History, index: number): string | undefined => history[index]?.hash
|
|
210
212
|
|
|
@@ -278,7 +280,7 @@ const buildRun = (
|
|
|
278
280
|
startParentHash,
|
|
279
281
|
diffBase: first?.reviewBase ?? startParentHash,
|
|
280
282
|
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
281
|
-
costEntries: parsed.flatMap(costEntriesOf),
|
|
283
|
+
costEntries: parsed.flatMap((m, i) => costEntriesOf(processCommits[i]!.hash, m)),
|
|
282
284
|
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
283
285
|
marks: parsed.flatMap((m) => m.marks),
|
|
284
286
|
approvals: processCommits.flatMap((c, i) => approvalEntryOf(c.hash, parsed[i]!)),
|
|
@@ -904,6 +906,11 @@ const costByModel = (entries: readonly CostEntry[]): ModelCost[] => {
|
|
|
904
906
|
.sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model))
|
|
905
907
|
}
|
|
906
908
|
|
|
909
|
+
const costTotals = (entries: readonly CostEntry[]): Rest["cost"] => ({
|
|
910
|
+
total: entries.reduce((sum, entry) => sum + entry.cost, 0),
|
|
911
|
+
byModel: costByModel(entries),
|
|
912
|
+
})
|
|
913
|
+
|
|
907
914
|
export type RestRequirements = ConfigRequirements
|
|
908
915
|
|
|
909
916
|
const setupFor = (
|
|
@@ -1061,10 +1068,7 @@ export const restAt = (
|
|
|
1061
1068
|
memory: memory.key,
|
|
1062
1069
|
memoryResumed: memory.resumed,
|
|
1063
1070
|
hints: hintsOf(stepDef),
|
|
1064
|
-
cost:
|
|
1065
|
-
total: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
|
|
1066
|
-
byModel: costByModel(run.costEntries),
|
|
1067
|
-
},
|
|
1071
|
+
cost: costTotals(run.costEntries),
|
|
1068
1072
|
setup,
|
|
1069
1073
|
}
|
|
1070
1074
|
})
|
|
@@ -1076,21 +1080,42 @@ export interface ProcessSlices {
|
|
|
1076
1080
|
readonly versionWarning?: string
|
|
1077
1081
|
}
|
|
1078
1082
|
|
|
1083
|
+
type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
|
|
1084
|
+
type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
|
|
1085
|
+
|
|
1086
|
+
/** Every slice refusal reads `<command>: refused — <reason>`; replay's own `gtd: ` lead is dropped. */
|
|
1087
|
+
const slicesDivergence = (command: string, run: ProcessRun, outcome: ReplayOutcome): Error => {
|
|
1088
|
+
const reason =
|
|
1089
|
+
outcome.kind === "rest" && run.closingHash !== undefined
|
|
1090
|
+
? `${run.closingHash.slice(0, 7)} closes a process that does not end under today's config`
|
|
1091
|
+
: (replayError(outcome)?.message.replace(/^gtd: /, "") ?? "replay failed")
|
|
1092
|
+
return new Error(`${command}: refused — ${reason}`)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1079
1095
|
/**
|
|
1080
|
-
* The
|
|
1081
|
-
*
|
|
1082
|
-
*
|
|
1096
|
+
* The replay `gtd slices` and `gtd summary --slice` share: the slice ranges of
|
|
1097
|
+
* `run`, plus whether HEAD closes it. Reads like `historyTrail`, never
|
|
1098
|
+
* `restAt`: it narrates and writes nothing.
|
|
1083
1099
|
*/
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1100
|
+
const replayedSlices = (
|
|
1101
|
+
config: ConfigOperations,
|
|
1102
|
+
run: ProcessRun,
|
|
1103
|
+
how: { readonly command: string; readonly configReason: string },
|
|
1104
|
+
): Effect.Effect<
|
|
1105
|
+
{
|
|
1106
|
+
readonly ranges: ReturnType<typeof sliceRangesOf>
|
|
1107
|
+
readonly closed: boolean
|
|
1108
|
+
readonly versionWarning?: string
|
|
1109
|
+
},
|
|
1110
|
+
Error,
|
|
1111
|
+
RestRequirements
|
|
1112
|
+
> =>
|
|
1113
|
+
Effect.gen(function* () {
|
|
1088
1114
|
const workspace = yield* Workspace
|
|
1089
1115
|
const host = yield* Host
|
|
1090
|
-
const run = yield* runAtHead(git, config.workflow)
|
|
1091
1116
|
if (run.trace.length === 0) {
|
|
1092
1117
|
return yield* Effect.fail(
|
|
1093
|
-
new Error(
|
|
1118
|
+
new Error(`${how.command}: refused — HEAD neither closes nor sits inside a process`),
|
|
1094
1119
|
)
|
|
1095
1120
|
}
|
|
1096
1121
|
const closing = run.closingHash
|
|
@@ -1098,42 +1123,51 @@ export const processSlices: Effect.Effect<ProcessSlices, Error, RestRequirements
|
|
|
1098
1123
|
let versionWarning: string | undefined
|
|
1099
1124
|
if (closing !== undefined) {
|
|
1100
1125
|
const replayed = yield* replayFinishedProcess(config, run, closing, {
|
|
1101
|
-
command:
|
|
1102
|
-
configReason:
|
|
1103
|
-
|
|
1104
|
-
notEnded: (o) => slicesDivergence(run, o),
|
|
1126
|
+
command: how.command,
|
|
1127
|
+
configReason: how.configReason,
|
|
1128
|
+
notEnded: (o) => slicesDivergence(how.command, run, o),
|
|
1105
1129
|
})
|
|
1106
1130
|
outcome = replayed.outcome
|
|
1107
1131
|
versionWarning = replayed.versionWarning
|
|
1108
1132
|
} else {
|
|
1109
1133
|
const { setup } = yield* setupFor(config, run, workspace, host)
|
|
1110
1134
|
outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1111
|
-
if (outcome.kind !== "rest")
|
|
1135
|
+
if (outcome.kind !== "rest")
|
|
1136
|
+
return yield* Effect.fail(slicesDivergence(how.command, run, outcome))
|
|
1112
1137
|
}
|
|
1113
1138
|
return {
|
|
1114
|
-
|
|
1139
|
+
ranges: sliceRangesOf(outcome.slices, run),
|
|
1140
|
+
closed: closing !== undefined,
|
|
1141
|
+
...(versionWarning !== undefined ? { versionWarning } : {}),
|
|
1142
|
+
}
|
|
1143
|
+
})
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* The slices of the process HEAD closes or sits inside, current episode only,
|
|
1147
|
+
* stack order. Returns a version warning for a closed run.
|
|
1148
|
+
*/
|
|
1149
|
+
export const processSlices: Effect.Effect<ProcessSlices, Error, RestRequirements> = Effect.gen(
|
|
1150
|
+
function* () {
|
|
1151
|
+
const git = yield* GitService
|
|
1152
|
+
const config = yield* (yield* ConfigService).load
|
|
1153
|
+
const run = yield* runAtHead(git, config.workflow)
|
|
1154
|
+
const { ranges, closed, versionWarning } = yield* replayedSlices(config, run, {
|
|
1155
|
+
command: "gtd slices",
|
|
1156
|
+
configReason:
|
|
1157
|
+
"HEAD's process replays under today's config; revert or stash the edit to list its slices",
|
|
1158
|
+
})
|
|
1159
|
+
return {
|
|
1160
|
+
slices: ranges.map(({ name, base, tip, open }) => ({
|
|
1115
1161
|
name,
|
|
1116
1162
|
base,
|
|
1117
1163
|
tip,
|
|
1118
|
-
closed:
|
|
1164
|
+
closed: closed || !open,
|
|
1119
1165
|
})),
|
|
1120
1166
|
...(versionWarning !== undefined ? { versionWarning } : {}),
|
|
1121
1167
|
}
|
|
1122
1168
|
},
|
|
1123
1169
|
)
|
|
1124
1170
|
|
|
1125
|
-
type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
|
|
1126
|
-
type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
|
|
1127
|
-
|
|
1128
|
-
/** Every `gtd slices` refusal reads `gtd slices: refused — <reason>`; replay's own `gtd: ` lead is dropped. */
|
|
1129
|
-
const slicesDivergence = (run: ProcessRun, outcome: ReplayOutcome): Error => {
|
|
1130
|
-
const reason =
|
|
1131
|
-
outcome.kind === "rest" && run.closingHash !== undefined
|
|
1132
|
-
? `${run.closingHash.slice(0, 7)} closes a process that does not end under today's config`
|
|
1133
|
-
: (replayError(outcome)?.message.replace(/^gtd: /, "") ?? "replay failed")
|
|
1134
|
-
return new Error(`gtd slices: refused — ${reason}`)
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
1171
|
/**
|
|
1138
1172
|
* Whether `gtd --workflow <name>` can start a process here: the workflow must
|
|
1139
1173
|
* reach a step from the tree the opening commit would capture. That step is
|
|
@@ -1440,29 +1474,107 @@ export const snapshotFromRest = (
|
|
|
1440
1474
|
|
|
1441
1475
|
// ── Summary ─────────────────────────────────────────────────────────────────
|
|
1442
1476
|
|
|
1443
|
-
|
|
1477
|
+
const narrowToSlice = (
|
|
1478
|
+
run: ProcessRun,
|
|
1479
|
+
slice: SummarySlice | undefined,
|
|
1480
|
+
): Pick<ProcessRun, "trace" | "costEntries"> => {
|
|
1481
|
+
if (slice === undefined) return { trace: run.trace, costEntries: run.costEntries }
|
|
1482
|
+
// `base` is exclusive: -1 when it is the episode's start parent, before the first entry.
|
|
1483
|
+
const from =
|
|
1484
|
+
slice.base === run.startParentHash ? -1 : run.trace.findIndex((e) => e.hash === slice.base)
|
|
1485
|
+
const to = run.trace.findIndex((e) => e.hash === slice.tip)
|
|
1486
|
+
const trace = run.trace.slice(from + 1, to + 1)
|
|
1487
|
+
const hashes = new Set(trace.map((e) => e.hash))
|
|
1488
|
+
return { trace, costEntries: run.costEntries.filter((c) => hashes.has(c.hash)) }
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
interface SummaryResult {
|
|
1492
|
+
readonly prompt: string | undefined
|
|
1493
|
+
readonly versionWarning?: string
|
|
1494
|
+
readonly unknownSlice?: string
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const humanCommitsOf = (
|
|
1498
|
+
trace: ProcessRun["trace"],
|
|
1499
|
+
entryCommit: string,
|
|
1500
|
+
): { hash: string; state: StateName }[] =>
|
|
1501
|
+
trace
|
|
1502
|
+
.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit)
|
|
1503
|
+
.map((entry) => ({ hash: entry.hash, state: entry.state }))
|
|
1504
|
+
|
|
1505
|
+
const summaryInput = (
|
|
1506
|
+
run: ProcessRun,
|
|
1507
|
+
slice: SummarySlice | undefined,
|
|
1508
|
+
settings: Pick<SummaryContext, "vars" | "env">,
|
|
1509
|
+
) => {
|
|
1510
|
+
const entryCommit = run.trace[0]!.hash
|
|
1511
|
+
const { trace, costEntries } = narrowToSlice(run, slice)
|
|
1512
|
+
const { total, byModel } = costTotals(costEntries)
|
|
1513
|
+
return {
|
|
1514
|
+
entryCommit,
|
|
1515
|
+
processBase: slice?.base ?? run.startParentHash,
|
|
1516
|
+
processTip: slice?.tip ?? run.trace[run.trace.length - 1]!.hash,
|
|
1517
|
+
humanCommits: humanCommitsOf(trace, entryCommit),
|
|
1518
|
+
processCost: total,
|
|
1519
|
+
processCostByModel: byModel,
|
|
1520
|
+
marks: run.marks,
|
|
1521
|
+
...settings,
|
|
1522
|
+
...(slice !== undefined ? { slice } : {}),
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const locateSlice = (
|
|
1527
|
+
config: ConfigOperations,
|
|
1528
|
+
run: ProcessRun,
|
|
1529
|
+
sliceName: string | undefined,
|
|
1530
|
+
): Effect.Effect<
|
|
1531
|
+
| { readonly slice?: SummarySlice; readonly versionWarning?: string }
|
|
1532
|
+
| { readonly unknownSlice: string; readonly versionWarning?: string },
|
|
1533
|
+
Error,
|
|
1534
|
+
RestRequirements
|
|
1535
|
+
> =>
|
|
1536
|
+
Effect.gen(function* () {
|
|
1537
|
+
if (sliceName === undefined) return {}
|
|
1538
|
+
const replayed = yield* replayedSlices(config, run, {
|
|
1539
|
+
command: "gtd summary",
|
|
1540
|
+
configReason:
|
|
1541
|
+
"HEAD's process replays under today's config; revert or stash the edit to summarize its slices",
|
|
1542
|
+
})
|
|
1543
|
+
const { ranges, versionWarning } = replayed
|
|
1544
|
+
const warn = versionWarning !== undefined ? { versionWarning } : {}
|
|
1545
|
+
const at = ranges.findIndex((r) => r.name === sliceName)
|
|
1546
|
+
if (at < 0) {
|
|
1547
|
+
return {
|
|
1548
|
+
unknownSlice: `gtd summary: refused — no slice named "${sliceName}"; the slices, in stack order: ${ranges.map((r) => r.name).join(", ")}`,
|
|
1549
|
+
...warn,
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
const { base, tip } = ranges[at]!
|
|
1553
|
+
return { slice: { name: sliceName, base, tip, index: at + 1, count: ranges.length }, ...warn }
|
|
1554
|
+
})
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* A plain call never replays; under a slice, `entryCommit` and `marks` stay
|
|
1558
|
+
* whole-process.
|
|
1559
|
+
*/
|
|
1444
1560
|
export const summaryFor = (
|
|
1445
1561
|
run: ProcessRun,
|
|
1446
|
-
|
|
1562
|
+
sliceName?: string,
|
|
1563
|
+
): Effect.Effect<SummaryResult, Error, RestRequirements> =>
|
|
1447
1564
|
Effect.gen(function* () {
|
|
1448
1565
|
const config = yield* (yield* ConfigService).load
|
|
1449
1566
|
const host = yield* Host
|
|
1450
1567
|
const resolved = yield* workflowOf(config, run)
|
|
1451
1568
|
const summary = resolved.def.summary
|
|
1452
|
-
if (summary === undefined
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
|
|
1462
|
-
processCostByModel: costByModel(run.costEntries),
|
|
1463
|
-
marks: run.marks,
|
|
1464
|
-
...settingsFor(resolved, run.pinnedVars, host.env, {}, {}),
|
|
1465
|
-
})
|
|
1569
|
+
if (summary === undefined) return { prompt: undefined }
|
|
1570
|
+
if (sliceName === undefined && run.trace.length === 0) return { prompt: undefined }
|
|
1571
|
+
const located = yield* locateSlice(config, run, sliceName)
|
|
1572
|
+
if ("unknownSlice" in located) return { prompt: undefined, ...located }
|
|
1573
|
+
const { slice, versionWarning } = located
|
|
1574
|
+
const prompt = summary(
|
|
1575
|
+
summaryInput(run, slice, settingsFor(resolved, run.pinnedVars, host.env, {}, {})),
|
|
1576
|
+
)
|
|
1577
|
+
return { prompt, ...(versionWarning !== undefined ? { versionWarning } : {}) }
|
|
1466
1578
|
})
|
|
1467
1579
|
|
|
1468
1580
|
export interface HistoryStep {
|
|
@@ -1601,15 +1713,20 @@ const replayFinishedProcess = (
|
|
|
1601
1713
|
const git = yield* GitService
|
|
1602
1714
|
const workspace = yield* Workspace
|
|
1603
1715
|
const host = yield* Host
|
|
1604
|
-
const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
|
|
1605
|
-
if (differing.length > 0)
|
|
1606
|
-
return yield* Effect.fail(configRefusal(how.command, hash, differing, how.configReason))
|
|
1607
|
-
const { setup } = yield* setupFor(config, run, workspace, host)
|
|
1608
|
-
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1609
|
-
if (outcome.kind !== "ended") return yield* Effect.fail(how.notEnded(outcome))
|
|
1610
1716
|
const recorded = parseCommitMessage(
|
|
1611
1717
|
run.episode.commits.find((c) => c.hash === hash)?.message ?? "",
|
|
1612
1718
|
).version
|
|
1613
1719
|
const versionWarning = versionWarningFor(recorded)
|
|
1720
|
+
// A refusal is often caused by the version change, so it carries the warning.
|
|
1721
|
+
const withWarning = (err: Error): Error =>
|
|
1722
|
+
versionWarning === undefined ? err : new Error(`${err.message}\n${versionWarning}`)
|
|
1723
|
+
const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
|
|
1724
|
+
if (differing.length > 0)
|
|
1725
|
+
return yield* Effect.fail(
|
|
1726
|
+
withWarning(configRefusal(how.command, hash, differing, how.configReason)),
|
|
1727
|
+
)
|
|
1728
|
+
const { setup } = yield* setupFor(config, run, workspace, host)
|
|
1729
|
+
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1730
|
+
if (outcome.kind !== "ended") return yield* Effect.fail(withWarning(how.notEnded(outcome)))
|
|
1614
1731
|
return { outcome, recorded, ...(versionWarning !== undefined ? { versionWarning } : {}) }
|
|
1615
1732
|
})
|