@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,669 @@
|
|
|
1
|
+
import {
|
|
2
|
+
installContext,
|
|
3
|
+
protocolMismatch,
|
|
4
|
+
type FlowContext,
|
|
5
|
+
type Change,
|
|
6
|
+
type JudgeAnswer,
|
|
7
|
+
type JudgeQuestion,
|
|
8
|
+
type ScopeAccess,
|
|
9
|
+
type AccessDef,
|
|
10
|
+
type ScopeOptions,
|
|
11
|
+
type StepRequest,
|
|
12
|
+
type Flow,
|
|
13
|
+
} from "../flows/index.js"
|
|
14
|
+
import {
|
|
15
|
+
headingSectionBodies,
|
|
16
|
+
headingSections,
|
|
17
|
+
steeringFormatFor,
|
|
18
|
+
parseCodeThreads,
|
|
19
|
+
parseThreadsWithFindings,
|
|
20
|
+
unansweredQuestions,
|
|
21
|
+
} from "../steering/index.js"
|
|
22
|
+
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
23
|
+
import { globMatches } from "../wire/index.js"
|
|
24
|
+
import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
|
|
25
|
+
import {
|
|
26
|
+
formatStepId,
|
|
27
|
+
parseCommitMessage,
|
|
28
|
+
type CommitMessage,
|
|
29
|
+
type JudgeVerdict,
|
|
30
|
+
type StepId,
|
|
31
|
+
} from "./Trailers.js"
|
|
32
|
+
|
|
33
|
+
export interface EpisodeCommit {
|
|
34
|
+
readonly hash: string
|
|
35
|
+
readonly message: string
|
|
36
|
+
readonly tree: TreeView
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The commits one episode consists of. `base` is where the first step starts
|
|
41
|
+
* reading from: the commit before the episode, or an entered process's opening
|
|
42
|
+
* commit — which completes no step and so is never in `commits`.
|
|
43
|
+
*/
|
|
44
|
+
export interface Episode {
|
|
45
|
+
readonly base: { readonly hash: string; readonly tree: TreeView }
|
|
46
|
+
readonly commits: readonly EpisodeCommit[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The turn a landing is about to commit, completing the step the episode rests at. */
|
|
50
|
+
export interface PendingTurn {
|
|
51
|
+
readonly tree: TreeView
|
|
52
|
+
readonly verdicts?: readonly JudgeVerdict[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ReplayInput {
|
|
56
|
+
readonly flow: Flow
|
|
57
|
+
readonly episode: Episode
|
|
58
|
+
/** Process settings: flow code may branch on these. */
|
|
59
|
+
readonly vars: Readonly<Record<string, string>>
|
|
60
|
+
/** Environment settings: read live, so flow code must not let them change the next step. */
|
|
61
|
+
readonly env: Readonly<Record<string, string>>
|
|
62
|
+
/** The process's diff base — what `start()` returns. */
|
|
63
|
+
readonly start: string
|
|
64
|
+
/** `start`'s tree, for a diff base outside the episode (an entered process's review base predates its opening commit). */
|
|
65
|
+
readonly startTree?: TreeView
|
|
66
|
+
readonly budgetBytes: number
|
|
67
|
+
readonly pending?: PendingTurn
|
|
68
|
+
/** Bundled skill lists keyed by scope full name — `WorkflowDefinition.skills` applied to the process's vars. The fallback beneath a `scope()` option and `configuredSkills`. */
|
|
69
|
+
readonly skills?: Readonly<Record<string, readonly string[]>>
|
|
70
|
+
/** `config.skills` entries by scope full name; kept apart from `skills` so they outrank a `scope()` option while the bundled export does not. */
|
|
71
|
+
readonly configuredSkills?: Readonly<Record<string, readonly string[]>>
|
|
72
|
+
/** Bundled access keyed by scope full name — the fallback beneath a `scope()` option and `configuredAccess`. */
|
|
73
|
+
readonly access?: Readonly<Record<string, ScopeAccess>>
|
|
74
|
+
/** `config.access` entries by scope full name; they outrank a `scope()` option. */
|
|
75
|
+
readonly configuredAccess?: Readonly<Record<string, ScopeAccess>>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type StepKind = Exclude<StepRequest["kind"], "restart">
|
|
79
|
+
|
|
80
|
+
type Actor = "agent" | "human" | "check" | "judge"
|
|
81
|
+
|
|
82
|
+
const actorOfKind = (kind: StepKind): Actor =>
|
|
83
|
+
kind === "run" ? "check" : kind === "agent" ? "agent" : kind === "human" ? "human" : "judge"
|
|
84
|
+
|
|
85
|
+
/** One step replay reached, completed or not. */
|
|
86
|
+
export interface ReachedStep {
|
|
87
|
+
readonly id: StepId
|
|
88
|
+
/** The scoped name — the step's node id and subject name. */
|
|
89
|
+
readonly name: string
|
|
90
|
+
readonly kind: StepKind
|
|
91
|
+
readonly actor: Actor
|
|
92
|
+
readonly request: Exclude<StepRequest, { kind: "restart" }>
|
|
93
|
+
/** Everything before the name's last `.` — `""` at the root. */
|
|
94
|
+
readonly memoryScope: string
|
|
95
|
+
/** The scope's resolved skill list, for an agent step; absent when none. */
|
|
96
|
+
readonly skills?: readonly string[] | undefined
|
|
97
|
+
/** The scope's resolved access with the step's steering file and waiting code threads folded in, for an agent step. */
|
|
98
|
+
readonly access?: AccessDef | undefined
|
|
99
|
+
/** The commit replay stood on when it reached this step. */
|
|
100
|
+
readonly enteredAt: string
|
|
101
|
+
/** Whether the judge budget cut this step's evidence. */
|
|
102
|
+
readonly truncated: boolean
|
|
103
|
+
/** The named `scope()` calls enclosing the step, outermost first: each call's prefix and its own id. */
|
|
104
|
+
readonly scopeCalls: readonly { readonly prefix: string; readonly call: number }[]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type ReplayOutcome =
|
|
108
|
+
| {
|
|
109
|
+
readonly kind: "rest"
|
|
110
|
+
readonly rest: ReachedStep
|
|
111
|
+
readonly trace: readonly ReachedStep[]
|
|
112
|
+
/** The step the pending turn completed, when one was supplied. */
|
|
113
|
+
readonly landed: ReachedStep | undefined
|
|
114
|
+
}
|
|
115
|
+
| {
|
|
116
|
+
readonly kind: "ended"
|
|
117
|
+
readonly via: "return" | "restart"
|
|
118
|
+
readonly trace: readonly ReachedStep[]
|
|
119
|
+
readonly landed: ReachedStep | undefined
|
|
120
|
+
}
|
|
121
|
+
| { readonly kind: "divergence"; readonly message: string }
|
|
122
|
+
| { readonly kind: "refused"; readonly message: string }
|
|
123
|
+
| { readonly kind: "failed"; readonly message: string }
|
|
124
|
+
|
|
125
|
+
const memoryScopeOf = (name: string): string => {
|
|
126
|
+
const dot = name.lastIndexOf(".")
|
|
127
|
+
return dot === -1 ? "" : name.slice(0, dot)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
class Stop extends Error {}
|
|
131
|
+
|
|
132
|
+
const short = (hash: string): string => hash.slice(0, 7)
|
|
133
|
+
|
|
134
|
+
/** One step's changes; content is read only when a flow asks for it. */
|
|
135
|
+
const changesBetween = (before: TreeView, after: TreeView): readonly Change[] => {
|
|
136
|
+
const diff = diffTrees(before, after)
|
|
137
|
+
const change = (path: string, status: Change["status"]): Change => ({
|
|
138
|
+
path,
|
|
139
|
+
status,
|
|
140
|
+
get before() {
|
|
141
|
+
return status === "added" ? undefined : before.read(path)
|
|
142
|
+
},
|
|
143
|
+
get after() {
|
|
144
|
+
return status === "deleted" ? undefined : after.read(path)
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
return [
|
|
148
|
+
...diff.added.map((path) => change(path, "added")),
|
|
149
|
+
...diff.modified.map((path) => change(path, "modified")),
|
|
150
|
+
...diff.deleted.map((path) => change(path, "deleted")),
|
|
151
|
+
].sort((a, b) => a.path.localeCompare(b.path))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const normalizeAnswer = (answer: string | number | boolean): string =>
|
|
155
|
+
typeof answer === "boolean" ? (answer ? "yes" : "no") : String(answer)
|
|
156
|
+
|
|
157
|
+
const answersFrom = (
|
|
158
|
+
verdicts: readonly JudgeVerdict[],
|
|
159
|
+
questions: readonly JudgeQuestion[],
|
|
160
|
+
): Readonly<Record<string, JudgeAnswer | undefined>> => {
|
|
161
|
+
const answers: Record<string, JudgeAnswer | undefined> = {}
|
|
162
|
+
for (const question of questions) answers[question.id] = undefined
|
|
163
|
+
for (const verdict of verdicts) {
|
|
164
|
+
if (!(verdict.id in answers)) continue
|
|
165
|
+
answers[verdict.id] = { answer: normalizeAnswer(verdict.answer), p: verdict.p }
|
|
166
|
+
}
|
|
167
|
+
return answers
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The last `allowedBytes` bytes of `content`, from the first whole line on. */
|
|
171
|
+
const tailOf = (content: string, allowedBytes: number): string => {
|
|
172
|
+
const buf = Buffer.from(content, "utf8")
|
|
173
|
+
if (buf.length <= allowedBytes) return content
|
|
174
|
+
const tail = buf.subarray(buf.length - allowedBytes).toString("utf8")
|
|
175
|
+
const newline = tail.indexOf("\n")
|
|
176
|
+
return newline === -1 ? "" : tail.slice(newline + 1)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Cut each evidence value to an even share of the judge budget; which keys were cut. */
|
|
180
|
+
const budgeted = (
|
|
181
|
+
evidence: Readonly<Record<string, string>>,
|
|
182
|
+
budgetBytes: number,
|
|
183
|
+
): {
|
|
184
|
+
readonly evidence: Readonly<Record<string, string>>
|
|
185
|
+
readonly truncated: readonly string[]
|
|
186
|
+
} => {
|
|
187
|
+
const keys = Object.keys(evidence)
|
|
188
|
+
const share = Math.floor(budgetBytes / Math.max(keys.length, 1))
|
|
189
|
+
const bounded: Record<string, string> = {}
|
|
190
|
+
const truncated: string[] = []
|
|
191
|
+
for (const key of keys) {
|
|
192
|
+
const value = evidence[key]!
|
|
193
|
+
bounded[key] = tailOf(value, share)
|
|
194
|
+
if (bounded[key] !== value) truncated.push(key)
|
|
195
|
+
}
|
|
196
|
+
return { evidence: bounded, truncated }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface Identity {
|
|
200
|
+
readonly model: string | undefined
|
|
201
|
+
readonly system: string | undefined
|
|
202
|
+
readonly skills: readonly string[] | undefined
|
|
203
|
+
/** Scope-level and unfolded: two steps differing only in steering file share a conversation. */
|
|
204
|
+
readonly access: ScopeAccess | undefined
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const sameSkills = (a: readonly string[] | undefined, b: readonly string[] | undefined): boolean =>
|
|
208
|
+
(a?.length ?? 0) === (b?.length ?? 0) && (a ?? []).every((skill, i) => skill === b?.[i])
|
|
209
|
+
|
|
210
|
+
const samePersona = (a: Identity, b: Identity): boolean =>
|
|
211
|
+
a.model === b.model &&
|
|
212
|
+
a.system === b.system &&
|
|
213
|
+
sameSkills(a.skills, b.skills) &&
|
|
214
|
+
sameAccess(a.access, b.access)
|
|
215
|
+
|
|
216
|
+
interface Position {
|
|
217
|
+
readonly hash: string
|
|
218
|
+
readonly tree: TreeView
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface ParsedCommit extends EpisodeCommit {
|
|
222
|
+
readonly parsed: CommitMessage
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Replay one episode: run its entry flow, answering each step from the
|
|
227
|
+
* episode's commits in order, until a step has no commit left — the rest.
|
|
228
|
+
* Pure over its input: the same episode always yields the same outcome, and
|
|
229
|
+
* the only reads are through `TreeView`s.
|
|
230
|
+
*/
|
|
231
|
+
const STEERING_OPTIONS = ["file", "mode", "label", "base"]
|
|
232
|
+
// The option keys each step accepts. A gtd.config.ts is evaluated without a
|
|
233
|
+
// type check, so a misspelt key would otherwise be silently ignored.
|
|
234
|
+
const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
|
|
235
|
+
agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty"]),
|
|
236
|
+
human: new Set([...STEERING_OPTIONS, "message", "acceptClean"]),
|
|
237
|
+
run: new Set(STEERING_OPTIONS),
|
|
238
|
+
judge: new Set([...STEERING_OPTIONS, "message"]),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const unknownOptions = (step: ReachedStep): string | undefined => {
|
|
242
|
+
const unknown = Object.keys(step.request.options).filter(
|
|
243
|
+
(key) => !KNOWN_OPTIONS[step.kind].has(key),
|
|
244
|
+
)
|
|
245
|
+
if (unknown.length === 0) return undefined
|
|
246
|
+
return `gtd: step "${step.name}": unknown key(s) ${unknown.join(", ")} in ${step.kind}() options`
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
250
|
+
const commits: readonly ParsedCommit[] = input.episode.commits.map((c) => ({
|
|
251
|
+
...c,
|
|
252
|
+
parsed: parseCommitMessage(c.message),
|
|
253
|
+
}))
|
|
254
|
+
|
|
255
|
+
// Every replay re-derives base, commits and head() from whatever history
|
|
256
|
+
// exists right now, so a hash a flow reads this same run (head()/start())
|
|
257
|
+
// always names a commit `treeAt` can find here. Only a hash a flow gets from
|
|
258
|
+
// somewhere else — stored state, another branch, a fabricated value — can
|
|
259
|
+
// miss.
|
|
260
|
+
const treeAt = (hash: string): TreeView | undefined =>
|
|
261
|
+
hash === input.episode.base.hash
|
|
262
|
+
? input.episode.base.tree
|
|
263
|
+
: commits.find((c) => c.hash === hash)?.tree
|
|
264
|
+
|
|
265
|
+
let cursor = 0
|
|
266
|
+
let pendingUsed = false
|
|
267
|
+
let position: Position = input.episode.base
|
|
268
|
+
let previousPosition: Position = input.episode.base
|
|
269
|
+
let expectedNext: { readonly name: string; readonly hash: string } | undefined
|
|
270
|
+
const occurrences = new Map<string, number>()
|
|
271
|
+
const personas = new Map<string, Identity>()
|
|
272
|
+
const scopes: ScopeOptions[] = []
|
|
273
|
+
// Numbers each scope() call in replay order, so two calls in a row are two conversations.
|
|
274
|
+
const scopeCallIds: number[] = []
|
|
275
|
+
let scopeCallCount = 0
|
|
276
|
+
const trace: ReachedStep[] = []
|
|
277
|
+
let landed: ReachedStep | undefined
|
|
278
|
+
let outcome: ReplayOutcome | undefined
|
|
279
|
+
let signal: (() => void) | undefined
|
|
280
|
+
const settled = new Promise<void>((resolve) => {
|
|
281
|
+
signal = resolve
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const finish = (result: ReplayOutcome): Promise<never> => {
|
|
285
|
+
outcome ??= result
|
|
286
|
+
signal?.()
|
|
287
|
+
throw new Stop()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const namedScopeCalls = (): { prefix: string; call: number }[] => {
|
|
291
|
+
const calls: { prefix: string; call: number }[] = []
|
|
292
|
+
const names: string[] = []
|
|
293
|
+
scopes.forEach((s, i) => {
|
|
294
|
+
if (s.name === undefined) return
|
|
295
|
+
names.push(s.name)
|
|
296
|
+
calls.push({ prefix: names.join("."), call: scopeCallIds[i]! })
|
|
297
|
+
})
|
|
298
|
+
return calls
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const scoped = (name: string): string =>
|
|
302
|
+
[...scopes.flatMap((s) => (s.name === undefined ? [] : [s.name])), name].join(".")
|
|
303
|
+
|
|
304
|
+
const isAttemptAt = (commit: ParsedCommit, name: string): boolean =>
|
|
305
|
+
commit.parsed.step === undefined &&
|
|
306
|
+
commit.parsed.parsed !== undefined &&
|
|
307
|
+
commit.parsed.parsed.from === undefined &&
|
|
308
|
+
commit.parsed.parsed.to === name &&
|
|
309
|
+
isEmptyDiff(diffTrees(position.tree, commit.tree))
|
|
310
|
+
|
|
311
|
+
const advance = (next: Position): void => {
|
|
312
|
+
previousPosition = position
|
|
313
|
+
position = next
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const replyFor = (
|
|
317
|
+
request: Exclude<StepRequest, { kind: "restart" }>,
|
|
318
|
+
verdicts: readonly JudgeVerdict[],
|
|
319
|
+
): unknown =>
|
|
320
|
+
request.kind === "judge"
|
|
321
|
+
? {
|
|
322
|
+
answers: answersFrom(verdicts, request.questions),
|
|
323
|
+
truncated: judgeCuts.get(request) ?? [],
|
|
324
|
+
}
|
|
325
|
+
: undefined
|
|
326
|
+
|
|
327
|
+
const judgeCuts = new WeakMap<object, readonly string[]>()
|
|
328
|
+
|
|
329
|
+
// Resolved per memory scope `memory`: for each prefix of it, innermost
|
|
330
|
+
// first, a `config` entry, then the innermost `scope()` call at that prefix
|
|
331
|
+
// (an unnamed scope counts at its parent's prefix), then the bundled export.
|
|
332
|
+
const resolveScoped = <T>(
|
|
333
|
+
memory: string,
|
|
334
|
+
pick: (s: (typeof scopes)[number]) => T | undefined,
|
|
335
|
+
configured: Readonly<Record<string, T>> | undefined,
|
|
336
|
+
bundled: Readonly<Record<string, T>> | undefined,
|
|
337
|
+
): T | undefined => {
|
|
338
|
+
const own = new Map<string, T>()
|
|
339
|
+
const names: string[] = []
|
|
340
|
+
for (const s of scopes) {
|
|
341
|
+
if (s.name !== undefined) names.push(s.name)
|
|
342
|
+
const v = pick(s)
|
|
343
|
+
if (v !== undefined) own.set(names.join("."), v)
|
|
344
|
+
}
|
|
345
|
+
const parts = memory === "" ? [] : memory.split(".")
|
|
346
|
+
const prefixes = parts.map((_, i) => parts.slice(0, parts.length - i).join(".")).concat("")
|
|
347
|
+
return prefixes
|
|
348
|
+
.map((p) => configured?.[p] ?? own.get(p) ?? bundled?.[p])
|
|
349
|
+
.find((r) => r !== undefined)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// An empty result collapses to `undefined` so it is absent on the wire.
|
|
353
|
+
const resolveSkills = (memory: string): readonly string[] | undefined => {
|
|
354
|
+
const hit = resolveScoped(memory, (s) => s.skills, input.configuredSkills, input.skills)
|
|
355
|
+
return hit === undefined || hit.length === 0 ? undefined : hit
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// `{}` is a hit meaning unrestricted.
|
|
359
|
+
const resolveAccess = (memory: string): ScopeAccess | undefined =>
|
|
360
|
+
resolveScoped(memory, (s) => s.access, input.configuredAccess, input.access)
|
|
361
|
+
|
|
362
|
+
const foldedAccessFor = (
|
|
363
|
+
scopeAccess: ScopeAccess | undefined,
|
|
364
|
+
file: string | undefined,
|
|
365
|
+
): AccessDef => {
|
|
366
|
+
// Reading code threads parses every changed file: only a restricted side needs them.
|
|
367
|
+
const restricted = scopeAccess?.read !== undefined || scopeAccess?.write !== undefined
|
|
368
|
+
const threadPaths = restricted
|
|
369
|
+
? context
|
|
370
|
+
.codeThreads()
|
|
371
|
+
.filter((t) => t.waitingOn === "agent")
|
|
372
|
+
.map((t) => t.path)
|
|
373
|
+
: []
|
|
374
|
+
return foldAccess(scopeAccess, file, threadPaths)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const scopeAccessFault = (): string | undefined => {
|
|
378
|
+
for (const [i, s] of scopes.entries()) {
|
|
379
|
+
if (s.access === undefined) continue
|
|
380
|
+
const fault = accessShapeFault(s.access)
|
|
381
|
+
if (fault === undefined) continue
|
|
382
|
+
const name = scopes
|
|
383
|
+
.slice(0, i + 1)
|
|
384
|
+
.flatMap((o) => (o.name === undefined ? [] : [o.name]))
|
|
385
|
+
.join(".")
|
|
386
|
+
return `gtd: scope "${name || "root"}": ${fault}`
|
|
387
|
+
}
|
|
388
|
+
return undefined
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const unfoldedAccess = new WeakMap<ReachedStep, ScopeAccess | undefined>()
|
|
392
|
+
|
|
393
|
+
const resolve = (
|
|
394
|
+
request: Exclude<StepRequest, { kind: "restart" }>,
|
|
395
|
+
): Exclude<StepRequest, { kind: "restart" }> => {
|
|
396
|
+
if (request.kind === "agent") {
|
|
397
|
+
const persona: { model?: string; system?: string } = {}
|
|
398
|
+
for (const s of scopes) {
|
|
399
|
+
if (s.model !== undefined) persona.model = s.model
|
|
400
|
+
if (s.system !== undefined) persona.system = s.system
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
...request,
|
|
404
|
+
options: {
|
|
405
|
+
...persona,
|
|
406
|
+
...request.options,
|
|
407
|
+
},
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (request.kind === "judge") {
|
|
411
|
+
const { evidence, truncated } = budgeted(request.evidence, input.budgetBytes)
|
|
412
|
+
const bounded = { ...request, evidence }
|
|
413
|
+
judgeCuts.set(bounded, truncated)
|
|
414
|
+
return bounded
|
|
415
|
+
}
|
|
416
|
+
return request
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const reach = (request: Exclude<StepRequest, { kind: "restart" }>): ReachedStep => {
|
|
420
|
+
const name = scoped(request.name)
|
|
421
|
+
const occurrence = (occurrences.get(name) ?? 0) + 1
|
|
422
|
+
occurrences.set(name, occurrence)
|
|
423
|
+
const resolved = resolve(request)
|
|
424
|
+
const memory = memoryScopeOf(name)
|
|
425
|
+
const scopeAccess = resolved.kind === "agent" ? resolveAccess(memory) : undefined
|
|
426
|
+
const reached: ReachedStep = {
|
|
427
|
+
id: { name, occurrence },
|
|
428
|
+
name,
|
|
429
|
+
kind: resolved.kind,
|
|
430
|
+
actor: actorOfKind(resolved.kind),
|
|
431
|
+
request: resolved,
|
|
432
|
+
memoryScope: memoryScopeOf(name),
|
|
433
|
+
...(resolved.kind === "agent"
|
|
434
|
+
? {
|
|
435
|
+
skills: resolveSkills(memory),
|
|
436
|
+
access: foldedAccessFor(scopeAccess, resolved.options.file),
|
|
437
|
+
}
|
|
438
|
+
: {}),
|
|
439
|
+
enteredAt: position.hash,
|
|
440
|
+
truncated: (judgeCuts.get(resolved) ?? []).length > 0,
|
|
441
|
+
scopeCalls: namedScopeCalls(),
|
|
442
|
+
}
|
|
443
|
+
if (resolved.kind === "agent") unfoldedAccess.set(reached, scopeAccess)
|
|
444
|
+
trace.push(reached)
|
|
445
|
+
return reached
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const checkIdentity = (step: ReachedStep): string | undefined => {
|
|
449
|
+
if (step.request.kind !== "agent") return undefined
|
|
450
|
+
const identity: Identity = {
|
|
451
|
+
model: step.request.options.model,
|
|
452
|
+
system: step.request.options.system,
|
|
453
|
+
skills: step.skills,
|
|
454
|
+
access: unfoldedAccess.get(step),
|
|
455
|
+
}
|
|
456
|
+
const seen = personas.get(step.memoryScope)
|
|
457
|
+
if (seen === undefined) {
|
|
458
|
+
personas.set(step.memoryScope, identity)
|
|
459
|
+
return undefined
|
|
460
|
+
}
|
|
461
|
+
return samePersona(seen, identity)
|
|
462
|
+
? undefined
|
|
463
|
+
: `gtd: "${step.name}" runs with a different model, system prompt, skills or file access than an earlier agent step in memory scope "${step.memoryScope || "root"}" — one scope is one conversation, and a conversation has one identity`
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const DIVERGED = "the workflow changed under this process; run `gtd abandon` to start over"
|
|
467
|
+
|
|
468
|
+
// Checks a reached step against what the previous commit's subject promised
|
|
469
|
+
// and against the persona of its scope — the reasons replay cannot go on.
|
|
470
|
+
const blockerAt = (step: ReachedStep): ReplayOutcome | undefined => {
|
|
471
|
+
const expected = expectedNext
|
|
472
|
+
expectedNext = undefined
|
|
473
|
+
if (expected !== undefined && expected.name !== step.name) {
|
|
474
|
+
return {
|
|
475
|
+
kind: "divergence",
|
|
476
|
+
message: `gtd: commit ${short(expected.hash)} names "${expected.name}" as the next step, but replay reached "${step.name}" — ${DIVERGED}`,
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const error = unknownOptions(step) ?? checkIdentity(step)
|
|
480
|
+
return error === undefined ? undefined : { kind: "failed", message: error }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const consumeCommit = (step: ReachedStep, commit: ParsedCommit): unknown => {
|
|
484
|
+
const recorded = commit.parsed.step
|
|
485
|
+
const expected = formatStepId(step.id)
|
|
486
|
+
if (recorded === undefined || formatStepId(recorded) !== expected) {
|
|
487
|
+
const what =
|
|
488
|
+
recorded === undefined ? "records no step" : `records step "${formatStepId(recorded)}"`
|
|
489
|
+
return finish({
|
|
490
|
+
kind: "divergence",
|
|
491
|
+
message: `gtd: commit ${short(commit.hash)} ${what}, but replay expected "${expected}" — ${DIVERGED}`,
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
cursor++
|
|
495
|
+
advance(commit)
|
|
496
|
+
const to = commit.parsed.parsed?.to
|
|
497
|
+
if (to !== undefined) expectedNext = { name: to, hash: commit.hash }
|
|
498
|
+
return replyFor(step.request, commit.parsed.judge)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const answer = (step: ReachedStep): unknown => {
|
|
502
|
+
while (cursor < commits.length && isAttemptAt(commits[cursor]!, step.name)) cursor++
|
|
503
|
+
const commit = commits[cursor]
|
|
504
|
+
if (commit !== undefined) return consumeCommit(step, commit)
|
|
505
|
+
if (input.pending === undefined || pendingUsed) {
|
|
506
|
+
return finish({ kind: "rest", rest: step, trace, landed })
|
|
507
|
+
}
|
|
508
|
+
pendingUsed = true
|
|
509
|
+
landed = step
|
|
510
|
+
advance({ hash: "", tree: input.pending.tree })
|
|
511
|
+
return replyFor(step.request, input.pending.verdicts ?? [])
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// gtd.config.ts is loaded without a type check.
|
|
515
|
+
const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
|
|
516
|
+
if (request.kind === "run" && typeof request.body !== "string") {
|
|
517
|
+
return `gtd: step "${scoped(request.name)}": a run() body is a shell script string — decide in flow code, then render the script (see checkScript and friends)`
|
|
518
|
+
}
|
|
519
|
+
return request.kind === "agent" ? scopeAccessFault() : undefined
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const handleStep = async (request: StepRequest): Promise<unknown> => {
|
|
523
|
+
if (outcome !== undefined) throw new Stop()
|
|
524
|
+
if (request.kind === "restart") return finish(endOfFlow("restart"))
|
|
525
|
+
if (typeof request.name !== "string" || request.name === "") {
|
|
526
|
+
return finish({ kind: "failed", message: "gtd: a step was called without a name" })
|
|
527
|
+
}
|
|
528
|
+
const fault = requestFault(request)
|
|
529
|
+
if (fault !== undefined) return finish({ kind: "failed", message: fault })
|
|
530
|
+
const step = reach(request)
|
|
531
|
+
const blocker = blockerAt(step)
|
|
532
|
+
return blocker === undefined ? answer(step) : finish(blocker)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const endOfFlow = (via: "return" | "restart"): ReplayOutcome =>
|
|
536
|
+
cursor < commits.length
|
|
537
|
+
? {
|
|
538
|
+
kind: "divergence",
|
|
539
|
+
message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
|
|
540
|
+
}
|
|
541
|
+
: { kind: "ended", via, trace, landed }
|
|
542
|
+
|
|
543
|
+
const changesSince = (hash: string): readonly Change[] => {
|
|
544
|
+
const tree = treeAt(hash)
|
|
545
|
+
if (tree === undefined) {
|
|
546
|
+
throw new Error(
|
|
547
|
+
`gtd: changesSince(${hash}): ${hash} is not the episode base or one of its commits — pass a hash this run read from head() or start(), not one captured earlier, read from state, or from another branch`,
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
return changesBetween(tree, position.tree)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const context: FlowContext = {
|
|
554
|
+
step: handleStep,
|
|
555
|
+
refuse: (message) => {
|
|
556
|
+
outcome ??= { kind: "refused", message }
|
|
557
|
+
signal?.()
|
|
558
|
+
throw new Stop()
|
|
559
|
+
},
|
|
560
|
+
pushScope: (scope) => {
|
|
561
|
+
scopes.push(scope)
|
|
562
|
+
scopeCallIds.push(scopeCallCount++)
|
|
563
|
+
},
|
|
564
|
+
popScope: () => {
|
|
565
|
+
scopes.pop()
|
|
566
|
+
scopeCallIds.pop()
|
|
567
|
+
},
|
|
568
|
+
read: (path) => position.tree.read(path),
|
|
569
|
+
glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
|
|
570
|
+
changes: () => changesBetween(previousPosition.tree, position.tree),
|
|
571
|
+
changesSince,
|
|
572
|
+
matches: globMatches,
|
|
573
|
+
sections: (text) => headingSections(text),
|
|
574
|
+
sectionBodies: (text) => headingSectionBodies(text),
|
|
575
|
+
openQuestions: (text) => {
|
|
576
|
+
const qa = steeringFormatFor("qa")
|
|
577
|
+
return qa === undefined
|
|
578
|
+
? []
|
|
579
|
+
: unansweredQuestions(qa, text).map((q) => ({
|
|
580
|
+
question: q.question,
|
|
581
|
+
line: q.headingLine + 1,
|
|
582
|
+
}))
|
|
583
|
+
},
|
|
584
|
+
threads: (text) => {
|
|
585
|
+
const { threads, findings } = parseThreadsWithFindings(text)
|
|
586
|
+
return threads.map((t) => ({
|
|
587
|
+
name: t.name,
|
|
588
|
+
line: t.line + 1,
|
|
589
|
+
waitingOn: t.waitingOn,
|
|
590
|
+
faults: findings
|
|
591
|
+
.filter((f) => f.message.startsWith(`Footnote thread "[^${t.name}]": `))
|
|
592
|
+
.map((f) => f.message),
|
|
593
|
+
}))
|
|
594
|
+
},
|
|
595
|
+
codeThreads: () =>
|
|
596
|
+
changesBetween(
|
|
597
|
+
treeAt(input.start) ?? input.startTree ?? input.episode.base.tree,
|
|
598
|
+
position.tree,
|
|
599
|
+
)
|
|
600
|
+
.filter((c) => c.status !== "deleted")
|
|
601
|
+
.flatMap((c) => {
|
|
602
|
+
const { threads, findings } = parseCodeThreads(c.path, position.tree.read(c.path) ?? "")
|
|
603
|
+
return threads.map((t) => ({
|
|
604
|
+
path: t.path,
|
|
605
|
+
line: t.line + 1,
|
|
606
|
+
waitingOn: t.waitingOn,
|
|
607
|
+
first: t.entries[0]?.text ?? "",
|
|
608
|
+
faults: findings
|
|
609
|
+
.filter((f) => f.message.startsWith(`Code thread at ${c.path}:${t.line + 1}: `))
|
|
610
|
+
.map((f) => f.message),
|
|
611
|
+
}))
|
|
612
|
+
}),
|
|
613
|
+
vars: input.vars,
|
|
614
|
+
env: input.env,
|
|
615
|
+
start: () => input.start,
|
|
616
|
+
skillsFor: (localName) => resolveSkills(memoryScopeOf(scoped(localName))) ?? [],
|
|
617
|
+
accessFor: (localName, file) =>
|
|
618
|
+
foldedAccessFor(resolveAccess(memoryScopeOf(scoped(localName))), file),
|
|
619
|
+
// At the rest, trailing attempts sit above the last step commit: the head a
|
|
620
|
+
// prompt names is the commit the process actually stands on.
|
|
621
|
+
head: () => {
|
|
622
|
+
const rest = commits.slice(cursor)
|
|
623
|
+
return rest.length > 0 && rest.every((c) => c.parsed.step === undefined)
|
|
624
|
+
? rest[rest.length - 1]!.hash
|
|
625
|
+
: position.hash
|
|
626
|
+
},
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
installContext(context)
|
|
630
|
+
try {
|
|
631
|
+
// A pre-upgrade flow destructuring `{ entry }` reads `undefined` (an
|
|
632
|
+
// ordinary start) instead of crashing.
|
|
633
|
+
const flowDone = (input.flow as (a: object) => Promise<void>)({}).then(
|
|
634
|
+
() => {
|
|
635
|
+
if (outcome === undefined && trace.length === 0) {
|
|
636
|
+
outcome = { kind: "failed", message: "gtd: the flow returned without reaching any step" }
|
|
637
|
+
}
|
|
638
|
+
outcome ??= endOfFlow("return")
|
|
639
|
+
},
|
|
640
|
+
(error: unknown) => {
|
|
641
|
+
if (error instanceof Stop) return
|
|
642
|
+
outcome ??= {
|
|
643
|
+
kind: "failed",
|
|
644
|
+
// By name, not instanceof: a workflow loading its own copy of the
|
|
645
|
+
// flows facade throws that copy's class.
|
|
646
|
+
message:
|
|
647
|
+
error instanceof Error && error.name === "FlowsProtocolError"
|
|
648
|
+
? error.message
|
|
649
|
+
: `gtd: the workflow threw: ${error instanceof Error ? error.message : String(error)}`,
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
)
|
|
653
|
+
// Replay runs on microtasks alone: a flow still unsettled once a macrotask
|
|
654
|
+
// fires awaited something other than a step.
|
|
655
|
+
const stuck = new Promise<void>((resolve) => setImmediate(resolve))
|
|
656
|
+
await Promise.race([flowDone, settled, stuck])
|
|
657
|
+
const mismatch = protocolMismatch()
|
|
658
|
+
if (mismatch !== undefined) return { kind: "failed", message: mismatch.message }
|
|
659
|
+
} finally {
|
|
660
|
+
installContext(undefined)
|
|
661
|
+
}
|
|
662
|
+
return (
|
|
663
|
+
outcome ?? {
|
|
664
|
+
kind: "failed",
|
|
665
|
+
message:
|
|
666
|
+
"gtd: the flow awaited something that is not a step — flow code may suspend only at steps",
|
|
667
|
+
}
|
|
668
|
+
)
|
|
669
|
+
}
|