@pmelab/gtd 26.0.0 → 26.1.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 +5 -0
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +134 -18
- package/dist/testkit.mjs +284 -55
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +3 -3
- package/src/cli/History.test.ts +2 -2
- package/src/cli/Program.test.ts +10 -12
- package/src/edge/Edge.test.ts +113 -16
- package/src/edge/Edge.ts +22 -6
- package/src/emit/GitScript.test.ts +65 -2
- package/src/emit/GitScript.ts +34 -1
- package/src/emit/index.ts +1 -0
- package/src/flags/index.ts +0 -1
- package/src/flows/runtime.test.ts +33 -0
- package/src/flows/runtime.ts +31 -0
- package/src/platform/RetainedHistory.test.ts +9 -0
- package/src/platform/RetainedHistory.ts +3 -0
- package/src/platform/Workspace.test.ts +1 -1
- package/src/platform/index.ts +1 -0
- package/src/replay/RefName.test.ts +40 -0
- package/src/replay/RefName.ts +19 -0
- package/src/replay/Replay.test.ts +193 -2
- package/src/replay/Replay.ts +51 -6
- package/src/replay/Trailers.test.ts +26 -0
- package/src/replay/Trailers.ts +8 -0
- package/src/replay/index.ts +1 -0
- package/src/step/LandStep.ts +1 -0
- package/src/step/RepoSnapshot.ts +2 -0
- package/src/step/planStart.test.ts +5 -5
- package/src/step/planStep.test.ts +53 -0
- package/src/step/planStep.ts +7 -1
- package/src/testkit/EmittedScriptRecognizer.test.ts +57 -29
- package/src/testkit/EmittedScriptRecognizer.ts +26 -4
- package/src/testkit/EmittedScripts.test.ts +182 -0
- package/src/testkit/FakeGitOperations.ts +8 -2
- package/src/testkit/GitTiers.ts +103 -24
- package/src/testkit/InMemRepo.ts +57 -12
- package/src/testkit/TestRepo.ts +2 -2
- package/src/workflow/Workflow.ts +1 -0
- package/src/workflow/discovery.test.ts +1 -1
- package/src/workflows/shared/text.fixture.ts +1 -0
|
@@ -9,6 +9,9 @@ export const PROCESS_HISTORY_PREFIX = "refs/gtd/history/"
|
|
|
9
9
|
|
|
10
10
|
export const processHistoryRef = (hash: string): string => `${PROCESS_HISTORY_PREFIX}${hash}`
|
|
11
11
|
|
|
12
|
+
/** Directory of a step's turns set aside by `keep: true`, each at `<this>/<kept hash>`; own namespace, so history list/show/fetch never see it. */
|
|
13
|
+
export const keptRef = (entry: string, step: string): string => `refs/gtd/kept/${entry}/${step}`
|
|
14
|
+
|
|
12
15
|
export const readRetainedHistory = (
|
|
13
16
|
git: GitOperations,
|
|
14
17
|
): Effect.Effect<Option.Option<string>, Error> => git.readRefOption(HISTORY_REF)
|
|
@@ -83,7 +83,7 @@ const makeInMemTier = (): WorkspaceTier => {
|
|
|
83
83
|
writeWorking: (path, content) => repo.writeFile(path, content),
|
|
84
84
|
commit: (path, content) => {
|
|
85
85
|
repo.writeFile(path, content)
|
|
86
|
-
repo.
|
|
86
|
+
repo.seedCommit(`commit ${path}`)
|
|
87
87
|
},
|
|
88
88
|
provide: (eff) =>
|
|
89
89
|
Effect.runPromise(eff.pipe(Effect.provide(Layer.succeed(Workspace, workspaceOps)))),
|
package/src/platform/index.ts
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { refNameFault } from "./RefName.js"
|
|
4
|
+
|
|
5
|
+
// The predicate's owner: it must agree with git, or an invalid keep name only fails at landing.
|
|
6
|
+
describe("refNameFault", () => {
|
|
7
|
+
it.each([
|
|
8
|
+
"a",
|
|
9
|
+
"a.b",
|
|
10
|
+
"build.naive",
|
|
11
|
+
"-a",
|
|
12
|
+
"ünï",
|
|
13
|
+
"a@b",
|
|
14
|
+
"a/b",
|
|
15
|
+
"a.",
|
|
16
|
+
"a//b",
|
|
17
|
+
"/a",
|
|
18
|
+
"a/",
|
|
19
|
+
"@",
|
|
20
|
+
"a@{b",
|
|
21
|
+
"a..b",
|
|
22
|
+
"a b",
|
|
23
|
+
"a~b",
|
|
24
|
+
"a^b",
|
|
25
|
+
"a:b",
|
|
26
|
+
"a?b",
|
|
27
|
+
"a*b",
|
|
28
|
+
"a[b",
|
|
29
|
+
"a\\b",
|
|
30
|
+
"a\tb",
|
|
31
|
+
".a",
|
|
32
|
+
"a.lock",
|
|
33
|
+
"a/.b",
|
|
34
|
+
"a.lock/b",
|
|
35
|
+
"",
|
|
36
|
+
])("agrees with git check-ref-format for %j", (name) => {
|
|
37
|
+
const git = spawnSync("git", ["check-ref-format", `refs/gtd/kept/e/${name}`])
|
|
38
|
+
expect(refNameFault(name) === undefined).toBe(git.status === 0)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A kept ref is `refs/gtd/kept/<entry>/<step>`: the step's full name must survive `git check-ref-format`.
|
|
2
|
+
|
|
3
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
4
|
+
const FORBIDDEN = /[\u0000- \u007f~^:?*[\\]/
|
|
5
|
+
|
|
6
|
+
/** Why `name` cannot be (part of) a git ref name, or `undefined` when it can. */
|
|
7
|
+
export const refNameFault = (name: string): string | undefined => {
|
|
8
|
+
if (name === "") return "it is empty"
|
|
9
|
+
if (FORBIDDEN.test(name)) return "it contains a space, control character or one of ~^:?*[\\"
|
|
10
|
+
if (name.includes("..")) return 'it contains ".."'
|
|
11
|
+
if (name.includes("@{")) return 'it contains "@{"'
|
|
12
|
+
if (name.endsWith(".")) return 'it ends with "."'
|
|
13
|
+
for (const part of name.split("/")) {
|
|
14
|
+
if (part === "") return 'it has an empty "/" component'
|
|
15
|
+
if (part.startsWith(".")) return 'a component starts with "."'
|
|
16
|
+
if (part.endsWith(".lock")) return 'a component ends with ".lock"'
|
|
17
|
+
}
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest"
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest"
|
|
2
2
|
import fc from "fast-check"
|
|
3
3
|
import {
|
|
4
4
|
FLOWS_PROTOCOL,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
codeThreads,
|
|
9
9
|
human,
|
|
10
10
|
judge,
|
|
11
|
+
kept,
|
|
11
12
|
read,
|
|
12
13
|
refuse,
|
|
13
14
|
restart,
|
|
@@ -32,6 +33,7 @@ interface Commit {
|
|
|
32
33
|
readonly hash: string
|
|
33
34
|
readonly message: string
|
|
34
35
|
readonly tree: TreeView
|
|
36
|
+
readonly kept?: { readonly hash: string; readonly tree: TreeView }
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
const hashOf = (n: number): string => n.toString(16).padStart(40, "0")
|
|
@@ -64,12 +66,19 @@ class History {
|
|
|
64
66
|
return this
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
raw(
|
|
69
|
+
raw(
|
|
70
|
+
message: string,
|
|
71
|
+
edit: Record<string, string | null> = {},
|
|
72
|
+
keptTip?: { hash: string; files: Record<string, string> },
|
|
73
|
+
): this {
|
|
68
74
|
this.apply(edit)
|
|
69
75
|
this.commits.push({
|
|
70
76
|
hash: hashOf(this.counter++),
|
|
71
77
|
message,
|
|
72
78
|
tree: treeFromRecord({ ...this.files }),
|
|
79
|
+
...(keptTip !== undefined
|
|
80
|
+
? { kept: { hash: keptTip.hash, tree: treeFromRecord(keptTip.files) } }
|
|
81
|
+
: {}),
|
|
73
82
|
})
|
|
74
83
|
return this
|
|
75
84
|
}
|
|
@@ -600,6 +609,188 @@ describe("replay", () => {
|
|
|
600
609
|
})
|
|
601
610
|
})
|
|
602
611
|
|
|
612
|
+
describe("replay: keep", () => {
|
|
613
|
+
let seen: { read?: string | undefined; changes?: number }
|
|
614
|
+
beforeEach(() => {
|
|
615
|
+
seen = {}
|
|
616
|
+
})
|
|
617
|
+
const keepFlow =
|
|
618
|
+
(name = "naive"): Flow =>
|
|
619
|
+
async () => {
|
|
620
|
+
await human("idle")
|
|
621
|
+
await agent(name, "p", { keep: true })
|
|
622
|
+
seen.read = read("naive.txt")
|
|
623
|
+
seen.changes = changes().length
|
|
624
|
+
await human("after")
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
it("accepts keep as an agent option", async () => {
|
|
628
|
+
const h = new History().land("human", "idle", 1, "naive", {})
|
|
629
|
+
const outcome = await replayOf(keepFlow(), h)
|
|
630
|
+
expect(outcome.kind).toBe("rest")
|
|
631
|
+
expect(restName(outcome)).toBe("naive#1")
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it("a pending turn completes the keep step but replay stays on the pre-turn tree", async () => {
|
|
635
|
+
const h = new History().land("human", "idle", 1, "naive", {})
|
|
636
|
+
const outcome = await replayOf(keepFlow(), h, { pending: { "naive.txt": "naive\n" } })
|
|
637
|
+
expect(restName(outcome)).toBe("after#1")
|
|
638
|
+
expect(outcome.kind === "rest" && outcome.landed?.name).toBe("naive")
|
|
639
|
+
expect(seen.read).toBeUndefined()
|
|
640
|
+
expect(seen.changes).toBe(0)
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
it("a marker commit answers its step and the next step is reached", async () => {
|
|
644
|
+
const marker = formatCommitMessage({
|
|
645
|
+
actor: "agent",
|
|
646
|
+
from: "naive",
|
|
647
|
+
to: "after",
|
|
648
|
+
step: { name: "naive", occurrence: 1 },
|
|
649
|
+
})
|
|
650
|
+
const h = new History()
|
|
651
|
+
.land("human", "idle", 1, "naive", {})
|
|
652
|
+
.raw(`${marker}\nGtd-Kept: ${hashOf(77)}`)
|
|
653
|
+
const outcome = await replayOf(keepFlow(), h)
|
|
654
|
+
expect(restName(outcome)).toBe("after#1")
|
|
655
|
+
expect(seen.read).toBeUndefined()
|
|
656
|
+
expect(seen.changes).toBe(0)
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it("rejects keep together with allowEmpty", async () => {
|
|
660
|
+
const flow: Flow = async () => {
|
|
661
|
+
await human("idle")
|
|
662
|
+
await agent("naive", "p", { keep: true, allowEmpty: true })
|
|
663
|
+
}
|
|
664
|
+
const h = new History().land("human", "idle", 1, "naive", {})
|
|
665
|
+
const outcome = await replayOf(flow, h)
|
|
666
|
+
expect(outcome.kind === "failed" && outcome.message).toContain("allowEmpty")
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it.each(["a b", "a..b", "a~b", "a.lock", "a:b", "a/b"])(
|
|
670
|
+
"fails naming the step when %j is not a valid ref component",
|
|
671
|
+
async (name) => {
|
|
672
|
+
const h = new History().land("human", "idle", 1, name, {})
|
|
673
|
+
const outcome = await replayOf(keepFlow(name), h)
|
|
674
|
+
expect(outcome.kind).toBe("failed")
|
|
675
|
+
expect(outcome.kind === "failed" && outcome.message).toContain(`step "${name}"`)
|
|
676
|
+
},
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
it("does not check the name of a step that does not keep", async () => {
|
|
680
|
+
const flow: Flow = async () => {
|
|
681
|
+
await human("idle")
|
|
682
|
+
await agent("a b", "p")
|
|
683
|
+
}
|
|
684
|
+
const h = new History().land("human", "idle", 1, "a b", {})
|
|
685
|
+
expect(restName(await replayOf(flow, h))).toBe("a b#1")
|
|
686
|
+
})
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
describe("replay: kept()", () => {
|
|
690
|
+
const markerFor = (name: string, to: string, occurrence = 1, tip = hashOf(77)): string =>
|
|
691
|
+
`${formatCommitMessage({
|
|
692
|
+
actor: "agent",
|
|
693
|
+
from: name,
|
|
694
|
+
to,
|
|
695
|
+
step: { name, occurrence },
|
|
696
|
+
})}\nGtd-Kept: ${tip}`
|
|
697
|
+
|
|
698
|
+
type Tip = ReturnType<typeof kept>
|
|
699
|
+
let seen: { before?: Tip; after?: Tip }
|
|
700
|
+
beforeEach(() => {
|
|
701
|
+
seen = {}
|
|
702
|
+
})
|
|
703
|
+
const flow =
|
|
704
|
+
(name = "naive"): Flow =>
|
|
705
|
+
async () => {
|
|
706
|
+
await human("idle")
|
|
707
|
+
seen.before = kept(name)
|
|
708
|
+
await agent("naive", "p", { keep: true })
|
|
709
|
+
seen.after = kept(name)
|
|
710
|
+
await human("after")
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
it("returns the newest consumed marker's hash and its diff against the marker's tree", async () => {
|
|
714
|
+
const h = new History()
|
|
715
|
+
.land("human", "idle", 1, "naive", { "base.txt": "b\n" })
|
|
716
|
+
.raw(
|
|
717
|
+
markerFor("naive", "after"),
|
|
718
|
+
{},
|
|
719
|
+
{ hash: hashOf(77), files: { "base.txt": "b\n", "naive.ts": "export const n = 1\n" } },
|
|
720
|
+
)
|
|
721
|
+
const outcome = await replayOf(flow(), h)
|
|
722
|
+
expect(restName(outcome)).toBe("after#1")
|
|
723
|
+
expect(seen.after?.hash).toBe(hashOf(77))
|
|
724
|
+
expect(seen.after?.changes.map((c) => [c.path, c.status, c.after])).toEqual([
|
|
725
|
+
["naive.ts", "added", "export const n = 1\n"],
|
|
726
|
+
])
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
it("the newest of two passes wins", async () => {
|
|
730
|
+
const h = new History()
|
|
731
|
+
.land("human", "idle", 1, "naive", {})
|
|
732
|
+
.raw(markerFor("naive", "naive", 1, hashOf(70)), {}, { hash: hashOf(70), files: { a: "1" } })
|
|
733
|
+
.raw(markerFor("naive", "after", 2, hashOf(71)), {}, { hash: hashOf(71), files: { a: "2" } })
|
|
734
|
+
const twice: Flow = async () => {
|
|
735
|
+
await human("idle")
|
|
736
|
+
await agent("naive", "p", { keep: true })
|
|
737
|
+
await agent("naive", "p", { keep: true })
|
|
738
|
+
seen.after = kept("naive")
|
|
739
|
+
await human("after")
|
|
740
|
+
}
|
|
741
|
+
await replayOf(twice, h)
|
|
742
|
+
expect(seen.after?.hash).toBe(hashOf(71))
|
|
743
|
+
expect(seen.after?.changes.get("a")?.after).toBe("2")
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
it("is undefined for a step that never kept", async () => {
|
|
747
|
+
const h = new History().land("human", "idle", 1, "naive", {}).land("agent", "naive", 1, "after")
|
|
748
|
+
expect(restName(await replayOf(flow(), h))).toBe("after#1")
|
|
749
|
+
expect(seen.after).toBeUndefined()
|
|
750
|
+
})
|
|
751
|
+
|
|
752
|
+
it("is undefined when the marker's kept object is gone", async () => {
|
|
753
|
+
const h = new History().land("human", "idle", 1, "naive", {}).raw(markerFor("naive", "after"))
|
|
754
|
+
expect(restName(await replayOf(flow(), h))).toBe("after#1")
|
|
755
|
+
expect(seen.after).toBeUndefined()
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it("resolves the name through scope() like agent()", async () => {
|
|
759
|
+
const scoped: Flow = async () => {
|
|
760
|
+
await human("idle")
|
|
761
|
+
await scope("build", async () => {
|
|
762
|
+
await agent("naive", "p", { keep: true })
|
|
763
|
+
seen.after = kept("naive")
|
|
764
|
+
})
|
|
765
|
+
seen.before = kept("naive")
|
|
766
|
+
await human("after")
|
|
767
|
+
}
|
|
768
|
+
const h = new History()
|
|
769
|
+
.land("human", "idle", 1, "build.naive", {})
|
|
770
|
+
.raw(markerFor("build.naive", "after"), {}, { hash: hashOf(77), files: { "x.ts": "x" } })
|
|
771
|
+
await replayOf(scoped, h)
|
|
772
|
+
expect(seen.after?.hash).toBe(hashOf(77))
|
|
773
|
+
expect(seen.before).toBeUndefined()
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
it('shows the pending tip with hash "" during the landing decision', async () => {
|
|
777
|
+
const h = new History().land("human", "idle", 1, "naive", {})
|
|
778
|
+
const outcome = await replayOf(flow(), h, { pending: { "naive.ts": "n\n" } })
|
|
779
|
+
expect(restName(outcome)).toBe("after#1")
|
|
780
|
+
expect(seen.after?.hash).toBe("")
|
|
781
|
+
expect(seen.after?.changes.map((c) => [c.path, c.status])).toEqual([["naive.ts", "added"]])
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it("does not show a marker the flow has not consumed yet", async () => {
|
|
785
|
+
const h = new History()
|
|
786
|
+
.land("human", "idle", 1, "naive", {})
|
|
787
|
+
.raw(markerFor("naive", "after"), {}, { hash: hashOf(77), files: { "x.ts": "x" } })
|
|
788
|
+
await replayOf(flow(), h)
|
|
789
|
+
expect(seen.before).toBeUndefined()
|
|
790
|
+
expect(seen.after?.hash).toBe(hashOf(77))
|
|
791
|
+
})
|
|
792
|
+
})
|
|
793
|
+
|
|
603
794
|
describe("replay: skills resolution", () => {
|
|
604
795
|
interface Tiers {
|
|
605
796
|
skills?: Record<string, readonly string[]>
|
package/src/replay/Replay.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../steering/index.js"
|
|
22
22
|
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
23
23
|
import { globMatches } from "../wire/index.js"
|
|
24
|
+
import { refNameFault } from "./RefName.js"
|
|
24
25
|
import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
|
|
25
26
|
import {
|
|
26
27
|
formatStepId,
|
|
@@ -34,6 +35,8 @@ export interface EpisodeCommit {
|
|
|
34
35
|
readonly hash: string
|
|
35
36
|
readonly message: string
|
|
36
37
|
readonly tree: TreeView
|
|
38
|
+
/** Present when the commit carries Gtd-Kept and that object still exists. */
|
|
39
|
+
readonly kept?: { readonly hash: string; readonly tree: TreeView }
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/**
|
|
@@ -232,7 +235,7 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
|
|
|
232
235
|
// The option keys each step accepts. A gtd.config.ts is evaluated without a
|
|
233
236
|
// type check, so a misspelt key would otherwise be silently ignored.
|
|
234
237
|
const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
|
|
235
|
-
agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty"]),
|
|
238
|
+
agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
|
|
236
239
|
human: new Set([...STEERING_OPTIONS, "message", "acceptClean"]),
|
|
237
240
|
run: new Set(STEERING_OPTIONS),
|
|
238
241
|
judge: new Set([...STEERING_OPTIONS, "message"]),
|
|
@@ -275,6 +278,11 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
275
278
|
const scopeCallIds: number[] = []
|
|
276
279
|
let scopeCallCount = 0
|
|
277
280
|
const trace: ReachedStep[] = []
|
|
281
|
+
// Newest consumed keep per scoped step name; `before` is the tree the kept turn diverged from.
|
|
282
|
+
const keptTips = new Map<
|
|
283
|
+
string,
|
|
284
|
+
{ readonly hash: string; readonly before: TreeView; readonly after: TreeView }
|
|
285
|
+
>()
|
|
278
286
|
let landed: ReachedStep | undefined
|
|
279
287
|
let outcome: ReplayOutcome | undefined
|
|
280
288
|
let signal: (() => void) | undefined
|
|
@@ -494,11 +502,32 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
494
502
|
}
|
|
495
503
|
cursor++
|
|
496
504
|
advance(commit)
|
|
505
|
+
if (commit.parsed.kept !== undefined) {
|
|
506
|
+
if (commit.kept === undefined) keptTips.delete(step.name)
|
|
507
|
+
else
|
|
508
|
+
keptTips.set(step.name, {
|
|
509
|
+
hash: commit.kept.hash,
|
|
510
|
+
before: commit.tree,
|
|
511
|
+
after: commit.kept.tree,
|
|
512
|
+
})
|
|
513
|
+
}
|
|
497
514
|
const to = commit.parsed.parsed?.to
|
|
498
515
|
if (to !== undefined) expectedNext = { name: to, hash: commit.hash }
|
|
499
516
|
return replyFor(step.request, commit.parsed.judge)
|
|
500
517
|
}
|
|
501
518
|
|
|
519
|
+
const landPending = (step: ReachedStep, pending: NonNullable<typeof input.pending>): unknown => {
|
|
520
|
+
pendingUsed = true
|
|
521
|
+
landed = step
|
|
522
|
+
// A kept turn is set aside: replay stays on the pre-turn tree, as it will once the marker lands.
|
|
523
|
+
const keeping = step.request.kind === "agent" && step.request.options.keep === true
|
|
524
|
+
if (keeping) {
|
|
525
|
+
keptTips.set(step.name, { hash: "", before: position.tree, after: pending.tree })
|
|
526
|
+
}
|
|
527
|
+
advance({ hash: "", tree: keeping ? position.tree : pending.tree })
|
|
528
|
+
return replyFor(step.request, pending.verdicts ?? [])
|
|
529
|
+
}
|
|
530
|
+
|
|
502
531
|
const answer = (step: ReachedStep): unknown => {
|
|
503
532
|
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) cursor++
|
|
504
533
|
const commit = commits[cursor]
|
|
@@ -506,10 +535,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
506
535
|
if (input.pending === undefined || pendingUsed) {
|
|
507
536
|
return finish({ kind: "rest", rest: step, trace, landed })
|
|
508
537
|
}
|
|
509
|
-
|
|
510
|
-
landed = step
|
|
511
|
-
advance({ hash: "", tree: input.pending.tree })
|
|
512
|
-
return replyFor(step.request, input.pending.verdicts ?? [])
|
|
538
|
+
return landPending(step, input.pending)
|
|
513
539
|
}
|
|
514
540
|
|
|
515
541
|
// gtd.config.ts is loaded without a type check.
|
|
@@ -517,7 +543,20 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
517
543
|
if (request.kind === "run" && typeof request.body !== "string") {
|
|
518
544
|
return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
|
|
519
545
|
}
|
|
520
|
-
|
|
546
|
+
if (request.kind !== "agent") return undefined
|
|
547
|
+
const name = scoped(request.name)
|
|
548
|
+
if (request.options.keep === true) {
|
|
549
|
+
// A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
|
|
550
|
+
if (request.options.allowEmpty === true) {
|
|
551
|
+
return `gtd: step "${name}": keep: true cannot be combined with allowEmpty — a turn that changes nothing has nothing to keep`
|
|
552
|
+
}
|
|
553
|
+
// "/" would let one step's ref directory collide with another step's ref (git refuses `a` and `a/b` together).
|
|
554
|
+
const refFault = name.includes("/") ? 'it contains "/"' : refNameFault(name)
|
|
555
|
+
if (refFault !== undefined) {
|
|
556
|
+
return `gtd: step "${name}": keep: true files the turn under a git ref named after the step, but ${refFault}`
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return scopeAccessFault()
|
|
521
560
|
}
|
|
522
561
|
|
|
523
562
|
const handleStep = async (request: StepRequest): Promise<unknown> => {
|
|
@@ -611,6 +650,12 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
611
650
|
.map((f) => f.message),
|
|
612
651
|
}))
|
|
613
652
|
}),
|
|
653
|
+
kept: (name) => {
|
|
654
|
+
const tip = keptTips.get(scoped(name))
|
|
655
|
+
return tip === undefined
|
|
656
|
+
? undefined
|
|
657
|
+
: { hash: tip.hash, changes: changesBetween(tip.before, tip.after) }
|
|
658
|
+
},
|
|
614
659
|
vars: input.vars,
|
|
615
660
|
env: input.env,
|
|
616
661
|
start: () => input.start,
|
|
@@ -147,3 +147,29 @@ describe("the commit-message codec", () => {
|
|
|
147
147
|
expect(parseSubject("gtd(agent): fix")).toEqual({ actor: "agent", to: "fix" })
|
|
148
148
|
})
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
describe("Gtd-Kept", () => {
|
|
152
|
+
const hash = "0123456789abcdef0123456789abcdef01234567"
|
|
153
|
+
|
|
154
|
+
it("parses a 40-hex hash", () => {
|
|
155
|
+
expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Step: a#1\nGtd-Kept: ${hash}`).kept).toBe(
|
|
156
|
+
hash,
|
|
157
|
+
)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("parses a 64-hex (sha256) hash", () => {
|
|
161
|
+
const sha256 = hash.repeat(2).slice(0, 64)
|
|
162
|
+
expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${sha256}`).kept).toBe(sha256)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("skips a non-hash value", () => {
|
|
166
|
+
expect(parseCommitMessage("gtd(agent): a → b\n\nGtd-Kept: not-a-hash").kept).toBeUndefined()
|
|
167
|
+
expect(parseCommitMessage(`gtd(agent): a → b\n\nGtd-Kept: ${hash}0`).kept).toBeUndefined()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("reads undefined when absent, and leaves Gtd-Format at 1", () => {
|
|
171
|
+
const parsed = parseCommitMessage(formatCommitMessage({ actor: "agent", to: "b" }))
|
|
172
|
+
expect(parsed.kept).toBeUndefined()
|
|
173
|
+
expect(parsed.format).toBe(1)
|
|
174
|
+
})
|
|
175
|
+
})
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -75,6 +75,8 @@ export interface CommitMessage {
|
|
|
75
75
|
/** Written only on a process's finishing landing. */
|
|
76
76
|
readonly branch: string | undefined
|
|
77
77
|
readonly version: string | undefined
|
|
78
|
+
/** The off-branch tip a keep step set aside; a non-hash value is skipped. */
|
|
79
|
+
readonly kept: string | undefined
|
|
78
80
|
readonly format: number
|
|
79
81
|
}
|
|
80
82
|
|
|
@@ -114,6 +116,7 @@ const decodePath = (raw: string): string | undefined => {
|
|
|
114
116
|
return typeof path === "string" && path !== "" && !CONTROL_CHARACTER.test(path) ? path : undefined
|
|
115
117
|
}
|
|
116
118
|
const FORMAT_RE = /^[1-9][0-9]*$/
|
|
119
|
+
const HASH_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
117
120
|
|
|
118
121
|
interface Trailers {
|
|
119
122
|
step: StepId | undefined
|
|
@@ -121,6 +124,7 @@ interface Trailers {
|
|
|
121
124
|
reviewBase: string | undefined
|
|
122
125
|
branch: string | undefined
|
|
123
126
|
version: string | undefined
|
|
127
|
+
kept: string | undefined
|
|
124
128
|
readonly judge: JudgeVerdict[]
|
|
125
129
|
readonly vars: Record<string, string>
|
|
126
130
|
readonly config: Record<string, string>
|
|
@@ -157,6 +161,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
157
161
|
"Gtd-Version": (value, into) => {
|
|
158
162
|
if (value !== "") into.version ??= value
|
|
159
163
|
},
|
|
164
|
+
"Gtd-Kept": (value, into) => {
|
|
165
|
+
if (HASH_RE.test(value)) into.kept ??= value
|
|
166
|
+
},
|
|
160
167
|
"Gtd-Format": (value, into) => {
|
|
161
168
|
if (FORMAT_RE.test(value)) into.format ??= Number(value)
|
|
162
169
|
},
|
|
@@ -175,6 +182,7 @@ export const parseCommitMessage = (message: string): CommitMessage => {
|
|
|
175
182
|
reviewBase: undefined,
|
|
176
183
|
branch: undefined,
|
|
177
184
|
version: undefined,
|
|
185
|
+
kept: undefined,
|
|
178
186
|
judge: [],
|
|
179
187
|
vars: {},
|
|
180
188
|
config: {},
|
package/src/replay/index.ts
CHANGED
package/src/step/LandStep.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { StateName } from "../workflow/index.js"
|
|
|
9
9
|
export type GitWrite =
|
|
10
10
|
| { readonly kind: "commitAll"; readonly message: string }
|
|
11
11
|
| { readonly kind: "updateRef"; readonly ref: string; readonly target: "HEAD" }
|
|
12
|
+
| { readonly kind: "keepTurn"; readonly ref: string; readonly message: string }
|
|
12
13
|
|
|
13
14
|
/** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
|
|
14
15
|
export type Outcome =
|
package/src/step/RepoSnapshot.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type Landing =
|
|
|
17
17
|
readonly spec: CommitSpec
|
|
18
18
|
/** The full history ref a finishing landing points at its own commit. */
|
|
19
19
|
readonly keepHistory?: string
|
|
20
|
+
/** The kept ref this landing's turn goes to; the branch gets a marker commit instead. */
|
|
21
|
+
readonly keepTurn?: string
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -27,7 +27,7 @@ export const base = (workflow, vars) => (workflow === "reviewcheck" ? (vars.base
|
|
|
27
27
|
const repoAt = (): InMemRepo => {
|
|
28
28
|
const repo = new InMemRepo()
|
|
29
29
|
repo.writeFile("gtd.config.ts", WORKFLOW)
|
|
30
|
-
repo.
|
|
30
|
+
repo.seedCommit("chore: add custom workflow")
|
|
31
31
|
return repo
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -104,7 +104,7 @@ describe("planStart", () => {
|
|
|
104
104
|
const fork = repo.resolveRef("HEAD")!
|
|
105
105
|
repo.updateRef("main", fork)
|
|
106
106
|
repo.writeFile("a.txt", "a")
|
|
107
|
-
repo.
|
|
107
|
+
repo.seedCommit("feat: ahead of main")
|
|
108
108
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
|
|
109
109
|
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
110
110
|
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
@@ -117,11 +117,11 @@ describe("planStart", () => {
|
|
|
117
117
|
const repo = repoAt()
|
|
118
118
|
const fork = repo.resolveRef("HEAD")!
|
|
119
119
|
repo.writeFile("other.txt", "o")
|
|
120
|
-
repo.
|
|
120
|
+
repo.seedCommit("feat: other branch")
|
|
121
121
|
repo.updateRef("other", repo.resolveRef("HEAD")!)
|
|
122
122
|
repo.hardResetTo(fork)
|
|
123
123
|
repo.writeFile("mine.txt", "m")
|
|
124
|
-
repo.
|
|
124
|
+
repo.seedCommit("feat: my branch")
|
|
125
125
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
|
|
126
126
|
if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
|
|
127
127
|
const write = plan.steps.find((s) => s.kind === "gitWrite")
|
|
@@ -143,7 +143,7 @@ describe("planStart", () => {
|
|
|
143
143
|
repo.updateRef("old-root", repo.resolveRef("HEAD")!)
|
|
144
144
|
repo.mixedResetTo("4b825dc642cb6eb9a060e54bf8d69288fbee4904")
|
|
145
145
|
repo.writeFile("gtd.config.ts", WORKFLOW)
|
|
146
|
-
repo.
|
|
146
|
+
repo.seedCommit("chore: a second root")
|
|
147
147
|
const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "old-root" } })
|
|
148
148
|
expect(plan).toEqual({
|
|
149
149
|
kind: "refusal",
|
|
@@ -130,3 +130,56 @@ describe("planStep — keepHistory", () => {
|
|
|
130
130
|
).toBe(false)
|
|
131
131
|
})
|
|
132
132
|
})
|
|
133
|
+
|
|
134
|
+
describe("planStep — keepTurn", () => {
|
|
135
|
+
const kept = "refs/gtd/kept/abc/building"
|
|
136
|
+
|
|
137
|
+
it("emits keepTurn in place of commitAll, carrying the step's own commit message", () => {
|
|
138
|
+
const s = snapshot({
|
|
139
|
+
state: "building",
|
|
140
|
+
stepDef: { actor: "agent", kind: "prompt" },
|
|
141
|
+
actor: "agent",
|
|
142
|
+
changes: [{ status: "A", path: "naive.txt" }],
|
|
143
|
+
landing: { ...commitTo("building", "checking", "agent"), keepTurn: kept },
|
|
144
|
+
})
|
|
145
|
+
const outcome = planStep(s)
|
|
146
|
+
if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
|
|
147
|
+
expect(outcome.steps.map((step) => step.kind)).toEqual(["gitWrite", "outcome"])
|
|
148
|
+
expect(outcome.steps[0]).toEqual({
|
|
149
|
+
kind: "gitWrite",
|
|
150
|
+
write: {
|
|
151
|
+
kind: "keepTurn",
|
|
152
|
+
ref: kept,
|
|
153
|
+
message: "gtd(agent): building → checking\n\nGtd-Step: building#1\nGtd-Format: 1",
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("a finishing keep landing points the history ref after the marker", () => {
|
|
159
|
+
const s = snapshot({
|
|
160
|
+
state: "building",
|
|
161
|
+
stepDef: { actor: "agent", kind: "prompt" },
|
|
162
|
+
actor: "agent",
|
|
163
|
+
landing: {
|
|
164
|
+
...commitTo("building", "idle", "agent"),
|
|
165
|
+
keepTurn: kept,
|
|
166
|
+
keepHistory: "refs/gtd/history/abc",
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
const outcome = planStep(s)
|
|
170
|
+
if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
|
|
171
|
+
expect(outcome.steps.map((step) => step.kind)).toEqual(["gitWrite", "gitWrite", "outcome"])
|
|
172
|
+
expect(outcome.steps[0]).toMatchObject({ write: { kind: "keepTurn" } })
|
|
173
|
+
expect(outcome.steps[1]).toEqual({
|
|
174
|
+
kind: "gitWrite",
|
|
175
|
+
write: { kind: "updateRef", ref: "refs/gtd/history/abc", target: "HEAD" },
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("without keepTurn the landing stays a commitAll", () => {
|
|
180
|
+
const s = snapshot({ state: "building", stepDef: {}, landing: commitTo("building", "done") })
|
|
181
|
+
const outcome = planStep(s)
|
|
182
|
+
if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
|
|
183
|
+
expect(outcome.steps[0]).toMatchObject({ write: { kind: "commitAll" } })
|
|
184
|
+
})
|
|
185
|
+
})
|
package/src/step/planStep.ts
CHANGED
|
@@ -82,7 +82,13 @@ export const planStep = (
|
|
|
82
82
|
subject,
|
|
83
83
|
steps: [
|
|
84
84
|
...uncheckStep(snapshot),
|
|
85
|
-
{
|
|
85
|
+
{
|
|
86
|
+
kind: "gitWrite",
|
|
87
|
+
write:
|
|
88
|
+
landing.keepTurn !== undefined
|
|
89
|
+
? { kind: "keepTurn", ref: landing.keepTurn, message }
|
|
90
|
+
: { kind: "commitAll", message },
|
|
91
|
+
},
|
|
86
92
|
...(landing.keepHistory !== undefined
|
|
87
93
|
? [
|
|
88
94
|
{
|