@pmelab/gtd 23.2.0 → 24.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 +44 -11
- package/claude/hooks/access.ts +1 -1
- 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 +43830 -56044
- 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 +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- 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/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -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/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import fc from "fast-check"
|
|
3
|
+
import {
|
|
4
|
+
FLOWS_PROTOCOL,
|
|
5
|
+
agent,
|
|
6
|
+
changes,
|
|
7
|
+
changesSince,
|
|
8
|
+
codeThreads,
|
|
9
|
+
human,
|
|
10
|
+
judge,
|
|
11
|
+
read,
|
|
12
|
+
refuse,
|
|
13
|
+
restart,
|
|
14
|
+
run,
|
|
15
|
+
scope,
|
|
16
|
+
threads,
|
|
17
|
+
type Flow,
|
|
18
|
+
type ScopeAccess,
|
|
19
|
+
} from "../flows/index.js"
|
|
20
|
+
import type { Episode, ReplayOutcome } from "./Replay.js"
|
|
21
|
+
import {
|
|
22
|
+
formatCommitMessage,
|
|
23
|
+
replay,
|
|
24
|
+
treeFromRecord,
|
|
25
|
+
type JudgeVerdict,
|
|
26
|
+
type TreeView,
|
|
27
|
+
} from "./index.js"
|
|
28
|
+
|
|
29
|
+
// A tiny in-memory history: each `land` commits a tree and a message, the way
|
|
30
|
+
// a landing would, so tests read like the process they model.
|
|
31
|
+
interface Commit {
|
|
32
|
+
readonly hash: string
|
|
33
|
+
readonly message: string
|
|
34
|
+
readonly tree: TreeView
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const hashOf = (n: number): string => n.toString(16).padStart(40, "0")
|
|
38
|
+
|
|
39
|
+
class History {
|
|
40
|
+
readonly commits: Commit[] = []
|
|
41
|
+
files: Record<string, string> = {}
|
|
42
|
+
private counter = 1
|
|
43
|
+
|
|
44
|
+
land(
|
|
45
|
+
actor: string,
|
|
46
|
+
from: string,
|
|
47
|
+
occurrence: number,
|
|
48
|
+
to: string,
|
|
49
|
+
edit: Record<string, string | null> = {},
|
|
50
|
+
judgeVerdicts?: readonly JudgeVerdict[],
|
|
51
|
+
): this {
|
|
52
|
+
this.apply(edit)
|
|
53
|
+
this.commits.push({
|
|
54
|
+
hash: hashOf(this.counter++),
|
|
55
|
+
message: formatCommitMessage({
|
|
56
|
+
actor,
|
|
57
|
+
from,
|
|
58
|
+
to,
|
|
59
|
+
step: { name: from, occurrence },
|
|
60
|
+
...(judgeVerdicts !== undefined ? { judge: judgeVerdicts } : {}),
|
|
61
|
+
}),
|
|
62
|
+
tree: treeFromRecord({ ...this.files }),
|
|
63
|
+
})
|
|
64
|
+
return this
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
raw(message: string, edit: Record<string, string | null> = {}): this {
|
|
68
|
+
this.apply(edit)
|
|
69
|
+
this.commits.push({
|
|
70
|
+
hash: hashOf(this.counter++),
|
|
71
|
+
message,
|
|
72
|
+
tree: treeFromRecord({ ...this.files }),
|
|
73
|
+
})
|
|
74
|
+
return this
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private apply(edit: Record<string, string | null>): void {
|
|
78
|
+
const next = { ...this.files }
|
|
79
|
+
for (const [path, content] of Object.entries(edit)) {
|
|
80
|
+
if (content === null) delete next[path]
|
|
81
|
+
else next[path] = content
|
|
82
|
+
}
|
|
83
|
+
this.files = next
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
episode(): Episode {
|
|
87
|
+
return {
|
|
88
|
+
base: { hash: hashOf(0), tree: treeFromRecord({ "README.md": "# x\n" }) },
|
|
89
|
+
commits: this.commits,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const replayOf = (
|
|
95
|
+
wf: Flow,
|
|
96
|
+
h: History,
|
|
97
|
+
extra: { pending?: Record<string, string>; verdicts?: JudgeVerdict[] } = {},
|
|
98
|
+
): Promise<ReplayOutcome> =>
|
|
99
|
+
replay({
|
|
100
|
+
flow: wf,
|
|
101
|
+
episode: h.episode(),
|
|
102
|
+
vars: { cap: "2" },
|
|
103
|
+
env: {},
|
|
104
|
+
start: hashOf(0),
|
|
105
|
+
budgetBytes: 1024,
|
|
106
|
+
...(extra.pending !== undefined
|
|
107
|
+
? {
|
|
108
|
+
pending: {
|
|
109
|
+
tree: treeFromRecord({ ...h.files, ...extra.pending }),
|
|
110
|
+
...(extra.verdicts !== undefined ? { verdicts: extra.verdicts } : {}),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const PROTOCOL_KEY = Symbol.for("@pmelab/gtd/flow-protocol")
|
|
117
|
+
|
|
118
|
+
describe("the flows protocol handshake", () => {
|
|
119
|
+
it("installs the protocol it implements under the registered key", async () => {
|
|
120
|
+
let seen: unknown
|
|
121
|
+
await replayOf(async () => {
|
|
122
|
+
seen = (globalThis as Record<symbol, unknown>)[PROTOCOL_KEY]
|
|
123
|
+
await human("idle")
|
|
124
|
+
}, new History())
|
|
125
|
+
expect(seen).toBe(FLOWS_PROTOCOL)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("refuses a mismatch the workflow caught and swallowed", async () => {
|
|
129
|
+
const outcome = await replayOf(async () => {
|
|
130
|
+
;(globalThis as Record<symbol, unknown>)[PROTOCOL_KEY] = FLOWS_PROTOCOL + 1
|
|
131
|
+
await human("idle").catch(() => undefined)
|
|
132
|
+
}, new History())
|
|
133
|
+
expect(outcome).toEqual({
|
|
134
|
+
kind: "failed",
|
|
135
|
+
message: `gtd: the workflow speaks flows protocol ${FLOWS_PROTOCOL}, but the engine installed protocol ${FLOWS_PROTOCOL + 1} — upgrade the @pmelab/gtd the workflow imports`,
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("frames an ordinary workflow error as the workflow throwing", async () => {
|
|
140
|
+
const outcome = await replayOf(async () => {
|
|
141
|
+
throw new Error("boom")
|
|
142
|
+
}, new History())
|
|
143
|
+
expect(outcome).toEqual({ kind: "failed", message: "gtd: the workflow threw: boom" })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("unwraps a mismatch thrown by a second copy of the flows facade", async () => {
|
|
147
|
+
// A workflow package resolving its own @pmelab/gtd throws that copy's
|
|
148
|
+
// class, which `instanceof` against the engine's copy never matches.
|
|
149
|
+
class FlowsProtocolError extends Error {
|
|
150
|
+
override name = "FlowsProtocolError"
|
|
151
|
+
}
|
|
152
|
+
const outcome = await replayOf(async () => {
|
|
153
|
+
throw new FlowsProtocolError("gtd: the workflow speaks flows protocol 0")
|
|
154
|
+
}, new History())
|
|
155
|
+
expect(outcome).toEqual({
|
|
156
|
+
kind: "failed",
|
|
157
|
+
message: "gtd: the workflow speaks flows protocol 0",
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const restName = (outcome: ReplayOutcome): string => {
|
|
163
|
+
if (outcome.kind !== "rest") throw new Error(`expected a rest, got ${JSON.stringify(outcome)}`)
|
|
164
|
+
return `${outcome.rest.name}#${outcome.rest.id.occurrence}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Plain loops, a counter cap, and helper-driven branches: the shape the
|
|
168
|
+
// bundled workflow has.
|
|
169
|
+
const fixLoop = async () => {
|
|
170
|
+
await human("idle", { file: "TODO.md" })
|
|
171
|
+
for (let attempt = 1; ; attempt++) {
|
|
172
|
+
await run("check", "npm test")
|
|
173
|
+
if (read(".gtd/FEEDBACK.md") === undefined) break
|
|
174
|
+
if (attempt > 2) {
|
|
175
|
+
await human("stuck")
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
await agent("fix", "fix it")
|
|
179
|
+
}
|
|
180
|
+
await human("done")
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe("replay", () => {
|
|
184
|
+
it("rests at the first step of an empty episode", async () => {
|
|
185
|
+
expect(restName(await replayOf(fixLoop, new History()))).toBe("idle#1")
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("answers each step from its commit and rests at the first step without one", async () => {
|
|
189
|
+
const h = new History()
|
|
190
|
+
.land("human", "idle", 1, "check", { "TODO.md": "go" })
|
|
191
|
+
.land("check", "check", 1, "fix", { ".gtd/FEEDBACK.md": "red" })
|
|
192
|
+
expect(restName(await replayOf(fixLoop, h))).toBe("fix#1")
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("branches on the tree the step committed", async () => {
|
|
196
|
+
const h = new History()
|
|
197
|
+
.land("human", "idle", 1, "check", { "TODO.md": "go" })
|
|
198
|
+
.land("check", "check", 1, "done", {})
|
|
199
|
+
expect(restName(await replayOf(fixLoop, h))).toBe("done#1")
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("counts loop rounds with an ordinary counter", async () => {
|
|
203
|
+
const h = new History().land("human", "idle", 1, "check", { "TODO.md": "go" })
|
|
204
|
+
for (let round = 1; round <= 2; round++) {
|
|
205
|
+
h.land("check", "check", round, "fix", { ".gtd/FEEDBACK.md": `red ${round}` })
|
|
206
|
+
h.land("agent", "fix", round, "check", { "src/a.ts": `${round}` })
|
|
207
|
+
}
|
|
208
|
+
h.land("check", "check", 3, "stuck", { ".gtd/FEEDBACK.md": "red 3" })
|
|
209
|
+
expect(restName(await replayOf(fixLoop, h))).toBe("stuck#1")
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("is deterministic: the same history always yields the same rest", async () => {
|
|
213
|
+
await fc.assert(
|
|
214
|
+
fc.asyncProperty(fc.array(fc.boolean(), { maxLength: 6 }), async (reds) => {
|
|
215
|
+
const h = new History().land("human", "idle", 1, "check", { "TODO.md": "go" })
|
|
216
|
+
let checks = 0
|
|
217
|
+
let fixes = 0
|
|
218
|
+
for (const red of reds) {
|
|
219
|
+
checks++
|
|
220
|
+
if (!red) {
|
|
221
|
+
h.land("check", "check", checks, "done", { ".gtd/FEEDBACK.md": null })
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
const to = checks > 2 ? "stuck" : "fix"
|
|
225
|
+
h.land("check", "check", checks, to, { ".gtd/FEEDBACK.md": `red ${checks}` })
|
|
226
|
+
if (to === "stuck") {
|
|
227
|
+
h.land("human", "stuck", checks - 2, "check")
|
|
228
|
+
} else {
|
|
229
|
+
fixes++
|
|
230
|
+
h.land("agent", "fix", fixes, "check", { "src/a.ts": `${fixes}` })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const first = await replayOf(fixLoop, h)
|
|
234
|
+
const second = await replayOf(fixLoop, h)
|
|
235
|
+
expect(first.kind).not.toBe("divergence")
|
|
236
|
+
expect(JSON.stringify(second)).toBe(JSON.stringify(first))
|
|
237
|
+
}),
|
|
238
|
+
{ numRuns: 40 },
|
|
239
|
+
)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("threads() gives a flow each thread's name, 1-based line and waitingOn", async () => {
|
|
243
|
+
const doc = "a.[^q]\n\n[^q]:\n - H: why?\n - A: because\n"
|
|
244
|
+
const flow = async () => {
|
|
245
|
+
await human("idle")
|
|
246
|
+
const open = threads(read("NOTES.md") ?? "").filter((t) => t.waitingOn === "human")
|
|
247
|
+
await human(
|
|
248
|
+
open.length === 1 && open[0]!.name === "q" && open[0]!.line === 3 ? "open" : "none",
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
const h = new History().land("human", "idle", 1, "open", { "NOTES.md": doc })
|
|
252
|
+
expect(restName(await replayOf(flow, h))).toBe("open#1")
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("threads() carries each thread's own syntax faults, prefixed with its name", async () => {
|
|
256
|
+
const doc = "a.[^q] b.[^r]\n\n[^q]:\n - H: why?\n - H: again\n\n[^r]:\n - H: ok\n"
|
|
257
|
+
const flow = async () => {
|
|
258
|
+
await human("idle")
|
|
259
|
+
const [q, r] = threads(read("NOTES.md") ?? "")
|
|
260
|
+
const ok =
|
|
261
|
+
q!.faults.length === 1 &&
|
|
262
|
+
q!.faults[0]!.startsWith('Footnote thread "[^q]": two consecutive') &&
|
|
263
|
+
r!.faults.length === 0
|
|
264
|
+
await human(ok ? "open" : "none")
|
|
265
|
+
}
|
|
266
|
+
const h = new History().land("human", "idle", 1, "open", { "NOTES.md": doc })
|
|
267
|
+
expect(restName(await replayOf(flow, h))).toBe("open#1")
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("codeThreads() returns threads of files changed since the process base only", async () => {
|
|
271
|
+
const thread = ["// H: why?", "// A: because"].join("\n")
|
|
272
|
+
const open = ["x", "// H: ask"].join("\n")
|
|
273
|
+
const flow = async () => {
|
|
274
|
+
await human("idle")
|
|
275
|
+
const found = codeThreads()
|
|
276
|
+
const ok =
|
|
277
|
+
found.length === 1 &&
|
|
278
|
+
found[0]!.path === "src/new.ts" &&
|
|
279
|
+
found[0]!.line === 2 &&
|
|
280
|
+
found[0]!.first === "ask" &&
|
|
281
|
+
found[0]!.waitingOn === "agent"
|
|
282
|
+
await human(ok ? "open" : "none")
|
|
283
|
+
}
|
|
284
|
+
const h = new History().land("human", "idle", 1, "open", { "src/new.ts": open })
|
|
285
|
+
h.files = { ...h.files, "src/old.ts": thread }
|
|
286
|
+
const episode = h.episode()
|
|
287
|
+
const withBase: Episode = {
|
|
288
|
+
...episode,
|
|
289
|
+
base: {
|
|
290
|
+
...episode.base,
|
|
291
|
+
tree: treeFromRecord({ "src/old.ts": thread, "src/gone.ts": open }),
|
|
292
|
+
},
|
|
293
|
+
commits: [
|
|
294
|
+
{
|
|
295
|
+
...episode.commits[0]!,
|
|
296
|
+
tree: treeFromRecord({ "src/old.ts": thread, "src/new.ts": open }),
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
}
|
|
300
|
+
const outcome = await replay({
|
|
301
|
+
flow,
|
|
302
|
+
episode: withBase,
|
|
303
|
+
vars: {},
|
|
304
|
+
env: {},
|
|
305
|
+
start: hashOf(0),
|
|
306
|
+
budgetBytes: 1024,
|
|
307
|
+
})
|
|
308
|
+
expect(restName(outcome)).toBe("open#1")
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it("codeThreads() reaches back to a diff base older than the episode base", async () => {
|
|
312
|
+
// An entered process: its review base predates the opening commit, which
|
|
313
|
+
// is the episode base — so the opening commit's own edits still count.
|
|
314
|
+
const open = ["x", "// H: ask"].join("\n")
|
|
315
|
+
const flow = async () => {
|
|
316
|
+
await human("idle")
|
|
317
|
+
await human(codeThreads().length === 1 ? "open" : "none")
|
|
318
|
+
}
|
|
319
|
+
const h = new History()
|
|
320
|
+
h.files = { "src/a.ts": open }
|
|
321
|
+
h.land("human", "idle", 1, "open", { "TODO.md": "go" })
|
|
322
|
+
const episode = h.episode()
|
|
323
|
+
const outcome = await replay({
|
|
324
|
+
flow,
|
|
325
|
+
episode: {
|
|
326
|
+
...episode,
|
|
327
|
+
base: { hash: hashOf(99), tree: treeFromRecord({ "src/a.ts": open }) },
|
|
328
|
+
},
|
|
329
|
+
vars: {},
|
|
330
|
+
env: {},
|
|
331
|
+
start: hashOf(98),
|
|
332
|
+
startTree: treeFromRecord({}),
|
|
333
|
+
budgetBytes: 1024,
|
|
334
|
+
})
|
|
335
|
+
expect(restName(outcome)).toBe("open#1")
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
describe("divergence", () => {
|
|
339
|
+
it("is reported when a commit records a different step than replay reaches", async () => {
|
|
340
|
+
const h = new History().land("human", "idle", 1, "check", { "TODO.md": "go" })
|
|
341
|
+
h.land("check", "fix", 1, "check", { ".gtd/FEEDBACK.md": "red" })
|
|
342
|
+
const outcome = await replayOf(fixLoop, h)
|
|
343
|
+
expect(outcome.kind).toBe("divergence")
|
|
344
|
+
expect(outcome.kind === "divergence" && outcome.message).toContain('expected "check#1"')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it("is reported when a step commit carries no Gtd-Step trailer", async () => {
|
|
348
|
+
const h = new History().raw("gtd(human): idle → check", { "TODO.md": "go" })
|
|
349
|
+
const outcome = await replayOf(fixLoop, h)
|
|
350
|
+
expect(outcome.kind).toBe("divergence")
|
|
351
|
+
expect(outcome.kind === "divergence" && outcome.message).toContain("records no step")
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it("is reported when the subject names a different next step than replay reaches", async () => {
|
|
355
|
+
const h = new History().land("human", "idle", 1, "fix", { "TODO.md": "go" })
|
|
356
|
+
const outcome = await replayOf(fixLoop, h)
|
|
357
|
+
expect(outcome.kind).toBe("divergence")
|
|
358
|
+
expect(outcome.kind === "divergence" && outcome.message).toContain('reached "check"')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it("is reported when history continues past the end of the flow", async () => {
|
|
362
|
+
const short = async () => void (await human("only"))
|
|
363
|
+
const h = new History().land("human", "only", 1, "only").land("human", "only", 2, "only")
|
|
364
|
+
expect((await replayOf(short, h)).kind).toBe("divergence")
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it("skips an attempt — an empty, trailer-less self-loop at the resting step", async () => {
|
|
369
|
+
const h = new History()
|
|
370
|
+
.land("human", "idle", 1, "check", { "TODO.md": "go" })
|
|
371
|
+
.land("check", "check", 1, "fix", { ".gtd/FEEDBACK.md": "red" })
|
|
372
|
+
.raw("gtd(agent): fix")
|
|
373
|
+
expect(restName(await replayOf(fixLoop, h))).toBe("fix#1")
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it("lands a pending turn on the resting step and rests at the next one", async () => {
|
|
377
|
+
const h = new History().land("human", "idle", 1, "check", { "TODO.md": "go" })
|
|
378
|
+
const outcome = await replayOf(fixLoop, h, { pending: { ".gtd/FEEDBACK.md": "red" } })
|
|
379
|
+
expect(outcome.kind === "rest" && outcome.landed?.name).toBe("check")
|
|
380
|
+
expect(restName(outcome)).toBe("fix#1")
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it("reads helpers against the replay position, not the working tree", async () => {
|
|
384
|
+
const wf = async () => {
|
|
385
|
+
await human("write")
|
|
386
|
+
if (changes(".gtd/*.md").some((c) => c.status === "added"))
|
|
387
|
+
await agent("saw", read(".gtd/NOTE.md") ?? "")
|
|
388
|
+
else await agent("missed", "nothing")
|
|
389
|
+
}
|
|
390
|
+
const h = new History().land("human", "write", 1, "saw", { ".gtd/NOTE.md": "hello" })
|
|
391
|
+
const outcome = await replayOf(wf, h)
|
|
392
|
+
expect(restName(outcome)).toBe("saw#1")
|
|
393
|
+
expect(
|
|
394
|
+
outcome.kind === "rest" &&
|
|
395
|
+
outcome.rest.request.kind === "agent" &&
|
|
396
|
+
outcome.rest.request.prompt,
|
|
397
|
+
).toBe("hello")
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it("reports what the last step changed, with content on both sides", async () => {
|
|
401
|
+
let seen: unknown
|
|
402
|
+
const wf = async () => {
|
|
403
|
+
for (;;) {
|
|
404
|
+
await human("write")
|
|
405
|
+
seen = changes().map(({ path, status, before, after }) => ({ path, status, before, after }))
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const h = new History()
|
|
409
|
+
.land("human", "write", 1, "write", { keep: "old", gone: "bye" })
|
|
410
|
+
.land("human", "write", 2, "write", { keep: "new", gone: null, fresh: "hi" })
|
|
411
|
+
await replayOf(wf, h)
|
|
412
|
+
expect(seen).toEqual([
|
|
413
|
+
{ path: "fresh", status: "added", before: undefined, after: "hi" },
|
|
414
|
+
{ path: "gone", status: "deleted", before: "bye", after: undefined },
|
|
415
|
+
{ path: "keep", status: "modified", before: "old", after: "new" },
|
|
416
|
+
])
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it("changesSince(hash) diffs the tree at hash to the tree replay stands on", async () => {
|
|
420
|
+
let seen: unknown
|
|
421
|
+
const wf = async () => {
|
|
422
|
+
const since = hashOf(1)
|
|
423
|
+
for (;;) {
|
|
424
|
+
await human("write")
|
|
425
|
+
seen = changesSince(since).map(({ path, status }) => ({ path, status }))
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const h = new History()
|
|
429
|
+
.land("human", "write", 1, "write", { a: "1" })
|
|
430
|
+
.land("human", "write", 2, "write", { b: "1" })
|
|
431
|
+
.land("human", "write", 3, "write", { c: "1" })
|
|
432
|
+
await replayOf(wf, h)
|
|
433
|
+
expect(seen).toEqual([
|
|
434
|
+
{ path: "b", status: "added" },
|
|
435
|
+
{ path: "c", status: "added" },
|
|
436
|
+
])
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it("changesSince(hash) resolves against the episode base, and is empty over a zero-length range", async () => {
|
|
440
|
+
let seenFromBase: unknown
|
|
441
|
+
let seenFromLast: unknown
|
|
442
|
+
const wf = async () => {
|
|
443
|
+
for (;;) {
|
|
444
|
+
await human("write")
|
|
445
|
+
seenFromBase = changesSince(hashOf(0)).paths
|
|
446
|
+
seenFromLast = changesSince(hashOf(2)).paths
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const h = new History()
|
|
450
|
+
.land("human", "write", 1, "write", { a: "1" })
|
|
451
|
+
.land("human", "write", 2, "write", { b: "1" })
|
|
452
|
+
await replayOf(wf, h)
|
|
453
|
+
expect(seenFromBase).toEqual(["a", "b", "README.md"])
|
|
454
|
+
expect(seenFromLast).toEqual([])
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
// A squash or rebase never strands a captured hash: `since` is never
|
|
458
|
+
// persisted, and every replay re-derives its own commit list from whatever
|
|
459
|
+
// history exists at that moment (see the comment at `treeAt`). What DOES
|
|
460
|
+
// reach the guard is a hash from outside that run entirely — read out of
|
|
461
|
+
// state, from another branch, or otherwise fabricated. This case models
|
|
462
|
+
// that: a hash real in one episode, handed to a second, unrelated episode
|
|
463
|
+
// that never held it.
|
|
464
|
+
it("fails the step when changesSince is given a hash that resolved in an earlier episode but not this one", async () => {
|
|
465
|
+
const hash = hashOf(2)
|
|
466
|
+
const wf = async () => {
|
|
467
|
+
for (;;) {
|
|
468
|
+
await human("write")
|
|
469
|
+
changesSince(hash)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// First: prove `hash` really does resolve — a two-commit episode where it
|
|
473
|
+
// names the second commit.
|
|
474
|
+
const resolvable = new History()
|
|
475
|
+
.land("human", "write", 1, "write", { a: "1" })
|
|
476
|
+
.land("human", "write", 2, "write", { b: "1" })
|
|
477
|
+
const resolved = await replayOf(wf, resolvable)
|
|
478
|
+
expect(resolved.kind).not.toBe("failed")
|
|
479
|
+
|
|
480
|
+
// Then: a squash collapses those two commits into one, so the same hash
|
|
481
|
+
// no longer names anything in this episode's commit list.
|
|
482
|
+
const squashed = new History().land("human", "write", 1, "write", { a: "1", b: "1" })
|
|
483
|
+
const outcome = await replayOf(wf, squashed)
|
|
484
|
+
expect(outcome.kind).toBe("failed")
|
|
485
|
+
expect(outcome.kind === "failed" && outcome.message).toContain(
|
|
486
|
+
`changesSince(${hash}): ${hash} is not the episode base or one of its commits`,
|
|
487
|
+
)
|
|
488
|
+
expect(outcome.kind === "failed" && outcome.message).toContain("pass a hash this run read from")
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it("keeps a local variable across replayed steps", async () => {
|
|
492
|
+
let seen: string | undefined = "unset"
|
|
493
|
+
const wf = async () => {
|
|
494
|
+
let previous: string | undefined
|
|
495
|
+
for (;;) {
|
|
496
|
+
await run("check", "true")
|
|
497
|
+
seen = previous
|
|
498
|
+
previous = read("OUT")
|
|
499
|
+
await human("look")
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const h = new History()
|
|
503
|
+
.land("check", "check", 1, "look", { OUT: "first" })
|
|
504
|
+
.land("human", "look", 1, "check")
|
|
505
|
+
.land("check", "check", 2, "look", { OUT: "second" })
|
|
506
|
+
await replayOf(wf, h)
|
|
507
|
+
expect(seen).toBe("first")
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it("answers a judge step from its Gtd-Judge trailer", async () => {
|
|
511
|
+
const question = { id: "verdict", primitive: "choice", instructions: "", criteria: "" } as const
|
|
512
|
+
const wf = async () => {
|
|
513
|
+
const { answers } = await judge("same", { questions: [question], evidence: {} })
|
|
514
|
+
const verdict = answers.verdict
|
|
515
|
+
if (verdict?.answer === "identical" && verdict.p >= 0.7) await human("escalate")
|
|
516
|
+
else await human("retry")
|
|
517
|
+
}
|
|
518
|
+
const confident = new History().land("judge", "same", 1, "escalate", {}, [
|
|
519
|
+
{ id: "verdict", answer: "identical", p: 0.9 },
|
|
520
|
+
])
|
|
521
|
+
expect(restName(await replayOf(wf, confident))).toBe("escalate#1")
|
|
522
|
+
const unsure = new History().land("judge", "same", 1, "retry", {}, [
|
|
523
|
+
{ id: "verdict", answer: "identical", p: 0.5 },
|
|
524
|
+
])
|
|
525
|
+
expect(restName(await replayOf(wf, unsure))).toBe("retry#1")
|
|
526
|
+
const skipped = new History().land("judge", "same", 1, "retry")
|
|
527
|
+
expect(restName(await replayOf(wf, skipped))).toBe("retry#1")
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it("prefixes names inside scope() and derives the memory scope from the name", async () => {
|
|
531
|
+
const wf = () => scope("build", () => scope("health", () => agent("fix", "x")))
|
|
532
|
+
const outcome = await replayOf(wf, new History())
|
|
533
|
+
expect(restName(outcome)).toBe("build.health.fix#1")
|
|
534
|
+
expect(outcome.kind === "rest" && outcome.rest.memoryScope).toBe("build.health")
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it("gives a scope's model to the agent steps inside, and refuses two identities in one scope", async () => {
|
|
538
|
+
const wf = () =>
|
|
539
|
+
scope({ model: "smart" }, async () => {
|
|
540
|
+
await agent("one", "a")
|
|
541
|
+
await scope({ name: "plan", model: "base" }, () => agent("two", "b"))
|
|
542
|
+
await agent("three", "c", { model: "base" })
|
|
543
|
+
})
|
|
544
|
+
const modelAt = (outcome: ReplayOutcome) =>
|
|
545
|
+
outcome.kind === "rest" &&
|
|
546
|
+
outcome.rest.request.kind === "agent" &&
|
|
547
|
+
outcome.rest.request.options.model
|
|
548
|
+
expect(modelAt(await replayOf(wf, new History()))).toBe("smart")
|
|
549
|
+
const h = new History().land("agent", "one", 1, "plan.two", { a: "1" })
|
|
550
|
+
expect(modelAt(await replayOf(wf, h))).toBe("base")
|
|
551
|
+
h.land("agent", "plan.two", 1, "three", { b: "1" })
|
|
552
|
+
expect((await replayOf(wf, h)).kind).toBe("failed")
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it("ends the episode on return and on restart", async () => {
|
|
556
|
+
const returning = async () => void (await human("only"))
|
|
557
|
+
const h = new History().land("human", "only", 1, "only")
|
|
558
|
+
expect(await replayOf(returning, h)).toMatchObject({ kind: "ended", via: "return" })
|
|
559
|
+
|
|
560
|
+
const restarting = async () => {
|
|
561
|
+
await human("first")
|
|
562
|
+
await restart()
|
|
563
|
+
await human("never")
|
|
564
|
+
}
|
|
565
|
+
const r = new History().land("human", "first", 1, "first")
|
|
566
|
+
expect(await replayOf(restarting, r)).toMatchObject({ kind: "ended", via: "restart" })
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it("reports refuse() as a refusal and a throw as a failure", async () => {
|
|
570
|
+
const refusing = async () => {
|
|
571
|
+
await human("gate")
|
|
572
|
+
if (read("bad") !== undefined) refuse("no bad files")
|
|
573
|
+
await human("next")
|
|
574
|
+
}
|
|
575
|
+
const outcome = await replayOf(refusing, new History(), { pending: { bad: "x" } })
|
|
576
|
+
expect(outcome).toEqual({ kind: "refused", message: "no bad files" })
|
|
577
|
+
|
|
578
|
+
const throwing = async () => {
|
|
579
|
+
throw new Error("boom")
|
|
580
|
+
}
|
|
581
|
+
expect(await replayOf(throwing, new History())).toMatchObject({ kind: "failed" })
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it("fails a run step whose body is not a shell script", async () => {
|
|
585
|
+
const wf = async () => {
|
|
586
|
+
await run("check", (() => undefined) as unknown as string)
|
|
587
|
+
}
|
|
588
|
+
const outcome = await replayOf(wf, new History())
|
|
589
|
+
expect(outcome).toMatchObject({ kind: "failed" })
|
|
590
|
+
expect(outcome.kind === "failed" && outcome.message).toContain("shell script string")
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it("fails a flow that awaits something other than a step", async () => {
|
|
594
|
+
const waiting = async () => {
|
|
595
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
596
|
+
await human("late")
|
|
597
|
+
}
|
|
598
|
+
const outcome = await replayOf(waiting, new History())
|
|
599
|
+
expect(outcome.kind === "failed" && outcome.message).toContain("not a step")
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
describe("replay: skills resolution", () => {
|
|
604
|
+
interface Tiers {
|
|
605
|
+
skills?: Record<string, readonly string[]>
|
|
606
|
+
configuredSkills?: Record<string, readonly string[]>
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const restOf = async (flow: Flow, tiers: Tiers = {}) => {
|
|
610
|
+
const outcome = await replay({
|
|
611
|
+
flow,
|
|
612
|
+
episode: new History().episode(),
|
|
613
|
+
vars: {},
|
|
614
|
+
env: {},
|
|
615
|
+
start: hashOf(0),
|
|
616
|
+
budgetBytes: 1024,
|
|
617
|
+
...tiers,
|
|
618
|
+
})
|
|
619
|
+
return outcome
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const wireSkills = async (flow: Flow, tiers: Tiers = {}) => {
|
|
623
|
+
const outcome = await restOf(flow, tiers)
|
|
624
|
+
if (outcome.kind !== "rest") throw new Error(`expected a rest, got ${JSON.stringify(outcome)}`)
|
|
625
|
+
return outcome.rest.skills
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
type Body = () => Promise<void>
|
|
629
|
+
const inScope =
|
|
630
|
+
(options: { name: string; skills?: readonly string[] }, ...rest: Body[]): Flow =>
|
|
631
|
+
async () => {
|
|
632
|
+
await scope(options, async () => {
|
|
633
|
+
for (const f of rest) await f()
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
const step: Body = async () => {
|
|
637
|
+
await agent("step", "prompt")
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
it("rejects a skills option on agent()", async () => {
|
|
641
|
+
const outcome = await restOf(async () => {
|
|
642
|
+
await agent("step", "prompt", { skills: ["own"] } as never)
|
|
643
|
+
})
|
|
644
|
+
expect(outcome).toMatchObject({
|
|
645
|
+
kind: "failed",
|
|
646
|
+
message: 'gtd: step "step": unknown key(s) skills in agent() options',
|
|
647
|
+
})
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it("reads the list a scope() option declares", async () => {
|
|
651
|
+
expect(await wireSkills(inScope({ name: "a", skills: ["own"] }, step))).toEqual(["own"])
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it("inherits a parent scope's list in a nested scope", async () => {
|
|
655
|
+
const flow = inScope({ name: "a", skills: ["parent"] }, async () => {
|
|
656
|
+
await scope("b", step)
|
|
657
|
+
})
|
|
658
|
+
expect(await wireSkills(flow)).toEqual(["parent"])
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it("replaces the parent's list wholesale when a nested scope sets its own", async () => {
|
|
662
|
+
const flow = inScope({ name: "a", skills: ["parent"] }, async () => {
|
|
663
|
+
await scope({ name: "b", skills: ["child"] }, step)
|
|
664
|
+
})
|
|
665
|
+
expect(await wireSkills(flow)).toEqual(["child"])
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it("falls back to the bundled export keyed by scope, walking up prefixes", async () => {
|
|
669
|
+
const flow = inScope({ name: "a" }, async () => {
|
|
670
|
+
await scope("b", step)
|
|
671
|
+
})
|
|
672
|
+
expect(await wireSkills(flow, { skills: { a: ["bundled"] } })).toEqual(["bundled"])
|
|
673
|
+
expect(await wireSkills(flow, { skills: { a: ["x"], "a.b": ["inner"] } })).toEqual(["inner"])
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it("resolves an implicit dotted scope from its own export entry", async () => {
|
|
677
|
+
const flow = inScope({ name: "a" }, async () => {
|
|
678
|
+
await agent("review.reviewing", "prompt")
|
|
679
|
+
})
|
|
680
|
+
expect(await wireSkills(flow, { skills: { a: ["x"], "a.review": ["own"] } })).toEqual(["own"])
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it("ranks the config export above the scope() option above the export", async () => {
|
|
684
|
+
const flow = inScope({ name: "a", skills: ["option"] }, step)
|
|
685
|
+
expect(await wireSkills(flow, { skills: { a: ["bundled"] } })).toEqual(["option"])
|
|
686
|
+
expect(
|
|
687
|
+
await wireSkills(flow, { skills: { a: ["bundled"] }, configuredSkills: { a: ["rc"] } }),
|
|
688
|
+
).toEqual(["rc"])
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
it("lets a parent config entry reach a nested scope with no list, but not one with its own", async () => {
|
|
692
|
+
const bare = inScope({ name: "a" }, async () => {
|
|
693
|
+
await scope("b", step)
|
|
694
|
+
})
|
|
695
|
+
const own = inScope({ name: "a" }, async () => {
|
|
696
|
+
await scope({ name: "b", skills: ["child"] }, step)
|
|
697
|
+
})
|
|
698
|
+
expect(await wireSkills(bare, { configuredSkills: { a: ["rc"] } })).toEqual(["rc"])
|
|
699
|
+
expect(await wireSkills(own, { configuredSkills: { a: ["rc"] } })).toEqual(["child"])
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it("leaves an empty resolved list absent, never []", async () => {
|
|
703
|
+
const flow = inScope({ name: "a", skills: ["option"] }, step)
|
|
704
|
+
expect(await wireSkills(flow, { configuredSkills: { a: [] } })).toBeUndefined()
|
|
705
|
+
expect(await wireSkills(inScope({ name: "a" }, step), { skills: { a: [] } })).toBeUndefined()
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
it("fails two agent steps in one memory scope with different lists", async () => {
|
|
709
|
+
const flow: Flow = async () => {
|
|
710
|
+
await scope({ name: "a", skills: ["one"] }, async () => {
|
|
711
|
+
await agent("first", "prompt")
|
|
712
|
+
})
|
|
713
|
+
await scope({ name: "a", skills: ["two"] }, async () => {
|
|
714
|
+
await agent("second", "prompt")
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
const history = new History()
|
|
718
|
+
history.land("agent", "a.first", 1, "a.second", { "x.txt": "x" })
|
|
719
|
+
const outcome = await replay({
|
|
720
|
+
flow,
|
|
721
|
+
episode: history.episode(),
|
|
722
|
+
vars: {},
|
|
723
|
+
env: {},
|
|
724
|
+
start: hashOf(0),
|
|
725
|
+
budgetBytes: 1024,
|
|
726
|
+
})
|
|
727
|
+
expect(outcome.kind === "failed" && outcome.message).toContain(
|
|
728
|
+
"different model, system prompt, skills or file access",
|
|
729
|
+
)
|
|
730
|
+
})
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
describe("replay: access resolution", () => {
|
|
734
|
+
interface Tiers {
|
|
735
|
+
access?: Record<string, ScopeAccess>
|
|
736
|
+
configuredAccess?: Record<string, ScopeAccess>
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const restOf = (flow: Flow, tiers: Tiers = {}, history = new History()) =>
|
|
740
|
+
replay({
|
|
741
|
+
flow,
|
|
742
|
+
episode: history.episode(),
|
|
743
|
+
vars: {},
|
|
744
|
+
env: {},
|
|
745
|
+
start: hashOf(0),
|
|
746
|
+
budgetBytes: 1024,
|
|
747
|
+
...tiers,
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
const wireAccess = async (flow: Flow, tiers: Tiers = {}) => {
|
|
751
|
+
const outcome = await restOf(flow, tiers)
|
|
752
|
+
if (outcome.kind !== "rest") throw new Error(`expected a rest, got ${JSON.stringify(outcome)}`)
|
|
753
|
+
return outcome.rest.access
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const step = async () => {
|
|
757
|
+
await agent("step", "prompt")
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
it("is unrestricted when nothing declares access", async () => {
|
|
761
|
+
expect(await wireAccess(async () => step())).toEqual({ read: null, write: null })
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
it("rejects an access option on agent()", async () => {
|
|
765
|
+
const outcome = await restOf(async () => {
|
|
766
|
+
await agent("step", "prompt", { access: { write: [] } } as never)
|
|
767
|
+
})
|
|
768
|
+
expect(outcome).toMatchObject({
|
|
769
|
+
kind: "failed",
|
|
770
|
+
message: 'gtd: step "step": unknown key(s) access in agent() options',
|
|
771
|
+
})
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
it("reads a scope() access, adding the step's steering file to each restricted side", async () => {
|
|
775
|
+
const flow: Flow = async () => {
|
|
776
|
+
await scope({ name: "a", access: { read: ["docs/**"] } }, async () => {
|
|
777
|
+
await agent("step", "prompt", { file: ".gtd/NOTES.md" })
|
|
778
|
+
})
|
|
779
|
+
}
|
|
780
|
+
expect(await wireAccess(flow)).toEqual({
|
|
781
|
+
read: ["docs/**", ".gtd/NOTES.md"],
|
|
782
|
+
write: null,
|
|
783
|
+
})
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it("keeps read and write independent: write grants no read", async () => {
|
|
787
|
+
const flow: Flow = async () => {
|
|
788
|
+
await scope({ name: "a", access: { write: ["src/**"] } }, step)
|
|
789
|
+
}
|
|
790
|
+
expect(await wireAccess(flow)).toEqual({ read: null, write: ["src/**"] })
|
|
791
|
+
const readOnly: Flow = async () => {
|
|
792
|
+
await scope({ name: "a", access: { read: ["docs/**"], write: ["src/**"] } }, step)
|
|
793
|
+
}
|
|
794
|
+
expect((await wireAccess(readOnly))?.read).toEqual(["docs/**"])
|
|
795
|
+
})
|
|
796
|
+
|
|
797
|
+
it("inherits a parent's access, replaces it wholesale, and reopens with {}", async () => {
|
|
798
|
+
const inherit: Flow = async () => {
|
|
799
|
+
await scope({ name: "a", access: { write: ["p/**"] } }, () => scope("b", step))
|
|
800
|
+
}
|
|
801
|
+
expect(await wireAccess(inherit)).toEqual({ read: null, write: ["p/**"] })
|
|
802
|
+
const replace: Flow = async () => {
|
|
803
|
+
await scope({ name: "a", access: { write: ["p/**"] } }, () =>
|
|
804
|
+
scope({ name: "b", access: { read: ["c/**"] } }, step),
|
|
805
|
+
)
|
|
806
|
+
}
|
|
807
|
+
expect(await wireAccess(replace)).toEqual({ read: ["c/**"], write: null })
|
|
808
|
+
const reopen: Flow = async () => {
|
|
809
|
+
await scope({ name: "a", access: { write: ["p/**"] } }, () =>
|
|
810
|
+
scope({ name: "b", access: {} }, step),
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
expect(await wireAccess(reopen)).toEqual({ read: null, write: null })
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
it("ranks the config export above the scope() option above the export", async () => {
|
|
817
|
+
const flow: Flow = async () => {
|
|
818
|
+
await scope({ name: "a", access: { write: ["option"] } }, step)
|
|
819
|
+
}
|
|
820
|
+
expect((await wireAccess(flow, { access: { a: { write: ["bundled"] } } }))?.write).toEqual([
|
|
821
|
+
"option",
|
|
822
|
+
])
|
|
823
|
+
expect(
|
|
824
|
+
(
|
|
825
|
+
await wireAccess(flow, {
|
|
826
|
+
access: { a: { write: ["bundled"] } },
|
|
827
|
+
configuredAccess: { a: { write: ["rc"] } },
|
|
828
|
+
})
|
|
829
|
+
)?.write,
|
|
830
|
+
).toEqual(["rc"])
|
|
831
|
+
const bare: Flow = async () => {
|
|
832
|
+
await scope("a", () => scope("b", step))
|
|
833
|
+
}
|
|
834
|
+
expect((await wireAccess(bare, { configuredAccess: { a: { write: ["rc"] } } }))?.write).toEqual(
|
|
835
|
+
["rc"],
|
|
836
|
+
)
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
it("adds a code thread waiting on the agent to the restricted sides", async () => {
|
|
840
|
+
const history = new History().land("human", "idle", 1, "a.step", {
|
|
841
|
+
"src/a.ts": "const a = 1\n// H: fix this\n",
|
|
842
|
+
})
|
|
843
|
+
const flow: Flow = async () => {
|
|
844
|
+
await human("idle")
|
|
845
|
+
await scope({ name: "a", access: { write: [] } }, step)
|
|
846
|
+
}
|
|
847
|
+
const outcome = await restOf(flow, {}, history)
|
|
848
|
+
if (outcome.kind !== "rest") throw new Error("expected a rest")
|
|
849
|
+
expect(outcome.rest.access).toEqual({ read: null, write: ["src/a.ts"] })
|
|
850
|
+
})
|
|
851
|
+
|
|
852
|
+
it("fails two agent steps in one memory scope with different scope access", async () => {
|
|
853
|
+
const flow: Flow = async () => {
|
|
854
|
+
await scope({ name: "a", access: { write: ["one"] } }, () => agent("first", "prompt"))
|
|
855
|
+
await scope({ name: "a", access: { write: ["two"] } }, () => agent("second", "prompt"))
|
|
856
|
+
}
|
|
857
|
+
const history = new History()
|
|
858
|
+
history.land("agent", "a.first", 1, "a.second", { "x.txt": "x" })
|
|
859
|
+
const outcome = await restOf(flow, {}, history)
|
|
860
|
+
expect(outcome.kind === "failed" && outcome.message).toContain(
|
|
861
|
+
"different model, system prompt, skills or file access",
|
|
862
|
+
)
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
it("does not conflict two steps that differ only in steering file", async () => {
|
|
866
|
+
const flow: Flow = async () => {
|
|
867
|
+
await scope({ name: "a", access: { write: [] } }, async () => {
|
|
868
|
+
await agent("first", "prompt", { file: ".gtd/ONE.md" })
|
|
869
|
+
await agent("second", "prompt", { file: ".gtd/TWO.md" })
|
|
870
|
+
})
|
|
871
|
+
}
|
|
872
|
+
const history = new History()
|
|
873
|
+
history.land("agent", "a.first", 1, "a.second", { "x.txt": "x" })
|
|
874
|
+
const outcome = await restOf(flow, {}, history)
|
|
875
|
+
expect(outcome.kind).toBe("rest")
|
|
876
|
+
if (outcome.kind === "rest") expect(outcome.rest.access?.write).toEqual([".gtd/TWO.md"])
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
it("fails a malformed scope() access, naming the scope", async () => {
|
|
880
|
+
const flow: Flow = async () => {
|
|
881
|
+
await scope({ name: "a", access: { write: "src/**" } as never }, () => scope("b", step))
|
|
882
|
+
}
|
|
883
|
+
const outcome = await restOf(flow)
|
|
884
|
+
expect(outcome).toMatchObject({ kind: "failed" })
|
|
885
|
+
expect(outcome.kind === "failed" && outcome.message).toContain('scope "a"')
|
|
886
|
+
})
|
|
887
|
+
})
|