@pmelab/gtd 24.0.0 → 24.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 +42 -13
- package/claude/hooks/access.ts +26 -1
- package/claude/hooks/assist.ts +135 -0
- package/claude/hooks/drive.ts +15 -7
- package/claude/hooks/judge.ts +27 -0
- package/claude/hooks/register.tsx +294 -135
- package/claude/hooks/tools.ts +121 -0
- package/claude/hooks/wording.ts +2 -35
- package/claude/types/index.d.ts +9 -0
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43339 -55575
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +8 -6
- package/package.json +69 -8
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
import { Effect, Exit } from "effect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { runCli } from "../cli/index.js"
|
|
4
|
+
import {
|
|
5
|
+
currentRest,
|
|
6
|
+
currentRun,
|
|
7
|
+
noProcessUnderway,
|
|
8
|
+
renderRest,
|
|
9
|
+
restAt,
|
|
10
|
+
restIsIdle,
|
|
11
|
+
reviewBaseFor,
|
|
12
|
+
snapshotFromRest,
|
|
13
|
+
stalledAt,
|
|
14
|
+
summaryRun,
|
|
15
|
+
TRUNCATION_NOTICE,
|
|
16
|
+
UNATTRIBUTED_MODEL,
|
|
17
|
+
type RestRequirements,
|
|
18
|
+
} from "./Edge.js"
|
|
19
|
+
import { GitService, Workspace, type GitOperations, type WorkspaceOps } from "../platform/index.js"
|
|
20
|
+
import { formatCommitMessage, type CommitSpec } from "../replay/index.js"
|
|
21
|
+
import {
|
|
22
|
+
fakeGitOperations,
|
|
23
|
+
InMemRepo,
|
|
24
|
+
applyEmittedScript,
|
|
25
|
+
makeCapturingCliIo,
|
|
26
|
+
makeInMemoryWorkspaceOps,
|
|
27
|
+
testLayers,
|
|
28
|
+
} from "../testkit/index.js"
|
|
29
|
+
|
|
30
|
+
type Env = Readonly<Record<string, string | undefined>>
|
|
31
|
+
type Files = Readonly<Record<string, string>>
|
|
32
|
+
|
|
33
|
+
const provide = <A>(
|
|
34
|
+
eff: Effect.Effect<A, Error, RestRequirements>,
|
|
35
|
+
repo: InMemRepo,
|
|
36
|
+
env: Env = {},
|
|
37
|
+
): Promise<A> => Effect.runPromise(eff.pipe(Effect.provide(testLayers(repo, { env }))))
|
|
38
|
+
|
|
39
|
+
const provideExit = <A>(
|
|
40
|
+
eff: Effect.Effect<A, Error, RestRequirements>,
|
|
41
|
+
repo: InMemRepo,
|
|
42
|
+
env: Env = {},
|
|
43
|
+
): Promise<Exit.Exit<A, Error>> =>
|
|
44
|
+
Effect.runPromiseExit(eff.pipe(Effect.provide(testLayers(repo, { env }))))
|
|
45
|
+
|
|
46
|
+
/** Like `provide`, but with `git` swapping in for the run's `GitService` — for tests that need to observe or interfere with the exact git calls a run makes. */
|
|
47
|
+
const provideWithGit = <A>(
|
|
48
|
+
eff: Effect.Effect<A, Error, RestRequirements>,
|
|
49
|
+
git: GitOperations,
|
|
50
|
+
repo: InMemRepo,
|
|
51
|
+
env: Env = {},
|
|
52
|
+
): Promise<A> =>
|
|
53
|
+
Effect.runPromise(
|
|
54
|
+
eff.pipe(Effect.provideService(GitService, git), Effect.provide(testLayers(repo, { env }))),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
/** Like `provideWithGit`, but with `workspace` swapping in for the run's `Workspace` — for tests observing exactly when `worktreeSync` runs. */
|
|
58
|
+
const provideWithWorkspace = <A>(
|
|
59
|
+
eff: Effect.Effect<A, Error, RestRequirements>,
|
|
60
|
+
workspace: WorkspaceOps,
|
|
61
|
+
repo: InMemRepo,
|
|
62
|
+
): Promise<A> =>
|
|
63
|
+
Effect.runPromise(
|
|
64
|
+
eff.pipe(Effect.provideService(Workspace, workspace), Effect.provide(testLayers(repo, {}))),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const write = (repo: InMemRepo, files: Files): void => {
|
|
68
|
+
for (const [path, content] of Object.entries(files)) repo.writeFile(path, content)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const headOf = (repo: InMemRepo): string => repo.resolveRef("HEAD")!
|
|
72
|
+
|
|
73
|
+
const repoWith = (config: string, files: Files = {}): InMemRepo => {
|
|
74
|
+
const repo = new InMemRepo()
|
|
75
|
+
write(repo, { "gtd.config.ts": config, ...files })
|
|
76
|
+
repo.commitAllWithPrefix("chore: add workflow")
|
|
77
|
+
return repo
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cli = async (repo: InMemRepo, ...args: string[]) => {
|
|
81
|
+
const { io, result } = makeCapturingCliIo(repo)
|
|
82
|
+
await Effect.runPromise(runCli(["node", "gtd.js", ...args], io))
|
|
83
|
+
return result()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const applied = (repo: InMemRepo, script: string): string => {
|
|
87
|
+
const outcome = applyEmittedScript(repo, new Map(), script)
|
|
88
|
+
if (!outcome.ok) throw new Error(outcome.error)
|
|
89
|
+
return headOf(repo)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Lands the pending turn the way a driver does — `gtd land --json`, then its script — and returns the new HEAD. */
|
|
93
|
+
const land = async (repo: InMemRepo, files: Files = {}): Promise<string> => {
|
|
94
|
+
write(repo, files)
|
|
95
|
+
const { stdout, stderr, exitCode } = await cli(repo, "land", "--json")
|
|
96
|
+
if (exitCode !== 0) throw new Error(`gtd land exited ${exitCode}: ${stderr}${stdout}`)
|
|
97
|
+
return applied(repo, (JSON.parse(stdout) as { readonly script: string }).script)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const enter = async (repo: InMemRepo, workflow: string, ...vars: string[]): Promise<string> => {
|
|
101
|
+
const flags = vars.flatMap((v) => ["--var", v])
|
|
102
|
+
const { stdout, stderr, exitCode } = await cli(repo, "--workflow", workflow, ...flags)
|
|
103
|
+
if (exitCode !== 0) throw new Error(`gtd --workflow exited ${exitCode}: ${stderr}`)
|
|
104
|
+
return applied(repo, stdout)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Commits an exact message — for trailers no landing writes on its own. */
|
|
108
|
+
const commit = (repo: InMemRepo, spec: CommitSpec, files: Files = {}): string => {
|
|
109
|
+
write(repo, files)
|
|
110
|
+
repo.commitAllWithPrefix(formatCommitMessage(spec))
|
|
111
|
+
return headOf(repo)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const LINEAR = `import { agent, human } from "@pmelab/gtd/flows"
|
|
115
|
+
|
|
116
|
+
export default async () => {
|
|
117
|
+
await human("idle", { message: "idle-message" })
|
|
118
|
+
await agent("building", "build-prompt")
|
|
119
|
+
await agent("checking", "check-prompt")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const side = async () => {
|
|
123
|
+
await agent("fixing", "fix-prompt")
|
|
124
|
+
await agent("tidying", "tidy-prompt", { allowEmpty: true })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const review = async () => {
|
|
128
|
+
await agent("fixing", "fix-prompt")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const defaults = { base: "", reviewer: "nobody" }
|
|
132
|
+
|
|
133
|
+
export const base = (workflow, vars) => (workflow === "review" ? (vars.base ?? "") : undefined)
|
|
134
|
+
`
|
|
135
|
+
|
|
136
|
+
describe("currentRun", () => {
|
|
137
|
+
it("is empty at a non-gtd HEAD, which is both its start and its parent", async () => {
|
|
138
|
+
const repo = repoWith(LINEAR)
|
|
139
|
+
const boundary = headOf(repo)
|
|
140
|
+
const run = await provide(currentRun, repo)
|
|
141
|
+
expect(run).toMatchObject({
|
|
142
|
+
workflow: undefined,
|
|
143
|
+
startHash: boundary,
|
|
144
|
+
startParentHash: boundary,
|
|
145
|
+
diffBase: boundary,
|
|
146
|
+
trace: [],
|
|
147
|
+
costEntries: [],
|
|
148
|
+
judgeVerdicts: [],
|
|
149
|
+
pinnedVars: {},
|
|
150
|
+
headTurn: undefined,
|
|
151
|
+
closingHash: undefined,
|
|
152
|
+
legacyOpening: false,
|
|
153
|
+
episode: { base: boundary, commits: [] },
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("traces each step commit back to the nearest non-gtd commit", async () => {
|
|
158
|
+
const repo = repoWith(LINEAR)
|
|
159
|
+
const boundary = headOf(repo)
|
|
160
|
+
const building = await land(repo, { "a.txt": "a\n" })
|
|
161
|
+
const checking = await land(repo, { "b.txt": "b\n" })
|
|
162
|
+
const run = await provide(currentRun, repo)
|
|
163
|
+
expect(run.startParentHash).toBe(boundary)
|
|
164
|
+
expect(run.startHash).toBe(building)
|
|
165
|
+
expect(run.trace).toEqual([
|
|
166
|
+
{ state: "building", hash: building, actor: "human" },
|
|
167
|
+
{ state: "checking", hash: checking, actor: "agent" },
|
|
168
|
+
])
|
|
169
|
+
expect(run.episode.base).toBe(boundary)
|
|
170
|
+
expect(run.episode.commits.map((c) => c.hash)).toEqual([building, checking])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("a non-gtd commit on top of a process bounds a fresh episode", async () => {
|
|
174
|
+
const repo = repoWith(LINEAR)
|
|
175
|
+
await land(repo, { "a.txt": "a\n" })
|
|
176
|
+
repo.writeFile("b.txt", "b\n")
|
|
177
|
+
repo.commitAllWithPrefix("feat: unrelated")
|
|
178
|
+
const run = await provide(currentRun, repo)
|
|
179
|
+
expect(run.trace).toEqual([])
|
|
180
|
+
expect(run.startParentHash).toBe(headOf(repo))
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("a commit entering the default workflow's first step closes the episode", async () => {
|
|
184
|
+
const repo = repoWith(LINEAR)
|
|
185
|
+
await land(repo, { "a.txt": "a\n" })
|
|
186
|
+
await land(repo, { "b.txt": "b\n" })
|
|
187
|
+
const closing = await land(repo, { "c.txt": "c\n" })
|
|
188
|
+
expect(repo.lastCommitSubject()).toBe("gtd(agent): checking → idle")
|
|
189
|
+
const next = await land(repo, { "d.txt": "d\n" })
|
|
190
|
+
const run = await provide(currentRun, repo)
|
|
191
|
+
expect(run.startParentHash).toBe(closing)
|
|
192
|
+
expect(run.trace).toEqual([{ state: "building", hash: next, actor: "human" }])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("a step-less gtd(human): <step> commit opens a started workflow's episode as its first commit", async () => {
|
|
196
|
+
const repo = repoWith(LINEAR)
|
|
197
|
+
const before = headOf(repo)
|
|
198
|
+
const opening = await enter(repo, "side")
|
|
199
|
+
expect(repo.lastCommitMessage()).toBe(
|
|
200
|
+
"gtd(human): fixing\n\nGtd-Workflow: side\nGtd-Var: base=\nGtd-Var: reviewer=nobody\nGtd-Format: 1",
|
|
201
|
+
)
|
|
202
|
+
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
203
|
+
const run = await provide(currentRun, repo)
|
|
204
|
+
expect(run.workflow).toBe("side")
|
|
205
|
+
expect(run.startHash).toBe(opening)
|
|
206
|
+
expect(run.startParentHash).toBe(before)
|
|
207
|
+
expect(run.trace).toEqual([
|
|
208
|
+
{ state: "fixing", hash: opening, actor: "human" },
|
|
209
|
+
{ state: "tidying", hash: fixed, actor: "agent" },
|
|
210
|
+
])
|
|
211
|
+
expect(run.episode.base).toBe(opening)
|
|
212
|
+
expect(run.episode.commits.map((c) => c.hash)).toEqual([fixed])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("a workflow starting at the default's initial step still opens its own episode, not the empty tree", async () => {
|
|
216
|
+
const STARTS_AT_INITIAL = `import { agent, human } from "@pmelab/gtd/flows"
|
|
217
|
+
|
|
218
|
+
export default async () => {
|
|
219
|
+
await human("idle", { message: "idle-message" })
|
|
220
|
+
await agent("building", "build-prompt")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export const again = async () => {
|
|
224
|
+
await human("idle", { message: "idle-message" })
|
|
225
|
+
await agent("fixing", "fix-prompt")
|
|
226
|
+
await agent("tidying", "tidy-prompt", { allowEmpty: true })
|
|
227
|
+
}
|
|
228
|
+
`
|
|
229
|
+
const repo = repoWith(STARTS_AT_INITIAL)
|
|
230
|
+
const before = headOf(repo)
|
|
231
|
+
const opening = await enter(repo, "again")
|
|
232
|
+
expect(repo.lastCommitMessage()).toBe("gtd(human): idle\n\nGtd-Workflow: again\nGtd-Format: 1")
|
|
233
|
+
const fixed = await land(repo, { "fix.txt": "x\n" })
|
|
234
|
+
const run = await provide(currentRun, repo)
|
|
235
|
+
expect(run.workflow).toBe("again")
|
|
236
|
+
expect(run.startHash).toBe(opening)
|
|
237
|
+
expect(run.startParentHash).toBe(before)
|
|
238
|
+
expect(run.diffBase).toBe(before)
|
|
239
|
+
expect(run.trace).toEqual([
|
|
240
|
+
{ state: "idle", hash: opening, actor: "human" },
|
|
241
|
+
{ state: "fixing", hash: fixed, actor: "human" },
|
|
242
|
+
])
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("collects every process commit's Gtd-Cost, attributing a model-less one to UNATTRIBUTED_MODEL", async () => {
|
|
246
|
+
const repo = repoWith(LINEAR)
|
|
247
|
+
commit(
|
|
248
|
+
repo,
|
|
249
|
+
{
|
|
250
|
+
actor: "human",
|
|
251
|
+
from: "idle",
|
|
252
|
+
to: "building",
|
|
253
|
+
step: { name: "idle", occurrence: 1 },
|
|
254
|
+
cost: { cost: 120, model: "opus" },
|
|
255
|
+
},
|
|
256
|
+
{ "a.txt": "a\n" },
|
|
257
|
+
)
|
|
258
|
+
commit(repo, { actor: "agent", to: "building", cost: { cost: 50 } })
|
|
259
|
+
commit(
|
|
260
|
+
repo,
|
|
261
|
+
{
|
|
262
|
+
actor: "agent",
|
|
263
|
+
from: "building",
|
|
264
|
+
to: "checking",
|
|
265
|
+
step: { name: "building", occurrence: 1 },
|
|
266
|
+
cost: { cost: 300, model: "haiku" },
|
|
267
|
+
},
|
|
268
|
+
{ "b.txt": "b\n" },
|
|
269
|
+
)
|
|
270
|
+
const run = await provide(currentRun, repo)
|
|
271
|
+
expect(run.costEntries).toEqual([
|
|
272
|
+
{ cost: 120, model: "opus" },
|
|
273
|
+
{ cost: 50, model: UNATTRIBUTED_MODEL },
|
|
274
|
+
{ cost: 300, model: "haiku" },
|
|
275
|
+
])
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("collects every Gtd-Judge verdict oldest first, skipping one that is not valid JSON", async () => {
|
|
279
|
+
const repo = repoWith(LINEAR)
|
|
280
|
+
commit(
|
|
281
|
+
repo,
|
|
282
|
+
{
|
|
283
|
+
actor: "human",
|
|
284
|
+
from: "idle",
|
|
285
|
+
to: "building",
|
|
286
|
+
step: { name: "idle", occurrence: 1 },
|
|
287
|
+
judge: [{ id: "q1", answer: true, p: 0.97 }],
|
|
288
|
+
},
|
|
289
|
+
{ "a.txt": "a\n" },
|
|
290
|
+
)
|
|
291
|
+
repo.writeFile("b.txt", "b\n")
|
|
292
|
+
repo.commitAllWithPrefix(
|
|
293
|
+
[
|
|
294
|
+
"gtd(agent): building → checking",
|
|
295
|
+
"",
|
|
296
|
+
"Gtd-Step: building#1",
|
|
297
|
+
"Gtd-Judge: not json",
|
|
298
|
+
'Gtd-Judge: {"id":"q2","answer":3,"p":0.8}',
|
|
299
|
+
].join("\n"),
|
|
300
|
+
)
|
|
301
|
+
const run = await provide(currentRun, repo)
|
|
302
|
+
expect(run.judgeVerdicts).toEqual([
|
|
303
|
+
{ id: "q1", answer: true, p: 0.97 },
|
|
304
|
+
{ id: "q2", answer: 3, p: 0.8 },
|
|
305
|
+
])
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it("the opening commit's Gtd-Review-Base overrides diffBase, leaving startParentHash alone", async () => {
|
|
309
|
+
const repo = repoWith(LINEAR)
|
|
310
|
+
const base = headOf(repo)
|
|
311
|
+
repo.writeFile("later.txt", "later\n")
|
|
312
|
+
repo.commitAllWithPrefix("feat: later")
|
|
313
|
+
const before = headOf(repo)
|
|
314
|
+
await enter(repo, "review", `base=${base}`)
|
|
315
|
+
const run = await provide(currentRun, repo)
|
|
316
|
+
expect(run.startParentHash).toBe(before)
|
|
317
|
+
expect(run.diffBase).toBe(base)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("a Gtd-Review-Base on a later process commit is never consulted", async () => {
|
|
321
|
+
const repo = repoWith(LINEAR)
|
|
322
|
+
const boundary = headOf(repo)
|
|
323
|
+
await land(repo, { "a.txt": "a\n" })
|
|
324
|
+
commit(
|
|
325
|
+
repo,
|
|
326
|
+
{
|
|
327
|
+
actor: "agent",
|
|
328
|
+
from: "building",
|
|
329
|
+
to: "checking",
|
|
330
|
+
step: { name: "building", occurrence: 1 },
|
|
331
|
+
reviewBase: "not-the-real-base",
|
|
332
|
+
},
|
|
333
|
+
{ "b.txt": "b\n" },
|
|
334
|
+
)
|
|
335
|
+
const run = await provide(currentRun, repo)
|
|
336
|
+
expect(run.diffBase).toBe(boundary)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it("reads pinnedVars off a manual entry's opening commit", async () => {
|
|
340
|
+
const repo = repoWith(LINEAR)
|
|
341
|
+
await enter(repo, "side", "base=main", "reviewer=alice")
|
|
342
|
+
await land(repo, { "fix.txt": "x\n" })
|
|
343
|
+
const run = await provide(currentRun, repo)
|
|
344
|
+
expect(run.pinnedVars).toEqual({ base: "main", reviewer: "alice" })
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it("a default-entry episode pins the Gtd-Var trailers of its first commit", async () => {
|
|
348
|
+
const repo = repoWith(LINEAR)
|
|
349
|
+
commit(
|
|
350
|
+
repo,
|
|
351
|
+
{
|
|
352
|
+
actor: "human",
|
|
353
|
+
from: "idle",
|
|
354
|
+
to: "building",
|
|
355
|
+
step: { name: "idle", occurrence: 1 },
|
|
356
|
+
vars: { reviewer: "alice" },
|
|
357
|
+
},
|
|
358
|
+
{ "a.txt": "a\n" },
|
|
359
|
+
)
|
|
360
|
+
const run = await provide(currentRun, repo)
|
|
361
|
+
expect(run.pinnedVars).toEqual({ reviewer: "alice" })
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
describe("headTurn", () => {
|
|
365
|
+
it("describes a step commit that changed something", async () => {
|
|
366
|
+
const repo = repoWith(LINEAR)
|
|
367
|
+
await land(repo, { "a.txt": "a\n" })
|
|
368
|
+
const run = await provide(currentRun, repo)
|
|
369
|
+
expect(run.headTurn).toEqual({ state: "building", actor: "human", empty: false, step: true })
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it("describes an attempt: a step-less, empty commit", async () => {
|
|
373
|
+
const repo = repoWith(LINEAR)
|
|
374
|
+
await land(repo, { "a.txt": "a\n" })
|
|
375
|
+
await land(repo)
|
|
376
|
+
expect(repo.lastCommitMessage()).toBe("gtd(agent): building\n\nGtd-Format: 1")
|
|
377
|
+
const run = await provide(currentRun, repo)
|
|
378
|
+
expect(run.headTurn).toEqual({ state: "building", actor: "agent", empty: true, step: false })
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it("is undefined at a non-gtd HEAD", async () => {
|
|
382
|
+
const repo = repoWith(LINEAR)
|
|
383
|
+
const run = await provide(currentRun, repo)
|
|
384
|
+
expect(run.headTurn).toBeUndefined()
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
describe("history reads stay bounded by the episode, not the repository", () => {
|
|
390
|
+
/** Piles up `count` ordinary non-gtd commits before the workflow even exists — the repo's "age". */
|
|
391
|
+
const repoWithHistory = (count: number): InMemRepo => {
|
|
392
|
+
const repo = new InMemRepo()
|
|
393
|
+
repo.writeFile("seed.txt", "0")
|
|
394
|
+
repo.commitAllWithPrefix("chore: seed 0")
|
|
395
|
+
for (let i = 1; i < count; i++) {
|
|
396
|
+
repo.writeFile("seed.txt", String(i))
|
|
397
|
+
repo.commitAllWithPrefix(`chore: seed ${i}`)
|
|
398
|
+
}
|
|
399
|
+
write(repo, { "gtd.config.ts": LINEAR })
|
|
400
|
+
repo.commitAllWithPrefix("chore: add workflow")
|
|
401
|
+
return repo
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
it("currentRun never fetches a growing number of commit bodies as the repository ages", async () => {
|
|
405
|
+
const repo = repoWithHistory(600)
|
|
406
|
+
const boundary = headOf(repo)
|
|
407
|
+
const building = await land(repo, { "a.txt": "a\n" })
|
|
408
|
+
await land(repo, { "b.txt": "b\n" })
|
|
409
|
+
repo.bodyReadCount = 0
|
|
410
|
+
const run = await provide(currentRun, repo)
|
|
411
|
+
expect(run.startParentHash).toBe(boundary)
|
|
412
|
+
expect(run.startHash).toBe(building)
|
|
413
|
+
// Bounded by the episode (2 step commits) plus a small constant, never by
|
|
414
|
+
// the 600 unrelated commits sitting underneath it.
|
|
415
|
+
expect(repo.bodyReadCount).toBeLessThan(20)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it("summaryRun's closing read stays just as bounded", async () => {
|
|
419
|
+
const repo = repoWithHistory(600)
|
|
420
|
+
const boundary = headOf(repo)
|
|
421
|
+
await land(repo, { "a.txt": "a\n" })
|
|
422
|
+
await land(repo, { "b.txt": "b\n" })
|
|
423
|
+
const closing = await land(repo, { "c.txt": "c\n" })
|
|
424
|
+
repo.bodyReadCount = 0
|
|
425
|
+
const summary = await provide(summaryRun, repo)
|
|
426
|
+
expect(summary.closingHash).toBe(closing)
|
|
427
|
+
expect(summary.startParentHash).toBe(boundary)
|
|
428
|
+
expect(repo.bodyReadCount).toBeLessThan(20)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it("an episode reaching the repository's root commit still resolves, with the empty tree as its base", async () => {
|
|
432
|
+
const repo = new InMemRepo()
|
|
433
|
+
write(repo, { "gtd.config.ts": LINEAR })
|
|
434
|
+
const building = commit(
|
|
435
|
+
repo,
|
|
436
|
+
{ actor: "human", to: "building", step: { name: "idle", occurrence: 1 } },
|
|
437
|
+
{ "a.txt": "a\n" },
|
|
438
|
+
)
|
|
439
|
+
const run = await provide(currentRun, repo)
|
|
440
|
+
expect(run.startHash).toBe(building)
|
|
441
|
+
expect(run.startParentHash).toBe("4b825dc642cb6eb9a060e54bf8d69288fbee4904")
|
|
442
|
+
expect(run.episode.base).toBeUndefined()
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it("pins the body read to the hash the subject-only read resolved HEAD to, not a literal HEAD moved underneath it", async () => {
|
|
446
|
+
const repo = repoWith(LINEAR)
|
|
447
|
+
const boundary = headOf(repo)
|
|
448
|
+
await land(repo, { "a.txt": "a\n" })
|
|
449
|
+
const checking = await land(repo, { "b.txt": "b\n" })
|
|
450
|
+
expect(headOf(repo)).toBe(checking)
|
|
451
|
+
|
|
452
|
+
// Simulates a concurrent writer (a reset, another `gtd` on the same
|
|
453
|
+
// checkout) moving literal HEAD back to the boundary commit in the
|
|
454
|
+
// window between `historyUpTo`'s subject-only read and its body read.
|
|
455
|
+
// `subjectHistory`'s first call observes and returns the CURRENT
|
|
456
|
+
// (pre-move) history — exactly as a real `git log` would, already having
|
|
457
|
+
// read it before the move happens — then the move fires.
|
|
458
|
+
let moved = false
|
|
459
|
+
const base = fakeGitOperations(repo)
|
|
460
|
+
const raceyGit: GitOperations = {
|
|
461
|
+
...base,
|
|
462
|
+
subjectHistory: (pageSize, skip, head) =>
|
|
463
|
+
Effect.tap(base.subjectHistory(pageSize, skip, head), () => {
|
|
464
|
+
if (!moved) {
|
|
465
|
+
moved = true
|
|
466
|
+
repo.hardResetTo(boundary)
|
|
467
|
+
}
|
|
468
|
+
}),
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const run = await provideWithGit(currentRun, raceyGit, repo)
|
|
472
|
+
|
|
473
|
+
// Pinned to the hash the subject-only read resolved HEAD to (`checking`):
|
|
474
|
+
// the full episode, unaffected by the later move. An unpinned second read
|
|
475
|
+
// would resolve literal HEAD to `boundary` instead, find no commits in
|
|
476
|
+
// `base..boundary` (`boundary` is not a descendant of the computed
|
|
477
|
+
// `base`), and silently fold to an empty run at the empty tree — exactly
|
|
478
|
+
// what this test would catch.
|
|
479
|
+
expect(run.startParentHash).toBe(boundary)
|
|
480
|
+
expect(run.trace.map((t) => t.hash)).toHaveLength(2)
|
|
481
|
+
expect(run.trace.at(-1)?.hash).toBe(checking)
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
describe("summaryRun", () => {
|
|
486
|
+
it("matches currentRun while the process is in flight", async () => {
|
|
487
|
+
const repo = repoWith(LINEAR)
|
|
488
|
+
await land(repo, { "a.txt": "a\n" })
|
|
489
|
+
await land(repo, { "b.txt": "b\n" })
|
|
490
|
+
const [ordinary, summary] = await Promise.all([
|
|
491
|
+
provide(currentRun, repo),
|
|
492
|
+
provide(summaryRun, repo),
|
|
493
|
+
])
|
|
494
|
+
expect(summary).toEqual(ordinary)
|
|
495
|
+
expect(summary.closingHash).toBeUndefined()
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it("at a closing HEAD, keeps the closed process and folds the closing commit into its trace", async () => {
|
|
499
|
+
const repo = repoWith(LINEAR)
|
|
500
|
+
const boundary = headOf(repo)
|
|
501
|
+
const building = await land(repo, { "a.txt": "a\n" })
|
|
502
|
+
const checking = await land(repo, { "b.txt": "b\n" })
|
|
503
|
+
const closing = await land(repo, { "c.txt": "c\n" })
|
|
504
|
+
|
|
505
|
+
const ordinary = await provide(currentRun, repo)
|
|
506
|
+
expect(ordinary.trace).toEqual([])
|
|
507
|
+
expect(ordinary.startParentHash).toBe(closing)
|
|
508
|
+
|
|
509
|
+
const summary = await provide(summaryRun, repo)
|
|
510
|
+
expect(summary.closingHash).toBe(closing)
|
|
511
|
+
expect(summary.startParentHash).toBe(boundary)
|
|
512
|
+
expect(summary.trace).toEqual([
|
|
513
|
+
{ state: "building", hash: building, actor: "human" },
|
|
514
|
+
{ state: "checking", hash: checking, actor: "agent" },
|
|
515
|
+
{ state: "idle", hash: closing, actor: "agent" },
|
|
516
|
+
])
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it("no longer reaches a closed process once another commit lands on top", async () => {
|
|
520
|
+
const repo = repoWith(LINEAR)
|
|
521
|
+
await land(repo, { "a.txt": "a\n" })
|
|
522
|
+
await land(repo, { "b.txt": "b\n" })
|
|
523
|
+
await land(repo, { "c.txt": "c\n" })
|
|
524
|
+
repo.writeFile("d.txt", "d\n")
|
|
525
|
+
repo.commitAllWithPrefix("chore: unrelated")
|
|
526
|
+
const summary = await provide(summaryRun, repo)
|
|
527
|
+
expect(summary.closingHash).toBeUndefined()
|
|
528
|
+
expect(summary.trace).toEqual([])
|
|
529
|
+
})
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
describe("restAt", () => {
|
|
533
|
+
it("restAt(ref) resolves where the process rested at that commit", async () => {
|
|
534
|
+
const repo = repoWith(LINEAR)
|
|
535
|
+
const boundary = headOf(repo)
|
|
536
|
+
await land(repo, { "a.txt": "a\n" })
|
|
537
|
+
expect((await provide(currentRest, repo)).state).toBe("building")
|
|
538
|
+
expect((await provide(restAt(boundary), repo)).state).toBe("idle")
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it("ignores a stray refs/worktree/gtd/review-head ref", async () => {
|
|
542
|
+
const repo = repoWith(LINEAR)
|
|
543
|
+
const boundary = headOf(repo)
|
|
544
|
+
const building = await land(repo, { "a.txt": "a\n" })
|
|
545
|
+
await land(repo, { "b.txt": "b\n" })
|
|
546
|
+
repo.updateRef("refs/worktree/gtd/review-head", building)
|
|
547
|
+
const rest = await provide(currentRest, repo)
|
|
548
|
+
expect(rest.state).toBe("checking")
|
|
549
|
+
expect(rest.run.startParentHash).toBe(boundary)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it("narrates the resolved step and who it awaits", async () => {
|
|
553
|
+
const repo = repoWith(LINEAR)
|
|
554
|
+
await land(repo, { "a.txt": "a\n" })
|
|
555
|
+
const lines: string[] = []
|
|
556
|
+
await Effect.runPromise(
|
|
557
|
+
currentRest.pipe(Effect.provide(testLayers(repo, { narrate: (line) => lines.push(line) }))),
|
|
558
|
+
)
|
|
559
|
+
expect(lines).toContain("rest resolved: building (awaits agent)\n")
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it("refuses a rest whose step names a mode no layer declares", async () => {
|
|
563
|
+
const repo = repoWith(`import { human } from "@pmelab/gtd/flows"
|
|
564
|
+
|
|
565
|
+
export default async () => {
|
|
566
|
+
await human("idle", { file: "docs/adr.md", mode: "adr" })
|
|
567
|
+
}
|
|
568
|
+
`)
|
|
569
|
+
const exit = await provideExit(currentRest, repo)
|
|
570
|
+
expect(Exit.isFailure(exit) && String(exit.cause)).toContain(
|
|
571
|
+
'step "idle": mode "adr" is not a mode this workflow knows (qa, review)',
|
|
572
|
+
)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it("refuses a step option the step does not accept, naming a retired one's replacement", async () => {
|
|
576
|
+
const repo = repoWith(`import { agent } from "@pmelab/gtd/flows"
|
|
577
|
+
|
|
578
|
+
export default async () => {
|
|
579
|
+
await agent("work", "p", { memory: "plan" })
|
|
580
|
+
}
|
|
581
|
+
`)
|
|
582
|
+
const exit = await provideExit(currentRest, repo)
|
|
583
|
+
expect(Exit.isFailure(exit) && String(exit.cause)).toContain(
|
|
584
|
+
'step "work": unknown key(s) memory in agent() options',
|
|
585
|
+
)
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
describe("reviewBaseFor", () => {
|
|
590
|
+
const LOOP = `import { agent, head, human, read } from "@pmelab/gtd/flows"
|
|
591
|
+
|
|
592
|
+
export default async () => {
|
|
593
|
+
await human("idle")
|
|
594
|
+
while (read("DONE") === undefined) {
|
|
595
|
+
const base = head()
|
|
596
|
+
await human("checkpoint", { base })
|
|
597
|
+
await agent("building", "build-prompt", { base })
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export const review = async () => {
|
|
602
|
+
await agent("fixing", "fix-prompt")
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export const defaults = { base: "" }
|
|
606
|
+
|
|
607
|
+
export const base = (workflow, vars) => (workflow === "review" ? (vars.base ?? "") : undefined)
|
|
608
|
+
`
|
|
609
|
+
|
|
610
|
+
it("is the process's diff base at a step that names no base", async () => {
|
|
611
|
+
const repo = repoWith(LOOP)
|
|
612
|
+
const rest = await provide(currentRest, repo)
|
|
613
|
+
expect(reviewBaseFor(rest)).toBe(rest.run.diffBase)
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
it("is the base the resting step names", async () => {
|
|
617
|
+
const repo = repoWith(LOOP)
|
|
618
|
+
const first = await land(repo, { "a.txt": "a\n" })
|
|
619
|
+
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
620
|
+
await land(repo, { "b.txt": "b\n" })
|
|
621
|
+
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(first)
|
|
622
|
+
const second = await land(repo, { "c.txt": "c\n" })
|
|
623
|
+
const atCheckpoint = await provide(currentRest, repo)
|
|
624
|
+
expect(atCheckpoint.state).toBe("checkpoint")
|
|
625
|
+
expect(reviewBaseFor(atCheckpoint)).toBe(second)
|
|
626
|
+
await land(repo, { "d.txt": "d\n" })
|
|
627
|
+
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(second)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it("falls back to an entry's Gtd-Review-Base at a step that names no base", async () => {
|
|
631
|
+
const repo = repoWith(LOOP)
|
|
632
|
+
const base = headOf(repo)
|
|
633
|
+
repo.writeFile("later.txt", "later\n")
|
|
634
|
+
repo.commitAllWithPrefix("feat: later")
|
|
635
|
+
await enter(repo, "review", `base=${base}`)
|
|
636
|
+
expect(reviewBaseFor(await provide(currentRest, repo))).toBe(base)
|
|
637
|
+
})
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
describe("memory", () => {
|
|
641
|
+
const SCOPED = `import { agent, human, read, scope } from "@pmelab/gtd/flows"
|
|
642
|
+
|
|
643
|
+
export default async () => {
|
|
644
|
+
await human("idle")
|
|
645
|
+
do await agent("build", "b")
|
|
646
|
+
while (read("ROOT_DONE") === undefined)
|
|
647
|
+
for (const round of [1, 2]) {
|
|
648
|
+
await scope("a", async () => {
|
|
649
|
+
await agent("work", "a")
|
|
650
|
+
if (round === 2) {
|
|
651
|
+
await scope("child", () => agent("work", "c"))
|
|
652
|
+
await agent("again", "a-again")
|
|
653
|
+
}
|
|
654
|
+
})
|
|
655
|
+
if (round === 1) await scope("b", () => agent("work", "b"))
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
`
|
|
659
|
+
|
|
660
|
+
const memoryAt = async (repo: InMemRepo) => {
|
|
661
|
+
const rest = await provide(currentRest, repo)
|
|
662
|
+
const rendered = await provide(renderRest(rest), repo)
|
|
663
|
+
return { state: rest.state, key: rest.memory, resumed: rendered.memoryResumed, rendered }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
it("keys each agent rest by <scope>#<hash7>, resuming only within one unbroken run of its scope", async () => {
|
|
667
|
+
const repo = repoWith(SCOPED)
|
|
668
|
+
const boundary = headOf(repo)
|
|
669
|
+
let n = 0
|
|
670
|
+
const next = () => land(repo, { [`f${n++}.txt`]: "x\n" })
|
|
671
|
+
|
|
672
|
+
const idle = await memoryAt(repo)
|
|
673
|
+
expect(idle).toMatchObject({ state: "idle", key: undefined, resumed: false })
|
|
674
|
+
expect(idle.rendered).not.toHaveProperty("memory")
|
|
675
|
+
|
|
676
|
+
await next()
|
|
677
|
+
const rootKey = `root#${boundary.slice(0, 7)}`
|
|
678
|
+
expect(await memoryAt(repo)).toMatchObject({ state: "build", key: rootKey, resumed: false })
|
|
679
|
+
|
|
680
|
+
await next()
|
|
681
|
+
expect(await memoryAt(repo)).toMatchObject({ state: "build", key: rootKey, resumed: true })
|
|
682
|
+
|
|
683
|
+
await land(repo, { ROOT_DONE: "" })
|
|
684
|
+
const firstA = await memoryAt(repo)
|
|
685
|
+
expect(firstA).toMatchObject({ state: "a.work", resumed: false })
|
|
686
|
+
expect(firstA.key).toMatch(/^a#[0-9a-f]{7}$/)
|
|
687
|
+
|
|
688
|
+
await next()
|
|
689
|
+
expect(await memoryAt(repo)).toMatchObject({ state: "b.work", resumed: false })
|
|
690
|
+
|
|
691
|
+
await next()
|
|
692
|
+
const secondA = await memoryAt(repo)
|
|
693
|
+
expect(secondA).toMatchObject({ state: "a.work", resumed: false })
|
|
694
|
+
expect(secondA.key).toMatch(/^a#[0-9a-f]{7}$/)
|
|
695
|
+
expect(secondA.key).not.toBe(firstA.key)
|
|
696
|
+
|
|
697
|
+
await next()
|
|
698
|
+
expect((await memoryAt(repo)).key).toMatch(/^a\.child#[0-9a-f]{7}$/)
|
|
699
|
+
|
|
700
|
+
await next()
|
|
701
|
+
expect(await memoryAt(repo)).toMatchObject({
|
|
702
|
+
state: "a.again",
|
|
703
|
+
key: secondA.key,
|
|
704
|
+
resumed: true,
|
|
705
|
+
})
|
|
706
|
+
})
|
|
707
|
+
it("never resumes a session only a child scope's turn created", async () => {
|
|
708
|
+
const repo = repoWith(`import { agent, human, scope } from "@pmelab/gtd/flows"
|
|
709
|
+
|
|
710
|
+
export default async () => {
|
|
711
|
+
await human("idle")
|
|
712
|
+
await scope("child", () => agent("work", "c"))
|
|
713
|
+
await agent("build", "b")
|
|
714
|
+
}
|
|
715
|
+
`)
|
|
716
|
+
await land(repo, { "start.txt": "x\n" })
|
|
717
|
+
await land(repo, { "child.txt": "x\n" })
|
|
718
|
+
expect(await memoryAt(repo)).toMatchObject({ state: "build", resumed: false })
|
|
719
|
+
})
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
describe("vars", () => {
|
|
723
|
+
const VARS = `import { agent, human } from "@pmelab/gtd/flows"
|
|
724
|
+
|
|
725
|
+
export default async () => {
|
|
726
|
+
await human("idle")
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export const side = async () => {
|
|
730
|
+
await agent("working", "w")
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export const defaults = { deployTarget: "npm test", reviewer: "nobody" }
|
|
734
|
+
`
|
|
735
|
+
const seeded = () => repoWith(VARS)
|
|
736
|
+
|
|
737
|
+
it("resolve from the workflow's defaults", async () => {
|
|
738
|
+
const rest = await provide(currentRest, seeded())
|
|
739
|
+
expect(rest.vars).toEqual({ deployTarget: "npm test", reviewer: "nobody" })
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it("a started workflow's Gtd-Var overrides the workflow default", async () => {
|
|
743
|
+
const repo = seeded()
|
|
744
|
+
await enter(repo, "side", "deployTarget=npm run entry")
|
|
745
|
+
const rest = await provide(currentRest, repo)
|
|
746
|
+
expect(rest.vars.deployTarget).toBe("npm run entry")
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
it("GTD_<NAME> beats the default while nothing is pinned", async () => {
|
|
750
|
+
const rest = await provide(currentRest, seeded(), { GTD_DEPLOYTARGET: "echo env-wins" })
|
|
751
|
+
expect(rest.vars.deployTarget).toBe("echo env-wins")
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
it("a pinned value beats a GTD_<NAME> exported later, and a default edited later", async () => {
|
|
755
|
+
const repo = seeded()
|
|
756
|
+
await enter(repo, "side", "deployTarget=npm run entry")
|
|
757
|
+
repo.writeFile("gtd.config.ts", VARS.replace('"npm test"', '"npm run edited"'))
|
|
758
|
+
const rest = await provide(currentRest, repo, { GTD_DEPLOYTARGET: "echo env-late" })
|
|
759
|
+
expect(rest.vars.deployTarget).toBe("npm run entry")
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
it("a started workflow pins every process setting, so a later default change is ignored", async () => {
|
|
763
|
+
const repo = seeded()
|
|
764
|
+
await enter(repo, "side")
|
|
765
|
+
repo.writeFile("gtd.config.ts", VARS.replace('"nobody"', '"somebody"'))
|
|
766
|
+
const run = await provide(currentRun, repo)
|
|
767
|
+
expect(run.pinnedVars).toEqual({ reviewer: "nobody", deployTarget: "npm test" })
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it("ignores a GTD_ env var naming no declared var", async () => {
|
|
771
|
+
const rest = await provide(currentRest, seeded(), { GTD_BRANDNEW: "hello" })
|
|
772
|
+
expect(Object.keys(rest.vars).sort()).toEqual(["deployTarget", "reviewer"])
|
|
773
|
+
})
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
describe("environment settings", () => {
|
|
777
|
+
const ENVV = `import { agent, human } from "@pmelab/gtd/flows"
|
|
778
|
+
|
|
779
|
+
export default async () => {
|
|
780
|
+
await human("idle")
|
|
781
|
+
await agent("working", "w")
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export const defaults = { floor: "0.7" }
|
|
785
|
+
export const envDefaults = { testCommand: "npm test" }
|
|
786
|
+
`
|
|
787
|
+
const seeded = () => repoWith(ENVV)
|
|
788
|
+
|
|
789
|
+
it("resolve from envDefaults and GTD_<NAME>, and never reach vars", async () => {
|
|
790
|
+
const repo = seeded()
|
|
791
|
+
expect((await provide(currentRest, repo)).env).toEqual({ testCommand: "npm test" })
|
|
792
|
+
const rest = await provide(currentRest, repo, { GTD_TESTCOMMAND: "echo env" })
|
|
793
|
+
expect(rest.env.testCommand).toBe("echo env")
|
|
794
|
+
expect(rest.vars).toEqual({ floor: "0.7" })
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it("are read live mid-process: an edited envDefaults applies, and nothing is recorded", async () => {
|
|
798
|
+
const repo = seeded()
|
|
799
|
+
await land(repo, { "a.txt": "a\n" })
|
|
800
|
+
repo.writeFile("gtd.config.ts", ENVV.replace('"npm test"', '"npm run edited"'))
|
|
801
|
+
const rest = await provide(currentRest, repo)
|
|
802
|
+
expect(rest.state).toBe("working")
|
|
803
|
+
expect(rest.env.testCommand).toBe("npm run edited")
|
|
804
|
+
expect((await provide(currentRun, repo)).pinnedVars).toEqual({ floor: "0.7" })
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
it("an ordinary start's first commit pins every process setting, sorted by name", async () => {
|
|
808
|
+
const repo = repoWith(LINEAR)
|
|
809
|
+
await land(repo, { "a.txt": "a\n" })
|
|
810
|
+
expect(repo.lastCommitMessage()).toContain(
|
|
811
|
+
"\n\nGtd-Step: idle#1\nGtd-Var: base=\nGtd-Var: reviewer=nobody",
|
|
812
|
+
)
|
|
813
|
+
await land(repo)
|
|
814
|
+
expect((await provide(currentRun, repo)).pinnedVars).toEqual({ base: "", reviewer: "nobody" })
|
|
815
|
+
})
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
describe("stalledAt", () => {
|
|
819
|
+
it("is true on a clean tree when HEAD is an empty attempt at this agent rest", async () => {
|
|
820
|
+
const repo = repoWith(LINEAR)
|
|
821
|
+
await land(repo, { "a.txt": "a\n" })
|
|
822
|
+
await land(repo)
|
|
823
|
+
const rest = await provide(currentRest, repo)
|
|
824
|
+
expect(rest.state).toBe("building")
|
|
825
|
+
expect(stalledAt(rest)).toBe(true)
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
it("is false on a dirty tree", async () => {
|
|
829
|
+
const repo = repoWith(LINEAR)
|
|
830
|
+
await land(repo, { "a.txt": "a\n" })
|
|
831
|
+
await land(repo)
|
|
832
|
+
repo.writeFile("scratch.txt", "x\n")
|
|
833
|
+
expect(stalledAt(await provide(currentRest, repo))).toBe(false)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
it("is false when HEAD's turn changed something", async () => {
|
|
837
|
+
const repo = repoWith(LINEAR)
|
|
838
|
+
await land(repo, { "a.txt": "a\n" })
|
|
839
|
+
expect(stalledAt(await provide(currentRest, repo))).toBe(false)
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
it("is false at an allowEmpty step, even with an empty attempt at HEAD", async () => {
|
|
843
|
+
const repo = repoWith(LINEAR)
|
|
844
|
+
await enter(repo, "side")
|
|
845
|
+
await land(repo, { "fix.txt": "x\n" })
|
|
846
|
+
repo.commitAllWithPrefix("gtd(agent): tidying")
|
|
847
|
+
const rest = await provide(currentRest, repo)
|
|
848
|
+
expect(rest.state).toBe("tidying")
|
|
849
|
+
expect(stalledAt(rest)).toBe(false)
|
|
850
|
+
})
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
describe("noProcessUnderway / restIsIdle", () => {
|
|
854
|
+
it("both hold at the default entry's first step on a clean tree", async () => {
|
|
855
|
+
const rest = await provide(currentRest, repoWith(LINEAR))
|
|
856
|
+
expect(noProcessUnderway(rest)).toBe(true)
|
|
857
|
+
expect(restIsIdle(rest)).toBe(true)
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
it("a dirty tree there is a turn not yet landed — no process, but not idle", async () => {
|
|
861
|
+
const repo = repoWith(LINEAR)
|
|
862
|
+
repo.writeFile("a.txt", "a\n")
|
|
863
|
+
const rest = await provide(currentRest, repo)
|
|
864
|
+
expect(noProcessUnderway(rest)).toBe(true)
|
|
865
|
+
expect(restIsIdle(rest)).toBe(false)
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
it("neither holds once a turn has landed", async () => {
|
|
869
|
+
const repo = repoWith(LINEAR)
|
|
870
|
+
await land(repo, { "a.txt": "a\n" })
|
|
871
|
+
const rest = await provide(currentRest, repo)
|
|
872
|
+
expect(noProcessUnderway(rest)).toBe(false)
|
|
873
|
+
expect(restIsIdle(rest)).toBe(false)
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
it("neither holds inside a manual entry", async () => {
|
|
877
|
+
const repo = repoWith(LINEAR)
|
|
878
|
+
await enter(repo, "side")
|
|
879
|
+
expect(noProcessUnderway(await provide(currentRest, repo))).toBe(false)
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it("both hold again once the process closes", async () => {
|
|
883
|
+
const repo = repoWith(LINEAR)
|
|
884
|
+
await land(repo, { "a.txt": "a\n" })
|
|
885
|
+
await land(repo, { "b.txt": "b\n" })
|
|
886
|
+
await land(repo, { "c.txt": "c\n" })
|
|
887
|
+
expect(restIsIdle(await provide(currentRest, repo))).toBe(true)
|
|
888
|
+
})
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
describe("judge rests", () => {
|
|
892
|
+
const JUDGED = (budget: string) => `import { human, judge, read } from "@pmelab/gtd/flows"
|
|
893
|
+
|
|
894
|
+
export default async () => {
|
|
895
|
+
await human("idle")
|
|
896
|
+
await judge("verdict", {
|
|
897
|
+
questions: [{ id: "q", primitive: "noul", instructions: "i", criteria: "c" }],
|
|
898
|
+
evidence: { log: read("LOG.md") ?? "" },
|
|
899
|
+
message: "judge-message",
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export const defaults = { judgeBudgetBytes: ${JSON.stringify(budget)} }
|
|
904
|
+
`
|
|
905
|
+
const LOG = Array.from({ length: 10 }, (_, i) => `line ${i} of the log`).join("\n") + "\n"
|
|
906
|
+
|
|
907
|
+
const judgedAt = async (budget: string) => {
|
|
908
|
+
const repo = repoWith(JUDGED(budget), { "LOG.md": LOG })
|
|
909
|
+
await land(repo, { "a.txt": "a\n" })
|
|
910
|
+
const rest = await provide(currentRest, repo)
|
|
911
|
+
return { rest, rendered: await provide(renderRest(rest), repo) }
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
it("appends the truncation notice when a bounded read dropped evidence", async () => {
|
|
915
|
+
const { rest, rendered } = await judgedAt("40")
|
|
916
|
+
expect(rest.state).toBe("verdict")
|
|
917
|
+
expect(rendered.kind).toBe("message")
|
|
918
|
+
expect(rendered.content).toBe(`judge-message\n\n${TRUNCATION_NOTICE}`)
|
|
919
|
+
expect(rendered.judge).not.toContain("line 0 of the log")
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it("leaves the message alone when nothing was cut", async () => {
|
|
923
|
+
const { rendered } = await judgedAt("32768")
|
|
924
|
+
expect(rendered.content).toBe("judge-message")
|
|
925
|
+
expect(rendered.judge).toContain("line 0 of the log")
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it.each(["", "not-a-number", "0", "-1", "1.5"])(
|
|
929
|
+
"refuses to resolve the rest when judgeBudgetBytes is %j",
|
|
930
|
+
async (budget) => {
|
|
931
|
+
const exit = await provideExit(currentRest, repoWith(JUDGED(budget), { "LOG.md": LOG }))
|
|
932
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
933
|
+
expect(String(Exit.isFailure(exit) && exit.cause)).toContain("judgeBudgetBytes")
|
|
934
|
+
},
|
|
935
|
+
)
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
describe("snapshotFromRest", () => {
|
|
939
|
+
const STEERED = `import { agent, human } from "@pmelab/gtd/flows"
|
|
940
|
+
|
|
941
|
+
export default async () => {
|
|
942
|
+
await human("idle")
|
|
943
|
+
await agent("steered", "write-it", { file: ".gtd/AWAIT.md" })
|
|
944
|
+
}
|
|
945
|
+
`
|
|
946
|
+
|
|
947
|
+
it("names the resting step's steering file and decides the landing", async () => {
|
|
948
|
+
const repo = repoWith(STEERED)
|
|
949
|
+
await land(repo, { "src/a.ts": "a\n" })
|
|
950
|
+
repo.writeFile(".gtd/AWAIT.md", "pending content\n")
|
|
951
|
+
const snapshot = await provide(snapshotFromRest(await provide(currentRest, repo)), repo)
|
|
952
|
+
expect(snapshot.state).toBe("steered")
|
|
953
|
+
expect(snapshot.file).toBe(".gtd/AWAIT.md")
|
|
954
|
+
expect(snapshot.landing.kind).toBe("commit")
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
it("names no file at a step that declares none", async () => {
|
|
958
|
+
const snapshot = await provide(
|
|
959
|
+
snapshotFromRest(await provide(currentRest, repoWith(STEERED))),
|
|
960
|
+
repoWith(STEERED),
|
|
961
|
+
)
|
|
962
|
+
expect(snapshot.file).toBeUndefined()
|
|
963
|
+
})
|
|
964
|
+
})
|
|
965
|
+
|
|
966
|
+
describe("the pending working tree is read at most once per restAt, and never stale across a long-lived reuse", () => {
|
|
967
|
+
const countingWorkspace = (
|
|
968
|
+
repo: InMemRepo,
|
|
969
|
+
root = "/repo",
|
|
970
|
+
): { readonly workspace: WorkspaceOps; readonly calls: () => number } => {
|
|
971
|
+
const base = makeInMemoryWorkspaceOps(repo, root)
|
|
972
|
+
let calls = 0
|
|
973
|
+
return {
|
|
974
|
+
workspace: {
|
|
975
|
+
...base,
|
|
976
|
+
worktreeSync: () => {
|
|
977
|
+
calls++
|
|
978
|
+
return base.worktreeSync()
|
|
979
|
+
},
|
|
980
|
+
},
|
|
981
|
+
calls: () => calls,
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
it("decideLanding reads the working tree exactly once for one rest, even though it's the same ReplaySetup entryRefusal would also reuse", async () => {
|
|
986
|
+
const repo = repoWith(LINEAR)
|
|
987
|
+
write(repo, { "scratch.txt": "dirty\n" }) // a pending change forces the real replay path, not cleanLanding's early return
|
|
988
|
+
const { workspace, calls } = countingWorkspace(repo)
|
|
989
|
+
const rest = await provideWithWorkspace(currentRest, workspace, repo)
|
|
990
|
+
expect(calls()).toBe(0) // restAt itself never touches the pending tree
|
|
991
|
+
await provideWithWorkspace(snapshotFromRest(rest), workspace, repo)
|
|
992
|
+
expect(calls()).toBe(1)
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
it("a SECOND restAt call sharing the same Workspace instance (an LSP's long-lived runtimeCache reuse) reads the working tree fresh, not a stale first-request snapshot", async () => {
|
|
996
|
+
const repo = repoWith(LINEAR)
|
|
997
|
+
write(repo, { "scratch.txt": "first\n" })
|
|
998
|
+
const { workspace, calls } = countingWorkspace(repo)
|
|
999
|
+
|
|
1000
|
+
const rest1 = await provideWithWorkspace(currentRest, workspace, repo)
|
|
1001
|
+
const snap1 = await provideWithWorkspace(snapshotFromRest(rest1), workspace, repo)
|
|
1002
|
+
expect(snap1.changes.map((c) => c.path)).toContain("scratch.txt")
|
|
1003
|
+
|
|
1004
|
+
// Between two "requests" against the same long-lived Workspace, the tree changes.
|
|
1005
|
+
write(repo, { "second.txt": "second\n" })
|
|
1006
|
+
|
|
1007
|
+
const rest2 = await provideWithWorkspace(currentRest, workspace, repo)
|
|
1008
|
+
const snap2 = await provideWithWorkspace(snapshotFromRest(rest2), workspace, repo)
|
|
1009
|
+
expect(snap2.changes.map((c) => c.path)).toContain("second.txt")
|
|
1010
|
+
|
|
1011
|
+
// One worktreeSync call per request, not one total — a workspace-scoped
|
|
1012
|
+
// memo (rather than the per-`ReplaySetup` one) would have kept `calls()`
|
|
1013
|
+
// at 1 and `snap2` stuck on the first request's tree.
|
|
1014
|
+
expect(calls()).toBe(2)
|
|
1015
|
+
})
|
|
1016
|
+
})
|