@pmelab/gtd 24.0.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 +31 -5
- 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 +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
package/src/edge/Edge.ts
ADDED
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import {
|
|
3
|
+
Narrator,
|
|
4
|
+
GitService,
|
|
5
|
+
Host,
|
|
6
|
+
Workspace,
|
|
7
|
+
type GitOperations,
|
|
8
|
+
type WorkspaceOps,
|
|
9
|
+
} from "../platform/index.js"
|
|
10
|
+
import {
|
|
11
|
+
ConfigDiscovery,
|
|
12
|
+
ConfigService,
|
|
13
|
+
changedConfigFiles,
|
|
14
|
+
configFingerprint,
|
|
15
|
+
contentHash,
|
|
16
|
+
multilineSetting,
|
|
17
|
+
resolveVars,
|
|
18
|
+
resolveScopeAccess,
|
|
19
|
+
resolveScopeSkills,
|
|
20
|
+
type ConfigFingerprint,
|
|
21
|
+
type ConfigOperations,
|
|
22
|
+
type ResolvedWorkflow,
|
|
23
|
+
knownModes,
|
|
24
|
+
type ChangeStatus,
|
|
25
|
+
type PendingChange,
|
|
26
|
+
type StateName,
|
|
27
|
+
type StepDef,
|
|
28
|
+
type WorkflowDefinition,
|
|
29
|
+
} from "../workflow/index.js"
|
|
30
|
+
import {
|
|
31
|
+
formatFault,
|
|
32
|
+
formatSubject,
|
|
33
|
+
parseCommitMessage,
|
|
34
|
+
replay,
|
|
35
|
+
treeFromRecord,
|
|
36
|
+
type EpisodeCommit,
|
|
37
|
+
type JudgeVerdict,
|
|
38
|
+
type ReachedStep,
|
|
39
|
+
type ReplayOutcome,
|
|
40
|
+
type TreeView,
|
|
41
|
+
} from "../replay/index.js"
|
|
42
|
+
import type { ScopeAccess } from "../flows/index.js"
|
|
43
|
+
import { steeringFormatFor } from "../steering/index.js"
|
|
44
|
+
import { judgeJson, UNATTRIBUTED_MODEL, type ModelCost, type StepAccess } from "../wire/index.js"
|
|
45
|
+
import { accessRefusal, type Landing, type RepoSnapshot } from "../step/index.js"
|
|
46
|
+
|
|
47
|
+
export { UNATTRIBUTED_MODEL }
|
|
48
|
+
|
|
49
|
+
// git's empty-tree object — the base when a process starts at the repository's
|
|
50
|
+
// first commit.
|
|
51
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
52
|
+
|
|
53
|
+
const ACTORS: ReadonlySet<string> = new Set(["agent", "human", "check", "judge"])
|
|
54
|
+
|
|
55
|
+
type History = ReadonlyArray<{
|
|
56
|
+
readonly hash: string
|
|
57
|
+
readonly message: string
|
|
58
|
+
readonly touched: ReadonlyArray<string>
|
|
59
|
+
}>
|
|
60
|
+
|
|
61
|
+
// ── Episodes ────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
interface EpisodeLocation {
|
|
64
|
+
/** The workflow pinned on the opening commit; `undefined` for the default. */
|
|
65
|
+
readonly workflow: string | undefined
|
|
66
|
+
/** An opening commit with no `Gtd-Workflow` trailer: written by the removed `gtd --entry`. */
|
|
67
|
+
readonly legacyOpening: boolean
|
|
68
|
+
/** The commit replay starts reading from, or -1 for the empty tree before history. */
|
|
69
|
+
readonly baseIndex: number
|
|
70
|
+
/** The process's first commit — a `--workflow` process's opening commit, else the one after the base. */
|
|
71
|
+
readonly processStart: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Where the episode HEAD belongs to begins, read off subjects alone. Walking
|
|
76
|
+
* back from HEAD: a bare `gtd(human): <step>` commit with no `Gtd-Step` is what
|
|
77
|
+
* `gtd --workflow` writes — no landing ever produces one — and opens an
|
|
78
|
+
* episode as its first commit (its `Gtd-Workflow` trailer names the workflow;
|
|
79
|
+
* a legacy `--entry` opening has none); a commit that is not a gtd step commit, or one
|
|
80
|
+
* entering the flow's first step (a finished episode), bounds the episode
|
|
81
|
+
* from below.
|
|
82
|
+
*/
|
|
83
|
+
const locateEpisode = (def: WorkflowDefinition, history: History): EpisodeLocation => {
|
|
84
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
85
|
+
const message = parseCommitMessage(history[i]!.message)
|
|
86
|
+
const subject = message.parsed
|
|
87
|
+
const opening =
|
|
88
|
+
subject !== undefined &&
|
|
89
|
+
message.step === undefined &&
|
|
90
|
+
subject.from === undefined &&
|
|
91
|
+
subject.actor === "human"
|
|
92
|
+
if (opening) {
|
|
93
|
+
return {
|
|
94
|
+
workflow: message.workflow,
|
|
95
|
+
legacyOpening: message.workflow === undefined,
|
|
96
|
+
baseIndex: i,
|
|
97
|
+
processStart: i,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (subject === undefined || !ACTORS.has(subject.actor) || subject.to === def.initial) {
|
|
101
|
+
return { workflow: undefined, legacyOpening: false, baseIndex: i, processStart: i + 1 }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { workflow: undefined, legacyOpening: false, baseIndex: -1, processStart: 0 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── The current process run ─────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export interface CostEntry {
|
|
110
|
+
readonly cost: number
|
|
111
|
+
readonly model: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface JudgeVerdictEntry {
|
|
115
|
+
readonly id: string
|
|
116
|
+
readonly answer: string | number | boolean
|
|
117
|
+
readonly p: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One process commit: the step it entered (its subject's `<to>`), its hash, and who authored it. */
|
|
121
|
+
export interface TraceEntry {
|
|
122
|
+
readonly state: StateName
|
|
123
|
+
readonly hash: string
|
|
124
|
+
readonly actor: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ProcessRun {
|
|
128
|
+
/** The workflow the process's opening commit pinned; `undefined` replays on the default. */
|
|
129
|
+
readonly workflow: string | undefined
|
|
130
|
+
/** Opened by the removed `gtd --entry`: no workflow to replay, only `gtd abandon` applies. */
|
|
131
|
+
readonly legacyOpening: boolean
|
|
132
|
+
/** The process's first commit, or HEAD when none has landed yet. */
|
|
133
|
+
readonly startHash: string
|
|
134
|
+
/** The parent of the process's first commit — the empty tree when that is the root commit. */
|
|
135
|
+
readonly startParentHash: string
|
|
136
|
+
/** The diff base prompts name: `startParentHash`, unless the opening commit fixed a `Gtd-Review-Base`. */
|
|
137
|
+
readonly diffBase: string
|
|
138
|
+
readonly trace: readonly TraceEntry[]
|
|
139
|
+
readonly costEntries: readonly CostEntry[]
|
|
140
|
+
readonly judgeVerdicts: readonly JudgeVerdictEntry[]
|
|
141
|
+
/** The process settings recorded in the process's first commit (empty for a process started before pinning existed). */
|
|
142
|
+
readonly pinnedVars: Record<string, string>
|
|
143
|
+
/** Absent for a process started before recording existed. */
|
|
144
|
+
readonly pinnedConfig: { readonly commit: string; readonly files: ConfigFingerprint } | undefined
|
|
145
|
+
/** HEAD's own gtd commit, when it is one — `empty` is whether it changed nothing. */
|
|
146
|
+
readonly headTurn:
|
|
147
|
+
| {
|
|
148
|
+
readonly state: StateName
|
|
149
|
+
readonly actor: string
|
|
150
|
+
readonly empty: boolean
|
|
151
|
+
readonly step: boolean
|
|
152
|
+
}
|
|
153
|
+
| undefined
|
|
154
|
+
/** Set by `summaryRun` when HEAD is itself the commit that closed the process. */
|
|
155
|
+
readonly closingHash: string | undefined
|
|
156
|
+
/** The episode's step commits, oldest → newest, and the commit replay starts from. */
|
|
157
|
+
readonly episode: {
|
|
158
|
+
readonly base: string | undefined
|
|
159
|
+
readonly commits: readonly { readonly hash: string; readonly message: string }[]
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const headTurnOf = (head: History[number] | undefined): ProcessRun["headTurn"] => {
|
|
164
|
+
if (head === undefined) return undefined
|
|
165
|
+
const message = parseCommitMessage(head.message)
|
|
166
|
+
const subject = message.parsed
|
|
167
|
+
if (subject === undefined || !ACTORS.has(subject.actor)) return undefined
|
|
168
|
+
return {
|
|
169
|
+
state: subject.to,
|
|
170
|
+
actor: subject.actor,
|
|
171
|
+
empty: head.touched.length === 0,
|
|
172
|
+
step: message.step !== undefined,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const traceEntryOf = (
|
|
177
|
+
hash: string,
|
|
178
|
+
message: ReturnType<typeof parseCommitMessage>,
|
|
179
|
+
): TraceEntry => ({
|
|
180
|
+
state: message.parsed?.to ?? "",
|
|
181
|
+
hash,
|
|
182
|
+
actor: message.parsed?.actor ?? "",
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const costEntriesOf = (message: ReturnType<typeof parseCommitMessage>): CostEntry[] =>
|
|
186
|
+
message.cost.map((c) => ({ cost: c.cost, model: c.model ?? UNATTRIBUTED_MODEL }))
|
|
187
|
+
|
|
188
|
+
const hashAt = (history: History, index: number): string | undefined => history[index]?.hash
|
|
189
|
+
|
|
190
|
+
// The base is checked too: a foreign subject codec can hide a commit from the
|
|
191
|
+
// episode, leaving it as the base. Only a `gtd`-prefixed base counts, so a
|
|
192
|
+
// commit gtd never wrote cannot block it by quoting the trailer.
|
|
193
|
+
const historyFormatFault = (history: History, location: EpisodeLocation): string | undefined =>
|
|
194
|
+
formatFault(
|
|
195
|
+
history
|
|
196
|
+
.slice(location.baseIndex + 1)
|
|
197
|
+
.concat(
|
|
198
|
+
history[location.baseIndex]?.message.startsWith("gtd")
|
|
199
|
+
? [history[location.baseIndex]!]
|
|
200
|
+
: [],
|
|
201
|
+
)
|
|
202
|
+
.map((c) => ({ hash: c.hash, format: parseCommitMessage(c.message).format })),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
/** Fails on a history in a format this gtd does not read, before anything acts on it. */
|
|
206
|
+
const runOf = (
|
|
207
|
+
history: History,
|
|
208
|
+
location: EpisodeLocation,
|
|
209
|
+
closingHash: string | undefined = undefined,
|
|
210
|
+
): Effect.Effect<ProcessRun, Error> => {
|
|
211
|
+
const fault = historyFormatFault(history, location)
|
|
212
|
+
return fault === undefined
|
|
213
|
+
? Effect.succeed(buildRun(history, location, closingHash))
|
|
214
|
+
: Effect.fail(new Error(fault))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The newest recording, not the first process commit's: the episode is
|
|
218
|
+
// located by the CURRENT config's first step, so a config that renamed it can
|
|
219
|
+
// move the boundary onto the start commit itself, or behind it.
|
|
220
|
+
const pinnedConfigOf = (history: History): ProcessRun["pinnedConfig"] => {
|
|
221
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
222
|
+
const files = parseCommitMessage(history[i]!.message).config
|
|
223
|
+
if (Object.keys(files).length > 0) return { commit: history[i]!.hash, files }
|
|
224
|
+
}
|
|
225
|
+
return undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const buildRun = (
|
|
229
|
+
history: History,
|
|
230
|
+
location: EpisodeLocation,
|
|
231
|
+
closingHash: string | undefined,
|
|
232
|
+
): ProcessRun => {
|
|
233
|
+
const processCommits = history.slice(location.processStart)
|
|
234
|
+
const parsed = processCommits.map((c) => parseCommitMessage(c.message))
|
|
235
|
+
const first = parsed[0]
|
|
236
|
+
const startParentHash = hashAt(history, location.processStart - 1) ?? EMPTY_TREE
|
|
237
|
+
const head = history[history.length - 1]
|
|
238
|
+
return {
|
|
239
|
+
workflow: location.workflow,
|
|
240
|
+
legacyOpening: location.legacyOpening,
|
|
241
|
+
startHash: hashAt(history, location.processStart) ?? head?.hash ?? EMPTY_TREE,
|
|
242
|
+
startParentHash,
|
|
243
|
+
diffBase: first?.reviewBase ?? startParentHash,
|
|
244
|
+
trace: processCommits.map((c, i) => traceEntryOf(c.hash, parsed[i]!)),
|
|
245
|
+
costEntries: parsed.flatMap(costEntriesOf),
|
|
246
|
+
judgeVerdicts: parsed.flatMap((m) => m.judge),
|
|
247
|
+
pinnedVars: { ...first?.vars },
|
|
248
|
+
pinnedConfig: pinnedConfigOf(history.slice(Math.max(location.baseIndex, 0))),
|
|
249
|
+
headTurn: headTurnOf(head),
|
|
250
|
+
closingHash,
|
|
251
|
+
episode: {
|
|
252
|
+
base: hashAt(history, location.baseIndex),
|
|
253
|
+
commits: history.slice(location.baseIndex + 1),
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The page sizes `findBoundaryBase` doubles through before giving up and
|
|
259
|
+
// reading the whole repository — chosen so an ordinary episode (a handful of
|
|
260
|
+
// commits) resolves at the very first tier.
|
|
261
|
+
const PAGE_TIERS: ReadonlyArray<number> = [32, 128, 512, Infinity]
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `locateEpisode`'s own boundary test, subject-only: a commit that isn't a
|
|
265
|
+
* gtd step commit, or one landing the flow's initial state, bounds an
|
|
266
|
+
* episode from below — EXCEPT a bare `gtd(human): <x>` subject (no `→`),
|
|
267
|
+
* which is NEVER a boundary here, whatever `<x>` is. That bare shape is
|
|
268
|
+
* `locateEpisode`'s own `opening` branch (a started episode's first commit),
|
|
269
|
+
* which takes priority there over the initial-state check no matter what the
|
|
270
|
+
* workflow's first step is named — including one named after `def.initial`
|
|
271
|
+
* itself, where the two branches would otherwise disagree. A bare human commit can
|
|
272
|
+
* also be a self-loop step landing rather than a real opening (this
|
|
273
|
+
* subject-only pass can't read the `Gtd-Step` trailer that would tell them
|
|
274
|
+
* apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
|
|
275
|
+
* commits further back than strictly needed — never stop short of the
|
|
276
|
+
* boundary `locateEpisode` would find once it has full commit bodies in
|
|
277
|
+
* hand. That asymmetry is the only direction this approximation may err in;
|
|
278
|
+
* it must never call a real boundary "not yet found".
|
|
279
|
+
*/
|
|
280
|
+
const subjectIsBoundary = (def: WorkflowDefinition, subject: string): boolean => {
|
|
281
|
+
const parsed = parseCommitMessage(subject).parsed
|
|
282
|
+
if (parsed === undefined || !ACTORS.has(parsed.actor)) return true
|
|
283
|
+
if (parsed.actor === "human" && parsed.from === undefined) return false
|
|
284
|
+
return parsed.to === def.initial
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The last page index (walking newest→oldest, same direction as `locateEpisode`) whose subject is a boundary, or `undefined` if none in `page`. */
|
|
288
|
+
const boundaryIndexIn = (
|
|
289
|
+
def: WorkflowDefinition,
|
|
290
|
+
page: ReadonlyArray<{ readonly subject: string }>,
|
|
291
|
+
): number | undefined => {
|
|
292
|
+
for (let i = page.length - 1; i >= 0; i--) {
|
|
293
|
+
if (subjectIsBoundary(def, page[i]!.subject)) return i
|
|
294
|
+
}
|
|
295
|
+
return undefined
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `findBoundaryBase`'s result: `base` is the hash to pass `commitHistory` so
|
|
300
|
+
* a `git.commitHistory(base, head)` call reads exactly the episode ending at
|
|
301
|
+
* `head`, plus whatever a subject-only read couldn't rule out — never the
|
|
302
|
+
* whole repository. `base` is deliberately the boundary commit's PARENT, not
|
|
303
|
+
* the boundary commit itself: `commitHistory`'s range excludes `base`, and
|
|
304
|
+
* `runOf` needs the boundary commit present in the read history
|
|
305
|
+
* (`episode.base`, `startParentHash`). `base: undefined` means "read from the
|
|
306
|
+
* repository root" — an empty repository, an unresolvable `head`, or an
|
|
307
|
+
* episode that reaches the root commit, none of which cost anything extra
|
|
308
|
+
* since that IS the whole history in those cases.
|
|
309
|
+
*
|
|
310
|
+
* `head` is the CONCRETE hash the literal `head` argument resolved to on this
|
|
311
|
+
* call's first read — `undefined` only when it never resolved (an empty
|
|
312
|
+
* repository, or an unresolvable ref). The caller must hand this hash, not
|
|
313
|
+
* the original `head` argument, to its own `commitHistory` call: resolving a
|
|
314
|
+
* symbolic ref (literal `HEAD`, most callers' default) a second time risks
|
|
315
|
+
* reading a DIFFERENT commit than this function paged through, if something
|
|
316
|
+
* moves HEAD in between — pinning both reads to one resolved hash is what
|
|
317
|
+
* keeps the two-read split from disagreeing with itself.
|
|
318
|
+
*/
|
|
319
|
+
const findBoundaryBase = (
|
|
320
|
+
git: GitOperations,
|
|
321
|
+
def: WorkflowDefinition,
|
|
322
|
+
head: string | undefined,
|
|
323
|
+
): Effect.Effect<{ readonly base: string | undefined; readonly head: string | undefined }, Error> =>
|
|
324
|
+
Effect.gen(function* () {
|
|
325
|
+
let pinnedHead = head
|
|
326
|
+
for (const pageSize of PAGE_TIERS) {
|
|
327
|
+
const page = yield* git.subjectHistory(pageSize, 0, pinnedHead)
|
|
328
|
+
// The newest entry of the very first page IS whatever `head` resolved
|
|
329
|
+
// to — pin it now so every further page in this call, and the caller's
|
|
330
|
+
// own `commitHistory`, all read through this same concrete hash rather
|
|
331
|
+
// than re-resolving a moving literal `HEAD`.
|
|
332
|
+
if (pinnedHead === undefined) pinnedHead = page[page.length - 1]?.hash
|
|
333
|
+
const wholeHistory = !Number.isFinite(pageSize) || page.length < pageSize
|
|
334
|
+
const boundaryIdx = boundaryIndexIn(def, page)
|
|
335
|
+
// No room in this page for the boundary commit's own parent — either
|
|
336
|
+
// no boundary was found at all, or it sits at the page's own oldest
|
|
337
|
+
// slot. Either way, grow the page and look again, unless the page
|
|
338
|
+
// already IS the whole history (nothing earlier to find).
|
|
339
|
+
if (boundaryIdx === undefined || boundaryIdx === 0) {
|
|
340
|
+
if (wholeHistory) return { base: undefined, head: pinnedHead }
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
return { base: page[boundaryIdx - 1]!.hash, head: pinnedHead }
|
|
344
|
+
}
|
|
345
|
+
return { base: undefined, head: pinnedHead }
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const historyUpTo = (
|
|
349
|
+
git: GitOperations,
|
|
350
|
+
def: WorkflowDefinition,
|
|
351
|
+
head: string | undefined,
|
|
352
|
+
): Effect.Effect<History, Error> =>
|
|
353
|
+
Effect.flatMap(findBoundaryBase(git, def, head), ({ base, head: resolvedHead }) =>
|
|
354
|
+
git.commitHistory(base, resolvedHead),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
const computeProcessRun = (
|
|
358
|
+
git: GitOperations,
|
|
359
|
+
def: WorkflowDefinition,
|
|
360
|
+
head?: string,
|
|
361
|
+
): Effect.Effect<ProcessRun, Error> =>
|
|
362
|
+
Effect.flatMap(historyUpTo(git, def, head), (history) =>
|
|
363
|
+
runOf(history, locateEpisode(def, history)),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
// A file matching its copy in the recording commit is unchanged: the hashes
|
|
367
|
+
// are taken before the commit's own hooks run, and a hook may reformat it.
|
|
368
|
+
const changedSinceStart = (
|
|
369
|
+
pinned: ProcessRun["pinnedConfig"],
|
|
370
|
+
current: ConfigFingerprint,
|
|
371
|
+
workspace: WorkspaceOps,
|
|
372
|
+
): readonly string[] => {
|
|
373
|
+
if (pinned === undefined) return []
|
|
374
|
+
return changedConfigFiles(pinned.files, current).filter((path) => {
|
|
375
|
+
const committed = workspace.readCommittedSync(path, pinned.commit)
|
|
376
|
+
return (
|
|
377
|
+
current[path] === undefined ||
|
|
378
|
+
committed === undefined ||
|
|
379
|
+
contentHash(committed) !== current[path]
|
|
380
|
+
)
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const warnConfigChanges = (
|
|
385
|
+
run: ProcessRun,
|
|
386
|
+
current: ConfigFingerprint,
|
|
387
|
+
workspace: WorkspaceOps,
|
|
388
|
+
): Effect.Effect<void, never, Narrator> =>
|
|
389
|
+
Effect.gen(function* () {
|
|
390
|
+
const changed = changedSinceStart(run.pinnedConfig, current, workspace)
|
|
391
|
+
if (changed.length === 0) return
|
|
392
|
+
yield* (yield* Narrator).warn(
|
|
393
|
+
`gtd: warning: the config changed since this process started (${changed.join(", ")}) — the rest of the process replays under the changed config`,
|
|
394
|
+
)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
type ConfigRequirements = GitService | ConfigService | ConfigDiscovery | Narrator | Workspace | Host
|
|
398
|
+
|
|
399
|
+
/** The run alone, never replaying — `gtd abandon` must work even when replay would refuse, short of a history it cannot read. */
|
|
400
|
+
export const currentRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
|
|
401
|
+
function* () {
|
|
402
|
+
const git = yield* GitService
|
|
403
|
+
const config = yield* (yield* ConfigService).load
|
|
404
|
+
return yield* computeProcessRun(git, config.workflow)
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
/** The process HEAD closes or sits inside — `gtd summary`'s run. */
|
|
409
|
+
export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
|
|
410
|
+
function* () {
|
|
411
|
+
const git = yield* GitService
|
|
412
|
+
const def = (yield* (yield* ConfigService).load).workflow
|
|
413
|
+
// Cheap: just HEAD's own subject, to decide which of the two reads below
|
|
414
|
+
// this run needs — never the commits before it. Its hash is also this
|
|
415
|
+
// run's ONE resolution of literal HEAD: every further read below is
|
|
416
|
+
// pinned to it, rather than re-resolving a `head` that could move.
|
|
417
|
+
const headEntry = (yield* git.subjectHistory(1, 0))[0]
|
|
418
|
+
if (headEntry === undefined) {
|
|
419
|
+
// No commits at all — `historyUpTo` folds an empty repository to an
|
|
420
|
+
// empty run on its own; there is no hash yet to pin anything to.
|
|
421
|
+
const history = yield* historyUpTo(git, def, undefined)
|
|
422
|
+
return yield* runOf(history, locateEpisode(def, history))
|
|
423
|
+
}
|
|
424
|
+
const closes = parseCommitMessage(headEntry.subject).parsed?.to === def.initial
|
|
425
|
+
if (!closes) {
|
|
426
|
+
const history = yield* historyUpTo(git, def, headEntry.hash)
|
|
427
|
+
return yield* runOf(history, locateEpisode(def, history))
|
|
428
|
+
}
|
|
429
|
+
// HEAD itself closes the process: its own subject would otherwise read as
|
|
430
|
+
// a boundary to `findBoundaryBase` (landing the initial state), stopping
|
|
431
|
+
// the page one commit too soon. Search for the PRIOR episode's boundary
|
|
432
|
+
// from HEAD's parent instead, then read the closing commit separately —
|
|
433
|
+
// `commitHistory` falls back to the whole history when that parent ref
|
|
434
|
+
// doesn't resolve (HEAD is the repository's root commit).
|
|
435
|
+
const parentRef = `${headEntry.hash}~1`
|
|
436
|
+
const before = yield* historyUpTo(git, def, parentRef)
|
|
437
|
+
const location = locateEpisode(def, before)
|
|
438
|
+
let closing = yield* git.commitHistory(parentRef, headEntry.hash)
|
|
439
|
+
if (closing.length === 0) closing = yield* git.commitHistory(undefined, headEntry.hash)
|
|
440
|
+
return yield* runOf([...before, ...closing], { ...location }, headEntry.hash)
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
// ── Trees ───────────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
|
|
447
|
+
let entries: ReadonlyMap<string, string> | undefined
|
|
448
|
+
const list = () => (entries ??= workspace.treeSync(hash))
|
|
449
|
+
// Sorted once per commit, not per call: `Workspace.episodeTrees` has
|
|
450
|
+
// already materialized `list()` before this is ever read, so the sort
|
|
451
|
+
// itself is the only remaining per-call cost `paths()` used to pay.
|
|
452
|
+
let sortedPaths: readonly string[] | undefined
|
|
453
|
+
const contents = new Map<string, string | undefined>()
|
|
454
|
+
return {
|
|
455
|
+
paths: () => (sortedPaths ??= [...list().keys()].sort()),
|
|
456
|
+
read: (path) => {
|
|
457
|
+
if (!list().has(path)) return undefined
|
|
458
|
+
if (!contents.has(path)) contents.set(path, workspace.readCommittedSync(path, hash))
|
|
459
|
+
return contents.get(path)
|
|
460
|
+
},
|
|
461
|
+
id: (path) => list().get(path),
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A content rewrite the landing script applies to one path before it commits. */
|
|
466
|
+
interface Rewrite {
|
|
467
|
+
readonly path: string
|
|
468
|
+
readonly apply: (content: string) => string
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The tree a landing would commit: the working tree, with any rewrite the
|
|
473
|
+
* landing script applies first. `entries` is `setup.pendingWorktree()` —
|
|
474
|
+
* already memoized — never a fresh `workspace.worktreeSync()` call; see that
|
|
475
|
+
* field's own comment for why the memo lives there and not on `Workspace`.
|
|
476
|
+
*/
|
|
477
|
+
const pendingTree = (
|
|
478
|
+
workspace: WorkspaceOps,
|
|
479
|
+
entries: ReadonlyMap<string, string | undefined>,
|
|
480
|
+
rewrite: Rewrite | undefined,
|
|
481
|
+
): TreeView => {
|
|
482
|
+
const paths = [...entries.keys()]
|
|
483
|
+
const readWorktree = (path: string): string | undefined => {
|
|
484
|
+
try {
|
|
485
|
+
return workspace.readSync(path)
|
|
486
|
+
} catch {
|
|
487
|
+
// A submodule or another non-file entry has no content to read.
|
|
488
|
+
return undefined
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
paths: () => paths,
|
|
493
|
+
read: (path) => {
|
|
494
|
+
if (!entries.has(path)) return undefined
|
|
495
|
+
const content = readWorktree(path)
|
|
496
|
+
return content === undefined || rewrite?.path !== path ? content : rewrite.apply(content)
|
|
497
|
+
},
|
|
498
|
+
// The rewritten path's blob id is not the working tree's: compare its contents.
|
|
499
|
+
id: (path) => (rewrite?.path === path ? undefined : entries.get(path)),
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ── Variables ───────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The one resolver of both setting kinds. Process settings: the recorded
|
|
507
|
+
* (pinned) value wins per name, anything unrecorded resolves live — the
|
|
508
|
+
* fallback for a process started before pinning existed. Environment settings
|
|
509
|
+
* are always live. `startOverrides` are `--var` values, passed only while a
|
|
510
|
+
* process is being started.
|
|
511
|
+
*/
|
|
512
|
+
const settingsFor = (
|
|
513
|
+
workflow: Pick<ResolvedWorkflow, "vars" | "env">,
|
|
514
|
+
pinnedVars: Readonly<Record<string, string>>,
|
|
515
|
+
hostEnv: Readonly<Record<string, string | undefined>>,
|
|
516
|
+
startOverrides: Readonly<Record<string, string>> = {},
|
|
517
|
+
): { readonly vars: Record<string, string>; readonly env: Record<string, string> } => ({
|
|
518
|
+
vars: {
|
|
519
|
+
...resolveVars(workflow.vars, startOverrides, hostEnv),
|
|
520
|
+
...pinnedVars,
|
|
521
|
+
},
|
|
522
|
+
env: resolveVars(workflow.env, {}, hostEnv),
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
/** The workflow a run replays on: its pinned one, else the default. */
|
|
526
|
+
const workflowOf = (
|
|
527
|
+
config: ConfigOperations,
|
|
528
|
+
run: ProcessRun,
|
|
529
|
+
): Effect.Effect<ResolvedWorkflow, Error> => {
|
|
530
|
+
if (run.legacyOpening) {
|
|
531
|
+
return Effect.fail(
|
|
532
|
+
new Error(
|
|
533
|
+
"gtd: this process was opened by `gtd --entry`, which no longer exists — run `gtd abandon` to start over",
|
|
534
|
+
),
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
if (run.workflow === undefined) return Effect.succeed(config.defaultWorkflow)
|
|
538
|
+
const named = config.workflowNamed(run.workflow)
|
|
539
|
+
if (named !== undefined) return Effect.succeed(named)
|
|
540
|
+
return Effect.fail(
|
|
541
|
+
new Error(
|
|
542
|
+
`gtd: this process runs workflow "${run.workflow}", which gtd.config.ts no longer defines — run \`gtd abandon\` to start over`,
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* `judgeBudgetBytes` is the one var that refuses rather than disabling its
|
|
549
|
+
* mechanism when blanked: without a bound the judge payload is rejected
|
|
550
|
+
* anyway. Absent falls back to a conservative default.
|
|
551
|
+
*/
|
|
552
|
+
const DEFAULT_JUDGE_BUDGET_BYTES = 32768
|
|
553
|
+
const judgeBudgetBytes = (vars: Record<string, string>): number => {
|
|
554
|
+
const raw = vars.judgeBudgetBytes
|
|
555
|
+
if (raw === undefined) return DEFAULT_JUDGE_BUDGET_BYTES
|
|
556
|
+
const n = Number(raw)
|
|
557
|
+
if (raw.trim() === "" || !Number.isInteger(n) || n <= 0) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`"judgeBudgetBytes" must be a positive integer — got ${JSON.stringify(raw)} (blanking this var disables the payload bound rather than the mechanism it guards, so it is refused rather than defaulted)`,
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
return n
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── Replay ──────────────────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
interface ReplaySetup {
|
|
568
|
+
readonly def: WorkflowDefinition
|
|
569
|
+
readonly run: ProcessRun
|
|
570
|
+
readonly vars: Record<string, string>
|
|
571
|
+
readonly env: Record<string, string>
|
|
572
|
+
readonly budget: number
|
|
573
|
+
readonly skills: Record<string, readonly string[]>
|
|
574
|
+
readonly access: Record<string, ScopeAccess>
|
|
575
|
+
readonly workspace: WorkspaceOps
|
|
576
|
+
/**
|
|
577
|
+
* The episode's base tree and every commit's, built ONCE — after
|
|
578
|
+
* `Workspace.episodeTrees` has already materialized every one of them —
|
|
579
|
+
* and reused by both replays one `gtd` command runs (`restAt`'s own, then
|
|
580
|
+
* `decideLanding`'s). Rebuilding these per `replayFor` call bought nothing
|
|
581
|
+
* once the underlying `treeSync`/`readCommittedSync` reads are themselves
|
|
582
|
+
* cached, but still allocated a fresh `TreeView` (and its own `contents`
|
|
583
|
+
* memo) per replay for no reason.
|
|
584
|
+
*/
|
|
585
|
+
readonly base: { readonly hash: string; readonly tree: TreeView }
|
|
586
|
+
readonly episode: readonly EpisodeCommit[]
|
|
587
|
+
/**
|
|
588
|
+
* The working tree `git add -A` would commit, read at most once — memoized
|
|
589
|
+
* HERE, per `ReplaySetup` (built fresh every `restAt` call), rather than on
|
|
590
|
+
* `Workspace` itself: a long-lived runtime reusing one `Workspace` across
|
|
591
|
+
* requests would otherwise keep serving the FIRST request's working tree
|
|
592
|
+
* forever. A new
|
|
593
|
+
* `ReplaySetup` per `restAt` call is what makes each request see the
|
|
594
|
+
* working tree as it stands right then, while still reading it only once
|
|
595
|
+
* across that one request's `decideLanding`/`startRefusal` replay.
|
|
596
|
+
*/
|
|
597
|
+
readonly pendingWorktree: () => ReadonlyMap<string, string | undefined>
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const replayFor = (
|
|
601
|
+
setup: ReplaySetup,
|
|
602
|
+
pending?: { readonly tree: TreeView; readonly verdicts?: readonly JudgeVerdict[] },
|
|
603
|
+
): Promise<ReplayOutcome> =>
|
|
604
|
+
replay({
|
|
605
|
+
flow: setup.def.flow,
|
|
606
|
+
episode: { base: setup.base, commits: setup.episode },
|
|
607
|
+
vars: setup.vars,
|
|
608
|
+
env: setup.env,
|
|
609
|
+
start: setup.run.diffBase,
|
|
610
|
+
startTree:
|
|
611
|
+
setup.run.diffBase === EMPTY_TREE
|
|
612
|
+
? treeFromRecord({})
|
|
613
|
+
: commitTree(setup.workspace, setup.run.diffBase),
|
|
614
|
+
budgetBytes: setup.budget,
|
|
615
|
+
skills: setup.skills,
|
|
616
|
+
configuredSkills: setup.def.configuredSkills,
|
|
617
|
+
access: setup.access,
|
|
618
|
+
configuredAccess: setup.def.configuredAccess,
|
|
619
|
+
...(pending !== undefined ? { pending } : {}),
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
const replayError = (outcome: ReplayOutcome): Error | undefined => {
|
|
623
|
+
if (outcome.kind === "divergence" || outcome.kind === "failed") return new Error(outcome.message)
|
|
624
|
+
if (outcome.kind === "refused") return new Error(`gtd: ${outcome.message}`)
|
|
625
|
+
if (outcome.kind === "ended") {
|
|
626
|
+
return new Error(
|
|
627
|
+
"gtd: history ends the episode, but HEAD does not enter the default workflow's first step — the workflow changed under this process; run `gtd abandon` to start over",
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
return undefined
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── The rest ────────────────────────────────────────────────────────────────
|
|
634
|
+
|
|
635
|
+
const judgeDocument = (step: ReachedStep): string | undefined =>
|
|
636
|
+
step.request.kind === "judge"
|
|
637
|
+
? judgeJson(step.request.evidence, step.request.questions)
|
|
638
|
+
: undefined
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The fixed sentence appended to a judge gate's message when a bounded read
|
|
642
|
+
* dropped bytes to fit `judgeBudgetBytes` — a constant, so no workflow can
|
|
643
|
+
* reword or drop it.
|
|
644
|
+
*/
|
|
645
|
+
export const TRUNCATION_NOTICE =
|
|
646
|
+
"Note: some evidence above was truncated to fit the judge's payload budget."
|
|
647
|
+
|
|
648
|
+
const DEFAULT_JUDGE_MESSAGE =
|
|
649
|
+
"A judgment is pending. Run `gtd judge answer` and pipe a verdict, or land to take the conservative default with no verdict recorded."
|
|
650
|
+
|
|
651
|
+
const optional = <K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } =>
|
|
652
|
+
(value === undefined ? {} : { [key]: value }) as { [P in K]?: V }
|
|
653
|
+
|
|
654
|
+
type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode">
|
|
655
|
+
|
|
656
|
+
const commonOf = (step: ReachedStep): StepCommon => {
|
|
657
|
+
const options = step.request.options
|
|
658
|
+
return {
|
|
659
|
+
actor: step.actor,
|
|
660
|
+
...optional("label", options.label),
|
|
661
|
+
...optional("file", options.file),
|
|
662
|
+
...optional("mode", options.mode),
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
type RequestOf<K extends ReachedStep["request"]["kind"]> = Extract<
|
|
667
|
+
ReachedStep["request"],
|
|
668
|
+
{ kind: K }
|
|
669
|
+
>
|
|
670
|
+
|
|
671
|
+
const promptDef = (
|
|
672
|
+
common: StepCommon,
|
|
673
|
+
request: RequestOf<"agent">,
|
|
674
|
+
skills: readonly string[] | undefined,
|
|
675
|
+
access: StepAccess | undefined,
|
|
676
|
+
): StepDef => ({
|
|
677
|
+
...common,
|
|
678
|
+
kind: "prompt",
|
|
679
|
+
content: request.prompt,
|
|
680
|
+
...optional("model", request.options.model),
|
|
681
|
+
...optional("system", request.options.system),
|
|
682
|
+
...optional("allowEmpty", request.options.allowEmpty),
|
|
683
|
+
...optional("skills", skills),
|
|
684
|
+
...optional("access", access),
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
const scriptDef = (common: StepCommon, request: RequestOf<"run">): StepDef => ({
|
|
688
|
+
...common,
|
|
689
|
+
kind: "script",
|
|
690
|
+
content: request.body,
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
const judgeDef = (common: StepCommon, request: RequestOf<"judge">, step: ReachedStep): StepDef => {
|
|
694
|
+
const message = request.options.message ?? DEFAULT_JUDGE_MESSAGE
|
|
695
|
+
return {
|
|
696
|
+
...common,
|
|
697
|
+
kind: "message",
|
|
698
|
+
content: step.truncated ? `${message}\n\n${TRUNCATION_NOTICE}` : message,
|
|
699
|
+
...optional("judge", judgeDocument(step)),
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const messageDef = (
|
|
704
|
+
common: StepCommon,
|
|
705
|
+
request: RequestOf<"human">,
|
|
706
|
+
step: ReachedStep,
|
|
707
|
+
): StepDef => ({
|
|
708
|
+
...common,
|
|
709
|
+
kind: "message",
|
|
710
|
+
content: request.options.message ?? request.options.label ?? step.name,
|
|
711
|
+
...optional("acceptClean", request.options.acceptClean),
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
const stepDefOf = (step: ReachedStep): StepDef => {
|
|
715
|
+
const request = step.request
|
|
716
|
+
const common = commonOf(step)
|
|
717
|
+
switch (request.kind) {
|
|
718
|
+
case "agent":
|
|
719
|
+
return promptDef(common, request, step.skills, step.access)
|
|
720
|
+
case "run":
|
|
721
|
+
return scriptDef(common, request)
|
|
722
|
+
case "judge":
|
|
723
|
+
return judgeDef(common, request, step)
|
|
724
|
+
case "human":
|
|
725
|
+
return messageDef(common, request, step)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Is `name` inside `scope`'s subtree? The root scope `""` holds everything. */
|
|
730
|
+
const inScope = (name: string, scope: string): boolean =>
|
|
731
|
+
scope === "" || name === scope || name.startsWith(`${scope}.`)
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Where the current unbroken run of rests inside `scope`'s subtree began —
|
|
735
|
+
* a dip into a descendant scope does not break it, a sibling or ancestor
|
|
736
|
+
* does, and so does a fresh `scope()` call of the same scope.
|
|
737
|
+
*/
|
|
738
|
+
const scopeRunStart = (trace: readonly ReachedStep[], scope: string): number => {
|
|
739
|
+
let start = -1
|
|
740
|
+
for (let k = 0; k < trace.length; k++) {
|
|
741
|
+
if (!inScope(trace[k]!.memoryScope, scope)) continue
|
|
742
|
+
const previous = trace[k - 1]
|
|
743
|
+
const unbroken =
|
|
744
|
+
previous !== undefined &&
|
|
745
|
+
inScope(previous.memoryScope, scope) &&
|
|
746
|
+
scopeCallOf(previous, scope) === scopeCallOf(trace[k]!, scope)
|
|
747
|
+
if (!unbroken) start = k
|
|
748
|
+
}
|
|
749
|
+
return start
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** The innermost `scope()` call enclosing `scope` at `step` — a new call is a new conversation. */
|
|
753
|
+
const scopeCallOf = (step: ReachedStep, scope: string): number | undefined => {
|
|
754
|
+
let call: number | undefined
|
|
755
|
+
for (const entry of step.scopeCalls) if (inScope(scope, entry.prefix)) call = entry.call
|
|
756
|
+
return call
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Shown for the root scope, which has no name of its own.
|
|
760
|
+
const ROOT_MEMORY_SCOPE_NAME = "root"
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* An agent rest's memory key, `<scope>#<hash7>`: the hash is the commit the
|
|
764
|
+
* current unbroken run into the scope started FROM, so the same run always
|
|
765
|
+
* re-derives the same key. `resumed` is whether an agent turn in that run
|
|
766
|
+
* already landed.
|
|
767
|
+
*/
|
|
768
|
+
const memoryOf = (
|
|
769
|
+
trace: readonly ReachedStep[],
|
|
770
|
+
run: ProcessRun,
|
|
771
|
+
): { readonly key: string | undefined; readonly resumed: boolean } => {
|
|
772
|
+
const rest = trace[trace.length - 1]
|
|
773
|
+
if (rest === undefined || rest.kind !== "agent") return { key: undefined, resumed: false }
|
|
774
|
+
const start = scopeRunStart(trace, rest.memoryScope)
|
|
775
|
+
const token = start <= 0 ? run.startParentHash : trace[start - 1]!.enteredAt
|
|
776
|
+
// A child scope's turns keep the run unbroken but are their own conversation.
|
|
777
|
+
const resumed = trace
|
|
778
|
+
.slice(Math.max(start, 0), trace.length - 1)
|
|
779
|
+
.some((s) => s.kind === "agent" && s.memoryScope === rest.memoryScope)
|
|
780
|
+
return { key: `${rest.memoryScope || ROOT_MEMORY_SCOPE_NAME}#${token.slice(0, 7)}`, resumed }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* A rest's hints, as the wire carries them. Optional keys are omitted, never
|
|
785
|
+
* `undefined`. Not strings-only: `skills` carries an array, already split —
|
|
786
|
+
* the wire does no parsing of its own.
|
|
787
|
+
*/
|
|
788
|
+
export interface RestHints {
|
|
789
|
+
readonly model?: string
|
|
790
|
+
readonly label?: string
|
|
791
|
+
readonly file?: string
|
|
792
|
+
readonly judge?: string
|
|
793
|
+
readonly system?: string
|
|
794
|
+
readonly mode?: string
|
|
795
|
+
readonly skills?: readonly string[]
|
|
796
|
+
readonly access?: StepAccess
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const hintsOf = (def: StepDef): RestHints => ({
|
|
800
|
+
...optional("model", def.model),
|
|
801
|
+
...optional("label", def.label),
|
|
802
|
+
...optional("file", def.file),
|
|
803
|
+
...optional("judge", def.judge),
|
|
804
|
+
...optional("system", def.system),
|
|
805
|
+
...optional("mode", def.mode),
|
|
806
|
+
...optional("skills", def.skills),
|
|
807
|
+
...optional("access", def.access),
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
const normalizeStatus = (raw: string): ChangeStatus => (raw === "A" ? "A" : raw === "D" ? "D" : "M")
|
|
811
|
+
|
|
812
|
+
/** The currently rested step and its description — enough for the viewer and the LSP. */
|
|
813
|
+
interface ResolvedRest {
|
|
814
|
+
readonly def: WorkflowDefinition
|
|
815
|
+
/** The workflow the process runs: its pinned name, or the default's display name. */
|
|
816
|
+
readonly workflowName: string
|
|
817
|
+
readonly state: StateName
|
|
818
|
+
readonly stepDef: StepDef
|
|
819
|
+
readonly actor: string
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Where the process rests right now, fully resolved. ONE SNAPSHOT, taken
|
|
824
|
+
* before any mutation.
|
|
825
|
+
*/
|
|
826
|
+
interface Rest extends ResolvedRest {
|
|
827
|
+
readonly step: ReachedStep
|
|
828
|
+
readonly trace: readonly ReachedStep[]
|
|
829
|
+
readonly run: ProcessRun
|
|
830
|
+
readonly vars: Record<string, string>
|
|
831
|
+
readonly env: Record<string, string>
|
|
832
|
+
/** What a landing that starts the process records. */
|
|
833
|
+
readonly config: ConfigFingerprint
|
|
834
|
+
readonly changes: readonly PendingChange[]
|
|
835
|
+
readonly memory: string | undefined
|
|
836
|
+
readonly memoryResumed: boolean
|
|
837
|
+
readonly hints: RestHints
|
|
838
|
+
/** Total recorded cost of the process, and its per-model breakdown. */
|
|
839
|
+
readonly cost: { readonly total: number; readonly byModel: readonly ModelCost[] }
|
|
840
|
+
readonly setup: ReplaySetup
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Per-model token totals, highest-cost first (ties broken by model name). */
|
|
844
|
+
const costByModel = (entries: readonly CostEntry[]): ModelCost[] => {
|
|
845
|
+
const byModel = new Map<string, number>()
|
|
846
|
+
for (const entry of entries)
|
|
847
|
+
byModel.set(entry.model, (byModel.get(entry.model) ?? 0) + entry.cost)
|
|
848
|
+
return [...byModel.entries()]
|
|
849
|
+
.map(([model, cost]) => ({ model, cost }))
|
|
850
|
+
.sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model))
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export type RestRequirements = ConfigRequirements
|
|
854
|
+
|
|
855
|
+
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
856
|
+
export const restAt = (
|
|
857
|
+
ref: string | undefined,
|
|
858
|
+
startOverrides: Readonly<Record<string, string>> = {},
|
|
859
|
+
): Effect.Effect<Rest, Error, RestRequirements> =>
|
|
860
|
+
Effect.gen(function* () {
|
|
861
|
+
const git = yield* GitService
|
|
862
|
+
const config = yield* (yield* ConfigService).load
|
|
863
|
+
const workspace = yield* Workspace
|
|
864
|
+
const host = yield* Host
|
|
865
|
+
const run = yield* computeProcessRun(git, config.workflow, ref)
|
|
866
|
+
const resolved = yield* workflowOf(config, run)
|
|
867
|
+
const def = resolved.def
|
|
868
|
+
const { vars, env } = settingsFor(resolved, run.pinnedVars, host.env, startOverrides)
|
|
869
|
+
const skills = yield* resolveScopeSkills(def, vars)
|
|
870
|
+
const access = yield* resolveScopeAccess(def, skills, vars)
|
|
871
|
+
const budget = yield* Effect.try({
|
|
872
|
+
try: () => judgeBudgetBytes(vars),
|
|
873
|
+
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
|
874
|
+
})
|
|
875
|
+
// One bulk fetch materializes the base's and every commit's tree — via
|
|
876
|
+
// `Workspace`'s own cache, at most two subprocesses regardless of the
|
|
877
|
+
// episode's length — BEFORE `commitTree` below ever calls `treeSync`/
|
|
878
|
+
// `readCommittedSync` per commit, so those calls always hit the cache
|
|
879
|
+
// this just warmed.
|
|
880
|
+
yield* workspace.episodeTrees(
|
|
881
|
+
run.episode.base,
|
|
882
|
+
run.episode.commits.map((c) => c.hash),
|
|
883
|
+
)
|
|
884
|
+
const base =
|
|
885
|
+
run.episode.base === undefined
|
|
886
|
+
? { hash: EMPTY_TREE, tree: treeFromRecord({}) }
|
|
887
|
+
: { hash: run.episode.base, tree: commitTree(workspace, run.episode.base) }
|
|
888
|
+
const episode: readonly EpisodeCommit[] = run.episode.commits.map((c) => ({
|
|
889
|
+
hash: c.hash,
|
|
890
|
+
message: c.message,
|
|
891
|
+
tree: commitTree(workspace, c.hash),
|
|
892
|
+
}))
|
|
893
|
+
let pendingMemo: ReadonlyMap<string, string | undefined> | undefined
|
|
894
|
+
const setup: ReplaySetup = {
|
|
895
|
+
def,
|
|
896
|
+
run,
|
|
897
|
+
vars,
|
|
898
|
+
env,
|
|
899
|
+
budget,
|
|
900
|
+
skills,
|
|
901
|
+
access,
|
|
902
|
+
workspace,
|
|
903
|
+
base,
|
|
904
|
+
episode,
|
|
905
|
+
pendingWorktree: () => (pendingMemo ??= workspace.worktreeSync()),
|
|
906
|
+
}
|
|
907
|
+
// No `pending` tree: `restAt` resolves the LANDED process only, never a
|
|
908
|
+
// pending working-tree edit (that's `decideLanding`'s own `replayFor`
|
|
909
|
+
// call, below, deciding what landing right now WOULD do). `gtd lsp` caches
|
|
910
|
+
// `gtd next --json`'s `state`/`file`/`mode` per HEAD and depends on this
|
|
911
|
+
// staying true; passing a pending tree here would make it serve a stale
|
|
912
|
+
// steering file on every cache hit.
|
|
913
|
+
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
914
|
+
const error = replayError(outcome)
|
|
915
|
+
if (error !== undefined || outcome.kind !== "rest") {
|
|
916
|
+
return yield* Effect.fail(error ?? new Error("gtd: replay found no rest"))
|
|
917
|
+
}
|
|
918
|
+
const step = outcome.rest
|
|
919
|
+
yield* (yield* Narrator).narrate(`rest resolved: ${step.name} (awaits ${step.actor})`)
|
|
920
|
+
const stepDef = stepDefOf(step)
|
|
921
|
+
if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
|
|
922
|
+
return yield* Effect.fail(
|
|
923
|
+
new Error(
|
|
924
|
+
`gtd config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
|
|
925
|
+
),
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
const changes =
|
|
929
|
+
ref === undefined
|
|
930
|
+
? (yield* git.changedPaths()).map((e) => ({
|
|
931
|
+
status: normalizeStatus(e.status),
|
|
932
|
+
path: e.path,
|
|
933
|
+
}))
|
|
934
|
+
: []
|
|
935
|
+
const memory = memoryOf(outcome.trace, run)
|
|
936
|
+
const configNow = configFingerprint(config.workflowFiles, host.root)
|
|
937
|
+
// Only the current rest warns, so a command must read it once — a second
|
|
938
|
+
// read warns twice. An earlier ref (restore's tip) would warn about a
|
|
939
|
+
// process the command is not acting on.
|
|
940
|
+
if (ref === undefined) yield* warnConfigChanges(run, configNow, workspace)
|
|
941
|
+
return {
|
|
942
|
+
def,
|
|
943
|
+
workflowName: resolved.name,
|
|
944
|
+
state: step.name,
|
|
945
|
+
stepDef,
|
|
946
|
+
actor: step.actor,
|
|
947
|
+
step,
|
|
948
|
+
trace: outcome.trace,
|
|
949
|
+
run,
|
|
950
|
+
vars,
|
|
951
|
+
env,
|
|
952
|
+
config: configNow,
|
|
953
|
+
changes,
|
|
954
|
+
memory: memory.key,
|
|
955
|
+
memoryResumed: memory.resumed,
|
|
956
|
+
hints: hintsOf(stepDef),
|
|
957
|
+
cost: {
|
|
958
|
+
total: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
|
|
959
|
+
byModel: costByModel(run.costEntries),
|
|
960
|
+
},
|
|
961
|
+
setup,
|
|
962
|
+
}
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Whether `gtd --workflow <name>` can start a process here: the workflow must
|
|
969
|
+
* reach a step from the tree the opening commit would capture. That step is
|
|
970
|
+
* what the opening commit's subject names.
|
|
971
|
+
*/
|
|
972
|
+
export const startRefusal = (
|
|
973
|
+
rest: Rest,
|
|
974
|
+
resolved: ResolvedWorkflow,
|
|
975
|
+
startVars: Record<string, string>,
|
|
976
|
+
): Effect.Effect<
|
|
977
|
+
{ readonly firstStep: string } | { readonly refusal: string },
|
|
978
|
+
Error,
|
|
979
|
+
ConfigRequirements
|
|
980
|
+
> =>
|
|
981
|
+
Effect.gen(function* () {
|
|
982
|
+
const host = yield* Host
|
|
983
|
+
const { vars, env } = settingsFor(resolved, {}, host.env, startVars)
|
|
984
|
+
const skills = yield* resolveScopeSkills(resolved.def, vars)
|
|
985
|
+
const access = yield* resolveScopeAccess(resolved.def, skills, vars)
|
|
986
|
+
const workspace = rest.setup.workspace
|
|
987
|
+
const outcome = yield* Effect.promise(() =>
|
|
988
|
+
replay({
|
|
989
|
+
flow: resolved.def.flow,
|
|
990
|
+
episode: {
|
|
991
|
+
base: { hash: "", tree: pendingTree(workspace, rest.setup.pendingWorktree(), undefined) },
|
|
992
|
+
commits: [],
|
|
993
|
+
},
|
|
994
|
+
vars,
|
|
995
|
+
env,
|
|
996
|
+
start: "",
|
|
997
|
+
budgetBytes: rest.setup.budget,
|
|
998
|
+
skills,
|
|
999
|
+
configuredSkills: resolved.def.configuredSkills,
|
|
1000
|
+
access,
|
|
1001
|
+
configuredAccess: resolved.def.configuredAccess,
|
|
1002
|
+
}),
|
|
1003
|
+
)
|
|
1004
|
+
if (outcome.kind === "rest") return { firstStep: outcome.rest.name }
|
|
1005
|
+
if (outcome.kind === "refused") return { refusal: outcome.message }
|
|
1006
|
+
const why = outcome.kind === "ended" ? "the flow reaches no step" : outcome.message
|
|
1007
|
+
return { refusal: `workflow "${resolved.name}" cannot start — ${why}` }
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
/** The review window's diff base at the rest. */
|
|
1011
|
+
export const reviewBaseFor = (rest: Rest): string =>
|
|
1012
|
+
rest.step.request.options.base ?? rest.run.diffBase
|
|
1013
|
+
|
|
1014
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
1015
|
+
|
|
1016
|
+
export interface RenderedRest extends RestHints {
|
|
1017
|
+
readonly state: StateName
|
|
1018
|
+
readonly actor: string
|
|
1019
|
+
readonly kind: StepDef["kind"]
|
|
1020
|
+
readonly content: string
|
|
1021
|
+
readonly memory?: string
|
|
1022
|
+
readonly memoryResumed: boolean
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export const renderRest = (rest: Rest): Effect.Effect<RenderedRest, Error> =>
|
|
1026
|
+
Effect.succeed({
|
|
1027
|
+
state: rest.state,
|
|
1028
|
+
actor: rest.actor,
|
|
1029
|
+
kind: rest.stepDef.kind,
|
|
1030
|
+
content: rest.stepDef.content,
|
|
1031
|
+
...rest.hints,
|
|
1032
|
+
...(rest.memory !== undefined ? { memory: rest.memory } : {}),
|
|
1033
|
+
memoryResumed: rest.memoryResumed,
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Derived, restart-proof stall detection: the tree is clean, HEAD is an empty
|
|
1038
|
+
* attempt at this very agent rest, and another dispatch would repeat it.
|
|
1039
|
+
*/
|
|
1040
|
+
export const stalledAt = (rest: Rest): boolean =>
|
|
1041
|
+
rest.changes.length === 0 &&
|
|
1042
|
+
rest.stepDef.kind === "prompt" &&
|
|
1043
|
+
rest.stepDef.allowEmpty !== true &&
|
|
1044
|
+
rest.run.headTurn?.state === rest.state &&
|
|
1045
|
+
rest.run.headTurn.actor === rest.actor &&
|
|
1046
|
+
!rest.run.headTurn.step &&
|
|
1047
|
+
rest.run.headTurn.empty
|
|
1048
|
+
|
|
1049
|
+
/** No process is underway: the default workflow's first step, first visit — a dirty tree there is a turn not yet landed, not a process. */
|
|
1050
|
+
export const noProcessUnderway = (rest: Rest): boolean =>
|
|
1051
|
+
rest.run.workflow === undefined &&
|
|
1052
|
+
rest.state === rest.def.initial &&
|
|
1053
|
+
rest.step.id.occurrence === 1
|
|
1054
|
+
|
|
1055
|
+
/** `idle` means exactly one thing: no process underway, clean tree. */
|
|
1056
|
+
export const restIsIdle = (rest: Rest): boolean =>
|
|
1057
|
+
noProcessUnderway(rest) && rest.changes.length === 0
|
|
1058
|
+
|
|
1059
|
+
// ── Landing ─────────────────────────────────────────────────────────────────
|
|
1060
|
+
|
|
1061
|
+
const isHumanReviewGate = (def: StepDef): boolean => def.actor === "human" && def.mode === "review"
|
|
1062
|
+
|
|
1063
|
+
/** The landing a clean tree decides before any replay, if it decides one. */
|
|
1064
|
+
const cleanLanding = (rest: Rest): Landing | undefined => {
|
|
1065
|
+
if (rest.changes.length > 0) return undefined
|
|
1066
|
+
const def = rest.stepDef
|
|
1067
|
+
if (def.kind === "prompt" && def.allowEmpty !== true) {
|
|
1068
|
+
return { kind: "attempt", subject: formatSubject(rest.actor, rest.state) }
|
|
1069
|
+
}
|
|
1070
|
+
if (rest.actor === "human" && def.acceptClean !== true) return { kind: "noop", settled: false }
|
|
1071
|
+
return undefined
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** At the human review gate the pending tree lands with every tick cleared, as the landing script will. */
|
|
1075
|
+
const reviewGateRewrite = (def: StepDef): Rewrite | undefined => {
|
|
1076
|
+
const reviewFormat = steeringFormatFor("review")
|
|
1077
|
+
if (!isHumanReviewGate(def) || def.file === undefined || reviewFormat === undefined) {
|
|
1078
|
+
return undefined
|
|
1079
|
+
}
|
|
1080
|
+
return { path: def.file, apply: (content) => reviewFormat.clearTicks(content) }
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const multilineRefusal = (vars: Record<string, string>): Landing | undefined => {
|
|
1084
|
+
const multiline = multilineSetting(vars)
|
|
1085
|
+
return multiline === undefined
|
|
1086
|
+
? undefined
|
|
1087
|
+
: {
|
|
1088
|
+
kind: "refusal",
|
|
1089
|
+
message: `gtd: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`,
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const landingTarget = (
|
|
1094
|
+
outcome: Awaited<ReturnType<typeof replayFor>>,
|
|
1095
|
+
rest: Rest,
|
|
1096
|
+
): Effect.Effect<StateName | Landing, Error> => {
|
|
1097
|
+
if (outcome.kind === "refused")
|
|
1098
|
+
return Effect.succeed({ kind: "refusal", message: outcome.message })
|
|
1099
|
+
if (outcome.kind === "divergence" || outcome.kind === "failed") {
|
|
1100
|
+
return Effect.fail(new Error(outcome.message))
|
|
1101
|
+
}
|
|
1102
|
+
if (outcome.kind !== "rest") return Effect.succeed(rest.def.initial)
|
|
1103
|
+
const reserved = initialStepRefusal(rest.run.workflow, outcome.rest.name, rest.def.initial)
|
|
1104
|
+
return Effect.succeed(reserved === undefined ? outcome.rest.name : reserved)
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* A step commit entering the default's first step is what ends an episode, so
|
|
1109
|
+
* a named workflow landing there would read back as finished. Its opening
|
|
1110
|
+
* commit may name that step: a bare opening is never read as a boundary.
|
|
1111
|
+
*/
|
|
1112
|
+
const initialStepRefusal = (
|
|
1113
|
+
workflow: string | undefined,
|
|
1114
|
+
step: StateName,
|
|
1115
|
+
initial: StateName,
|
|
1116
|
+
): Landing | undefined =>
|
|
1117
|
+
workflow !== undefined && step === initial
|
|
1118
|
+
? {
|
|
1119
|
+
kind: "refusal",
|
|
1120
|
+
message: `gtd: workflow "${workflow}" reaches step "${step}", the default workflow's first step — that is where a finished process waits, so a started process must never land there; rename the step`,
|
|
1121
|
+
}
|
|
1122
|
+
: undefined
|
|
1123
|
+
|
|
1124
|
+
const landingTo = (rest: Rest, to: StateName): Landing => {
|
|
1125
|
+
if (rest.changes.length === 0 && rest.stepDef.kind === "script" && to === rest.state) {
|
|
1126
|
+
return { kind: "noop", settled: true }
|
|
1127
|
+
}
|
|
1128
|
+
// The landing that leaves the flow's first step starts the process: it
|
|
1129
|
+
// pins the process settings every later invocation reads back.
|
|
1130
|
+
const starts = noProcessUnderway(rest) && to !== rest.def.initial
|
|
1131
|
+
const refused = starts ? multilineRefusal(rest.vars) : undefined
|
|
1132
|
+
if (refused !== undefined) return refused
|
|
1133
|
+
return {
|
|
1134
|
+
kind: "commit",
|
|
1135
|
+
to,
|
|
1136
|
+
spec: {
|
|
1137
|
+
actor: rest.actor,
|
|
1138
|
+
from: rest.state,
|
|
1139
|
+
to,
|
|
1140
|
+
step: rest.step.id,
|
|
1141
|
+
...(starts ? { vars: rest.vars, config: rest.config } : {}),
|
|
1142
|
+
},
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* What landing the pending turn at `rest` does — a pure decision from a
|
|
1148
|
+
* replay with the working tree as the pending turn. The target step is
|
|
1149
|
+
* computed by replaying, never matched.
|
|
1150
|
+
*/
|
|
1151
|
+
const decideLanding = (
|
|
1152
|
+
rest: Rest,
|
|
1153
|
+
verdicts: readonly JudgeVerdict[] | undefined,
|
|
1154
|
+
): Effect.Effect<Landing, Error> =>
|
|
1155
|
+
Effect.gen(function* () {
|
|
1156
|
+
const early = cleanLanding(rest)
|
|
1157
|
+
if (early !== undefined) return early
|
|
1158
|
+
if (rest.stepDef.kind === "prompt" && rest.stepDef.access?.write != null) {
|
|
1159
|
+
const message = accessRefusal(rest.changes, rest.stepDef.access.write)
|
|
1160
|
+
if (message !== undefined) return { kind: "refusal", message }
|
|
1161
|
+
}
|
|
1162
|
+
const outcome = yield* Effect.promise(() =>
|
|
1163
|
+
replayFor(rest.setup, {
|
|
1164
|
+
tree: pendingTree(
|
|
1165
|
+
rest.setup.workspace,
|
|
1166
|
+
rest.setup.pendingWorktree(),
|
|
1167
|
+
reviewGateRewrite(rest.stepDef),
|
|
1168
|
+
),
|
|
1169
|
+
...(verdicts !== undefined ? { verdicts } : {}),
|
|
1170
|
+
}),
|
|
1171
|
+
)
|
|
1172
|
+
const to = yield* landingTarget(outcome, rest)
|
|
1173
|
+
if (typeof to !== "string") return to
|
|
1174
|
+
return landingTo(rest, to)
|
|
1175
|
+
})
|
|
1176
|
+
|
|
1177
|
+
/** Where landing the pending turn would leave the process, or `undefined` when it would land nothing. */
|
|
1178
|
+
export const previewLanding = (rest: Rest): Effect.Effect<StateName | undefined> =>
|
|
1179
|
+
decideLanding(rest, undefined).pipe(
|
|
1180
|
+
Effect.map((landing) => (landing.kind === "commit" ? landing.to : undefined)),
|
|
1181
|
+
Effect.catchAll(() => Effect.succeed(undefined)),
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
/** Every fact the pure landing planner needs, read once: the rest, its steering file, and the landing decision. */
|
|
1185
|
+
export const snapshotFromRest = (
|
|
1186
|
+
rest: Rest,
|
|
1187
|
+
verdicts?: readonly JudgeVerdict[],
|
|
1188
|
+
): Effect.Effect<RepoSnapshot, Error, Workspace | GitService> =>
|
|
1189
|
+
Effect.gen(function* () {
|
|
1190
|
+
const landing = yield* decideLanding(rest, verdicts)
|
|
1191
|
+
return {
|
|
1192
|
+
stepDef: rest.stepDef,
|
|
1193
|
+
state: rest.state,
|
|
1194
|
+
actor: rest.actor,
|
|
1195
|
+
changes: rest.changes,
|
|
1196
|
+
file: rest.hints.file,
|
|
1197
|
+
landing,
|
|
1198
|
+
}
|
|
1199
|
+
})
|
|
1200
|
+
|
|
1201
|
+
// ── Summary ─────────────────────────────────────────────────────────────────
|
|
1202
|
+
|
|
1203
|
+
/** `gtd summary`'s prompt for `run`, or `undefined` when the workflow has none or there is nothing to summarize. */
|
|
1204
|
+
export const summaryFor = (
|
|
1205
|
+
run: ProcessRun,
|
|
1206
|
+
): Effect.Effect<string | undefined, Error, RestRequirements> =>
|
|
1207
|
+
Effect.gen(function* () {
|
|
1208
|
+
const config = yield* (yield* ConfigService).load
|
|
1209
|
+
const host = yield* Host
|
|
1210
|
+
const resolved = yield* workflowOf(config, run)
|
|
1211
|
+
const summary = resolved.def.summary
|
|
1212
|
+
if (summary === undefined || run.trace.length === 0) return undefined
|
|
1213
|
+
const entryCommit = run.trace[0]!.hash
|
|
1214
|
+
return summary({
|
|
1215
|
+
entryCommit,
|
|
1216
|
+
processBase: run.startParentHash,
|
|
1217
|
+
processTip: run.trace[run.trace.length - 1]!.hash,
|
|
1218
|
+
humanCommits: run.trace
|
|
1219
|
+
.filter((entry) => entry.actor === "human" && entry.hash !== entryCommit)
|
|
1220
|
+
.map((entry) => ({ hash: entry.hash, state: entry.state })),
|
|
1221
|
+
processCost: run.costEntries.reduce((sum, entry) => sum + entry.cost, 0),
|
|
1222
|
+
processCostByModel: costByModel(run.costEntries),
|
|
1223
|
+
...settingsFor(resolved, run.pinnedVars, host.env),
|
|
1224
|
+
})
|
|
1225
|
+
})
|