@pmelab/gtd 25.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 +7 -0
- package/dist/flows.mjs +15 -1
- package/dist/gtd.bundle.mjs +161 -20
- package/dist/testkit.mjs +311 -57
- package/package.json +2 -1
- package/src/cli/Cli.test.ts +28 -3
- package/src/cli/Cli.ts +12 -1
- 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.test.ts +67 -0
- package/src/flags/index.ts +38 -0
- 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/workflow/vars.test.ts +16 -0
- package/src/workflow/vars.ts +4 -1
- package/src/workflows/shared/text.fixture.ts +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { FLAGS, resolveFlags, type FlagDeclaration, type Flags } from "./index.js"
|
|
3
|
+
|
|
4
|
+
const registry = [
|
|
5
|
+
{ name: "alpha", owner: "pmelab", expiry: "2020-01-01", kind: "release" },
|
|
6
|
+
{ name: "new_review", owner: "pmelab", expiry: "2099-01-01", kind: "ops" },
|
|
7
|
+
] as const satisfies readonly FlagDeclaration[]
|
|
8
|
+
|
|
9
|
+
describe("resolveFlags", () => {
|
|
10
|
+
it.each(["1", "true", "TRUE", "True"])("%s turns a flag on", (value) => {
|
|
11
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: value }, registry).alpha).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it.each(["0", "false", "FALSE"])("%s turns a flag off", (value) => {
|
|
15
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: value }, registry).alpha).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("reads unset and empty as off, with every declared flag a key", () => {
|
|
19
|
+
expect(resolveFlags({ GTD_FLAG_NEW_REVIEW: "" }, registry)).toEqual({
|
|
20
|
+
alpha: false,
|
|
21
|
+
new_review: false,
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it.each(["ture", "constructor", "__proto__", "toString"])(
|
|
26
|
+
"throws naming the variable, not the value %s",
|
|
27
|
+
(value) => {
|
|
28
|
+
expect(() => resolveFlags({ GTD_FLAG_NEW_REVIEW: value }, registry)).toThrow(
|
|
29
|
+
/^GTD_FLAG_NEW_REVIEW must be 1, 0, true or false$/,
|
|
30
|
+
)
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
it("reports the first garbled flag in registry order", () => {
|
|
35
|
+
expect(() => resolveFlags({ GTD_FLAG_NEW_REVIEW: "x", GTD_FLAG_ALPHA: "y" }, registry)).toThrow(
|
|
36
|
+
/^GTD_FLAG_ALPHA /,
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("ignores an undeclared GTD_FLAG_* key with a garbled value", () => {
|
|
41
|
+
expect(() => resolveFlags({ GTD_FLAG_NOPE: "ture" }, registry)).not.toThrow()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("resolves a flag past its expiry like any other", () => {
|
|
45
|
+
expect(registry[0].expiry < new Date().toISOString().slice(0, 10)).toBe(true)
|
|
46
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: "1" }, registry).alpha).toBe(true)
|
|
47
|
+
expect(resolveFlags({ GTD_FLAG_ALPHA: "0" }, registry).alpha).toBe(false)
|
|
48
|
+
expect(resolveFlags({}, registry).alpha).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("Flags<R> rejects a misspelt name", () => {
|
|
52
|
+
const flags: Flags<typeof registry> = resolveFlags({}, registry)
|
|
53
|
+
// @ts-expect-error misspelt flag name
|
|
54
|
+
void flags.alpah
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("FLAGS", () => {
|
|
59
|
+
it("declares settable, unique variable names", () => {
|
|
60
|
+
// Vacuous while FLAGS is empty; guards the first declared flag.
|
|
61
|
+
const names = (FLAGS as readonly FlagDeclaration[]).map(({ name }) => {
|
|
62
|
+
expect(name).toMatch(/^[a-z][a-z0-9_]*$/)
|
|
63
|
+
return name
|
|
64
|
+
})
|
|
65
|
+
expect(new Set(names).size).toBe(names.length)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A flag may change how a step runs, never which step comes next: flags are not recorded in
|
|
2
|
+
// history, so replay must not read them. No lint rule enforces this.
|
|
3
|
+
|
|
4
|
+
export type FlagKind = "release" | "ops" | "experiment"
|
|
5
|
+
|
|
6
|
+
export interface FlagDeclaration {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly owner: string
|
|
9
|
+
/** ISO `YYYY-MM-DD`; never enforced at runtime. */
|
|
10
|
+
readonly expiry: string
|
|
11
|
+
readonly kind: FlagKind
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const FLAGS = [] as const satisfies readonly FlagDeclaration[]
|
|
15
|
+
|
|
16
|
+
/** A misspelt name is a compile error. */
|
|
17
|
+
export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {
|
|
18
|
+
readonly [N in R[number]["name"]]: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BOOL: Record<string, boolean> = { "": false, "0": false, false: false, "1": true, true: true }
|
|
22
|
+
|
|
23
|
+
export const resolveFlags = <const R extends readonly FlagDeclaration[] = typeof FLAGS>(
|
|
24
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
25
|
+
registry: R = FLAGS as unknown as R,
|
|
26
|
+
): Flags<R> => {
|
|
27
|
+
const out: Record<string, boolean> = {}
|
|
28
|
+
for (const { name } of registry) {
|
|
29
|
+
const variable = `GTD_FLAG_${name.toUpperCase()}`
|
|
30
|
+
const raw = env[variable] ?? ""
|
|
31
|
+
const key = raw.toLowerCase()
|
|
32
|
+
if (!Object.hasOwn(BOOL, key)) {
|
|
33
|
+
throw new Error(`${variable} must be 1, 0, true or false`)
|
|
34
|
+
}
|
|
35
|
+
out[name] = BOOL[key]!
|
|
36
|
+
}
|
|
37
|
+
return out as Flags<R>
|
|
38
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FlowsProtocolError,
|
|
5
5
|
head,
|
|
6
6
|
installContext,
|
|
7
|
+
kept,
|
|
7
8
|
protocolMismatch,
|
|
8
9
|
type FlowContext,
|
|
9
10
|
} from "./runtime.js"
|
|
@@ -52,3 +53,35 @@ describe("the flows protocol handshake", () => {
|
|
|
52
53
|
)
|
|
53
54
|
})
|
|
54
55
|
})
|
|
56
|
+
|
|
57
|
+
describe("kept()", () => {
|
|
58
|
+
const change = { path: "a.ts", status: "added", before: undefined, after: "a" } as const
|
|
59
|
+
const withKept = (tip: unknown) =>
|
|
60
|
+
installContext({
|
|
61
|
+
kept: (name: string) => (name === "naive" ? tip : undefined),
|
|
62
|
+
} as unknown as FlowContext)
|
|
63
|
+
|
|
64
|
+
it("returns undefined as given", () => {
|
|
65
|
+
withKept(undefined)
|
|
66
|
+
expect(kept("naive")).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("wraps a tip's changes with paths and get", () => {
|
|
70
|
+
withKept({ hash: "abc", changes: [change] })
|
|
71
|
+
const tip = kept("naive")
|
|
72
|
+
expect(tip?.hash).toBe("abc")
|
|
73
|
+
expect(tip?.changes.paths).toEqual(["a.ts"])
|
|
74
|
+
expect(tip?.changes.get("a.ts")?.after).toBe("a")
|
|
75
|
+
expect(tip?.changes.get("b.ts")).toBeUndefined()
|
|
76
|
+
expect(tip?.changes).toHaveLength(1)
|
|
77
|
+
expect(kept("other")).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("throws the upgrade message on a context without kept, protocol still 1", () => {
|
|
81
|
+
installContext(context)
|
|
82
|
+
expect(() => kept("naive")).toThrow(
|
|
83
|
+
"gtd: kept() needs a newer gtd — upgrade the gtd that runs it",
|
|
84
|
+
)
|
|
85
|
+
expect(FLOWS_PROTOCOL).toBe(1)
|
|
86
|
+
})
|
|
87
|
+
})
|
package/src/flows/runtime.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface AgentOptions extends SteeringOptions {
|
|
|
24
24
|
* is an attempt: recorded, but the process stays at the step (a stall).
|
|
25
25
|
*/
|
|
26
26
|
readonly allowEmpty?: boolean | undefined
|
|
27
|
+
/**
|
|
28
|
+
* Set the turn aside: its tip goes to `refs/gtd/kept/<entry>/<step>/<hash>` and the
|
|
29
|
+
* branch stays where it was, a marker commit recording that the step ran.
|
|
30
|
+
* The step's full name must be usable as a git ref component.
|
|
31
|
+
*/
|
|
32
|
+
readonly keep?: boolean | undefined
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export interface HumanOptions extends SteeringOptions {
|
|
@@ -157,6 +163,10 @@ export interface FlowContext {
|
|
|
157
163
|
readonly skillsFor: (localName: string) => readonly string[]
|
|
158
164
|
/** The folded access the memory scope `localName` lands in resolves to; `file` is the step's own steering file. */
|
|
159
165
|
readonly accessFor: (localName: string, file?: string) => AccessDef
|
|
166
|
+
/** Absent on an engine older than `kept`. */
|
|
167
|
+
readonly kept?: (
|
|
168
|
+
name: string,
|
|
169
|
+
) => { readonly hash: string; readonly changes: readonly Change[] } | undefined
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
/**
|
|
@@ -302,6 +312,27 @@ export const changesSince = (hash: string, pattern?: string): Changes => {
|
|
|
302
312
|
)
|
|
303
313
|
}
|
|
304
314
|
|
|
315
|
+
export interface KeptTip {
|
|
316
|
+
/** The kept commit. `""` while the keeping turn is still pending (a landing decision): never branch on it, the landed replay sees the real hash. */
|
|
317
|
+
readonly hash: string
|
|
318
|
+
/** The kept tip against the commit it diverged from. */
|
|
319
|
+
readonly changes: Changes
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The latest kept tip of step `name` (scoped from here, same as `agent()`) in
|
|
324
|
+
* this episode, or `undefined` when none.
|
|
325
|
+
*/
|
|
326
|
+
export const kept = (name: string): KeptTip | undefined => {
|
|
327
|
+
const context = ctx()
|
|
328
|
+
// FLOWS_PROTOCOL stays 1, so an older engine's context simply lacks `kept`.
|
|
329
|
+
if (typeof context.kept !== "function") {
|
|
330
|
+
throw new Error("gtd: kept() needs a newer gtd — upgrade the gtd that runs it")
|
|
331
|
+
}
|
|
332
|
+
const tip = context.kept(name)
|
|
333
|
+
return tip === undefined ? undefined : { hash: tip.hash, changes: toChanges(tip.changes) }
|
|
334
|
+
}
|
|
335
|
+
|
|
305
336
|
/** The commit the process stands on at this point of the flow. */
|
|
306
337
|
export const head = (): string => ctx().head()
|
|
307
338
|
|
|
@@ -4,6 +4,7 @@ import { strictGitOperations as stubGit } from "../testkit/index.js"
|
|
|
4
4
|
import {
|
|
5
5
|
clearRetainedHistory,
|
|
6
6
|
HISTORY_REF,
|
|
7
|
+
keptRef,
|
|
7
8
|
PROCESS_HISTORY_PREFIX,
|
|
8
9
|
processHistoryRef,
|
|
9
10
|
readRetainedHistory,
|
|
@@ -77,3 +78,11 @@ describe("processHistoryRef", () => {
|
|
|
77
78
|
expect(HISTORY_REF.startsWith(PROCESS_HISTORY_PREFIX)).toBe(false)
|
|
78
79
|
})
|
|
79
80
|
})
|
|
81
|
+
|
|
82
|
+
describe("keptRef", () => {
|
|
83
|
+
it("keys a kept tip by entry and step, outside the finished-process namespace", () => {
|
|
84
|
+
const entry = "a".repeat(40)
|
|
85
|
+
expect(keptRef(entry, "build.naive")).toBe(`refs/gtd/kept/${entry}/build.naive`)
|
|
86
|
+
expect(keptRef(entry, "build.naive").startsWith(PROCESS_HISTORY_PREFIX)).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -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,
|